From a256175a39cf5075353c2cf08512e94714d330cb Mon Sep 17 00:00:00 2001 From: Jinyeong Seol Date: Tue, 9 Dec 2025 01:36:59 +0900 Subject: [PATCH 01/11] Enhance README with authors and API details Updated README.md to include additional authors and API information. --- README.md | 34 +++++++++++++++++++++++++++++----- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index dcf2238e..8522c237 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,36 @@ +

+ 토덕 To.duck Logo +

+

토덕 To.duck

+

+ 성인 ADHD인을 위한 토닥임 +

+

+ + Download on the App Store + +

+

+ Copyright © 2025 To.duck Team +

+
+ +### 🔗 API +- [https://api-toduck.seol.pro](https://api-toduck.seol.pro/) + - [Swagger UI](https://api-toduck.seol.pro/swagger-ui/index.html) + - [에러 코드](https://api-toduck.seol.pro/exception-codes) + +
+ ### 🧑‍💻 Authors +| 강민기 | 박준하 | 설진영 | 최연우 | +|:----------------------------------------------------------------------------:|:----------------------------------------------------------------------------:|:----------------------------------------------------------------------------:|:----------------------------------------------------------------------------:| +| | | | | +| [@kang20](https://github.com/kang20) | [@Junad-Park](https://github.com/Junad-Park) | [@Seol-JY](https://github.com/Seol-JY) | [@wafla](https://github.com/wafla) | -| 강민기 | 박준하 | 설진영 | -|:----------------------------------------------------------------------------:|:----------------------------------------------------------------------------:|:----------------------------------------------------------------------------:| -| | | | -| [@kang20](https://github.com/kang20) | [@Junad-Park](https://github.com/Junad-Park) | [@Seol-JY](https://github.com/Seol-JY) | +
### 📜 Docs - - [초기 개발환경 설정](https://kyxxn.notion.site/fdf5a3a523f040328a9c68d4377ff997?pvs=74) - [개발 가이드](https://kyxxn.notion.site/9a2670768f784ea5bb3ca8f044ede895) - [API 개요](https://kyxxn.notion.site/API-e775e161efa6459583a0ee0d586c4d19) From 75489e50c0efcaeb1a6e1606f7ccdc4224a58854 Mon Sep 17 00:00:00 2001 From: wafla Date: Thu, 5 Feb 2026 17:49:41 +0900 Subject: [PATCH 02/11] =?UTF-8?q?fix:=20=EC=98=A4=EB=A5=98=20=EC=9E=88?= =?UTF-8?q?=EB=8A=94=20=EB=B6=80=EB=B6=84=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/diary/domain/service/DiaryService.java | 3 --- .../domain/diary/domain/usecase/DiaryUseCase.java | 3 +++ .../domain/diary/persistence/entity/Diary.java | 2 +- .../domain/diary/persistence/entity/DiaryImage.java | 5 ----- .../detail/domain/service/EventsDetailService.java | 4 ++-- .../detail/domain/usecase/EventsDetailUseCase.java | 5 ++++- .../detail/persistence/entity/EventsDetail.java | 2 +- .../detail/persistence/entity/EventsDetailImg.java | 12 +++++++----- .../dto/request/EventsDetailCreateRequest.java | 2 +- .../domain/usecase/EventsDetailUseCaseTest.java | 9 --------- 10 files changed, 19 insertions(+), 28 deletions(-) diff --git a/src/main/java/im/toduck/domain/diary/domain/service/DiaryService.java b/src/main/java/im/toduck/domain/diary/domain/service/DiaryService.java index bd81d514..8aa4d5b1 100644 --- a/src/main/java/im/toduck/domain/diary/domain/service/DiaryService.java +++ b/src/main/java/im/toduck/domain/diary/domain/service/DiaryService.java @@ -62,9 +62,6 @@ public Optional getDiaryById(final Long diaryId) { @Transactional public void deleteDiary(final Diary diary) { - List imageFiles = diaryImageRepository.findAllByDiary(diary); - imageFiles.forEach(DiaryImage::softDelete); - diaryRepository.delete(diary); } diff --git a/src/main/java/im/toduck/domain/diary/domain/usecase/DiaryUseCase.java b/src/main/java/im/toduck/domain/diary/domain/usecase/DiaryUseCase.java index 59b1ba23..c0d6a1d9 100644 --- a/src/main/java/im/toduck/domain/diary/domain/usecase/DiaryUseCase.java +++ b/src/main/java/im/toduck/domain/diary/domain/usecase/DiaryUseCase.java @@ -8,6 +8,7 @@ import im.toduck.domain.diary.common.mapper.DiaryMapper; import im.toduck.domain.diary.domain.service.DiaryService; import im.toduck.domain.diary.persistence.entity.Diary; +import im.toduck.domain.diary.persistence.entity.DiaryImage; import im.toduck.domain.diary.presentation.dto.request.DiaryCreateRequest; import im.toduck.domain.diary.presentation.dto.request.DiaryUpdateRequest; import im.toduck.domain.diary.presentation.dto.response.DiaryListResponse; @@ -55,6 +56,8 @@ public void deleteDiary(Long userId, Long diaryId) { throw CommonException.from(ExceptionCode.UNAUTHORIZED_ACCESS_DIARY); } + diary.getDiaryImages().forEach(DiaryImage::softDelete); + diaryService.deleteDiary(diary); log.info("일기 삭제 - UserId: {}, DiaryId: {}", userId, diaryId); } diff --git a/src/main/java/im/toduck/domain/diary/persistence/entity/Diary.java b/src/main/java/im/toduck/domain/diary/persistence/entity/Diary.java index b43432a6..14b31dfc 100644 --- a/src/main/java/im/toduck/domain/diary/persistence/entity/Diary.java +++ b/src/main/java/im/toduck/domain/diary/persistence/entity/Diary.java @@ -56,7 +56,7 @@ public class Diary extends BaseEntity { @Lob private String memo; - @OneToMany(mappedBy = "diary", cascade = CascadeType.ALL, orphanRemoval = true) + @OneToMany(mappedBy = "diary", cascade = CascadeType.ALL) private List diaryImages = new ArrayList<>(); @OneToMany(mappedBy = "diary", cascade = CascadeType.ALL, orphanRemoval = true) diff --git a/src/main/java/im/toduck/domain/diary/persistence/entity/DiaryImage.java b/src/main/java/im/toduck/domain/diary/persistence/entity/DiaryImage.java index 05c897f0..df2c27d6 100644 --- a/src/main/java/im/toduck/domain/diary/persistence/entity/DiaryImage.java +++ b/src/main/java/im/toduck/domain/diary/persistence/entity/DiaryImage.java @@ -2,9 +2,6 @@ import java.time.LocalDateTime; -import org.hibernate.annotations.SQLDelete; -import org.hibernate.annotations.SQLRestriction; - import im.toduck.global.base.entity.BaseEntity; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -22,8 +19,6 @@ @Table(name = "diary_image_file") @Getter @NoArgsConstructor -@SQLDelete(sql = "UPDATE diary_image_file SET deleted_at = NOW() where id=?") -@SQLRestriction(value = "deleted_at is NULL") public class DiaryImage extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/src/main/java/im/toduck/domain/events/detail/domain/service/EventsDetailService.java b/src/main/java/im/toduck/domain/events/detail/domain/service/EventsDetailService.java index eb2b41b9..0f516609 100644 --- a/src/main/java/im/toduck/domain/events/detail/domain/service/EventsDetailService.java +++ b/src/main/java/im/toduck/domain/events/detail/domain/service/EventsDetailService.java @@ -43,7 +43,7 @@ public EventsDetail createEventsDetail(final EventsDetailCreateRequest request) } @Transactional - public void addEventsDetailImges(final EventsDetail eventsDetail, final List imgUrls) { + public void addEventsDetailImages(final EventsDetail eventsDetail, final List imgUrls) { List safeImgs = Optional.ofNullable(imgUrls).orElse(Collections.emptyList()); List eventsDetailImgs = safeImgs.stream() @@ -95,7 +95,7 @@ public void updateEventsDetail( if (request.eventsDetailImgs() != null && !request.eventsDetailImgs().isEmpty()) { eventsDetailImgRepository.deleteAllByEventsDetail(eventsDetail); - addEventsDetailImges(eventsDetail, request.eventsDetailImgs()); + addEventsDetailImages(eventsDetail, request.eventsDetailImgs()); } else { eventsDetailImgRepository.deleteAllByEventsDetail(eventsDetail); } diff --git a/src/main/java/im/toduck/domain/events/detail/domain/usecase/EventsDetailUseCase.java b/src/main/java/im/toduck/domain/events/detail/domain/usecase/EventsDetailUseCase.java index 7a837186..fb0b829d 100644 --- a/src/main/java/im/toduck/domain/events/detail/domain/usecase/EventsDetailUseCase.java +++ b/src/main/java/im/toduck/domain/events/detail/domain/usecase/EventsDetailUseCase.java @@ -7,6 +7,7 @@ import im.toduck.domain.events.detail.common.mapper.EventsDetailMapper; import im.toduck.domain.events.detail.domain.service.EventsDetailService; import im.toduck.domain.events.detail.persistence.entity.EventsDetail; +import im.toduck.domain.events.detail.persistence.entity.EventsDetailImg; import im.toduck.domain.events.detail.presentation.dto.request.EventsDetailCreateRequest; import im.toduck.domain.events.detail.presentation.dto.request.EventsDetailUpdateRequest; import im.toduck.domain.events.detail.presentation.dto.response.EventsDetailListResponse; @@ -47,7 +48,7 @@ public EventsDetail createEventsDetail(final EventsDetailCreateRequest request, } EventsDetail eventsDetail = eventsDetailService.createEventsDetail(request); - eventsDetailService.addEventsDetailImges(eventsDetail, request.eventsDetailImgs()); + eventsDetailService.addEventsDetailImages(eventsDetail, request.eventsDetailImgs()); return eventsDetail; } @@ -76,6 +77,8 @@ public void deleteEventsDetail(final Long eventsDetailId, final Long userId) { EventsDetail eventsDetail = eventsDetailService.findById(eventsDetailId) .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_EVENTS_DETAIL)); + eventsDetail.getEventsDetailImgs().forEach(EventsDetailImg::softDelete); + eventsDetailService.deleteEventsDetail(eventsDetail); } } diff --git a/src/main/java/im/toduck/domain/events/detail/persistence/entity/EventsDetail.java b/src/main/java/im/toduck/domain/events/detail/persistence/entity/EventsDetail.java index bd28f115..2a00d789 100644 --- a/src/main/java/im/toduck/domain/events/detail/persistence/entity/EventsDetail.java +++ b/src/main/java/im/toduck/domain/events/detail/persistence/entity/EventsDetail.java @@ -47,7 +47,7 @@ public class EventsDetail extends BaseEntity { @Column(length = 63) private String buttonText; - @OneToMany(mappedBy = "eventsDetail", cascade = CascadeType.ALL, orphanRemoval = true) + @OneToMany(mappedBy = "eventsDetail", cascade = CascadeType.ALL) private List eventsDetailImgs = new ArrayList<>(); @Builder diff --git a/src/main/java/im/toduck/domain/events/detail/persistence/entity/EventsDetailImg.java b/src/main/java/im/toduck/domain/events/detail/persistence/entity/EventsDetailImg.java index 19f30f01..6d9b2ce2 100644 --- a/src/main/java/im/toduck/domain/events/detail/persistence/entity/EventsDetailImg.java +++ b/src/main/java/im/toduck/domain/events/detail/persistence/entity/EventsDetailImg.java @@ -1,11 +1,11 @@ package im.toduck.domain.events.detail.persistence.entity; -import org.hibernate.annotations.SQLDelete; -import org.hibernate.annotations.SQLRestriction; +import java.time.LocalDateTime; import im.toduck.global.base.entity.BaseEntity; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; @@ -20,14 +20,12 @@ @Table(name = "events_detail_img") @Getter @NoArgsConstructor -@SQLDelete(sql = "UPDATE events_detail_img SET deleted_at = NOW() where id=?") -@SQLRestriction(value = "deleted_at is NULL") public class EventsDetailImg extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @ManyToOne + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "events_detail_id", nullable = false) private EventsDetail eventsDetail; @@ -47,4 +45,8 @@ public void updateEventsDetail(final EventsDetail eventsDetail) { public void updateDetailImgUrl(final String detailImgUrl) { this.detailImgUrl = detailImgUrl; } + + public void softDelete() { + this.deletedAt = LocalDateTime.now(); + } } diff --git a/src/main/java/im/toduck/domain/events/detail/presentation/dto/request/EventsDetailCreateRequest.java b/src/main/java/im/toduck/domain/events/detail/presentation/dto/request/EventsDetailCreateRequest.java index 7a27786f..8b07a5e6 100644 --- a/src/main/java/im/toduck/domain/events/detail/presentation/dto/request/EventsDetailCreateRequest.java +++ b/src/main/java/im/toduck/domain/events/detail/presentation/dto/request/EventsDetailCreateRequest.java @@ -26,7 +26,7 @@ public record EventsDetailCreateRequest( @Schema(description = "버튼 내용", example = "당첨 확인하기") String buttonText, - @Schema(description = "이벤트 디테일 URL 목록", example = "[\"https://cdn.toduck.app/image1.jpg\"]") + @Schema(description = "이벤트 디테일 사진 URL 목록", example = "[\"https://cdn.toduck.app/image1.jpg\"]") List eventsDetailImgs ) { diff --git a/src/test/java/im/toduck/domain/events/detail/domain/usecase/EventsDetailUseCaseTest.java b/src/test/java/im/toduck/domain/events/detail/domain/usecase/EventsDetailUseCaseTest.java index 37058e8f..a969231b 100644 --- a/src/test/java/im/toduck/domain/events/detail/domain/usecase/EventsDetailUseCaseTest.java +++ b/src/test/java/im/toduck/domain/events/detail/domain/usecase/EventsDetailUseCaseTest.java @@ -116,15 +116,6 @@ void setUp() { .eventsDetailImgs(Arrays.asList("asdf", "ㅁㄴㅇㄹ")) .build(); - EventsDetailCreateRequest eventsDetailCreateRequest2 = - EventsDetailCreateRequest.builder() - .eventsId(savedEvent.getId()) - .routingUrl("toduck://anotherPost") - .buttonVisible(true) - .buttonText("당첨 확인하기") - .eventsDetailImgs(Arrays.asList("xyz", "123")) - .build(); - EventsDetail eventsDetail1 = eventsDetailUseCase.createEventsDetail(eventsDetailCreateRequest, savedAdminUser.getId()); From f7094c7778bc5446e4a2eadd6ca3845cc7ec77f3 Mon Sep 17 00:00:00 2001 From: wafla Date: Thu, 5 Feb 2026 17:53:33 +0900 Subject: [PATCH 03/11] =?UTF-8?q?feat:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=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 --- .../admin/common/mapper/AdminMapper.java | 32 ++++ .../admin/domain/service/AdminService.java | 89 +++++++++ .../admin/domain/usecase/AdminUseCase.java | 99 ++++++++++ .../admin/persistence/entity/Admin.java | 51 +++++ .../repository/AdminRepository.java | 11 ++ .../querydsl/AdminRepositoryCustom.java | 14 ++ .../querydsl/AdminRepositoryCustomImpl.java | 53 ++++++ .../admin/presentation/api/AdminApi.java | 129 +++++++++++++ .../controller/AdminController.java | 85 +++++++++ .../dto/request/AdminCreateRequest.java | 20 ++ .../dto/request/AdminUpdateRequest.java | 12 ++ .../dto/response/AdminListResponse.java | 21 +++ .../dto/response/AdminResponse.java | 18 ++ .../mypage/domain/usecase/MyPageUseCase.java | 8 + .../domain/user/persistence/entity/User.java | 8 + .../controller/AdminControllerTest.java | 174 ++++++++++++++++++ 16 files changed, 824 insertions(+) create mode 100644 src/main/java/im/toduck/domain/admin/common/mapper/AdminMapper.java create mode 100644 src/main/java/im/toduck/domain/admin/domain/service/AdminService.java create mode 100644 src/main/java/im/toduck/domain/admin/domain/usecase/AdminUseCase.java create mode 100644 src/main/java/im/toduck/domain/admin/persistence/entity/Admin.java create mode 100644 src/main/java/im/toduck/domain/admin/persistence/repository/AdminRepository.java create mode 100644 src/main/java/im/toduck/domain/admin/persistence/repository/querydsl/AdminRepositoryCustom.java create mode 100644 src/main/java/im/toduck/domain/admin/persistence/repository/querydsl/AdminRepositoryCustomImpl.java create mode 100644 src/main/java/im/toduck/domain/admin/presentation/api/AdminApi.java create mode 100644 src/main/java/im/toduck/domain/admin/presentation/controller/AdminController.java create mode 100644 src/main/java/im/toduck/domain/admin/presentation/dto/request/AdminCreateRequest.java create mode 100644 src/main/java/im/toduck/domain/admin/presentation/dto/request/AdminUpdateRequest.java create mode 100644 src/main/java/im/toduck/domain/admin/presentation/dto/response/AdminListResponse.java create mode 100644 src/main/java/im/toduck/domain/admin/presentation/dto/response/AdminResponse.java create mode 100644 src/test/java/im/toduck/domain/admin/presentation/controller/AdminControllerTest.java diff --git a/src/main/java/im/toduck/domain/admin/common/mapper/AdminMapper.java b/src/main/java/im/toduck/domain/admin/common/mapper/AdminMapper.java new file mode 100644 index 00000000..88339681 --- /dev/null +++ b/src/main/java/im/toduck/domain/admin/common/mapper/AdminMapper.java @@ -0,0 +1,32 @@ +package im.toduck.domain.admin.common.mapper; + +import java.util.List; + +import im.toduck.domain.admin.persistence.entity.Admin; +import im.toduck.domain.admin.presentation.dto.response.AdminListResponse; +import im.toduck.domain.admin.presentation.dto.response.AdminResponse; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class AdminMapper { + public static AdminResponse toAdminResponse(final Admin admin) { + return new AdminResponse( + admin.getId(), + admin.getUser().getId(), + admin.getDisplayName() + ); + } + + public static AdminListResponse toLAdminListResponse(final List admins) { + return AdminListResponse.toListAdminResponse(admins); + } + + public static AdminResponse fromAdmin(final Admin admin) { + return new AdminResponse( + admin.getId(), + admin.getUser().getId(), + admin.getDisplayName() + ); + } +} diff --git a/src/main/java/im/toduck/domain/admin/domain/service/AdminService.java b/src/main/java/im/toduck/domain/admin/domain/service/AdminService.java new file mode 100644 index 00000000..7328e47c --- /dev/null +++ b/src/main/java/im/toduck/domain/admin/domain/service/AdminService.java @@ -0,0 +1,89 @@ +package im.toduck.domain.admin.domain.service; + +import java.util.List; +import java.util.Optional; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import im.toduck.domain.admin.common.mapper.AdminMapper; +import im.toduck.domain.admin.persistence.entity.Admin; +import im.toduck.domain.admin.persistence.repository.AdminRepository; +import im.toduck.domain.admin.presentation.dto.request.AdminCreateRequest; +import im.toduck.domain.admin.presentation.dto.request.AdminUpdateRequest; +import im.toduck.domain.admin.presentation.dto.response.AdminResponse; +import im.toduck.domain.user.persistence.entity.User; +import im.toduck.domain.user.persistence.repository.UserRepository; +import im.toduck.global.exception.CommonException; +import im.toduck.global.exception.ExceptionCode; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class AdminService { + private final UserRepository userRepository; + private final AdminRepository adminRepository; + + @Transactional + public Admin getAdmin(final Long userId) { + return adminRepository.findActiveAdminByUserId(userId) + .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_ADMIN)); + } + + // Role은 Admin이지만 admin 테이블에 등록되어있지 않은 경우 사용합니다. + @Transactional + public Admin getAdminBySameUser(final Long userId) { + return adminRepository.findActiveAdminByUserId(userId) + .orElseGet(() -> createDefaultAdmin(userId)); + } + + @Transactional + private Admin createDefaultAdmin(final Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_USER)); + + Admin admin = Admin.builder() + .user(user) + .displayName("토덕 관리자") + .build(); + + return adminRepository.save(admin); + } + + @Transactional(readOnly = true) + public List getAdmins() { + List admins = adminRepository.findAllActiveAdmins(); + return admins.stream() + .map(AdminMapper::fromAdmin) + .toList(); + } + + @Transactional(readOnly = true) + public Optional getExistingAdmin(final Long userId) { + return adminRepository.findByUserIdIncludeDeleted(userId); + } + + @Transactional + public Admin createAdmin(final AdminCreateRequest request, final User user) { + Admin admin = Admin.builder() + .user(user) + .displayName(request.displayName()) + .build(); + + return adminRepository.save(admin); + } + + @Transactional + public void updateAdmin(final Long userId, final AdminUpdateRequest request) { + if (request.displayName() != null) { + Admin admin = adminRepository.findActiveAdminByUserId(userId) + .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_ADMIN)); + admin.updateDisplayName(request.displayName()); + } + } + + @Transactional + public void deleteAdmin(final Admin admin) { + adminRepository.delete(admin); + } +} diff --git a/src/main/java/im/toduck/domain/admin/domain/usecase/AdminUseCase.java b/src/main/java/im/toduck/domain/admin/domain/usecase/AdminUseCase.java new file mode 100644 index 00000000..97e2d231 --- /dev/null +++ b/src/main/java/im/toduck/domain/admin/domain/usecase/AdminUseCase.java @@ -0,0 +1,99 @@ +package im.toduck.domain.admin.domain.usecase; + +import java.util.List; + +import org.springframework.transaction.annotation.Transactional; + +import im.toduck.domain.admin.common.mapper.AdminMapper; +import im.toduck.domain.admin.domain.service.AdminService; +import im.toduck.domain.admin.persistence.entity.Admin; +import im.toduck.domain.admin.presentation.dto.request.AdminCreateRequest; +import im.toduck.domain.admin.presentation.dto.request.AdminUpdateRequest; +import im.toduck.domain.admin.presentation.dto.response.AdminListResponse; +import im.toduck.domain.admin.presentation.dto.response.AdminResponse; +import im.toduck.domain.user.domain.service.UserService; +import im.toduck.domain.user.persistence.entity.User; +import im.toduck.domain.user.persistence.entity.UserRole; +import im.toduck.global.annotation.UseCase; +import im.toduck.global.exception.CommonException; +import im.toduck.global.exception.ExceptionCode; +import lombok.RequiredArgsConstructor; + +@UseCase +@RequiredArgsConstructor +public class AdminUseCase { + private final AdminService adminService; + private final UserService userService; + + @Transactional + public AdminResponse getAdmin(final Long userId) { + Admin admin = adminService.getAdmin(userId); + + return AdminMapper.toAdminResponse(admin); + } + + @Transactional + public AdminListResponse getAdmins() { + List admins = adminService.getAdmins(); + + return AdminMapper.toLAdminListResponse(admins); + } + + @Transactional + public Admin createAdmin(final AdminCreateRequest request) { + User user = userService.getUserById(request.userId()) + .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_USER)); + + Admin existingAdmin = adminService.getExistingAdmin(user.getId()).orElse(null); + + // 이미 활성화된 관리자인 경우 + if (existingAdmin != null && existingAdmin.getDeletedAt() == null) { + throw CommonException.from(ExceptionCode.DUPLICATE_ADMIN); + } + + // 삭제된 관리자 복구 + if (existingAdmin != null) { + existingAdmin.revive(); + existingAdmin.updateDisplayName(request.displayName()); + + if (user.getRole() == UserRole.USER) { + user.promoteToAdmin(); + } + + return existingAdmin; + } + + // 신규 관리자 + Admin admin = adminService.createAdmin(request, user); + + if (user.getRole() == UserRole.USER) { + user.promoteToAdmin(); + } + + return admin; + } + + @Transactional + public void updateAdmin(final Long userId, final AdminUpdateRequest request) { + User user = userService.getUserById(userId) + .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_USER)); + + Admin admin = adminService.getAdmin(userId); + + adminService.updateAdmin(userId, request); + } + + @Transactional + public void deleteAdmin(final Long userId) { + Admin admin = adminService.getAdmin(userId); + + if (admin.getDeletedAt() != null) { + return; + } + + User user = admin.getUser(); + user.demoteToUser(); + + adminService.deleteAdmin(admin); + } +} diff --git a/src/main/java/im/toduck/domain/admin/persistence/entity/Admin.java b/src/main/java/im/toduck/domain/admin/persistence/entity/Admin.java new file mode 100644 index 00000000..ac0203e8 --- /dev/null +++ b/src/main/java/im/toduck/domain/admin/persistence/entity/Admin.java @@ -0,0 +1,51 @@ +package im.toduck.domain.admin.persistence.entity; + +import org.hibernate.annotations.SQLDelete; + +import im.toduck.domain.user.persistence.entity.User; +import im.toduck.global.base.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "admin") +@Getter +@NoArgsConstructor +@SQLDelete(sql = "UPDATE admin SET deleted_at = NOW() where id=?") +public class Admin extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false, unique = true) + private User user; + + @Column(length = 255, nullable = false) + private String displayName; + + @Builder + private Admin(User user, + String displayName) { + this.user = user; + this.displayName = displayName; + } + + public void updateDisplayName(final String displayName) { + this.displayName = displayName; + } + + public void revive() { + this.deletedAt = null; + } +} diff --git a/src/main/java/im/toduck/domain/admin/persistence/repository/AdminRepository.java b/src/main/java/im/toduck/domain/admin/persistence/repository/AdminRepository.java new file mode 100644 index 00000000..513a1bcc --- /dev/null +++ b/src/main/java/im/toduck/domain/admin/persistence/repository/AdminRepository.java @@ -0,0 +1,11 @@ +package im.toduck.domain.admin.persistence.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import im.toduck.domain.admin.persistence.entity.Admin; +import im.toduck.domain.admin.persistence.repository.querydsl.AdminRepositoryCustom; + +@Repository +public interface AdminRepository extends JpaRepository, AdminRepositoryCustom { +} diff --git a/src/main/java/im/toduck/domain/admin/persistence/repository/querydsl/AdminRepositoryCustom.java b/src/main/java/im/toduck/domain/admin/persistence/repository/querydsl/AdminRepositoryCustom.java new file mode 100644 index 00000000..fd96da3a --- /dev/null +++ b/src/main/java/im/toduck/domain/admin/persistence/repository/querydsl/AdminRepositoryCustom.java @@ -0,0 +1,14 @@ +package im.toduck.domain.admin.persistence.repository.querydsl; + +import java.util.List; +import java.util.Optional; + +import im.toduck.domain.admin.persistence.entity.Admin; + +public interface AdminRepositoryCustom { + Optional findActiveAdminByUserId(Long userId); + + List findAllActiveAdmins(); + + Optional findByUserIdIncludeDeleted(Long userId); +} diff --git a/src/main/java/im/toduck/domain/admin/persistence/repository/querydsl/AdminRepositoryCustomImpl.java b/src/main/java/im/toduck/domain/admin/persistence/repository/querydsl/AdminRepositoryCustomImpl.java new file mode 100644 index 00000000..39bf181f --- /dev/null +++ b/src/main/java/im/toduck/domain/admin/persistence/repository/querydsl/AdminRepositoryCustomImpl.java @@ -0,0 +1,53 @@ +package im.toduck.domain.admin.persistence.repository.querydsl; + +import java.util.List; +import java.util.Optional; + +import com.querydsl.jpa.impl.JPAQueryFactory; + +import im.toduck.domain.admin.persistence.entity.Admin; +import im.toduck.domain.admin.persistence.entity.QAdmin; +import im.toduck.domain.user.persistence.entity.QUser; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class AdminRepositoryCustomImpl implements AdminRepositoryCustom { + + private final JPAQueryFactory queryFactory; + private final QAdmin admin = QAdmin.admin; + private final QUser user = QUser.user; + + @Override + public Optional findActiveAdminByUserId(final Long userId) { + Admin result = queryFactory + .selectFrom(admin) + .join(admin.user, user).fetchJoin() + .where( + user.id.eq(userId), + admin.deletedAt.isNull() + ) + .fetchOne(); + + return Optional.ofNullable(result); + } + + @Override + public List findAllActiveAdmins() { + return queryFactory + .selectFrom(admin) + .where(admin.deletedAt.isNull()) + .fetch(); + } + + @Override + public Optional findByUserIdIncludeDeleted(final Long userId) { + Admin result = queryFactory + .selectFrom(admin) + .where( + admin.user.id.eq(userId) + ) + .fetchOne(); + + return Optional.ofNullable(result); + } +} diff --git a/src/main/java/im/toduck/domain/admin/presentation/api/AdminApi.java b/src/main/java/im/toduck/domain/admin/presentation/api/AdminApi.java new file mode 100644 index 00000000..140dae08 --- /dev/null +++ b/src/main/java/im/toduck/domain/admin/presentation/api/AdminApi.java @@ -0,0 +1,129 @@ +package im.toduck.domain.admin.presentation.api; + +import java.util.Map; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; + +import im.toduck.domain.admin.presentation.dto.request.AdminCreateRequest; +import im.toduck.domain.admin.presentation.dto.request.AdminUpdateRequest; +import im.toduck.domain.admin.presentation.dto.response.AdminListResponse; +import im.toduck.domain.admin.presentation.dto.response.AdminResponse; +import im.toduck.global.annotation.swagger.ApiErrorResponseExplanation; +import im.toduck.global.annotation.swagger.ApiResponseExplanations; +import im.toduck.global.annotation.swagger.ApiSuccessResponseExplanation; +import im.toduck.global.exception.ExceptionCode; +import im.toduck.global.presentation.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Admin") +public interface AdminApi { + @Operation( + summary = "관리자 조회", + description = "관리자를 조회합니다. 관리자 권한을 가진 사용자만 요청할 수 있습니다." + ) + @ApiResponseExplanations( + success = @ApiSuccessResponseExplanation( + responseClass = AdminResponse.class, + description = "관리자 조회 성공. 관리자 데이터를 반환합니다." + ), + errors = { + @ApiErrorResponseExplanation(exceptionCode = ExceptionCode.NOT_FOUND_ADMIN) + } + ) + ResponseEntity> getAdmin( + @PathVariable final Long userId + ); + + @Operation( + summary = "관리자 목록 조회", + description = "관리자 목록을 조회합니다. 관리자 권한을 가진 사용자만 요청할 수 있습니다." + ) + @ApiResponseExplanations( + success = @ApiSuccessResponseExplanation( + responseClass = AdminListResponse.class, + description = "관리자 목록 조회 성공. 관리자 목록 데이터를 반환합니다." + ) + ) + ResponseEntity> getAdmins(); + + @Operation( + summary = "관리자 생성", + description = """ + 관리자를 생성합니다. 관리자 권한을 가진 사용자만 요청할 수 있습니다.
+ "userId": 관리자에 등록할 사용자 ID를 입력합니다. Role이 User에서 Admin으로 승격됩니다.
+ "displayName": 문의 답변 등에서 사용자들에게 보여질 이름입니다.
+ """ + ) + @ApiResponseExplanations( + success = @ApiSuccessResponseExplanation( + description = "관리자 생성 성공. 빈 content 객체를 반환합니다." + ), + errors = { + @ApiErrorResponseExplanation(exceptionCode = ExceptionCode.NOT_FOUND_USER), + @ApiErrorResponseExplanation(exceptionCode = ExceptionCode.DUPLICATE_ADMIN) + } + ) + ResponseEntity>> createAdmin( + @RequestBody final AdminCreateRequest request + ); + + @Operation( + summary = "관리자 정보 수정", + description = """ + 관리자 정보를 수정합니다. 관리자 권한을 가진 사용자만 요청할 수 있습니다.
+ Path Variable에는 관리자의 사용자 ID를 입력합니다.
+
+ "displayName": 문의 답변 등에서 사용자들에게 보여질 이름입니다. null인 경우 수정되지 않습니다.
+ """ + ) + @ApiResponseExplanations( + success = @ApiSuccessResponseExplanation( + description = "관리자 정보 수정 성공. 빈 content 객체를 반환합니다." + ), + errors = { + @ApiErrorResponseExplanation(exceptionCode = ExceptionCode.NOT_FOUND_USER), + @ApiErrorResponseExplanation(exceptionCode = ExceptionCode.NOT_FOUND_ADMIN) + } + ) + ResponseEntity>> updateAdmin( + @Parameter( + name = "userId", + description = "수정할 관리자의 사용자 ID", + required = true, + example = "1" + ) + @PathVariable final Long userId, + @RequestBody final AdminUpdateRequest request + ); + + @Operation( + summary = "관리자 정보 삭제", + description = """ + 관리자 정보를 삭제합니다. 관리자 권한을 가진 사용자만 요청할 수 있습니다.
+ Path Variable에는 관리자의 사용자 ID를 입력합니다.
+ 해당 사용자의 관리자 정보가 논리 삭제되며 관리자 권한 또한 제거됩니다.
+ """ + ) + @ApiResponseExplanations( + success = @ApiSuccessResponseExplanation( + description = "관리자 정보 삭제 성공. 빈 content 객체를 반환합니다." + ), + errors = { + @ApiErrorResponseExplanation(exceptionCode = ExceptionCode.NOT_FOUND_USER), + @ApiErrorResponseExplanation(exceptionCode = ExceptionCode.NOT_FOUND_ADMIN) + } + ) + ResponseEntity>> deleteAdmin( + @Parameter( + name = "userId", + description = "삭제할 관리자의 사용자 ID", + required = true, + example = "1" + ) + @PathVariable final Long userId + ); +} diff --git a/src/main/java/im/toduck/domain/admin/presentation/controller/AdminController.java b/src/main/java/im/toduck/domain/admin/presentation/controller/AdminController.java new file mode 100644 index 00000000..f5ef25a5 --- /dev/null +++ b/src/main/java/im/toduck/domain/admin/presentation/controller/AdminController.java @@ -0,0 +1,85 @@ +package im.toduck.domain.admin.presentation.controller; + +import java.util.Map; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import im.toduck.domain.admin.domain.usecase.AdminUseCase; +import im.toduck.domain.admin.presentation.api.AdminApi; +import im.toduck.domain.admin.presentation.dto.request.AdminCreateRequest; +import im.toduck.domain.admin.presentation.dto.request.AdminUpdateRequest; +import im.toduck.domain.admin.presentation.dto.response.AdminListResponse; +import im.toduck.domain.admin.presentation.dto.response.AdminResponse; +import im.toduck.global.presentation.ApiResponse; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/v1/admin") +public class AdminController implements AdminApi { + + private final AdminUseCase adminUseCase; + + @Override + @GetMapping("/{userId}") + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity> getAdmin( + @PathVariable final Long userId + ) { + AdminResponse response = adminUseCase.getAdmin(userId); + + return ResponseEntity.ok(ApiResponse.createSuccess(response)); + } + + @Override + @GetMapping + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity> getAdmins() { + AdminListResponse response = adminUseCase.getAdmins(); + + return ResponseEntity.ok(ApiResponse.createSuccess(response)); + } + + @Override + @PostMapping + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity>> createAdmin( + @RequestBody final AdminCreateRequest request + ) { + adminUseCase.createAdmin(request); + + return ResponseEntity.ok().body(ApiResponse.createSuccessWithNoContent()); + } + + @Override + @PatchMapping("/{userId}") + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity>> updateAdmin( + @PathVariable final Long userId, + @RequestBody final AdminUpdateRequest request + ) { + adminUseCase.updateAdmin(userId, request); + + return ResponseEntity.ok().body(ApiResponse.createSuccessWithNoContent()); + } + + @Override + @DeleteMapping("/{userId}") + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity>> deleteAdmin( + @PathVariable final Long userId + ) { + adminUseCase.deleteAdmin(userId); + + return ResponseEntity.ok().body(ApiResponse.createSuccessWithNoContent()); + } +} diff --git a/src/main/java/im/toduck/domain/admin/presentation/dto/request/AdminCreateRequest.java b/src/main/java/im/toduck/domain/admin/presentation/dto/request/AdminCreateRequest.java new file mode 100644 index 00000000..676458b2 --- /dev/null +++ b/src/main/java/im/toduck/domain/admin/presentation/dto/request/AdminCreateRequest.java @@ -0,0 +1,20 @@ +package im.toduck.domain.admin.presentation.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Builder; + +@Builder +@Schema(description = "관리자 생성 요청 DTO") +public record AdminCreateRequest( + @NotNull(message = "사용자 ID는 비어있을 수 없습니다.") + @Schema(description = "사용자 ID", example = "1") + Long userId, + + @NotNull(message = "관리자 표시명은 비어있을 수 없습니다.") + @Size(max = 255, message = "관리자 표시명은 255자를 초과할 수 없습니다.") + @Schema(description = "관리자 표시명", example = "토덕 관리자") + String displayName +) { +} diff --git a/src/main/java/im/toduck/domain/admin/presentation/dto/request/AdminUpdateRequest.java b/src/main/java/im/toduck/domain/admin/presentation/dto/request/AdminUpdateRequest.java new file mode 100644 index 00000000..3b104595 --- /dev/null +++ b/src/main/java/im/toduck/domain/admin/presentation/dto/request/AdminUpdateRequest.java @@ -0,0 +1,12 @@ +package im.toduck.domain.admin.presentation.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +@Builder +@Schema(description = "관리자 수정 요청 DTO") +public record AdminUpdateRequest( + @Schema(description = "관리자 표시명", example = "토덕 관리자") + String displayName +) { +} diff --git a/src/main/java/im/toduck/domain/admin/presentation/dto/response/AdminListResponse.java b/src/main/java/im/toduck/domain/admin/presentation/dto/response/AdminListResponse.java new file mode 100644 index 00000000..53e6c566 --- /dev/null +++ b/src/main/java/im/toduck/domain/admin/presentation/dto/response/AdminListResponse.java @@ -0,0 +1,21 @@ +package im.toduck.domain.admin.presentation.dto.response; + +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +@Builder +@Schema(description = "관리자 목록 응답") +public record AdminListResponse( + @Schema(description = "관리자 목록") + List adminDtos +) { + public static AdminListResponse toListAdminResponse( + final List admins + ) { + return AdminListResponse.builder() + .adminDtos(admins) + .build(); + } +} diff --git a/src/main/java/im/toduck/domain/admin/presentation/dto/response/AdminResponse.java b/src/main/java/im/toduck/domain/admin/presentation/dto/response/AdminResponse.java new file mode 100644 index 00000000..7ae769ce --- /dev/null +++ b/src/main/java/im/toduck/domain/admin/presentation/dto/response/AdminResponse.java @@ -0,0 +1,18 @@ +package im.toduck.domain.admin.presentation.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +@Builder +@Schema(description = "관리자 응답") +public record AdminResponse( + @Schema(description = "관리자 ID", example = "1") + Long adminId, + + @Schema(description = "사용자 ID", example = "1") + Long userId, + + @Schema(description = "관리자 표시명", example = "토덕 관리자") + String displayName +) { +} diff --git a/src/main/java/im/toduck/domain/mypage/domain/usecase/MyPageUseCase.java b/src/main/java/im/toduck/domain/mypage/domain/usecase/MyPageUseCase.java index dfc391f6..dc4c176a 100644 --- a/src/main/java/im/toduck/domain/mypage/domain/usecase/MyPageUseCase.java +++ b/src/main/java/im/toduck/domain/mypage/domain/usecase/MyPageUseCase.java @@ -4,6 +4,8 @@ import org.springframework.transaction.annotation.Transactional; +import im.toduck.domain.admin.domain.service.AdminService; +import im.toduck.domain.admin.persistence.entity.Admin; import im.toduck.domain.mypage.common.mapper.MyPageMapper; import im.toduck.domain.mypage.domain.service.MyPageService; import im.toduck.domain.mypage.presentation.dto.request.NickNameUpdateRequest; @@ -16,6 +18,7 @@ import im.toduck.domain.user.domain.service.FollowService; import im.toduck.domain.user.domain.service.UserService; import im.toduck.domain.user.persistence.entity.User; +import im.toduck.domain.user.persistence.entity.UserRole; import im.toduck.global.annotation.UseCase; import im.toduck.global.exception.CommonException; import im.toduck.global.exception.ExceptionCode; @@ -32,6 +35,7 @@ public class MyPageUseCase { private final MyPageService myPageService; private final FollowService followService; private final SocialBoardService socialBoardService; + private final AdminService adminService; @Transactional public void updateNickname(Long userId, NickNameUpdateRequest request) { @@ -64,6 +68,10 @@ public void deleteAccount(Long userId, UserDeleteRequest request) { .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_USER)); myPageService.recordUserDeletionLog(user, request); + if (user.getRole() == UserRole.ADMIN) { + Admin admin = adminService.getAdmin(user.getId()); + adminService.deleteAdmin(admin); + } this.deleteUserData(user); } diff --git a/src/main/java/im/toduck/domain/user/persistence/entity/User.java b/src/main/java/im/toduck/domain/user/persistence/entity/User.java index 7598743a..95817682 100644 --- a/src/main/java/im/toduck/domain/user/persistence/entity/User.java +++ b/src/main/java/im/toduck/domain/user/persistence/entity/User.java @@ -121,4 +121,12 @@ public void unsuspend() { this.suspendedUntil = null; this.suspensionReason = null; } + + public void promoteToAdmin() { + this.role = UserRole.ADMIN; + } + + public void demoteToUser() { + this.role = UserRole.USER; + } } diff --git a/src/test/java/im/toduck/domain/admin/presentation/controller/AdminControllerTest.java b/src/test/java/im/toduck/domain/admin/presentation/controller/AdminControllerTest.java new file mode 100644 index 00000000..dc8af1a8 --- /dev/null +++ b/src/test/java/im/toduck/domain/admin/presentation/controller/AdminControllerTest.java @@ -0,0 +1,174 @@ +package im.toduck.domain.admin.presentation.controller; + +import static im.toduck.fixtures.user.UserFixtures.*; +import static org.assertj.core.api.SoftAssertions.*; + +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.springframework.beans.factory.annotation.Autowired; +import org.springframework.transaction.annotation.Transactional; + +import im.toduck.ServiceTest; +import im.toduck.domain.admin.domain.usecase.AdminUseCase; +import im.toduck.domain.admin.persistence.entity.Admin; +import im.toduck.domain.admin.persistence.repository.AdminRepository; +import im.toduck.domain.admin.presentation.dto.request.AdminCreateRequest; +import im.toduck.domain.admin.presentation.dto.request.AdminUpdateRequest; +import im.toduck.domain.admin.presentation.dto.response.AdminListResponse; +import im.toduck.domain.admin.presentation.dto.response.AdminResponse; +import im.toduck.domain.user.persistence.entity.OAuthProvider; +import im.toduck.domain.user.persistence.entity.User; +import im.toduck.domain.user.persistence.entity.UserRole; +import im.toduck.domain.user.persistence.repository.UserRepository; + +class AdminControllerTest extends ServiceTest { + + @Autowired + AdminUseCase adminUseCase; + + @Autowired + AdminRepository adminRepository; + + @Autowired + UserRepository userRepository; + + @Transactional + @Nested + @DisplayName("관리자") + class AdminTest { + private User savedAdminUser, savedGeneralUser; + + @BeforeEach + void setUp() { + savedAdminUser = userRepository.save( + User.builder() + .role(UserRole.ADMIN) + .nickname("admin") + .email("admin@naver.com") + .provider(OAuthProvider.APPLE) + .build() + ); + + savedGeneralUser = testFixtureBuilder.buildUser(GENERAL_USER()); + } + + @Test + void 성공적으로_생성_조회한다() { + // given - when + AdminCreateRequest adminCreateRequest = + AdminCreateRequest.builder() + .userId(savedAdminUser.getId()) + .displayName("토덕토덕") + .build(); + + AdminCreateRequest adminCreateRequest2 = + AdminCreateRequest.builder() + .userId(savedGeneralUser.getId()) + .displayName("토닥토닥") + .build(); + + Admin admin = adminUseCase.createAdmin(adminCreateRequest); + Admin admin2 = adminUseCase.createAdmin(adminCreateRequest2); + + // then + AdminResponse adminResponse = adminUseCase.getAdmin(savedAdminUser.getId()); + AdminResponse adminResponse2 = adminUseCase.getAdmin(savedGeneralUser.getId()); + AdminListResponse adminListResponse = adminUseCase.getAdmins(); + + assertSoftly(softly -> { + softly.assertThat(adminResponse.userId()).isEqualTo(admin.getUser().getId()); + softly.assertThat(adminResponse.displayName()).isEqualTo(admin.getDisplayName()); + }); + + assertSoftly(softly -> { + softly.assertThat(adminResponse2.userId()).isEqualTo(admin2.getUser().getId()); + softly.assertThat(adminResponse2.displayName()).isEqualTo(admin2.getDisplayName()); + softly.assertThat(savedGeneralUser.getRole()).isEqualTo(UserRole.ADMIN); + }); + + assertSoftly(softly -> { + softly.assertThat(adminListResponse.adminDtos().size()).isEqualTo(2); + softly.assertThat(adminListResponse.adminDtos().get(0).displayName()).isEqualTo(admin.getDisplayName()); + softly.assertThat(adminListResponse.adminDtos().get(1).displayName()) + .isEqualTo(admin2.getDisplayName()); + }); + } + + @Test + void 성공적으로_수정한다() { + // given + AdminCreateRequest adminCreateRequest = + AdminCreateRequest.builder() + .userId(savedAdminUser.getId()) + .displayName("토덕토덕") + .build(); + + Admin admin = adminUseCase.createAdmin(adminCreateRequest); + + // when + AdminUpdateRequest adminUpdateRequest = + AdminUpdateRequest.builder() + .displayName("토닥토닥") + .build(); + + adminUseCase.updateAdmin(savedAdminUser.getId(), adminUpdateRequest); + + // then + assertSoftly(softly -> { + softly.assertThat(admin.getDisplayName()).isEqualTo("토닥토닥"); + }); + } + + @Test + void 성공적으로_삭제한다() { + // given + AdminCreateRequest adminCreateRequest = + AdminCreateRequest.builder() + .userId(savedAdminUser.getId()) + .displayName("토덕토덕") + .build(); + + Admin admin = adminUseCase.createAdmin(adminCreateRequest); + + // when + adminUseCase.deleteAdmin(admin.getUser().getId()); + AdminListResponse adminListResponse = adminUseCase.getAdmins(); + + // then + assertSoftly(softly -> { + softly.assertThat(adminListResponse.adminDtos().size()).isEqualTo(0); + softly.assertThat(savedAdminUser.getRole()).isEqualTo(UserRole.USER); + }); + } + + @Test + void 삭제되지_않은_관리자만_조회한다() { + // given - when + AdminCreateRequest adminCreateRequest = + AdminCreateRequest.builder() + .userId(savedAdminUser.getId()) + .displayName("토덕토덕") + .build(); + + AdminCreateRequest adminCreateRequest2 = + AdminCreateRequest.builder() + .userId(savedGeneralUser.getId()) + .displayName("토닥토닥") + .build(); + + Admin admin = adminUseCase.createAdmin(adminCreateRequest); + Admin admin2 = adminUseCase.createAdmin(adminCreateRequest2); + + adminUseCase.deleteAdmin(admin2.getUser().getId()); + + // then + AdminListResponse adminListResponse = adminUseCase.getAdmins(); + + assertSoftly(softly -> { + softly.assertThat(adminListResponse.adminDtos().size()).isEqualTo(1); + }); + } + } +} From 1e750f3b7e34f8d1f606ba86894b25e0f8084db9 Mon Sep 17 00:00:00 2001 From: wafla Date: Thu, 5 Feb 2026 17:54:07 +0900 Subject: [PATCH 04/11] =?UTF-8?q?feat:=20=EB=AC=B8=EC=9D=98,=20=EB=AC=B8?= =?UTF-8?q?=EC=9D=98=20=EB=8B=B5=EB=B3=80=20=EA=B8=B0=EB=8A=A5=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 --- .../common/mapper/InquiryImgMapper.java | 20 + .../inquiry/common/mapper/InquiryMapper.java | 51 +++ .../domain/service/InquiryAnswerService.java | 93 +++++ .../domain/service/InquiryService.java | 95 +++++ .../domain/usecase/InquiryAnswerUseCase.java | 52 +++ .../domain/usecase/InquiryUseCase.java | 97 +++++ .../inquiry/persistence/entity/Inquiry.java | 95 +++++ .../persistence/entity/InquiryAnswer.java | 63 +++ .../persistence/entity/InquiryImage.java | 44 +++ .../inquiry/persistence/entity/Status.java | 6 + .../inquiry/persistence/entity/Type.java | 8 + .../repository/InquiryAnswerRepository.java | 22 ++ .../repository/InquiryImgRepository.java | 12 + .../repository/InquiryRepository.java | 10 + .../querydsl/InquiryRepositoryCustom.java | 11 + .../querydsl/InquiryRepositoryCustomImpl.java | 58 +++ .../presentation/api/InquiryAnswerApi.java | 78 ++++ .../inquiry/presentation/api/InquiryApi.java | 111 ++++++ .../controller/InquiryAnswerController.java | 67 ++++ .../controller/InquiryController.java | 91 +++++ .../request/InquiryAnswerCreateRequest.java | 20 + .../request/InquiryAnswerUpdateRequest.java | 16 + .../dto/request/InquiryCreateRequest.java | 26 ++ .../dto/request/InquiryUpdateRequest.java | 24 ++ .../dto/response/InquiryListResponse.java | 17 + .../dto/response/InquiryResponse.java | 52 +++ .../global/exception/ExceptionCode.java | 11 + .../InquiryAnswerControllerTest.java | 364 ++++++++++++++++++ .../controller/InquiryControllerTest.java | 210 ++++++++++ 29 files changed, 1824 insertions(+) create mode 100644 src/main/java/im/toduck/domain/inquiry/common/mapper/InquiryImgMapper.java create mode 100644 src/main/java/im/toduck/domain/inquiry/common/mapper/InquiryMapper.java create mode 100644 src/main/java/im/toduck/domain/inquiry/domain/service/InquiryAnswerService.java create mode 100644 src/main/java/im/toduck/domain/inquiry/domain/service/InquiryService.java create mode 100644 src/main/java/im/toduck/domain/inquiry/domain/usecase/InquiryAnswerUseCase.java create mode 100644 src/main/java/im/toduck/domain/inquiry/domain/usecase/InquiryUseCase.java create mode 100644 src/main/java/im/toduck/domain/inquiry/persistence/entity/Inquiry.java create mode 100644 src/main/java/im/toduck/domain/inquiry/persistence/entity/InquiryAnswer.java create mode 100644 src/main/java/im/toduck/domain/inquiry/persistence/entity/InquiryImage.java create mode 100644 src/main/java/im/toduck/domain/inquiry/persistence/entity/Status.java create mode 100644 src/main/java/im/toduck/domain/inquiry/persistence/entity/Type.java create mode 100644 src/main/java/im/toduck/domain/inquiry/persistence/repository/InquiryAnswerRepository.java create mode 100644 src/main/java/im/toduck/domain/inquiry/persistence/repository/InquiryImgRepository.java create mode 100644 src/main/java/im/toduck/domain/inquiry/persistence/repository/InquiryRepository.java create mode 100644 src/main/java/im/toduck/domain/inquiry/persistence/repository/querydsl/InquiryRepositoryCustom.java create mode 100644 src/main/java/im/toduck/domain/inquiry/persistence/repository/querydsl/InquiryRepositoryCustomImpl.java create mode 100644 src/main/java/im/toduck/domain/inquiry/presentation/api/InquiryAnswerApi.java create mode 100644 src/main/java/im/toduck/domain/inquiry/presentation/api/InquiryApi.java create mode 100644 src/main/java/im/toduck/domain/inquiry/presentation/controller/InquiryAnswerController.java create mode 100644 src/main/java/im/toduck/domain/inquiry/presentation/controller/InquiryController.java create mode 100644 src/main/java/im/toduck/domain/inquiry/presentation/dto/request/InquiryAnswerCreateRequest.java create mode 100644 src/main/java/im/toduck/domain/inquiry/presentation/dto/request/InquiryAnswerUpdateRequest.java create mode 100644 src/main/java/im/toduck/domain/inquiry/presentation/dto/request/InquiryCreateRequest.java create mode 100644 src/main/java/im/toduck/domain/inquiry/presentation/dto/request/InquiryUpdateRequest.java create mode 100644 src/main/java/im/toduck/domain/inquiry/presentation/dto/response/InquiryListResponse.java create mode 100644 src/main/java/im/toduck/domain/inquiry/presentation/dto/response/InquiryResponse.java create mode 100644 src/test/java/im/toduck/domain/inquiry/presentation/controller/InquiryAnswerControllerTest.java create mode 100644 src/test/java/im/toduck/domain/inquiry/presentation/controller/InquiryControllerTest.java diff --git a/src/main/java/im/toduck/domain/inquiry/common/mapper/InquiryImgMapper.java b/src/main/java/im/toduck/domain/inquiry/common/mapper/InquiryImgMapper.java new file mode 100644 index 00000000..3b51a794 --- /dev/null +++ b/src/main/java/im/toduck/domain/inquiry/common/mapper/InquiryImgMapper.java @@ -0,0 +1,20 @@ +package im.toduck.domain.inquiry.common.mapper; + +import im.toduck.domain.inquiry.persistence.entity.Inquiry; +import im.toduck.domain.inquiry.persistence.entity.InquiryImage; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class InquiryImgMapper { + public static InquiryImage toInquiryImg(Inquiry inquiry, String imgUrls) { + return InquiryImage.builder() + .inquiry(inquiry) + .url(imgUrls) + .build(); + } + + public static String fromInquiryImg(InquiryImage inquiryImage) { + return inquiryImage.getUrl(); + } +} diff --git a/src/main/java/im/toduck/domain/inquiry/common/mapper/InquiryMapper.java b/src/main/java/im/toduck/domain/inquiry/common/mapper/InquiryMapper.java new file mode 100644 index 00000000..038c23e7 --- /dev/null +++ b/src/main/java/im/toduck/domain/inquiry/common/mapper/InquiryMapper.java @@ -0,0 +1,51 @@ +package im.toduck.domain.inquiry.common.mapper; + +import java.util.List; + +import im.toduck.domain.inquiry.persistence.entity.Inquiry; +import im.toduck.domain.inquiry.persistence.entity.InquiryAnswer; +import im.toduck.domain.inquiry.persistence.entity.Status; +import im.toduck.domain.inquiry.presentation.dto.request.InquiryCreateRequest; +import im.toduck.domain.inquiry.presentation.dto.response.InquiryListResponse; +import im.toduck.domain.inquiry.presentation.dto.response.InquiryResponse; +import im.toduck.domain.user.persistence.entity.User; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class InquiryMapper { + + public static InquiryResponse fromInquiry(final Inquiry inquiry) { + + InquiryAnswer answer = inquiry.getInquiryAnswer(); + + return new InquiryResponse( + inquiry.getId(), + inquiry.getType(), + inquiry.getContent(), + inquiry.getStatus(), + inquiry.getCreatedAt(), + inquiry.getInquiryImages().stream() + .map(InquiryImgMapper::fromInquiryImg) + .toList(), + + answer != null ? answer.getId() : null, + answer != null ? answer.getAdmin().getDisplayName() : null, + answer != null ? answer.getContent() : null, + answer != null ? answer.getCreatedAt() : null + ); + } + + public static InquiryListResponse toListInquiryResponse(final List inquiries) { + return InquiryListResponse.toListInquiryResponse(inquiries); + } + + public static Inquiry toInquiry(final InquiryCreateRequest request, final User user) { + return Inquiry.builder() + .user(user) + .type(request.type()) + .content(request.content()) + .status(Status.PENDING) + .build(); + } +} diff --git a/src/main/java/im/toduck/domain/inquiry/domain/service/InquiryAnswerService.java b/src/main/java/im/toduck/domain/inquiry/domain/service/InquiryAnswerService.java new file mode 100644 index 00000000..20101691 --- /dev/null +++ b/src/main/java/im/toduck/domain/inquiry/domain/service/InquiryAnswerService.java @@ -0,0 +1,93 @@ +package im.toduck.domain.inquiry.domain.service; + +import java.util.Optional; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import im.toduck.domain.admin.persistence.entity.Admin; +import im.toduck.domain.inquiry.persistence.entity.Inquiry; +import im.toduck.domain.inquiry.persistence.entity.InquiryAnswer; +import im.toduck.domain.inquiry.persistence.entity.Status; +import im.toduck.domain.inquiry.persistence.repository.InquiryAnswerRepository; +import im.toduck.domain.inquiry.persistence.repository.InquiryRepository; +import im.toduck.domain.inquiry.presentation.dto.request.InquiryAnswerCreateRequest; +import im.toduck.domain.inquiry.presentation.dto.request.InquiryAnswerUpdateRequest; +import im.toduck.global.exception.CommonException; +import im.toduck.global.exception.ExceptionCode; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class InquiryAnswerService { + private final InquiryRepository inquiryRepository; + private final InquiryAnswerRepository inquiryAnswerRepository; + + @Transactional + public Optional findById(final Long inquiryAnswerId) { + return inquiryAnswerRepository.findById(inquiryAnswerId); + } + + @Transactional + public InquiryAnswer createInquiryAnswer(final InquiryAnswerCreateRequest request, final Admin admin) { + Inquiry inquiry = inquiryRepository.findById(request.inquiryId()) + .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_INQUIRY)); + + Optional anyAnswerOpt = + inquiryAnswerRepository.findAnyByInquiryIdIncludingDeleted(inquiry.getId()); + + if (anyAnswerOpt.isPresent()) { + InquiryAnswer existing = anyAnswerOpt.get(); + + if (existing.getDeletedAt() == null) { + throw CommonException.from(ExceptionCode.ALREADY_ANSWERED_INQUIRY); + } + + existing.revive(request.content(), admin); + inquiry.addAnswer(existing); + inquiry.changeStatus(Status.ANSWERED); + return inquiryAnswerRepository.save(existing); + } + + InquiryAnswer newAnswer = InquiryAnswer.builder() + .admin(admin) + .inquiry(inquiry) + .content(request.content()) + .build(); + + inquiry.addAnswer(newAnswer); + inquiryAnswerRepository.save(newAnswer); + inquiry.changeStatus(Status.ANSWERED); + + return newAnswer; + } + + @Transactional + public InquiryAnswer updateInquiryAnswer( + final Long inquiryId, + final InquiryAnswerUpdateRequest request, + final Admin admin) { + Inquiry inquiry = inquiryRepository.findById(inquiryId) + .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_INQUIRY)); + + InquiryAnswer inquiryAnswer = inquiry.getInquiryAnswer(); + inquiryAnswer.updateAnswer(request.content(), admin); + return inquiryAnswer; + } + + @Transactional + public void deleteInquiryAnswer(final Long inquiryId) { + Inquiry inquiry = inquiryRepository.findById(inquiryId) + .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_INQUIRY)); + + InquiryAnswer inquiryAnswer = inquiry.getInquiryAnswer(); + if (inquiryAnswer == null) { + throw CommonException.from(ExceptionCode.NOT_FOUND_INQUIRY_ANSWER); + } + + inquiry.changeStatus(Status.PENDING); + inquiry.removeAnswer(); + + inquiryAnswerRepository.delete(inquiryAnswer); + } +} diff --git a/src/main/java/im/toduck/domain/inquiry/domain/service/InquiryService.java b/src/main/java/im/toduck/domain/inquiry/domain/service/InquiryService.java new file mode 100644 index 00000000..5a88bcce --- /dev/null +++ b/src/main/java/im/toduck/domain/inquiry/domain/service/InquiryService.java @@ -0,0 +1,95 @@ +package im.toduck.domain.inquiry.domain.service; + +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import im.toduck.domain.inquiry.common.mapper.InquiryImgMapper; +import im.toduck.domain.inquiry.common.mapper.InquiryMapper; +import im.toduck.domain.inquiry.persistence.entity.Inquiry; +import im.toduck.domain.inquiry.persistence.entity.InquiryImage; +import im.toduck.domain.inquiry.persistence.repository.InquiryImgRepository; +import im.toduck.domain.inquiry.persistence.repository.InquiryRepository; +import im.toduck.domain.inquiry.persistence.repository.querydsl.InquiryRepositoryCustom; +import im.toduck.domain.inquiry.presentation.dto.request.InquiryCreateRequest; +import im.toduck.domain.inquiry.presentation.dto.request.InquiryUpdateRequest; +import im.toduck.domain.inquiry.presentation.dto.response.InquiryResponse; +import im.toduck.domain.user.persistence.entity.User; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class InquiryService { + private final InquiryRepository inquiryRepository; + private final InquiryImgRepository inquiryImgRepository; + private final InquiryRepositoryCustom inquiryRepositoryCustom; + + @Transactional(readOnly = true) + public List getInquiries(final Long userId) { + List inquiries = inquiryRepositoryCustom.findWithImgs(userId); + return inquiries.stream() + .map(InquiryMapper::fromInquiry) + .toList(); + } + + @Transactional + public Inquiry createInquiry(final InquiryCreateRequest request, final User user) { + Inquiry inquiry = InquiryMapper.toInquiry(request, user); + inquiryRepository.save(inquiry); + return inquiry; + } + + @Transactional + public void addInquiryImages(final Inquiry inquiry, final List imgUrls) { + List safeImgs = Optional.ofNullable(imgUrls).orElse(Collections.emptyList()); + + List inquiryImgs = safeImgs.stream() + .map(url -> InquiryImgMapper.toInquiryImg(inquiry, url)) + .toList(); + inquiryImgRepository.saveAll(inquiryImgs); + } + + @Transactional + public Optional getInquiryById(final Long inquiryId) { + return inquiryRepository.findById(inquiryId); + } + + @Transactional + public void updateInquiry(final InquiryUpdateRequest request, final Inquiry inquiry) { + if (request.type() != null) { + inquiry.updateType(request.type()); + } + + if (request.content() != null) { + inquiry.updateContent(request.content()); + } + + if (request.inquiryImgs() != null && !request.inquiryImgs().isEmpty()) { + inquiryImgRepository.deleteAllByInquiry(inquiry); + addInquiryImages(inquiry, request.inquiryImgs()); + } else { + inquiryImgRepository.deleteAllByInquiry(inquiry); + } + } + + @Transactional + public Optional findById(final Long inquiryId) { + return inquiryRepository.findById(inquiryId); + } + + @Transactional + public void deleteInquiry(final Inquiry inquiry) { + inquiryRepository.delete(inquiry); + } + + @Transactional(readOnly = true) + public List getAllInquiries() { + List inquiries = inquiryRepositoryCustom.findAllWithImgs(); + return inquiries.stream() + .map(InquiryMapper::fromInquiry) + .toList(); + } +} diff --git a/src/main/java/im/toduck/domain/inquiry/domain/usecase/InquiryAnswerUseCase.java b/src/main/java/im/toduck/domain/inquiry/domain/usecase/InquiryAnswerUseCase.java new file mode 100644 index 00000000..620657e8 --- /dev/null +++ b/src/main/java/im/toduck/domain/inquiry/domain/usecase/InquiryAnswerUseCase.java @@ -0,0 +1,52 @@ +package im.toduck.domain.inquiry.domain.usecase; + +import org.springframework.transaction.annotation.Transactional; + +import im.toduck.domain.admin.domain.service.AdminService; +import im.toduck.domain.admin.persistence.entity.Admin; +import im.toduck.domain.inquiry.domain.service.InquiryAnswerService; +import im.toduck.domain.inquiry.persistence.entity.InquiryAnswer; +import im.toduck.domain.inquiry.presentation.dto.request.InquiryAnswerCreateRequest; +import im.toduck.domain.inquiry.presentation.dto.request.InquiryAnswerUpdateRequest; +import im.toduck.domain.user.domain.service.UserService; +import im.toduck.domain.user.persistence.entity.User; +import im.toduck.global.annotation.UseCase; +import im.toduck.global.exception.CommonException; +import im.toduck.global.exception.ExceptionCode; +import lombok.RequiredArgsConstructor; + +@UseCase +@RequiredArgsConstructor +public class InquiryAnswerUseCase { + private final UserService userService; + private final AdminService adminService; + private final InquiryAnswerService inquiryAnswerService; + + @Transactional + public InquiryAnswer createInquiryAnswer(final InquiryAnswerCreateRequest request, final Long userId) { + User user = userService.getUserById(userId) + .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_USER)); + + Admin admin = adminService.getAdminBySameUser(user.getId()); + + return inquiryAnswerService.createInquiryAnswer(request, admin); + } + + @Transactional + public InquiryAnswer updateInquiryAnswer( + final Long inquiryId, + final InquiryAnswerUpdateRequest request, + final Long userId) { + User user = userService.getUserById(userId) + .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_USER)); + + Admin admin = adminService.getAdminBySameUser(userId); + + return inquiryAnswerService.updateInquiryAnswer(inquiryId, request, admin); + } + + @Transactional + public void deleteInquiryAnswer(final Long inquiryId) { + inquiryAnswerService.deleteInquiryAnswer(inquiryId); + } +} diff --git a/src/main/java/im/toduck/domain/inquiry/domain/usecase/InquiryUseCase.java b/src/main/java/im/toduck/domain/inquiry/domain/usecase/InquiryUseCase.java new file mode 100644 index 00000000..034321d8 --- /dev/null +++ b/src/main/java/im/toduck/domain/inquiry/domain/usecase/InquiryUseCase.java @@ -0,0 +1,97 @@ +package im.toduck.domain.inquiry.domain.usecase; + +import java.util.List; + +import org.springframework.transaction.annotation.Transactional; + +import im.toduck.domain.inquiry.common.mapper.InquiryMapper; +import im.toduck.domain.inquiry.domain.service.InquiryService; +import im.toduck.domain.inquiry.persistence.entity.Inquiry; +import im.toduck.domain.inquiry.persistence.entity.InquiryAnswer; +import im.toduck.domain.inquiry.persistence.entity.InquiryImage; +import im.toduck.domain.inquiry.presentation.dto.request.InquiryCreateRequest; +import im.toduck.domain.inquiry.presentation.dto.request.InquiryUpdateRequest; +import im.toduck.domain.inquiry.presentation.dto.response.InquiryListResponse; +import im.toduck.domain.inquiry.presentation.dto.response.InquiryResponse; +import im.toduck.domain.user.domain.service.UserService; +import im.toduck.domain.user.persistence.entity.User; +import im.toduck.global.annotation.UseCase; +import im.toduck.global.exception.CommonException; +import im.toduck.global.exception.ExceptionCode; +import lombok.RequiredArgsConstructor; + +@UseCase +@RequiredArgsConstructor +public class InquiryUseCase { + private final UserService userService; + private final InquiryService inquiryService; + + @Transactional(readOnly = true) + public InquiryListResponse getInquiries(Long userId) { + User user = userService.getUserById(userId) + .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_USER)); + + List inquiries = inquiryService.getInquiries(userId); + + return InquiryMapper.toListInquiryResponse(inquiries); + } + + @Transactional + public Inquiry createInquiry(final InquiryCreateRequest request, final Long userId) { + User user = userService.getUserById(userId) + .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_USER)); + + Inquiry inquiry = inquiryService.createInquiry(request, user); + inquiryService.addInquiryImages(inquiry, request.inquiryImgs()); + + return inquiry; + } + + @Transactional + public Inquiry updateInquiry( + final Long inquiryId, + final InquiryUpdateRequest request, + final Long userId) { + User user = userService.getUserById(userId) + .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_USER)); + + Inquiry inquiry = inquiryService.getInquiryById(inquiryId) + .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_INQUIRY)); + + InquiryAnswer answer = inquiry.getInquiryAnswer(); + if (answer != null && answer.getDeletedAt() == null) { + throw CommonException.from(ExceptionCode.ALREADY_ANSWERED_INQUIRY); + } + + inquiryService.updateInquiry(request, inquiry); + return inquiry; + } + + @Transactional + public void deleteInquiry(final Long inquiryId, final Long userId) { + User user = userService.getUserById(userId) + .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_USER)); + + Inquiry inquiry = inquiryService.findById(inquiryId) + .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_INQUIRY)); + + InquiryAnswer answer = inquiry.getInquiryAnswer(); + if (answer != null && answer.getDeletedAt() == null) { + throw CommonException.from(ExceptionCode.ALREADY_ANSWERED_INQUIRY); + } + + inquiry.getInquiryImages().forEach(InquiryImage::softDelete); + + inquiryService.deleteInquiry(inquiry); + } + + @Transactional(readOnly = true) + public InquiryListResponse getAllInquiries(final Long userId) { + User user = userService.getUserById(userId) + .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_USER)); + + List inquiries = inquiryService.getAllInquiries(); + + return InquiryMapper.toListInquiryResponse(inquiries); + } +} diff --git a/src/main/java/im/toduck/domain/inquiry/persistence/entity/Inquiry.java b/src/main/java/im/toduck/domain/inquiry/persistence/entity/Inquiry.java new file mode 100644 index 00000000..936bc2e9 --- /dev/null +++ b/src/main/java/im/toduck/domain/inquiry/persistence/entity/Inquiry.java @@ -0,0 +1,95 @@ +package im.toduck.domain.inquiry.persistence.entity; + +import java.util.ArrayList; +import java.util.List; + +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +import im.toduck.domain.user.persistence.entity.User; +import im.toduck.global.base.entity.BaseEntity; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "inquiry") +@Getter +@NoArgsConstructor +@SQLDelete(sql = "UPDATE inquiry SET deleted_at = NOW() where id=?") +@SQLRestriction(value = "deleted_at is NULL") +public class Inquiry extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private Type type; + + @Column(length = 1024) + private String content; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private Status status; + + @OneToMany(mappedBy = "inquiry", cascade = CascadeType.ALL) + private List inquiryImages = new ArrayList<>(); + + @OneToOne(mappedBy = "inquiry", fetch = FetchType.LAZY) + private InquiryAnswer inquiryAnswer; + + @Builder + private Inquiry(User user, + Type type, + String content, + Status status) { + this.user = user; + this.type = type; + this.content = content; + this.status = status; + } + + public void updateType(Type type) { + this.type = type; + } + + public void updateContent(String content) { + this.content = content; + } + + public void changeStatus(final Status status) { + this.status = status; + } + + public void addAnswer(final InquiryAnswer answer) { + this.inquiryAnswer = answer; + answer.setInquiry(this); + } + + public void removeAnswer() { + if (this.inquiryAnswer != null) { + this.inquiryAnswer.setInquiry(null); + this.inquiryAnswer = null; + } + } +} diff --git a/src/main/java/im/toduck/domain/inquiry/persistence/entity/InquiryAnswer.java b/src/main/java/im/toduck/domain/inquiry/persistence/entity/InquiryAnswer.java new file mode 100644 index 00000000..ddd87178 --- /dev/null +++ b/src/main/java/im/toduck/domain/inquiry/persistence/entity/InquiryAnswer.java @@ -0,0 +1,63 @@ +package im.toduck.domain.inquiry.persistence.entity; + +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +import im.toduck.domain.admin.persistence.entity.Admin; +import im.toduck.global.base.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Table(name = "inquiry_answer") +@Getter +@NoArgsConstructor +@SQLDelete(sql = "UPDATE inquiry_answer SET deleted_at = NOW() where id=?") +@SQLRestriction(value = "deleted_at is NULL") +public class InquiryAnswer extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "admin_id", nullable = false) + private Admin admin; + + @Setter + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "inquiry_id", nullable = false, unique = true) + private Inquiry inquiry; + + @Column(length = 1024) + private String content; + + @Builder + private InquiryAnswer(Admin admin, Inquiry inquiry, String content) { + this.admin = admin; + this.inquiry = inquiry; + this.content = content; + } + + public void updateAnswer(final String content, final Admin admin) { + this.content = content; + this.admin = admin; + } + + public void revive(final String content, final Admin admin) { + this.deletedAt = null; + this.content = content; + this.admin = admin; + } +} diff --git a/src/main/java/im/toduck/domain/inquiry/persistence/entity/InquiryImage.java b/src/main/java/im/toduck/domain/inquiry/persistence/entity/InquiryImage.java new file mode 100644 index 00000000..b9a9d5a0 --- /dev/null +++ b/src/main/java/im/toduck/domain/inquiry/persistence/entity/InquiryImage.java @@ -0,0 +1,44 @@ +package im.toduck.domain.inquiry.persistence.entity; + +import java.time.LocalDateTime; + +import im.toduck.global.base.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "inquiry_image_file") +@Getter +@NoArgsConstructor +public class InquiryImage extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "inquiry_id", nullable = false) + private Inquiry inquiry; + + @Column(name = "url", length = 1024, nullable = false) + private String url; + + @Builder + private InquiryImage(Inquiry inquiry, String url) { + this.inquiry = inquiry; + this.url = url; + } + + public void softDelete() { + this.deletedAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/im/toduck/domain/inquiry/persistence/entity/Status.java b/src/main/java/im/toduck/domain/inquiry/persistence/entity/Status.java new file mode 100644 index 00000000..948ae0c8 --- /dev/null +++ b/src/main/java/im/toduck/domain/inquiry/persistence/entity/Status.java @@ -0,0 +1,6 @@ +package im.toduck.domain.inquiry.persistence.entity; + +public enum Status { + PENDING, + ANSWERED +} diff --git a/src/main/java/im/toduck/domain/inquiry/persistence/entity/Type.java b/src/main/java/im/toduck/domain/inquiry/persistence/entity/Type.java new file mode 100644 index 00000000..5d591a75 --- /dev/null +++ b/src/main/java/im/toduck/domain/inquiry/persistence/entity/Type.java @@ -0,0 +1,8 @@ +package im.toduck.domain.inquiry.persistence.entity; + +public enum Type { + ERROR, + USAGE, + SUGGESTION, + ETC +} diff --git a/src/main/java/im/toduck/domain/inquiry/persistence/repository/InquiryAnswerRepository.java b/src/main/java/im/toduck/domain/inquiry/persistence/repository/InquiryAnswerRepository.java new file mode 100644 index 00000000..26ce02de --- /dev/null +++ b/src/main/java/im/toduck/domain/inquiry/persistence/repository/InquiryAnswerRepository.java @@ -0,0 +1,22 @@ +package im.toduck.domain.inquiry.persistence.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +import im.toduck.domain.inquiry.persistence.entity.Inquiry; +import im.toduck.domain.inquiry.persistence.entity.InquiryAnswer; + +@Repository +public interface InquiryAnswerRepository extends JpaRepository { + boolean existsByInquiry(Inquiry inquiry); + + @Query(value = """ + SELECT * + FROM inquiry_answer + WHERE inquiry_id = :inquiryId + """, nativeQuery = true) + Optional findAnyByInquiryIdIncludingDeleted(Long inquiryId); +} diff --git a/src/main/java/im/toduck/domain/inquiry/persistence/repository/InquiryImgRepository.java b/src/main/java/im/toduck/domain/inquiry/persistence/repository/InquiryImgRepository.java new file mode 100644 index 00000000..65727e15 --- /dev/null +++ b/src/main/java/im/toduck/domain/inquiry/persistence/repository/InquiryImgRepository.java @@ -0,0 +1,12 @@ +package im.toduck.domain.inquiry.persistence.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import im.toduck.domain.inquiry.persistence.entity.Inquiry; +import im.toduck.domain.inquiry.persistence.entity.InquiryImage; + +@Repository +public interface InquiryImgRepository extends JpaRepository { + void deleteAllByInquiry(Inquiry inquiry); +} diff --git a/src/main/java/im/toduck/domain/inquiry/persistence/repository/InquiryRepository.java b/src/main/java/im/toduck/domain/inquiry/persistence/repository/InquiryRepository.java new file mode 100644 index 00000000..57d772b2 --- /dev/null +++ b/src/main/java/im/toduck/domain/inquiry/persistence/repository/InquiryRepository.java @@ -0,0 +1,10 @@ +package im.toduck.domain.inquiry.persistence.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import im.toduck.domain.inquiry.persistence.entity.Inquiry; + +@Repository +public interface InquiryRepository extends JpaRepository { +} diff --git a/src/main/java/im/toduck/domain/inquiry/persistence/repository/querydsl/InquiryRepositoryCustom.java b/src/main/java/im/toduck/domain/inquiry/persistence/repository/querydsl/InquiryRepositoryCustom.java new file mode 100644 index 00000000..62036b74 --- /dev/null +++ b/src/main/java/im/toduck/domain/inquiry/persistence/repository/querydsl/InquiryRepositoryCustom.java @@ -0,0 +1,11 @@ +package im.toduck.domain.inquiry.persistence.repository.querydsl; + +import java.util.List; + +import im.toduck.domain.inquiry.persistence.entity.Inquiry; + +public interface InquiryRepositoryCustom { + List findWithImgs(final Long userId); + + List findAllWithImgs(); +} diff --git a/src/main/java/im/toduck/domain/inquiry/persistence/repository/querydsl/InquiryRepositoryCustomImpl.java b/src/main/java/im/toduck/domain/inquiry/persistence/repository/querydsl/InquiryRepositoryCustomImpl.java new file mode 100644 index 00000000..599d7552 --- /dev/null +++ b/src/main/java/im/toduck/domain/inquiry/persistence/repository/querydsl/InquiryRepositoryCustomImpl.java @@ -0,0 +1,58 @@ +package im.toduck.domain.inquiry.persistence.repository.querydsl; + +import static im.toduck.domain.admin.persistence.entity.QAdmin.*; + +import java.util.List; + +import org.springframework.stereotype.Repository; + +import com.querydsl.jpa.impl.JPAQueryFactory; + +import im.toduck.domain.inquiry.persistence.entity.Inquiry; +import im.toduck.domain.inquiry.persistence.entity.QInquiry; +import im.toduck.domain.inquiry.persistence.entity.QInquiryAnswer; +import im.toduck.domain.inquiry.persistence.entity.QInquiryImage; +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class InquiryRepositoryCustomImpl implements InquiryRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + @Override + public List findWithImgs(final Long userId) { + QInquiry iq = QInquiry.inquiry; + QInquiryImage iqi = QInquiryImage.inquiryImage; + QInquiryAnswer iqa = QInquiryAnswer.inquiryAnswer; + + return queryFactory + .selectFrom(iq) + .leftJoin(iq.inquiryImages, iqi).on(iqi.deletedAt.isNull()) + .leftJoin(iq.inquiryAnswer, iqa).on(iqa.deletedAt.isNull()) + .leftJoin(iqa.admin, admin) + .where( + iq.user.id.eq(userId), + iq.deletedAt.isNull()) + .orderBy(iq.createdAt.desc()) + .distinct() + .fetch(); + } + + @Override + public List findAllWithImgs() { + QInquiry iq = QInquiry.inquiry; + QInquiryImage iqi = QInquiryImage.inquiryImage; + QInquiryAnswer iqa = QInquiryAnswer.inquiryAnswer; + + return queryFactory + .selectFrom(iq) + .leftJoin(iq.inquiryImages, iqi).on(iqi.deletedAt.isNull()) + .leftJoin(iq.inquiryAnswer, iqa).on(iqa.deletedAt.isNull()) + .leftJoin(iqa.admin, admin) + .where(iq.deletedAt.isNull()) + .orderBy(iq.createdAt.desc()) + .distinct() + .fetch(); + } +} diff --git a/src/main/java/im/toduck/domain/inquiry/presentation/api/InquiryAnswerApi.java b/src/main/java/im/toduck/domain/inquiry/presentation/api/InquiryAnswerApi.java new file mode 100644 index 00000000..40ea2901 --- /dev/null +++ b/src/main/java/im/toduck/domain/inquiry/presentation/api/InquiryAnswerApi.java @@ -0,0 +1,78 @@ +package im.toduck.domain.inquiry.presentation.api; + +import java.util.Map; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; + +import im.toduck.domain.inquiry.presentation.dto.request.InquiryAnswerCreateRequest; +import im.toduck.domain.inquiry.presentation.dto.request.InquiryAnswerUpdateRequest; +import im.toduck.global.annotation.swagger.ApiErrorResponseExplanation; +import im.toduck.global.annotation.swagger.ApiResponseExplanations; +import im.toduck.global.annotation.swagger.ApiSuccessResponseExplanation; +import im.toduck.global.exception.ExceptionCode; +import im.toduck.global.presentation.ApiResponse; +import im.toduck.global.security.authentication.CustomUserDetails; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; + +@Tag(name = "InquiryAnswer") +public interface InquiryAnswerApi { + @Operation( + summary = "문의 답변 생성", + description = "관리자가 문의 답변을 생성합니다." + ) + @ApiResponseExplanations( + success = @ApiSuccessResponseExplanation( + description = "문의 답변 생성 성공. 빈 content 객체를 반환합니다." + ), + errors = { + @ApiErrorResponseExplanation(exceptionCode = ExceptionCode.NOT_FOUND_ADMIN), + @ApiErrorResponseExplanation(exceptionCode = ExceptionCode.NOT_FOUND_INQUIRY), + @ApiErrorResponseExplanation(exceptionCode = ExceptionCode.ALREADY_ANSWERED_INQUIRY) + } + ) + ResponseEntity>> createInquiryAnswer( + @RequestBody @Valid final InquiryAnswerCreateRequest request, + @AuthenticationPrincipal final CustomUserDetails userDetails + ); + + @Operation( + summary = "문의 답변 수정", + description = "관리자가 문의 답변을 수정합니다." + ) + @ApiResponseExplanations( + success = @ApiSuccessResponseExplanation( + description = "문의 답변 수정 성공. 빈 content 객체를 반환합니다." + ), + errors = { + @ApiErrorResponseExplanation(exceptionCode = ExceptionCode.NOT_FOUND_ADMIN), + @ApiErrorResponseExplanation(exceptionCode = ExceptionCode.NOT_FOUND_INQUIRY) + } + ) + ResponseEntity>> updateInquiryAnswer( + @PathVariable final Long inquiryId, + @RequestBody @Valid final InquiryAnswerUpdateRequest request, + @AuthenticationPrincipal final CustomUserDetails userDetails + ); + + @Operation( + summary = "문의 답변 삭제", + description = "관리자가 문의 답변을 삭제합니다." + ) + @ApiResponseExplanations( + success = @ApiSuccessResponseExplanation( + description = "문의 답변 삭제 성공. 빈 content 객체를 반환합니다." + ), + errors = { + @ApiErrorResponseExplanation(exceptionCode = ExceptionCode.NOT_FOUND_INQUIRY), + @ApiErrorResponseExplanation(exceptionCode = ExceptionCode.NOT_FOUND_INQUIRY_ANSWER) + } + ) + ResponseEntity>> deleteInquiryAnswer( + @PathVariable final Long inquiryId + ); +} diff --git a/src/main/java/im/toduck/domain/inquiry/presentation/api/InquiryApi.java b/src/main/java/im/toduck/domain/inquiry/presentation/api/InquiryApi.java new file mode 100644 index 00000000..41a1c5d7 --- /dev/null +++ b/src/main/java/im/toduck/domain/inquiry/presentation/api/InquiryApi.java @@ -0,0 +1,111 @@ +package im.toduck.domain.inquiry.presentation.api; + +import java.util.Map; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; + +import im.toduck.domain.inquiry.presentation.dto.request.InquiryCreateRequest; +import im.toduck.domain.inquiry.presentation.dto.request.InquiryUpdateRequest; +import im.toduck.domain.inquiry.presentation.dto.response.InquiryListResponse; +import im.toduck.global.annotation.swagger.ApiErrorResponseExplanation; +import im.toduck.global.annotation.swagger.ApiResponseExplanations; +import im.toduck.global.annotation.swagger.ApiSuccessResponseExplanation; +import im.toduck.global.exception.ExceptionCode; +import im.toduck.global.presentation.ApiResponse; +import im.toduck.global.security.authentication.CustomUserDetails; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; + +@Tag(name = "Inquiry") +public interface InquiryApi { + @Operation( + summary = "문의 목록 조회", + description = "사용자가 자신의 문의 목록을 조회합니다." + ) + @ApiResponseExplanations( + success = @ApiSuccessResponseExplanation( + responseClass = InquiryListResponse.class, + description = "문의 목록 조회 성공. 문의 목록을 반환합니다." + ) + ) + ResponseEntity> getInquiries( + @AuthenticationPrincipal final CustomUserDetails userDetails + ); + + @Operation( + summary = "문의 생성", + description = "사용자가 문의를 생성합니다." + ) + @ApiResponseExplanations( + success = @ApiSuccessResponseExplanation( + description = "문의 생성 성공. 빈 content 객체를 반환합니다." + ), + errors = { + @ApiErrorResponseExplanation(exceptionCode = ExceptionCode.NOT_FOUND_ADMIN) + } + ) + ResponseEntity>> createInquiry( + @RequestBody @Valid final InquiryCreateRequest request, + @AuthenticationPrincipal final CustomUserDetails userDetails + ); + + @Operation( + summary = "문의 수정", + description = + """ + 문의를 수정합니다.
+ "inquiryId" : 문의 일련번호
+ "type" : 문의 타입 [ERROR, USAGE, SUGGESTION, ETC]
+ "inquiryImgs" : 문의 이미지 url

+ 수정하지 않을 경우 null을 입력하면 됩니다. 이미지의 경우 빈 리스트를 넣으면 이미지가 전부 삭제됩니다. + """ + ) + @ApiResponseExplanations( + success = @ApiSuccessResponseExplanation( + description = "문의 수정 성공. 빈 content 객체를 반환합니다." + ), + errors = { + @ApiErrorResponseExplanation(exceptionCode = ExceptionCode.NOT_FOUND_INQUIRY) + } + ) + ResponseEntity>> updateInquiry( + @PathVariable final Long inquiryId, + @RequestBody @Valid final InquiryUpdateRequest request, + @AuthenticationPrincipal final CustomUserDetails userDetails + ); + + @Operation( + summary = "문의 삭제", + description = "문의를 삭제합니다." + ) + @ApiResponseExplanations( + success = @ApiSuccessResponseExplanation( + description = "문의 삭제 성공, 빈 content 객체를 반환합니다." + ), + errors = { + @ApiErrorResponseExplanation(exceptionCode = ExceptionCode.NOT_FOUND_INQUIRY) + } + ) + ResponseEntity>> deleteInquiry( + @PathVariable final Long inquiryId, + @AuthenticationPrincipal final CustomUserDetails userDetails + ); + + @Operation( + summary = "전체 문의 목록 조회", + description = "관리자가 전체 문의 목록을 조회합니다." + ) + @ApiResponseExplanations( + success = @ApiSuccessResponseExplanation( + responseClass = InquiryListResponse.class, + description = "전체 문의 목록 조회 성공. 문의 목록을 반환합니다." + ) + ) + ResponseEntity> getAllInquiries( + @AuthenticationPrincipal final CustomUserDetails userDetails + ); +} diff --git a/src/main/java/im/toduck/domain/inquiry/presentation/controller/InquiryAnswerController.java b/src/main/java/im/toduck/domain/inquiry/presentation/controller/InquiryAnswerController.java new file mode 100644 index 00000000..9b3c3503 --- /dev/null +++ b/src/main/java/im/toduck/domain/inquiry/presentation/controller/InquiryAnswerController.java @@ -0,0 +1,67 @@ +package im.toduck.domain.inquiry.presentation.controller; + +import java.util.Map; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +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; + +import im.toduck.domain.inquiry.domain.usecase.InquiryAnswerUseCase; +import im.toduck.domain.inquiry.presentation.api.InquiryAnswerApi; +import im.toduck.domain.inquiry.presentation.dto.request.InquiryAnswerCreateRequest; +import im.toduck.domain.inquiry.presentation.dto.request.InquiryAnswerUpdateRequest; +import im.toduck.global.presentation.ApiResponse; +import im.toduck.global.security.authentication.CustomUserDetails; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/v1/inquiry-answers") +public class InquiryAnswerController implements InquiryAnswerApi { + + private final InquiryAnswerUseCase inquiryAnswerUseCase; + + @Override + @PostMapping + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity>> createInquiryAnswer( + @RequestBody @Valid final InquiryAnswerCreateRequest request, + @AuthenticationPrincipal final CustomUserDetails userDetails + ) { + inquiryAnswerUseCase.createInquiryAnswer(request, userDetails.getUserId()); + + return ResponseEntity.ok().body(ApiResponse.createSuccessWithNoContent()); + } + + @Override + @PatchMapping("/{inquiryId}") + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity>> updateInquiryAnswer( + @PathVariable final Long inquiryId, + @RequestBody @Valid final InquiryAnswerUpdateRequest request, + @AuthenticationPrincipal final CustomUserDetails userDetails + ) { + inquiryAnswerUseCase.updateInquiryAnswer(inquiryId, request, userDetails.getUserId()); + + return ResponseEntity.ok().body(ApiResponse.createSuccessWithNoContent()); + } + + @Override + @DeleteMapping("/{inquiryId}") + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity>> deleteInquiryAnswer( + @PathVariable final Long inquiryId + ) { + inquiryAnswerUseCase.deleteInquiryAnswer(inquiryId); + + return ResponseEntity.ok().body(ApiResponse.createSuccessWithNoContent()); + } +} diff --git a/src/main/java/im/toduck/domain/inquiry/presentation/controller/InquiryController.java b/src/main/java/im/toduck/domain/inquiry/presentation/controller/InquiryController.java new file mode 100644 index 00000000..2dd96499 --- /dev/null +++ b/src/main/java/im/toduck/domain/inquiry/presentation/controller/InquiryController.java @@ -0,0 +1,91 @@ +package im.toduck.domain.inquiry.presentation.controller; + +import java.util.Map; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import im.toduck.domain.inquiry.domain.usecase.InquiryUseCase; +import im.toduck.domain.inquiry.presentation.api.InquiryApi; +import im.toduck.domain.inquiry.presentation.dto.request.InquiryCreateRequest; +import im.toduck.domain.inquiry.presentation.dto.request.InquiryUpdateRequest; +import im.toduck.domain.inquiry.presentation.dto.response.InquiryListResponse; +import im.toduck.global.presentation.ApiResponse; +import im.toduck.global.security.authentication.CustomUserDetails; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/v1/inquiries") +public class InquiryController implements InquiryApi { + private final InquiryUseCase inquiryUseCase; + + @Override + @GetMapping("/me") + @PreAuthorize("isAuthenticated()") + public ResponseEntity> getInquiries( + @AuthenticationPrincipal final CustomUserDetails userDetails + ) { + InquiryListResponse response = inquiryUseCase.getInquiries(userDetails.getUserId()); + + return ResponseEntity.ok(ApiResponse.createSuccess(response)); + } + + @Override + @PostMapping + @PreAuthorize("isAuthenticated()") + public ResponseEntity>> createInquiry( + @RequestBody @Valid final InquiryCreateRequest request, + @AuthenticationPrincipal final CustomUserDetails userDetails + ) { + inquiryUseCase.createInquiry(request, userDetails.getUserId()); + + return ResponseEntity.ok().body(ApiResponse.createSuccessWithNoContent()); + } + + @Override + @PatchMapping("/{inquiryId}") + @PreAuthorize("isAuthenticated()") + public ResponseEntity>> updateInquiry( + @PathVariable final Long inquiryId, + @RequestBody @Valid final InquiryUpdateRequest request, + @AuthenticationPrincipal final CustomUserDetails userDetails + ) { + inquiryUseCase.updateInquiry(inquiryId, request, userDetails.getUserId()); + + return ResponseEntity.ok().body(ApiResponse.createSuccessWithNoContent()); + } + + @Override + @DeleteMapping("/{inquiryId}") + @PreAuthorize("isAuthenticated()") + public ResponseEntity>> deleteInquiry( + @PathVariable final Long inquiryId, + @AuthenticationPrincipal final CustomUserDetails userDetails + ) { + inquiryUseCase.deleteInquiry(inquiryId, userDetails.getUserId()); + + return ResponseEntity.ok().body(ApiResponse.createSuccessWithNoContent()); + } + + @Override + @GetMapping("/admin") + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity> getAllInquiries( + @AuthenticationPrincipal final CustomUserDetails userDetails + ) { + InquiryListResponse response = inquiryUseCase.getAllInquiries(userDetails.getUserId()); + + return ResponseEntity.ok(ApiResponse.createSuccess(response)); + } +} diff --git a/src/main/java/im/toduck/domain/inquiry/presentation/dto/request/InquiryAnswerCreateRequest.java b/src/main/java/im/toduck/domain/inquiry/presentation/dto/request/InquiryAnswerCreateRequest.java new file mode 100644 index 00000000..9845de6d --- /dev/null +++ b/src/main/java/im/toduck/domain/inquiry/presentation/dto/request/InquiryAnswerCreateRequest.java @@ -0,0 +1,20 @@ +package im.toduck.domain.inquiry.presentation.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Builder; + +@Builder +@Schema(description = "문의 답변 생성 요청 DTO") +public record InquiryAnswerCreateRequest( + @NotNull(message = "문의 ID는 비어있을 수 없습니다.") + @Schema(description = "문의 ID", example = "1") + Long inquiryId, + + @NotNull + @Size(max = 1023, message = "문의 답변은 1023자를 초과할 수 없습니다.") + @Schema(description = "문의 답변", example = "현재로써는 루틴을 반복할 기간을 따로 설정할 수 있는 기능은 없습니다!") + String content +) { +} diff --git a/src/main/java/im/toduck/domain/inquiry/presentation/dto/request/InquiryAnswerUpdateRequest.java b/src/main/java/im/toduck/domain/inquiry/presentation/dto/request/InquiryAnswerUpdateRequest.java new file mode 100644 index 00000000..e1a6f2d2 --- /dev/null +++ b/src/main/java/im/toduck/domain/inquiry/presentation/dto/request/InquiryAnswerUpdateRequest.java @@ -0,0 +1,16 @@ +package im.toduck.domain.inquiry.presentation.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Builder; + +@Builder +@Schema(description = "문의 답변 수정 요청 DTO") +public record InquiryAnswerUpdateRequest( + @NotNull + @Size(max = 1023, message = "문의 답변은 1023자를 초과할 수 없습니다.") + @Schema(description = "문의 답변", example = "도움이 되셨기를 바라며, 좋은 하루 되세요 :)") + String content +) { +} diff --git a/src/main/java/im/toduck/domain/inquiry/presentation/dto/request/InquiryCreateRequest.java b/src/main/java/im/toduck/domain/inquiry/presentation/dto/request/InquiryCreateRequest.java new file mode 100644 index 00000000..700db8f7 --- /dev/null +++ b/src/main/java/im/toduck/domain/inquiry/presentation/dto/request/InquiryCreateRequest.java @@ -0,0 +1,26 @@ +package im.toduck.domain.inquiry.presentation.dto.request; + +import java.util.List; + +import im.toduck.domain.inquiry.persistence.entity.Type; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Builder; + +@Builder +@Schema(description = "사용자 문의 생성 요청 DTO") +public record InquiryCreateRequest( + @NotNull(message = "유형은 비어있을 수 없습니다.") + @Schema(description = "유형", example = "ERROR") + Type type, + + @Size(max = 500, message = "내용은 500자를 초과할 수 없습니다.") + @NotNull(message = "내용은 비어있을 수 없습니다.") + @Schema(description = "문의 내용", example = "설정한 루틴의 순서를 바꾸고 싶은데 어떻게 하는지 모르겠어요 ㅠㅠ") + String content, + + @Schema(description = "문의 사진 URL 목록", example = "[\"https://cdn.toduck.app/image1.jpg\"]") + List inquiryImgs +) { +} diff --git a/src/main/java/im/toduck/domain/inquiry/presentation/dto/request/InquiryUpdateRequest.java b/src/main/java/im/toduck/domain/inquiry/presentation/dto/request/InquiryUpdateRequest.java new file mode 100644 index 00000000..741bac82 --- /dev/null +++ b/src/main/java/im/toduck/domain/inquiry/presentation/dto/request/InquiryUpdateRequest.java @@ -0,0 +1,24 @@ +package im.toduck.domain.inquiry.presentation.dto.request; + +import java.util.List; + +import im.toduck.domain.inquiry.persistence.entity.Type; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +@Builder +@Schema(description = "문의 수정 요청 DTO") +public record InquiryUpdateRequest( + @Schema(description = "문의 ID", example = "1") + Long inquiryId, + + @Schema(description = "문의 유형", example = "USAGE") + Type type, + + @Schema(description = "문의 내용", example = "루틴을 매 달 반복되도록 설정할 수 있나요?") + String content, + + @Schema(description = "변경된 이미지 URL 목록", example = "[\"https://cdn.app/image123.jpg\"]") + List inquiryImgs +) { +} diff --git a/src/main/java/im/toduck/domain/inquiry/presentation/dto/response/InquiryListResponse.java b/src/main/java/im/toduck/domain/inquiry/presentation/dto/response/InquiryListResponse.java new file mode 100644 index 00000000..5fcf9efc --- /dev/null +++ b/src/main/java/im/toduck/domain/inquiry/presentation/dto/response/InquiryListResponse.java @@ -0,0 +1,17 @@ +package im.toduck.domain.inquiry.presentation.dto.response; + +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "문의 내역 목록 응답") +public record InquiryListResponse( + @Schema(description = "문의 내역 목록") + List inquiryDtos +) { + public static InquiryListResponse toListInquiryResponse( + final List inquiries + ) { + return new InquiryListResponse(inquiries); + } +} diff --git a/src/main/java/im/toduck/domain/inquiry/presentation/dto/response/InquiryResponse.java b/src/main/java/im/toduck/domain/inquiry/presentation/dto/response/InquiryResponse.java new file mode 100644 index 00000000..7bc1bc57 --- /dev/null +++ b/src/main/java/im/toduck/domain/inquiry/presentation/dto/response/InquiryResponse.java @@ -0,0 +1,52 @@ +package im.toduck.domain.inquiry.presentation.dto.response; + +import java.time.LocalDateTime; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer; + +import im.toduck.domain.inquiry.persistence.entity.Status; +import im.toduck.domain.inquiry.persistence.entity.Type; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +@Builder +@Schema(description = "문의 내역 응답") +public record InquiryResponse( + @Schema(description = "문의 ID", example = "1") + Long inquiryId, + + @Schema(description = "유형", example = "이용 문의") + Type type, + + @Schema(description = "내용", example = "문의 내용") + String content, + + @Schema(description = "상태", example = "PENDING") + Status status, + + @JsonSerialize(using = LocalDateTimeSerializer.class) + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss") + @Schema(description = "생성 날짜", example = "2026-01-22T14:30:00") + LocalDateTime createdAt, + + @Schema(description = "문의 내역 이미지 url 목록", example = "[\"https://cdn.toduck.app/image1.jpg\"]") + List inquiryImgUrl, + + @Schema(description = "문의 답변 ID", example = "1") + Long inquiryAnswerId, + + @Schema(description = "답변 작성자 이름", example = "토덕 관리자") + String answerAdminName, + + @Schema(description = "문의 내용", example = "현재로써는 루틴을 반복할 기간을 따로 설정할 수 있는 기능은 없습니다!") + String answerContent, + + @JsonSerialize(using = LocalDateTimeSerializer.class) + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss") + @Schema(description = "답변 생성 날짜", example = "2026-01-22T14:30:00") + LocalDateTime answerCreatedAt +) { +} diff --git a/src/main/java/im/toduck/global/exception/ExceptionCode.java b/src/main/java/im/toduck/global/exception/ExceptionCode.java index f0a5c573..c916a6c7 100644 --- a/src/main/java/im/toduck/global/exception/ExceptionCode.java +++ b/src/main/java/im/toduck/global/exception/ExceptionCode.java @@ -124,6 +124,17 @@ public enum ExceptionCode { NOT_FOUND_EVENTS_SOCIAL(HttpStatus.NOT_FOUND, 41203, "소셜 이벤트 참여 정보를 찾을 수 없습니다."), INVALID_CONTENT_LENGTH(HttpStatus.BAD_REQUEST, 41204, "소셜 게시글 글자 수가 이벤트 참여 조건에 부합하지 않습니다."), + /* 420xx Inquiry */ + NOT_FOUND_INQUIRY(HttpStatus.NOT_FOUND, 42001, "문의 정보를 찾을 수 없습니다."), + + /* 421xx InquiryAnswer */ + NOT_FOUND_INQUIRY_ANSWER(HttpStatus.NOT_FOUND, 42101, "문의 답변 정보를 찾을 수 없습니다."), + ALREADY_ANSWERED_INQUIRY(HttpStatus.CONFLICT, 42102, "이미 문의 답변이 존재합니다."), + + /* 422xx Admin */ + NOT_FOUND_ADMIN(HttpStatus.NOT_FOUND, 42201, "관리자 정보를 찾을 수 없습니다."), + DUPLICATE_ADMIN(HttpStatus.CONFLICT, 42202, "이미 관리자 정보가 존재합니다."), + /* 431xx schedule */ NOT_FOUND_SCHEDULE_RECORD(HttpStatus.NOT_FOUND, 43101, "일정 기록을 찾을 수 없습니다.", "일정 기록을 찾을 수 없을 때 발생하는 오류입니다."), diff --git a/src/test/java/im/toduck/domain/inquiry/presentation/controller/InquiryAnswerControllerTest.java b/src/test/java/im/toduck/domain/inquiry/presentation/controller/InquiryAnswerControllerTest.java new file mode 100644 index 00000000..d1a87639 --- /dev/null +++ b/src/test/java/im/toduck/domain/inquiry/presentation/controller/InquiryAnswerControllerTest.java @@ -0,0 +1,364 @@ +package im.toduck.domain.inquiry.presentation.controller; + +import static im.toduck.fixtures.user.UserFixtures.*; +import static org.assertj.core.api.Assertions.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.SoftAssertions.*; +import static org.junit.Assert.*; + +import java.util.Arrays; + +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.springframework.beans.factory.annotation.Autowired; +import org.springframework.transaction.annotation.Transactional; + +import im.toduck.ServiceTest; +import im.toduck.domain.admin.domain.usecase.AdminUseCase; +import im.toduck.domain.admin.persistence.entity.Admin; +import im.toduck.domain.admin.persistence.repository.AdminRepository; +import im.toduck.domain.inquiry.domain.usecase.InquiryAnswerUseCase; +import im.toduck.domain.inquiry.domain.usecase.InquiryUseCase; +import im.toduck.domain.inquiry.persistence.entity.Inquiry; +import im.toduck.domain.inquiry.persistence.entity.InquiryAnswer; +import im.toduck.domain.inquiry.persistence.entity.Status; +import im.toduck.domain.inquiry.persistence.entity.Type; +import im.toduck.domain.inquiry.presentation.dto.request.InquiryAnswerCreateRequest; +import im.toduck.domain.inquiry.presentation.dto.request.InquiryAnswerUpdateRequest; +import im.toduck.domain.inquiry.presentation.dto.request.InquiryCreateRequest; +import im.toduck.domain.inquiry.presentation.dto.response.InquiryListResponse; +import im.toduck.domain.user.persistence.entity.OAuthProvider; +import im.toduck.domain.user.persistence.entity.User; +import im.toduck.domain.user.persistence.entity.UserRole; +import im.toduck.domain.user.persistence.repository.UserRepository; +import im.toduck.global.exception.CommonException; +import im.toduck.global.exception.ExceptionCode; + +class InquiryAnswerControllerTest extends ServiceTest { + + @Autowired + InquiryUseCase inquiryUseCase; + + @Autowired + InquiryAnswerUseCase inquiryAnswerUseCase; + + @Autowired + AdminUseCase adminUseCase; + + @Autowired + UserRepository userRepository; + + @Autowired + AdminRepository adminRepository; + + @Transactional + @Nested + @DisplayName("문의 답변") + class InquiryAnswerTest { + private User savedAdminUser, savedGeneralUser; + + String content = "설정한 루틴의 순서를 바꾸고 싶은데 어떻게 하는지 모르겠어요 ㅠㅠ"; + String content2 = "루틴을 매 달 반복되도록 설정할 수 있나요?"; + + String answer = "현재로써는 루틴을 반복할 기간을 따로 설정할 수 있는 기능은 없습니다!"; + String answer2 = "도움이 되셨기를 바라며, 좋은 하루 되세요 :)"; + + InquiryCreateRequest inquiryCreateRequest = + InquiryCreateRequest.builder() + .type(Type.ERROR) + .content(content) + .inquiryImgs(Arrays.asList("asdf", "ㅁㄴㅇㄹ")) + .build(); + + InquiryCreateRequest inquiryCreateRequest2 = + InquiryCreateRequest.builder() + .type(Type.USAGE) + .content(content2) + .inquiryImgs(Arrays.asList("asdf", "ㅁㄴㅇㄹ")) + .build(); + + @BeforeEach + void setUp() { + savedAdminUser = userRepository.save( + User.builder() + .role(UserRole.ADMIN) + .nickname("admin") + .email("admin@naver.com") + .provider(OAuthProvider.APPLE) + .build() + ); + + savedGeneralUser = testFixtureBuilder.buildUser(GENERAL_USER()); + } + + @Transactional + @Test + void 성공적으로_생성_조회한다() { + // given + Inquiry inquiry = inquiryUseCase.createInquiry(inquiryCreateRequest, savedGeneralUser.getId()); + Inquiry inquiry2 = inquiryUseCase.createInquiry(inquiryCreateRequest2, savedGeneralUser.getId()); + + Admin admin = adminRepository.save( + Admin.builder() + .user(savedAdminUser) + .displayName("토덕 관리자") + .build() + ); + + // when + InquiryAnswerCreateRequest request = InquiryAnswerCreateRequest.builder() + .inquiryId(inquiry.getId()) + .content(answer) + .build(); + + InquiryAnswerCreateRequest request2 = InquiryAnswerCreateRequest.builder() + .inquiryId(inquiry2.getId()) + .content(answer2) + .build(); + + InquiryAnswer inquiryAnswer = inquiryAnswerUseCase.createInquiryAnswer(request, + admin.getUser().getId()); + + InquiryAnswer inquiryAnswer2 = inquiryAnswerUseCase.createInquiryAnswer(request2, + admin.getUser().getId()); + + // then + InquiryListResponse response = inquiryUseCase.getInquiries(savedGeneralUser.getId()); + + assertSoftly(softly -> { + softly.assertThat(response.inquiryDtos().size()).isEqualTo(2L); + softly.assertThat(response.inquiryDtos().get(1).type()) + .isEqualTo(inquiryCreateRequest.type()); + softly.assertThat(response.inquiryDtos().get(1).content()).isEqualTo(content); + softly.assertThat(response.inquiryDtos().get(1).status()).isEqualTo(Status.ANSWERED); + softly.assertThat(response.inquiryDtos().get(1).inquiryAnswerId()) + .isEqualTo(inquiryAnswer.getId()); + softly.assertThat(response.inquiryDtos().get(1).answerAdminName()) + .isEqualTo(admin.getDisplayName()); + softly.assertThat(response.inquiryDtos().get(1).answerContent()).isEqualTo(answer); + softly.assertThat(response.inquiryDtos().get(1).answerCreatedAt()) + .isEqualTo(inquiryAnswer.getCreatedAt()); + + softly.assertThat(response.inquiryDtos().get(0).type()) + .isEqualTo(inquiryCreateRequest2.type()); + softly.assertThat(response.inquiryDtos().get(0).content()).isEqualTo(content2); + softly.assertThat(response.inquiryDtos().get(0).status()).isEqualTo(Status.ANSWERED); + softly.assertThat(response.inquiryDtos().get(0).inquiryAnswerId()) + .isEqualTo(inquiryAnswer2.getId()); + softly.assertThat(response.inquiryDtos().get(0).answerAdminName()) + .isEqualTo(admin.getDisplayName()); + softly.assertThat(response.inquiryDtos().get(0).answerContent()).isEqualTo(answer2); + softly.assertThat(response.inquiryDtos().get(0).answerCreatedAt()) + .isEqualTo(inquiryAnswer2.getCreatedAt()); + }); + } + + @Transactional + @Test + void 이미_답변이_달린_경우_에러가_발생한다() { + // given + Inquiry inquiry = inquiryUseCase.createInquiry(inquiryCreateRequest, savedGeneralUser.getId()); + + Admin admin = adminRepository.save( + Admin.builder() + .user(savedAdminUser) + .displayName("토덕 관리자") + .build() + ); + + InquiryAnswerCreateRequest request = InquiryAnswerCreateRequest.builder() + .inquiryId(inquiry.getId()) + .content(answer) + .build(); + + InquiryAnswer inquiryAnswer = inquiryAnswerUseCase.createInquiryAnswer(request, + admin.getUser().getId()); + + // when - then + CommonException exception = assertThrows(CommonException.class, () -> + inquiryAnswerUseCase.createInquiryAnswer(request, admin.getUser().getId()) + ); + + assertThat(exception.getErrorCode()).isEqualTo(ExceptionCode.ALREADY_ANSWERED_INQUIRY.getErrorCode()); + } + + @Transactional + @Test + void 답변_작성자의_관리자_권한이_제거된_경우에도_답변_작성자가_표시된다() { + // given + Inquiry inquiry = inquiryUseCase.createInquiry(inquiryCreateRequest, savedGeneralUser.getId()); + + Admin admin = adminRepository.save( + Admin.builder() + .user(savedAdminUser) + .displayName("토덕 관리자") + .build() + ); + + InquiryAnswerCreateRequest request = InquiryAnswerCreateRequest.builder() + .inquiryId(inquiry.getId()) + .content(answer) + .build(); + + InquiryAnswer inquiryAnswer = inquiryAnswerUseCase.createInquiryAnswer(request, + admin.getUser().getId()); + + // when + adminUseCase.deleteAdmin(admin.getUser().getId()); + + // then + InquiryListResponse response = inquiryUseCase.getInquiries(savedGeneralUser.getId()); + + assertSoftly(softly -> { + softly.assertThat(response.inquiryDtos().size()).isEqualTo(1L); + softly.assertThat(response.inquiryDtos().get(0).type()) + .isEqualTo(inquiryCreateRequest.type()); + softly.assertThat(response.inquiryDtos().get(0).content()).isEqualTo(content); + softly.assertThat(response.inquiryDtos().get(0).status()).isEqualTo(Status.ANSWERED); + softly.assertThat(response.inquiryDtos().get(0).inquiryAnswerId()) + .isEqualTo(inquiryAnswer.getId()); + softly.assertThat(response.inquiryDtos().get(0).answerAdminName()) + .isEqualTo(admin.getDisplayName()); + softly.assertThat(response.inquiryDtos().get(0).answerContent()).isEqualTo(answer); + softly.assertThat(response.inquiryDtos().get(0).answerCreatedAt()) + .isEqualTo(inquiryAnswer.getCreatedAt()); + + softly.assertThat(savedAdminUser.getRole()).isEqualTo(UserRole.USER); + }); + } + + @Transactional + @Test + void 성공적으로_답변을_수정한다() { + // given + Inquiry inquiry = inquiryUseCase.createInquiry(inquiryCreateRequest, savedGeneralUser.getId()); + + Admin admin = adminRepository.save( + Admin.builder() + .user(savedAdminUser) + .displayName("토덕 관리자") + .build() + ); + + InquiryAnswerCreateRequest request = InquiryAnswerCreateRequest.builder() + .inquiryId(inquiry.getId()) + .content(answer) + .build(); + + InquiryAnswer inquiryAnswer = inquiryAnswerUseCase.createInquiryAnswer(request, + admin.getUser().getId()); + + // when + InquiryAnswerUpdateRequest updateRequest = InquiryAnswerUpdateRequest.builder() + .content(answer2) + .build(); + + inquiryAnswerUseCase.updateInquiryAnswer(inquiry.getId(), updateRequest, admin.getUser().getId()); + + // then + InquiryListResponse response = inquiryUseCase.getInquiries(savedGeneralUser.getId()); + + assertSoftly(softly -> { + softly.assertThat(response.inquiryDtos().size()).isEqualTo(1L); + softly.assertThat(response.inquiryDtos().get(0).type()) + .isEqualTo(inquiryCreateRequest.type()); + softly.assertThat(response.inquiryDtos().get(0).content()).isEqualTo(content); + softly.assertThat(response.inquiryDtos().get(0).status()).isEqualTo(Status.ANSWERED); + softly.assertThat(response.inquiryDtos().get(0).inquiryAnswerId()) + .isEqualTo(inquiryAnswer.getId()); + softly.assertThat(response.inquiryDtos().get(0).answerAdminName()) + .isEqualTo(admin.getDisplayName()); + softly.assertThat(response.inquiryDtos().get(0).answerContent()).isEqualTo(answer2); + softly.assertThat(response.inquiryDtos().get(0).answerCreatedAt()) + .isEqualTo(inquiryAnswer.getCreatedAt()); + softly.assertThat(inquiry.getInquiryAnswer().getUpdatedAt()).isNotNull(); + }); + } + + @Transactional + @Test + void 성공적으로_답변을_삭제한다() { + // given + Inquiry inquiry = inquiryUseCase.createInquiry(inquiryCreateRequest, savedGeneralUser.getId()); + + Admin admin = adminRepository.save( + Admin.builder() + .user(savedAdminUser) + .displayName("토덕 관리자") + .build() + ); + + InquiryAnswerCreateRequest request = InquiryAnswerCreateRequest.builder() + .inquiryId(inquiry.getId()) + .content(answer) + .build(); + + InquiryAnswer inquiryAnswer = inquiryAnswerUseCase.createInquiryAnswer(request, + admin.getUser().getId()); + + // when + inquiryAnswerUseCase.deleteInquiryAnswer(inquiry.getId()); + + // then + InquiryListResponse response = inquiryUseCase.getInquiries(savedGeneralUser.getId()); + + assertSoftly(softly -> { + softly.assertThat(response.inquiryDtos().size()).isEqualTo(1L); + softly.assertThat(response.inquiryDtos().get(0).type()) + .isEqualTo(inquiryCreateRequest.type()); + softly.assertThat(response.inquiryDtos().get(0).content()).isEqualTo(content); + softly.assertThat(response.inquiryDtos().get(0).status()).isEqualTo(Status.PENDING); + softly.assertThat(response.inquiryDtos().get(0).inquiryAnswerId()).isNull(); + softly.assertThat(response.inquiryDtos().get(0).answerAdminName()).isNull(); + softly.assertThat(response.inquiryDtos().get(0).answerContent()).isNull(); + softly.assertThat(response.inquiryDtos().get(0).answerCreatedAt()).isNull(); + }); + } + + @Transactional + @Test + void 성공적으로_답변_삭제_후_다시_생성한다() { + // given + Inquiry inquiry = inquiryUseCase.createInquiry(inquiryCreateRequest, savedGeneralUser.getId()); + + Admin admin = adminRepository.save( + Admin.builder() + .user(savedAdminUser) + .displayName("토덕 관리자") + .build() + ); + + InquiryAnswerCreateRequest request = InquiryAnswerCreateRequest.builder() + .inquiryId(inquiry.getId()) + .content(answer) + .build(); + + InquiryAnswer inquiryAnswer = inquiryAnswerUseCase.createInquiryAnswer(request, + admin.getUser().getId()); + + inquiryAnswerUseCase.deleteInquiryAnswer(inquiry.getId()); + + // when + InquiryAnswer inquiryAnswer2 = inquiryAnswerUseCase.createInquiryAnswer(request, admin.getUser().getId()); + + // then + InquiryListResponse response = inquiryUseCase.getInquiries(savedGeneralUser.getId()); + + assertSoftly(softly -> { + softly.assertThat(response.inquiryDtos().size()).isEqualTo(1L); + softly.assertThat(response.inquiryDtos().get(0).type()) + .isEqualTo(inquiryCreateRequest.type()); + softly.assertThat(response.inquiryDtos().get(0).content()).isEqualTo(content); + softly.assertThat(response.inquiryDtos().get(0).status()).isEqualTo(Status.ANSWERED); + softly.assertThat(response.inquiryDtos().get(0).inquiryAnswerId()).isEqualTo(inquiryAnswer2.getId()); + softly.assertThat(response.inquiryDtos().get(0).answerAdminName()).isEqualTo(admin.getDisplayName()); + softly.assertThat(response.inquiryDtos().get(0).answerContent()).isEqualTo(request.content()); + softly.assertThat(response.inquiryDtos().get(0).answerCreatedAt()) + .isEqualTo(inquiryAnswer2.getCreatedAt()); + softly.assertThat(inquiryAnswer2.getDeletedAt()).isNull(); + }); + } + } +} diff --git a/src/test/java/im/toduck/domain/inquiry/presentation/controller/InquiryControllerTest.java b/src/test/java/im/toduck/domain/inquiry/presentation/controller/InquiryControllerTest.java new file mode 100644 index 00000000..a17c41e3 --- /dev/null +++ b/src/test/java/im/toduck/domain/inquiry/presentation/controller/InquiryControllerTest.java @@ -0,0 +1,210 @@ +package im.toduck.domain.inquiry.presentation.controller; + +import static im.toduck.fixtures.user.UserFixtures.*; +import static org.assertj.core.api.AssertionsForClassTypes.*; +import static org.assertj.core.api.SoftAssertions.*; + +import java.util.Arrays; + +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.springframework.beans.factory.annotation.Autowired; +import org.springframework.transaction.annotation.Transactional; + +import im.toduck.ServiceTest; +import im.toduck.domain.inquiry.domain.usecase.InquiryUseCase; +import im.toduck.domain.inquiry.persistence.entity.Inquiry; +import im.toduck.domain.inquiry.persistence.entity.Status; +import im.toduck.domain.inquiry.persistence.entity.Type; +import im.toduck.domain.inquiry.persistence.repository.InquiryRepository; +import im.toduck.domain.inquiry.presentation.dto.request.InquiryCreateRequest; +import im.toduck.domain.inquiry.presentation.dto.request.InquiryUpdateRequest; +import im.toduck.domain.inquiry.presentation.dto.response.InquiryListResponse; +import im.toduck.domain.user.persistence.entity.OAuthProvider; +import im.toduck.domain.user.persistence.entity.User; +import im.toduck.domain.user.persistence.entity.UserRole; +import im.toduck.domain.user.persistence.repository.UserRepository; +import im.toduck.global.exception.CommonException; +import im.toduck.global.exception.ExceptionCode; + +class InquiryControllerTest extends ServiceTest { + + @Autowired + InquiryUseCase inquiryUseCase; + + @Autowired + InquiryRepository inquiryRepository; + + @Autowired + UserRepository userRepository; + + @Transactional + @Nested + @DisplayName("문의") + class InquiryTest { + private User savedAdminUser, savedGeneralUser; + + String content = "설정한 루틴의 순서를 바꾸고 싶은데 어떻게 하는지 모르겠어요 ㅠㅠ"; + String content2 = "루틴을 매 달 반복되도록 설정할 수 있나요?"; + + @BeforeEach + void setUp() { + savedAdminUser = userRepository.save( + User.builder() + .role(UserRole.ADMIN) + .nickname("admin") + .email("admin@naver.com") + .provider(OAuthProvider.APPLE) + .build() + ); + + savedGeneralUser = testFixtureBuilder.buildUser(GENERAL_USER()); + } + + @Test + void 성공적으로_생성한다() { + // given - when + InquiryCreateRequest inquiryCreateRequest = + InquiryCreateRequest.builder() + .type(Type.ERROR) + .content(content) + .inquiryImgs(Arrays.asList("asdf", "ㅁㄴㅇㄹ")) + .build(); + + Inquiry inquiry = inquiryUseCase.createInquiry(inquiryCreateRequest, savedGeneralUser.getId()); + + // then + Inquiry inquiry2 = inquiryRepository.findById(inquiry.getId()) + .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_INQUIRY)); + + assertSoftly(softly -> { + softly.assertThat(inquiry2.getUser().getId()) + .isEqualTo(inquiry.getUser().getId()); + softly.assertThat((inquiry2.getType())) + .isEqualTo(inquiry.getType()); + softly.assertThat(inquiry2.getContent()) + .isEqualTo(inquiry.getContent()); + softly.assertThat(inquiry2.getStatus()) + .isEqualTo(inquiry.getStatus()); + softly.assertThat(inquiry2.getCreatedAt()) + .isEqualTo(inquiry.getCreatedAt()); + }); + } + + @Test + void 성공적으로_사용자가_자신의_문의를_조회한다() { + // given + InquiryCreateRequest inquiryCreateRequest = + InquiryCreateRequest.builder() + .type(Type.ERROR) + .content(content) + .inquiryImgs(Arrays.asList("asdf", "ㅁㄴㅇㄹ")) + .build(); + + Inquiry inquiry = inquiryUseCase.createInquiry(inquiryCreateRequest, savedGeneralUser.getId()); + Inquiry inquiry2 = inquiryUseCase.createInquiry(inquiryCreateRequest, savedAdminUser.getId()); + + // when + InquiryListResponse inquiryListResponse = inquiryUseCase.getInquiries(savedGeneralUser.getId()); + + // then + assertSoftly(softly -> { + softly.assertThat(inquiryListResponse.inquiryDtos().size()).isEqualTo(1); + softly.assertThat(inquiryListResponse.inquiryDtos().get(0).type()).isEqualTo(Type.ERROR); + softly.assertThat(inquiryListResponse.inquiryDtos().get(0).content()).isEqualTo(content); + softly.assertThat(inquiryListResponse.inquiryDtos().get(0).status()).isEqualTo(Status.PENDING); + }); + } + + @Test + void 성공적으로_문의를_수정한다() { + // given + InquiryCreateRequest inquiryCreateRequest = + InquiryCreateRequest.builder() + .type(Type.ERROR) + .content(content) + .inquiryImgs(Arrays.asList("asdf", "ㅁㄴㅇㄹ")) + .build(); + + Inquiry inquiry = inquiryUseCase.createInquiry(inquiryCreateRequest, savedGeneralUser.getId()); + + // when + InquiryUpdateRequest inquiryUpdateRequest = + InquiryUpdateRequest.builder() + .type(Type.USAGE) + .content(content2) + .inquiryImgs(Arrays.asList("asdf", "ㅁㄴㅇㄹ")) + .build(); + + Inquiry inquiry2 = inquiryUseCase.updateInquiry(inquiry.getId(), inquiryUpdateRequest, + savedGeneralUser.getId()); + + InquiryListResponse inquiryListResponse = inquiryUseCase.getInquiries(savedGeneralUser.getId()); + + // then + assertSoftly(softly -> { + softly.assertThat(inquiryListResponse.inquiryDtos().size()).isEqualTo(1); + softly.assertThat(inquiryListResponse.inquiryDtos().get(0).type()).isEqualTo(Type.USAGE); + softly.assertThat(inquiryListResponse.inquiryDtos().get(0).content()).isEqualTo(content2); + softly.assertThat(inquiryListResponse.inquiryDtos().get(0).status()).isEqualTo(Status.PENDING); + }); + } + + @Test + void 성공적으로_삭제한다() { + // given + InquiryCreateRequest inquiryCreateRequest = + InquiryCreateRequest.builder() + .type(Type.ERROR) + .content(content) + .inquiryImgs(Arrays.asList("asdf", "ㅁㄴㅇㄹ")) + .build(); + + Inquiry inquiry = inquiryUseCase.createInquiry(inquiryCreateRequest, savedGeneralUser.getId()); + + // when + inquiryUseCase.deleteInquiry(inquiry.getId(), savedGeneralUser.getId()); + + // then + assertThat(inquiryRepository.findById(inquiry.getId())).isNotPresent(); + } + + @Test + void 성공적으로_관리자가_모든_문의를_조회한다() throws Exception { + // given + InquiryCreateRequest inquiryCreateRequest = + InquiryCreateRequest.builder() + .type(Type.ERROR) + .content(content) + .inquiryImgs(Arrays.asList("asdf", "ㅁㄴㅇㄹ")) + .build(); + + InquiryCreateRequest inquiryCreateRequest2 = + InquiryCreateRequest.builder() + .type(Type.USAGE) + .content(content2) + .inquiryImgs(Arrays.asList("asdf", "ㅁㄴㅇㄹ")) + .build(); + + Inquiry inquiry = inquiryUseCase.createInquiry(inquiryCreateRequest, savedGeneralUser.getId()); + Inquiry inquiry2 = inquiryUseCase.createInquiry(inquiryCreateRequest2, savedAdminUser.getId()); + + // when + InquiryListResponse inquiryListResponse = inquiryUseCase.getAllInquiries(savedAdminUser.getId()); + + // then + assertSoftly(softly -> { + softly.assertThat(inquiryListResponse.inquiryDtos().size()).isEqualTo(2); + softly.assertThat(inquiryListResponse.inquiryDtos().get(1).type()).isEqualTo(Type.ERROR); + softly.assertThat(inquiryListResponse.inquiryDtos().get(1).content()).isEqualTo(content); + softly.assertThat(inquiryListResponse.inquiryDtos().get(1).status()).isEqualTo(Status.PENDING); + + softly.assertThat(inquiryListResponse.inquiryDtos().get(0).type()).isEqualTo(Type.USAGE); + softly.assertThat(inquiryListResponse.inquiryDtos().get(0).content()).isEqualTo(content2); + softly.assertThat(inquiryListResponse.inquiryDtos().get(0).status()).isEqualTo(Status.PENDING); + }); + } + } +} From 8514f9fabcf88309dfedac4ca7aa993b9eaf8625 Mon Sep 17 00:00:00 2001 From: wafla Date: Thu, 5 Feb 2026 18:14:36 +0900 Subject: [PATCH 05/11] =?UTF-8?q?feat:=20=EB=AC=B8=EC=9D=98,=20=EB=AC=B8?= =?UTF-8?q?=EC=9D=98=20=EB=8B=B5=EB=B3=80=20=EA=B8=B0=EB=8A=A5=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 --- sql/init.sql | 51 +++++++++++++++++++ .../inquiry/persistence/entity/Inquiry.java | 2 +- .../persistence/entity/InquiryAnswer.java | 2 +- 3 files changed, 53 insertions(+), 2 deletions(-) diff --git a/sql/init.sql b/sql/init.sql index 9fa6cbb8..39ec89f6 100644 --- a/sql/init.sql +++ b/sql/init.sql @@ -408,6 +408,57 @@ CREATE TABLE events_social FOREIGN KEY (user_id) REFERENCES users (id) ); +CREATE TABLE admin +( + id BIGINT PRIMARY KEY auto_increment, + user_id BIGINT NOT NULL UNIQUE, + display_name VARCHAR(255) NOT NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + deleted_at DATETIME NULL, + FOREIGN KEY (user_id) REFERENCES users (id) +); + +CREATE TABLE inquiry +( + id BIGINT PRIMARY KEY auto_increment, + user_id BIGINT NOT NULL, + type VARCHAR(50) NOT NULL, + content VARCHAR(1024) NOT NULL, + status VARCHAR(50) NOT NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + deleted_at DATETIME NULL, + FOREIGN KEY (user_id) REFERENCES users (id) +); + +CREATE TABLE inquiry_image_file +( + id BIGINT PRIMARY KEY auto_increment, + inquiry_id BIGINT NOT NULL, + url VARCHAR(1024) NOT NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + deleted_at DATETIME NULL, + FOREIGN KEY (inquiry_id) REFERENCES inquiry (id) +); + +CREATE TABLE inquiry_answer +( + id BIGINT PRIMARY KEY auto_increment, + admin_id BIGINT NOT NULL, + inquiry_id BIGINT NOT NULL, + content VARCHAR(1024) NOT NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + deleted_at DATETIME NULL, + + CONSTRAINT uq_inquiry_answer UNIQUE (inquiry_id), + + FOREIGN KEY (admin_id) REFERENCES admin (id), + FOREIGN KEY (inquiry_id) REFERENCES inquiry (id) +); + CREATE TABLE account_deletion_log ( id BIGINT PRIMARY KEY auto_increment, diff --git a/src/main/java/im/toduck/domain/inquiry/persistence/entity/Inquiry.java b/src/main/java/im/toduck/domain/inquiry/persistence/entity/Inquiry.java index 936bc2e9..7a852a1f 100644 --- a/src/main/java/im/toduck/domain/inquiry/persistence/entity/Inquiry.java +++ b/src/main/java/im/toduck/domain/inquiry/persistence/entity/Inquiry.java @@ -45,7 +45,7 @@ public class Inquiry extends BaseEntity { @Column(nullable = false) private Type type; - @Column(length = 1024) + @Column(length = 1024, nullable = false) private String content; @Enumerated(EnumType.STRING) diff --git a/src/main/java/im/toduck/domain/inquiry/persistence/entity/InquiryAnswer.java b/src/main/java/im/toduck/domain/inquiry/persistence/entity/InquiryAnswer.java index ddd87178..b9a3beb0 100644 --- a/src/main/java/im/toduck/domain/inquiry/persistence/entity/InquiryAnswer.java +++ b/src/main/java/im/toduck/domain/inquiry/persistence/entity/InquiryAnswer.java @@ -40,7 +40,7 @@ public class InquiryAnswer extends BaseEntity { @JoinColumn(name = "inquiry_id", nullable = false, unique = true) private Inquiry inquiry; - @Column(length = 1024) + @Column(length = 1024, nullable = false) private String content; @Builder From ca6c7c6779d0921386911ee5b102c35cb24c5d3d Mon Sep 17 00:00:00 2001 From: wafla Date: Thu, 5 Feb 2026 18:37:55 +0900 Subject: [PATCH 06/11] =?UTF-8?q?fix:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=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 --- .../controller/InquiryAnswerControllerTest.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/test/java/im/toduck/domain/inquiry/presentation/controller/InquiryAnswerControllerTest.java b/src/test/java/im/toduck/domain/inquiry/presentation/controller/InquiryAnswerControllerTest.java index d1a87639..bc0a1328 100644 --- a/src/test/java/im/toduck/domain/inquiry/presentation/controller/InquiryAnswerControllerTest.java +++ b/src/test/java/im/toduck/domain/inquiry/presentation/controller/InquiryAnswerControllerTest.java @@ -1,8 +1,6 @@ package im.toduck.domain.inquiry.presentation.controller; import static im.toduck.fixtures.user.UserFixtures.*; -import static org.assertj.core.api.Assertions.*; -import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.SoftAssertions.*; import static org.junit.Assert.*; @@ -181,7 +179,10 @@ void setUp() { inquiryAnswerUseCase.createInquiryAnswer(request, admin.getUser().getId()) ); - assertThat(exception.getErrorCode()).isEqualTo(ExceptionCode.ALREADY_ANSWERED_INQUIRY.getErrorCode()); + assertSoftly(softly -> { + softly.assertThat(exception.getErrorCode()) + .isEqualTo(ExceptionCode.ALREADY_ANSWERED_INQUIRY.getErrorCode()); + }); } @Transactional From 0fd5c6100fd6638607c46ef91c7676fe964ec4bf Mon Sep 17 00:00:00 2001 From: wafla Date: Thu, 5 Feb 2026 18:45:43 +0900 Subject: [PATCH 07/11] =?UTF-8?q?fix:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=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 --- .../controller/InquiryAnswerControllerTest.java | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/test/java/im/toduck/domain/inquiry/presentation/controller/InquiryAnswerControllerTest.java b/src/test/java/im/toduck/domain/inquiry/presentation/controller/InquiryAnswerControllerTest.java index bc0a1328..91d16cd2 100644 --- a/src/test/java/im/toduck/domain/inquiry/presentation/controller/InquiryAnswerControllerTest.java +++ b/src/test/java/im/toduck/domain/inquiry/presentation/controller/InquiryAnswerControllerTest.java @@ -1,8 +1,8 @@ package im.toduck.domain.inquiry.presentation.controller; import static im.toduck.fixtures.user.UserFixtures.*; +import static org.assertj.core.api.AssertionsForClassTypes.*; import static org.assertj.core.api.SoftAssertions.*; -import static org.junit.Assert.*; import java.util.Arrays; @@ -175,14 +175,13 @@ void setUp() { admin.getUser().getId()); // when - then - CommonException exception = assertThrows(CommonException.class, () -> + assertThatThrownBy(() -> inquiryAnswerUseCase.createInquiryAnswer(request, admin.getUser().getId()) - ); - - assertSoftly(softly -> { - softly.assertThat(exception.getErrorCode()) - .isEqualTo(ExceptionCode.ALREADY_ANSWERED_INQUIRY.getErrorCode()); - }); + ) + .isInstanceOfSatisfying(CommonException.class, e -> + assertThat(e.getErrorCode()) + .isEqualTo(ExceptionCode.ALREADY_ANSWERED_INQUIRY.getErrorCode()) + ); } @Transactional From 78a9ffc849a34541e34385b66aa59609438f1321 Mon Sep 17 00:00:00 2001 From: wafla Date: Thu, 5 Feb 2026 20:55:05 +0900 Subject: [PATCH 08/11] =?UTF-8?q?fix:=20=EC=98=A4=EB=A5=98=20=EC=82=AC?= =?UTF-8?q?=ED=95=AD=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../toduck/domain/admin/common/mapper/AdminMapper.java | 10 +--------- .../domain/admin/domain/service/AdminService.java | 2 +- .../domain/admin/domain/usecase/AdminUseCase.java | 2 +- .../admin/presentation/controller/AdminController.java | 5 +++-- .../presentation/dto/request/AdminCreateRequest.java | 3 ++- .../presentation/dto/request/AdminUpdateRequest.java | 4 ++++ .../dto/request/InquiryAnswerCreateRequest.java | 3 ++- .../dto/request/InquiryAnswerUpdateRequest.java | 4 ++-- .../presentation/dto/request/InquiryCreateRequest.java | 7 ++++--- .../presentation/dto/request/InquiryUpdateRequest.java | 7 +++++++ 10 files changed, 27 insertions(+), 20 deletions(-) diff --git a/src/main/java/im/toduck/domain/admin/common/mapper/AdminMapper.java b/src/main/java/im/toduck/domain/admin/common/mapper/AdminMapper.java index 88339681..8b1bcad8 100644 --- a/src/main/java/im/toduck/domain/admin/common/mapper/AdminMapper.java +++ b/src/main/java/im/toduck/domain/admin/common/mapper/AdminMapper.java @@ -18,15 +18,7 @@ public static AdminResponse toAdminResponse(final Admin admin) { ); } - public static AdminListResponse toLAdminListResponse(final List admins) { + public static AdminListResponse toAdminListResponse(final List admins) { return AdminListResponse.toListAdminResponse(admins); } - - public static AdminResponse fromAdmin(final Admin admin) { - return new AdminResponse( - admin.getId(), - admin.getUser().getId(), - admin.getDisplayName() - ); - } } diff --git a/src/main/java/im/toduck/domain/admin/domain/service/AdminService.java b/src/main/java/im/toduck/domain/admin/domain/service/AdminService.java index 7328e47c..5b6353d2 100644 --- a/src/main/java/im/toduck/domain/admin/domain/service/AdminService.java +++ b/src/main/java/im/toduck/domain/admin/domain/service/AdminService.java @@ -54,7 +54,7 @@ private Admin createDefaultAdmin(final Long userId) { public List getAdmins() { List admins = adminRepository.findAllActiveAdmins(); return admins.stream() - .map(AdminMapper::fromAdmin) + .map(AdminMapper::toAdminResponse) .toList(); } diff --git a/src/main/java/im/toduck/domain/admin/domain/usecase/AdminUseCase.java b/src/main/java/im/toduck/domain/admin/domain/usecase/AdminUseCase.java index 97e2d231..9597153d 100644 --- a/src/main/java/im/toduck/domain/admin/domain/usecase/AdminUseCase.java +++ b/src/main/java/im/toduck/domain/admin/domain/usecase/AdminUseCase.java @@ -36,7 +36,7 @@ public AdminResponse getAdmin(final Long userId) { public AdminListResponse getAdmins() { List admins = adminService.getAdmins(); - return AdminMapper.toLAdminListResponse(admins); + return AdminMapper.toAdminListResponse(admins); } @Transactional diff --git a/src/main/java/im/toduck/domain/admin/presentation/controller/AdminController.java b/src/main/java/im/toduck/domain/admin/presentation/controller/AdminController.java index f5ef25a5..a1e99e65 100644 --- a/src/main/java/im/toduck/domain/admin/presentation/controller/AdminController.java +++ b/src/main/java/im/toduck/domain/admin/presentation/controller/AdminController.java @@ -20,6 +20,7 @@ import im.toduck.domain.admin.presentation.dto.response.AdminListResponse; import im.toduck.domain.admin.presentation.dto.response.AdminResponse; import im.toduck.global.presentation.ApiResponse; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @RestController @@ -53,7 +54,7 @@ public ResponseEntity> getAdmins() { @PostMapping @PreAuthorize("hasRole('ADMIN')") public ResponseEntity>> createAdmin( - @RequestBody final AdminCreateRequest request + @RequestBody @Valid final AdminCreateRequest request ) { adminUseCase.createAdmin(request); @@ -65,7 +66,7 @@ public ResponseEntity>> createAdmin( @PreAuthorize("hasRole('ADMIN')") public ResponseEntity>> updateAdmin( @PathVariable final Long userId, - @RequestBody final AdminUpdateRequest request + @RequestBody @Valid final AdminUpdateRequest request ) { adminUseCase.updateAdmin(userId, request); diff --git a/src/main/java/im/toduck/domain/admin/presentation/dto/request/AdminCreateRequest.java b/src/main/java/im/toduck/domain/admin/presentation/dto/request/AdminCreateRequest.java index 676458b2..e382b87c 100644 --- a/src/main/java/im/toduck/domain/admin/presentation/dto/request/AdminCreateRequest.java +++ b/src/main/java/im/toduck/domain/admin/presentation/dto/request/AdminCreateRequest.java @@ -1,6 +1,7 @@ package im.toduck.domain.admin.presentation.dto.request; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; import lombok.Builder; @@ -12,7 +13,7 @@ public record AdminCreateRequest( @Schema(description = "사용자 ID", example = "1") Long userId, - @NotNull(message = "관리자 표시명은 비어있을 수 없습니다.") + @NotBlank(message = "관리자 표시명은 비어있을 수 없습니다.") @Size(max = 255, message = "관리자 표시명은 255자를 초과할 수 없습니다.") @Schema(description = "관리자 표시명", example = "토덕 관리자") String displayName diff --git a/src/main/java/im/toduck/domain/admin/presentation/dto/request/AdminUpdateRequest.java b/src/main/java/im/toduck/domain/admin/presentation/dto/request/AdminUpdateRequest.java index 3b104595..e2b5491a 100644 --- a/src/main/java/im/toduck/domain/admin/presentation/dto/request/AdminUpdateRequest.java +++ b/src/main/java/im/toduck/domain/admin/presentation/dto/request/AdminUpdateRequest.java @@ -1,11 +1,15 @@ package im.toduck.domain.admin.presentation.dto.request; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; import lombok.Builder; @Builder @Schema(description = "관리자 수정 요청 DTO") public record AdminUpdateRequest( + @NotBlank(message = "관리자 표시명은 비어있을 수 없습니다.") + @Size(max = 255, message = "관리자 표시명은 255자를 초과할 수 없습니다.") @Schema(description = "관리자 표시명", example = "토덕 관리자") String displayName ) { diff --git a/src/main/java/im/toduck/domain/inquiry/presentation/dto/request/InquiryAnswerCreateRequest.java b/src/main/java/im/toduck/domain/inquiry/presentation/dto/request/InquiryAnswerCreateRequest.java index 9845de6d..16aed97f 100644 --- a/src/main/java/im/toduck/domain/inquiry/presentation/dto/request/InquiryAnswerCreateRequest.java +++ b/src/main/java/im/toduck/domain/inquiry/presentation/dto/request/InquiryAnswerCreateRequest.java @@ -1,6 +1,7 @@ package im.toduck.domain.inquiry.presentation.dto.request; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; import lombok.Builder; @@ -12,7 +13,7 @@ public record InquiryAnswerCreateRequest( @Schema(description = "문의 ID", example = "1") Long inquiryId, - @NotNull + @NotBlank @Size(max = 1023, message = "문의 답변은 1023자를 초과할 수 없습니다.") @Schema(description = "문의 답변", example = "현재로써는 루틴을 반복할 기간을 따로 설정할 수 있는 기능은 없습니다!") String content diff --git a/src/main/java/im/toduck/domain/inquiry/presentation/dto/request/InquiryAnswerUpdateRequest.java b/src/main/java/im/toduck/domain/inquiry/presentation/dto/request/InquiryAnswerUpdateRequest.java index e1a6f2d2..591b3613 100644 --- a/src/main/java/im/toduck/domain/inquiry/presentation/dto/request/InquiryAnswerUpdateRequest.java +++ b/src/main/java/im/toduck/domain/inquiry/presentation/dto/request/InquiryAnswerUpdateRequest.java @@ -1,14 +1,14 @@ package im.toduck.domain.inquiry.presentation.dto.request; import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; import lombok.Builder; @Builder @Schema(description = "문의 답변 수정 요청 DTO") public record InquiryAnswerUpdateRequest( - @NotNull + @NotBlank @Size(max = 1023, message = "문의 답변은 1023자를 초과할 수 없습니다.") @Schema(description = "문의 답변", example = "도움이 되셨기를 바라며, 좋은 하루 되세요 :)") String content diff --git a/src/main/java/im/toduck/domain/inquiry/presentation/dto/request/InquiryCreateRequest.java b/src/main/java/im/toduck/domain/inquiry/presentation/dto/request/InquiryCreateRequest.java index 700db8f7..e71d6b1e 100644 --- a/src/main/java/im/toduck/domain/inquiry/presentation/dto/request/InquiryCreateRequest.java +++ b/src/main/java/im/toduck/domain/inquiry/presentation/dto/request/InquiryCreateRequest.java @@ -4,6 +4,7 @@ import im.toduck.domain.inquiry.persistence.entity.Type; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; import lombok.Builder; @@ -11,12 +12,12 @@ @Builder @Schema(description = "사용자 문의 생성 요청 DTO") public record InquiryCreateRequest( - @NotNull(message = "유형은 비어있을 수 없습니다.") - @Schema(description = "유형", example = "ERROR") + @NotNull(message = "문의 유형은 비어있을 수 없습니다.") + @Schema(description = "문의 유형", example = "ERROR") Type type, @Size(max = 500, message = "내용은 500자를 초과할 수 없습니다.") - @NotNull(message = "내용은 비어있을 수 없습니다.") + @NotBlank(message = "내용은 비어있을 수 없습니다.") @Schema(description = "문의 내용", example = "설정한 루틴의 순서를 바꾸고 싶은데 어떻게 하는지 모르겠어요 ㅠㅠ") String content, diff --git a/src/main/java/im/toduck/domain/inquiry/presentation/dto/request/InquiryUpdateRequest.java b/src/main/java/im/toduck/domain/inquiry/presentation/dto/request/InquiryUpdateRequest.java index 741bac82..5e7ef328 100644 --- a/src/main/java/im/toduck/domain/inquiry/presentation/dto/request/InquiryUpdateRequest.java +++ b/src/main/java/im/toduck/domain/inquiry/presentation/dto/request/InquiryUpdateRequest.java @@ -4,17 +4,24 @@ import im.toduck.domain.inquiry.persistence.entity.Type; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; import lombok.Builder; @Builder @Schema(description = "문의 수정 요청 DTO") public record InquiryUpdateRequest( + @NotNull(message = "문의 ID는 비어있을 수 없습니다.") @Schema(description = "문의 ID", example = "1") Long inquiryId, + @NotNull(message = "문의 유형은 비어있을 수 없습니다.") @Schema(description = "문의 유형", example = "USAGE") Type type, + @Size(max = 500, message = "내용은 500자를 초과할 수 없습니다.") + @NotBlank(message = "내용은 비어있을 수 없습니다.") @Schema(description = "문의 내용", example = "루틴을 매 달 반복되도록 설정할 수 있나요?") String content, From 4f659279700d331167951903a3cca47cdc2fe8b0 Mon Sep 17 00:00:00 2001 From: wafla Date: Sat, 7 Feb 2026 21:04:52 +0900 Subject: [PATCH 09/11] =?UTF-8?q?fix:=20=EC=98=A4=EB=A5=98=20=EC=82=AC?= =?UTF-8?q?=ED=95=AD=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/domain/service/AdminService.java | 5 +++++ .../domain/service/InquiryAnswerService.java | 2 +- .../inquiry/domain/service/InquiryService.java | 11 ++++++----- .../inquiry/domain/usecase/InquiryUseCase.java | 8 ++++++++ .../inquiry/persistence/entity/Inquiry.java | 4 ++-- .../persistence/entity/InquiryAnswer.java | 6 ++++++ .../persistence/entity/InquiryImage.java | 5 +++++ .../repository/InquiryImgRepository.java | 4 +++- .../querydsl/InquiryRepositoryCustomImpl.java | 17 ++++++++--------- .../toduck/global/exception/ExceptionCode.java | 1 + 10 files changed, 45 insertions(+), 18 deletions(-) diff --git a/src/main/java/im/toduck/domain/admin/domain/service/AdminService.java b/src/main/java/im/toduck/domain/admin/domain/service/AdminService.java index 5b6353d2..5e3a8096 100644 --- a/src/main/java/im/toduck/domain/admin/domain/service/AdminService.java +++ b/src/main/java/im/toduck/domain/admin/domain/service/AdminService.java @@ -13,6 +13,7 @@ import im.toduck.domain.admin.presentation.dto.request.AdminUpdateRequest; import im.toduck.domain.admin.presentation.dto.response.AdminResponse; import im.toduck.domain.user.persistence.entity.User; +import im.toduck.domain.user.persistence.entity.UserRole; import im.toduck.domain.user.persistence.repository.UserRepository; import im.toduck.global.exception.CommonException; import im.toduck.global.exception.ExceptionCode; @@ -42,6 +43,10 @@ private Admin createDefaultAdmin(final Long userId) { User user = userRepository.findById(userId) .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_USER)); + if (user.getRole() != UserRole.ADMIN) { + throw CommonException.from(ExceptionCode.NOT_FOUND_ADMIN); + } + Admin admin = Admin.builder() .user(user) .displayName("토덕 관리자") diff --git a/src/main/java/im/toduck/domain/inquiry/domain/service/InquiryAnswerService.java b/src/main/java/im/toduck/domain/inquiry/domain/service/InquiryAnswerService.java index 20101691..65155333 100644 --- a/src/main/java/im/toduck/domain/inquiry/domain/service/InquiryAnswerService.java +++ b/src/main/java/im/toduck/domain/inquiry/domain/service/InquiryAnswerService.java @@ -44,6 +44,7 @@ public InquiryAnswer createInquiryAnswer(final InquiryAnswerCreateRequest reques } existing.revive(request.content(), admin); + existing.setInquiry(inquiry); inquiry.addAnswer(existing); inquiry.changeStatus(Status.ANSWERED); return inquiryAnswerRepository.save(existing); @@ -56,7 +57,6 @@ public InquiryAnswer createInquiryAnswer(final InquiryAnswerCreateRequest reques .build(); inquiry.addAnswer(newAnswer); - inquiryAnswerRepository.save(newAnswer); inquiry.changeStatus(Status.ANSWERED); return newAnswer; diff --git a/src/main/java/im/toduck/domain/inquiry/domain/service/InquiryService.java b/src/main/java/im/toduck/domain/inquiry/domain/service/InquiryService.java index 5a88bcce..280925ed 100644 --- a/src/main/java/im/toduck/domain/inquiry/domain/service/InquiryService.java +++ b/src/main/java/im/toduck/domain/inquiry/domain/service/InquiryService.java @@ -67,11 +67,12 @@ public void updateInquiry(final InquiryUpdateRequest request, final Inquiry inqu inquiry.updateContent(request.content()); } - if (request.inquiryImgs() != null && !request.inquiryImgs().isEmpty()) { - inquiryImgRepository.deleteAllByInquiry(inquiry); - addInquiryImages(inquiry, request.inquiryImgs()); - } else { - inquiryImgRepository.deleteAllByInquiry(inquiry); + if (request.inquiryImgs() != null) { + List images = inquiryImgRepository.findAllByInquiry(inquiry); + images.forEach(inquiryImgRepository::delete); + if (!request.inquiryImgs().isEmpty()) { + addInquiryImages(inquiry, request.inquiryImgs()); + } } } diff --git a/src/main/java/im/toduck/domain/inquiry/domain/usecase/InquiryUseCase.java b/src/main/java/im/toduck/domain/inquiry/domain/usecase/InquiryUseCase.java index 034321d8..26d1af58 100644 --- a/src/main/java/im/toduck/domain/inquiry/domain/usecase/InquiryUseCase.java +++ b/src/main/java/im/toduck/domain/inquiry/domain/usecase/InquiryUseCase.java @@ -58,6 +58,10 @@ public Inquiry updateInquiry( Inquiry inquiry = inquiryService.getInquiryById(inquiryId) .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_INQUIRY)); + if (!inquiry.getUser().getId().equals(userId)) { + throw CommonException.from(ExceptionCode.UNAUTHORIZED_ACCESS_INQUIRY); + } + InquiryAnswer answer = inquiry.getInquiryAnswer(); if (answer != null && answer.getDeletedAt() == null) { throw CommonException.from(ExceptionCode.ALREADY_ANSWERED_INQUIRY); @@ -75,6 +79,10 @@ public void deleteInquiry(final Long inquiryId, final Long userId) { Inquiry inquiry = inquiryService.findById(inquiryId) .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_INQUIRY)); + if (!inquiry.getUser().getId().equals(userId)) { + throw CommonException.from(ExceptionCode.UNAUTHORIZED_ACCESS_INQUIRY); + } + InquiryAnswer answer = inquiry.getInquiryAnswer(); if (answer != null && answer.getDeletedAt() == null) { throw CommonException.from(ExceptionCode.ALREADY_ANSWERED_INQUIRY); diff --git a/src/main/java/im/toduck/domain/inquiry/persistence/entity/Inquiry.java b/src/main/java/im/toduck/domain/inquiry/persistence/entity/Inquiry.java index 7a852a1f..d8c1410d 100644 --- a/src/main/java/im/toduck/domain/inquiry/persistence/entity/Inquiry.java +++ b/src/main/java/im/toduck/domain/inquiry/persistence/entity/Inquiry.java @@ -55,7 +55,7 @@ public class Inquiry extends BaseEntity { @OneToMany(mappedBy = "inquiry", cascade = CascadeType.ALL) private List inquiryImages = new ArrayList<>(); - @OneToOne(mappedBy = "inquiry", fetch = FetchType.LAZY) + @OneToOne(mappedBy = "inquiry", cascade = CascadeType.ALL, fetch = FetchType.LAZY) private InquiryAnswer inquiryAnswer; @Builder @@ -88,7 +88,7 @@ public void addAnswer(final InquiryAnswer answer) { public void removeAnswer() { if (this.inquiryAnswer != null) { - this.inquiryAnswer.setInquiry(null); + this.inquiryAnswer.delete(); this.inquiryAnswer = null; } } diff --git a/src/main/java/im/toduck/domain/inquiry/persistence/entity/InquiryAnswer.java b/src/main/java/im/toduck/domain/inquiry/persistence/entity/InquiryAnswer.java index b9a3beb0..5fadf2b7 100644 --- a/src/main/java/im/toduck/domain/inquiry/persistence/entity/InquiryAnswer.java +++ b/src/main/java/im/toduck/domain/inquiry/persistence/entity/InquiryAnswer.java @@ -1,5 +1,7 @@ package im.toduck.domain.inquiry.persistence.entity; +import java.time.LocalDateTime; + import org.hibernate.annotations.SQLDelete; import org.hibernate.annotations.SQLRestriction; @@ -60,4 +62,8 @@ public void revive(final String content, final Admin admin) { this.content = content; this.admin = admin; } + + public void delete() { + this.deletedAt = LocalDateTime.now(); + } } diff --git a/src/main/java/im/toduck/domain/inquiry/persistence/entity/InquiryImage.java b/src/main/java/im/toduck/domain/inquiry/persistence/entity/InquiryImage.java index b9a9d5a0..10fef170 100644 --- a/src/main/java/im/toduck/domain/inquiry/persistence/entity/InquiryImage.java +++ b/src/main/java/im/toduck/domain/inquiry/persistence/entity/InquiryImage.java @@ -2,6 +2,9 @@ import java.time.LocalDateTime; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + import im.toduck.global.base.entity.BaseEntity; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -20,6 +23,8 @@ @Table(name = "inquiry_image_file") @Getter @NoArgsConstructor +@SQLDelete(sql = "UPDATE inquiry_image_file SET deleted_at = NOW() where id=?") +@SQLRestriction(value = "deleted_at is NULL") public class InquiryImage extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/src/main/java/im/toduck/domain/inquiry/persistence/repository/InquiryImgRepository.java b/src/main/java/im/toduck/domain/inquiry/persistence/repository/InquiryImgRepository.java index 65727e15..06555d07 100644 --- a/src/main/java/im/toduck/domain/inquiry/persistence/repository/InquiryImgRepository.java +++ b/src/main/java/im/toduck/domain/inquiry/persistence/repository/InquiryImgRepository.java @@ -1,5 +1,7 @@ package im.toduck.domain.inquiry.persistence.repository; +import java.util.List; + import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @@ -8,5 +10,5 @@ @Repository public interface InquiryImgRepository extends JpaRepository { - void deleteAllByInquiry(Inquiry inquiry); + List findAllByInquiry(Inquiry inquiry); } diff --git a/src/main/java/im/toduck/domain/inquiry/persistence/repository/querydsl/InquiryRepositoryCustomImpl.java b/src/main/java/im/toduck/domain/inquiry/persistence/repository/querydsl/InquiryRepositoryCustomImpl.java index 599d7552..00357666 100644 --- a/src/main/java/im/toduck/domain/inquiry/persistence/repository/querydsl/InquiryRepositoryCustomImpl.java +++ b/src/main/java/im/toduck/domain/inquiry/persistence/repository/querydsl/InquiryRepositoryCustomImpl.java @@ -1,7 +1,5 @@ package im.toduck.domain.inquiry.persistence.repository.querydsl; -import static im.toduck.domain.admin.persistence.entity.QAdmin.*; - import java.util.List; import org.springframework.stereotype.Repository; @@ -28,12 +26,13 @@ public List findWithImgs(final Long userId) { return queryFactory .selectFrom(iq) - .leftJoin(iq.inquiryImages, iqi).on(iqi.deletedAt.isNull()) - .leftJoin(iq.inquiryAnswer, iqa).on(iqa.deletedAt.isNull()) - .leftJoin(iqa.admin, admin) + .leftJoin(iq.inquiryImages, iqi).fetchJoin() + .leftJoin(iq.inquiryAnswer, iqa).fetchJoin() + .leftJoin(iqa.admin).fetchJoin() .where( iq.user.id.eq(userId), - iq.deletedAt.isNull()) + iq.deletedAt.isNull() + ) .orderBy(iq.createdAt.desc()) .distinct() .fetch(); @@ -47,9 +46,9 @@ public List findAllWithImgs() { return queryFactory .selectFrom(iq) - .leftJoin(iq.inquiryImages, iqi).on(iqi.deletedAt.isNull()) - .leftJoin(iq.inquiryAnswer, iqa).on(iqa.deletedAt.isNull()) - .leftJoin(iqa.admin, admin) + .leftJoin(iq.inquiryImages, iqi).fetchJoin() + .leftJoin(iq.inquiryAnswer, iqa).fetchJoin() + .leftJoin(iqa.admin).fetchJoin() .where(iq.deletedAt.isNull()) .orderBy(iq.createdAt.desc()) .distinct() diff --git a/src/main/java/im/toduck/global/exception/ExceptionCode.java b/src/main/java/im/toduck/global/exception/ExceptionCode.java index c916a6c7..78b96d3d 100644 --- a/src/main/java/im/toduck/global/exception/ExceptionCode.java +++ b/src/main/java/im/toduck/global/exception/ExceptionCode.java @@ -126,6 +126,7 @@ public enum ExceptionCode { /* 420xx Inquiry */ NOT_FOUND_INQUIRY(HttpStatus.NOT_FOUND, 42001, "문의 정보를 찾을 수 없습니다."), + UNAUTHORIZED_ACCESS_INQUIRY(HttpStatus.FORBIDDEN, 42002, "문의에 접근 권한이 없습니다."), /* 421xx InquiryAnswer */ NOT_FOUND_INQUIRY_ANSWER(HttpStatus.NOT_FOUND, 42101, "문의 답변 정보를 찾을 수 없습니다."), From 9539a1bf36b6273290caf4dfae21d436bec3afb3 Mon Sep 17 00:00:00 2001 From: wafla Date: Mon, 9 Feb 2026 15:51:17 +0900 Subject: [PATCH 10/11] =?UTF-8?q?fix:=20=EC=98=A4=EB=A5=98=20=EC=82=AC?= =?UTF-8?q?=ED=95=AD=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../im/toduck/domain/admin/domain/service/AdminService.java | 1 - .../im/toduck/domain/admin/domain/usecase/AdminUseCase.java | 4 ---- .../toduck/domain/inquiry/domain/service/InquiryService.java | 5 ----- .../toduck/domain/inquiry/domain/usecase/InquiryUseCase.java | 2 +- .../presentation/dto/request/InquiryUpdateRequest.java | 4 ---- 5 files changed, 1 insertion(+), 15 deletions(-) diff --git a/src/main/java/im/toduck/domain/admin/domain/service/AdminService.java b/src/main/java/im/toduck/domain/admin/domain/service/AdminService.java index 5e3a8096..7b23ad5d 100644 --- a/src/main/java/im/toduck/domain/admin/domain/service/AdminService.java +++ b/src/main/java/im/toduck/domain/admin/domain/service/AdminService.java @@ -38,7 +38,6 @@ public Admin getAdminBySameUser(final Long userId) { .orElseGet(() -> createDefaultAdmin(userId)); } - @Transactional private Admin createDefaultAdmin(final Long userId) { User user = userRepository.findById(userId) .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_USER)); diff --git a/src/main/java/im/toduck/domain/admin/domain/usecase/AdminUseCase.java b/src/main/java/im/toduck/domain/admin/domain/usecase/AdminUseCase.java index 9597153d..f5177e67 100644 --- a/src/main/java/im/toduck/domain/admin/domain/usecase/AdminUseCase.java +++ b/src/main/java/im/toduck/domain/admin/domain/usecase/AdminUseCase.java @@ -87,10 +87,6 @@ public void updateAdmin(final Long userId, final AdminUpdateRequest request) { public void deleteAdmin(final Long userId) { Admin admin = adminService.getAdmin(userId); - if (admin.getDeletedAt() != null) { - return; - } - User user = admin.getUser(); user.demoteToUser(); diff --git a/src/main/java/im/toduck/domain/inquiry/domain/service/InquiryService.java b/src/main/java/im/toduck/domain/inquiry/domain/service/InquiryService.java index 280925ed..05ac4c06 100644 --- a/src/main/java/im/toduck/domain/inquiry/domain/service/InquiryService.java +++ b/src/main/java/im/toduck/domain/inquiry/domain/service/InquiryService.java @@ -76,11 +76,6 @@ public void updateInquiry(final InquiryUpdateRequest request, final Inquiry inqu } } - @Transactional - public Optional findById(final Long inquiryId) { - return inquiryRepository.findById(inquiryId); - } - @Transactional public void deleteInquiry(final Inquiry inquiry) { inquiryRepository.delete(inquiry); diff --git a/src/main/java/im/toduck/domain/inquiry/domain/usecase/InquiryUseCase.java b/src/main/java/im/toduck/domain/inquiry/domain/usecase/InquiryUseCase.java index 26d1af58..9e999534 100644 --- a/src/main/java/im/toduck/domain/inquiry/domain/usecase/InquiryUseCase.java +++ b/src/main/java/im/toduck/domain/inquiry/domain/usecase/InquiryUseCase.java @@ -76,7 +76,7 @@ public void deleteInquiry(final Long inquiryId, final Long userId) { User user = userService.getUserById(userId) .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_USER)); - Inquiry inquiry = inquiryService.findById(inquiryId) + Inquiry inquiry = inquiryService.getInquiryById(inquiryId) .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_INQUIRY)); if (!inquiry.getUser().getId().equals(userId)) { diff --git a/src/main/java/im/toduck/domain/inquiry/presentation/dto/request/InquiryUpdateRequest.java b/src/main/java/im/toduck/domain/inquiry/presentation/dto/request/InquiryUpdateRequest.java index 5e7ef328..a6f38e00 100644 --- a/src/main/java/im/toduck/domain/inquiry/presentation/dto/request/InquiryUpdateRequest.java +++ b/src/main/java/im/toduck/domain/inquiry/presentation/dto/request/InquiryUpdateRequest.java @@ -12,10 +12,6 @@ @Builder @Schema(description = "문의 수정 요청 DTO") public record InquiryUpdateRequest( - @NotNull(message = "문의 ID는 비어있을 수 없습니다.") - @Schema(description = "문의 ID", example = "1") - Long inquiryId, - @NotNull(message = "문의 유형은 비어있을 수 없습니다.") @Schema(description = "문의 유형", example = "USAGE") Type type, From fc15b2454b1d62d9a58b50f401383ebc30030363 Mon Sep 17 00:00:00 2001 From: wafla Date: Mon, 9 Feb 2026 16:02:16 +0900 Subject: [PATCH 11/11] =?UTF-8?q?fix:=20=EC=98=A4=EB=A5=98=20=EC=82=AC?= =?UTF-8?q?=ED=95=AD=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../im/toduck/domain/admin/domain/service/AdminService.java | 4 ++-- .../im/toduck/domain/admin/domain/usecase/AdminUseCase.java | 2 -- .../toduck/domain/inquiry/domain/usecase/InquiryUseCase.java | 3 --- 3 files changed, 2 insertions(+), 7 deletions(-) diff --git a/src/main/java/im/toduck/domain/admin/domain/service/AdminService.java b/src/main/java/im/toduck/domain/admin/domain/service/AdminService.java index 7b23ad5d..f4500eb0 100644 --- a/src/main/java/im/toduck/domain/admin/domain/service/AdminService.java +++ b/src/main/java/im/toduck/domain/admin/domain/service/AdminService.java @@ -79,9 +79,9 @@ public Admin createAdmin(final AdminCreateRequest request, final User user) { @Transactional public void updateAdmin(final Long userId, final AdminUpdateRequest request) { + Admin admin = adminRepository.findActiveAdminByUserId(userId) + .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_ADMIN)); if (request.displayName() != null) { - Admin admin = adminRepository.findActiveAdminByUserId(userId) - .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_ADMIN)); admin.updateDisplayName(request.displayName()); } } diff --git a/src/main/java/im/toduck/domain/admin/domain/usecase/AdminUseCase.java b/src/main/java/im/toduck/domain/admin/domain/usecase/AdminUseCase.java index f5177e67..3b1c1e6d 100644 --- a/src/main/java/im/toduck/domain/admin/domain/usecase/AdminUseCase.java +++ b/src/main/java/im/toduck/domain/admin/domain/usecase/AdminUseCase.java @@ -78,8 +78,6 @@ public void updateAdmin(final Long userId, final AdminUpdateRequest request) { User user = userService.getUserById(userId) .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_USER)); - Admin admin = adminService.getAdmin(userId); - adminService.updateAdmin(userId, request); } diff --git a/src/main/java/im/toduck/domain/inquiry/domain/usecase/InquiryUseCase.java b/src/main/java/im/toduck/domain/inquiry/domain/usecase/InquiryUseCase.java index 9e999534..0738ab56 100644 --- a/src/main/java/im/toduck/domain/inquiry/domain/usecase/InquiryUseCase.java +++ b/src/main/java/im/toduck/domain/inquiry/domain/usecase/InquiryUseCase.java @@ -8,7 +8,6 @@ import im.toduck.domain.inquiry.domain.service.InquiryService; import im.toduck.domain.inquiry.persistence.entity.Inquiry; import im.toduck.domain.inquiry.persistence.entity.InquiryAnswer; -import im.toduck.domain.inquiry.persistence.entity.InquiryImage; import im.toduck.domain.inquiry.presentation.dto.request.InquiryCreateRequest; import im.toduck.domain.inquiry.presentation.dto.request.InquiryUpdateRequest; import im.toduck.domain.inquiry.presentation.dto.response.InquiryListResponse; @@ -88,8 +87,6 @@ public void deleteInquiry(final Long inquiryId, final Long userId) { throw CommonException.from(ExceptionCode.ALREADY_ANSWERED_INQUIRY); } - inquiry.getInquiryImages().forEach(InquiryImage::softDelete); - inquiryService.deleteInquiry(inquiry); }