From 70af1bb049c98446a8fd81eea25c991b29b65bab Mon Sep 17 00:00:00 2001 From: Hwang HyeonSik <142300831+Choon0414@users.noreply.github.com> Date: Sun, 20 Oct 2024 21:15:52 +0900 Subject: [PATCH 01/12] =?UTF-8?q?fix=20:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=ED=95=84=EB=93=9C=20required=EB=A1=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20(#973)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore : 테스트 알림 필드 not null 수정 * chore : 테스트 알림 필드 not null 수정 --- .../koin/global/domain/test/controller/TestApi.java | 11 ++++++----- .../global/domain/test/controller/TestController.java | 8 ++++---- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/main/java/in/koreatech/koin/global/domain/test/controller/TestApi.java b/src/main/java/in/koreatech/koin/global/domain/test/controller/TestApi.java index c6c42e84a..180ee744d 100644 --- a/src/main/java/in/koreatech/koin/global/domain/test/controller/TestApi.java +++ b/src/main/java/in/koreatech/koin/global/domain/test/controller/TestApi.java @@ -28,11 +28,12 @@ public interface TestApi { @GetMapping("/notification") ResponseEntity testSendMessage( @Parameter(description = "device token") @RequestParam String deviceToken, - @Parameter(description = "알림 제목") @RequestParam(required = false) String title, - @Parameter(description = "알림 내용") @RequestParam(required = false) String body, - @Parameter(description = "이미지 url") @RequestParam(required = false) String image, - @Parameter(description = "app path") @RequestParam(required = false) MobileAppPath mobileAppPath, - @Parameter(description = "스킴 uri(ex: shop?id=1)") @RequestParam(required = false) String url + @Parameter(description = "알림 제목") @RequestParam String title, + @Parameter(description = "알림 내용") @RequestParam String body, + @Parameter(description = "이미지 url") @RequestParam String image, + @Parameter(description = "app path", required = true) + @RequestParam(defaultValue = "HOME") MobileAppPath mobileAppPath, + @Parameter(description = "스킴 uri(ex: shop?id=1)") @RequestParam String url ); } diff --git a/src/main/java/in/koreatech/koin/global/domain/test/controller/TestController.java b/src/main/java/in/koreatech/koin/global/domain/test/controller/TestController.java index 9cb0ff943..e6d14231b 100644 --- a/src/main/java/in/koreatech/koin/global/domain/test/controller/TestController.java +++ b/src/main/java/in/koreatech/koin/global/domain/test/controller/TestController.java @@ -21,11 +21,11 @@ public class TestController implements TestApi { @GetMapping("/notification") public ResponseEntity testSendMessage( @RequestParam String deviceToken, - @RequestParam(required = false) String title, - @RequestParam(required = false) String body, - @RequestParam(required = false) String image, + @RequestParam String title, + @RequestParam String body, + @RequestParam String image, @RequestParam(defaultValue = "HOME") MobileAppPath appPath, - @RequestParam(required = false) String uri + @RequestParam String uri ) { fcmClient.sendMessage( deviceToken, From 45e09402583e55e1254ca16d832816fb794b05a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=84=B1=EC=9E=AC?= <103095432+seongjae6751@users.noreply.github.com> Date: Wed, 23 Oct 2024 00:20:55 +0900 Subject: [PATCH 02/12] =?UTF-8?q?feat:=20=ED=82=A4=EC=9B=8C=EB=93=9C=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20redisson=20lock=20=EC=A0=90=EC=9C=A0=20?= =?UTF-8?q?=EC=8B=9C=EA=B0=84=20=EC=97=B0=EC=9E=A5,=20=ED=82=A4=EC=9B=8C?= =?UTF-8?q?=EB=93=9C=20=EC=82=AD=EC=A0=9C=20redisson=20=EC=A0=81=EC=9A=A9,?= =?UTF-8?q?=20=ED=95=99=EC=83=9D=20=EC=9D=B8=EC=A6=9D=20redisson=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9=20(#974)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: 학생 인증 동시성 제어 * feat: 키워드 생성 redisson 시간 연장, 키워드 삭제 redisson 적용 --- .../koin/domain/community/keyword/service/KeywordService.java | 4 ++-- .../koreatech/koin/domain/student/service/StudentService.java | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/main/java/in/koreatech/koin/domain/community/keyword/service/KeywordService.java b/src/main/java/in/koreatech/koin/domain/community/keyword/service/KeywordService.java index 14ad1f80c..87455e3d3 100644 --- a/src/main/java/in/koreatech/koin/domain/community/keyword/service/KeywordService.java +++ b/src/main/java/in/koreatech/koin/domain/community/keyword/service/KeywordService.java @@ -51,7 +51,7 @@ public class KeywordService { private final ArticleRepository articleRepository; private final UserRepository userRepository; - @ConcurrencyGuard(lockName = "createKeyword") + @ConcurrencyGuard(lockName = "createKeyword", waitTime = 7, leaseTime = 5) public ArticleKeywordResponse createKeyword(Integer userId, ArticleKeywordCreateRequest request) { String keyword = validateAndGetKeyword(request.keyword()); if (articleKeywordUserMapRepository.countByUserId(userId) >= ARTICLE_KEYWORD_LIMIT) { @@ -77,7 +77,7 @@ public ArticleKeywordResponse createKeyword(Integer userId, ArticleKeywordCreate return new ArticleKeywordResponse(keywordUserMap.getId(), existingKeyword.getKeyword()); } - @Transactional + @ConcurrencyGuard(lockName = "deleteKeyword") public void deleteKeyword(Integer userId, Integer keywordUserMapId) { ArticleKeywordUserMap articleKeywordUserMap = articleKeywordUserMapRepository.getById(keywordUserMapId); if (!Objects.equals(articleKeywordUserMap.getUser().getId(), userId)) { diff --git a/src/main/java/in/koreatech/koin/domain/student/service/StudentService.java b/src/main/java/in/koreatech/koin/domain/student/service/StudentService.java index bcbb447ae..06b6f10f3 100644 --- a/src/main/java/in/koreatech/koin/domain/student/service/StudentService.java +++ b/src/main/java/in/koreatech/koin/domain/student/service/StudentService.java @@ -37,6 +37,7 @@ import in.koreatech.koin.domain.user.repository.UserTokenRepository; import in.koreatech.koin.global.auth.JwtProvider; import in.koreatech.koin.global.auth.exception.AuthorizationException; +import in.koreatech.koin.global.concurrent.ConcurrencyGuard; import in.koreatech.koin.global.domain.email.exception.DuplicationEmailException; import in.koreatech.koin.global.domain.email.form.StudentPasswordChangeData; import in.koreatech.koin.global.domain.email.form.StudentRegistrationData; @@ -117,7 +118,7 @@ public void checkDepartmentValid(String department) { } } - @Transactional + @ConcurrencyGuard(lockName = "studentAuthenticate") public ModelAndView authenticate(AuthTokenRequest request) { Optional studentTemporaryStatus = studentRedisRepository.findByAuthToken( request.authToken()); From 0e588cdd9e255ea0b3a7a622d4ee6b0b5906bac8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=84=B1=EB=B9=88?= <46699595+ImTotem@users.noreply.github.com> Date: Sun, 27 Oct 2024 14:31:42 +0900 Subject: [PATCH 03/12] =?UTF-8?q?feat:=20=EA=B3=B5=EC=A7=80=EC=82=AC?= =?UTF-8?q?=ED=95=AD=20=EB=B0=94=EB=A1=9C=EA=B0=80=EA=B8=B0=20url=20Respon?= =?UTF-8?q?se=20=EC=B6=94=EA=B0=80=20(#980)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../koin/domain/community/article/dto/ArticleResponse.java | 4 ++++ .../koin/domain/community/article/model/Article.java | 5 ++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/main/java/in/koreatech/koin/domain/community/article/dto/ArticleResponse.java b/src/main/java/in/koreatech/koin/domain/community/article/dto/ArticleResponse.java index a578b8439..0d9515a42 100644 --- a/src/main/java/in/koreatech/koin/domain/community/article/dto/ArticleResponse.java +++ b/src/main/java/in/koreatech/koin/domain/community/article/dto/ArticleResponse.java @@ -35,6 +35,9 @@ public record ArticleResponse( @Schema(description = "조회수", example = "1", requiredMode = REQUIRED) Integer hit, + @Schema(description = "공지 원본 url", example = "https://portal.koreatech.ac.kr/ctt/bb/bulletin?b=14&ls=20&ln=1&dm=r&p=33248") + String url, + @Schema(description = "첨부 파일") List attachments, @@ -59,6 +62,7 @@ public static ArticleResponse of(Article article) { article.getContent(), article.getAuthor(), article.getTotalHit(), + article.getUrl(), article.getAttachments().stream() .map(InnerArticleAttachmentResponse::from) .toList(), diff --git a/src/main/java/in/koreatech/koin/domain/community/article/model/Article.java b/src/main/java/in/koreatech/koin/domain/community/article/model/Article.java index ce7c9113a..3363d78ad 100644 --- a/src/main/java/in/koreatech/koin/domain/community/article/model/Article.java +++ b/src/main/java/in/koreatech/koin/domain/community/article/model/Article.java @@ -148,7 +148,10 @@ public int getArticleNum() { } public String getUrl() { - return this.koreatechArticle.getUrl(); + if (this.koreatechArticle != null) { + return this.koreatechArticle.getUrl(); + } + return null; } public void delete() { From b17a046cdfd08c769b5ef54f14be250ed9ddd198 Mon Sep 17 00:00:00 2001 From: krSeonghyeon <149303551+krSeonghyeon@users.noreply.github.com> Date: Thu, 31 Oct 2024 23:06:08 +0900 Subject: [PATCH 04/12] =?UTF-8?q?fix:=20ShopCategory=20isDeleted=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=20=EC=82=AD=EC=A0=9C=20=EB=B0=8F=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95=20(#984)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: ShopCategory isDeleted 필드 삭제 및 관련 코드 수정 * refactor: 개행 추가 및 미사용 인자 삭제 * refactor: 인자 변경으로 인한 빌더변경 * refactor: 인자 변경으로 인한 테스트 코드 변경 * feat: 상점 카테고리 삭제 예외처리 추가 * feat: 응답코드 400 swagger 명세 추가 --- .../admin/shop/controller/AdminShopApi.java | 4 ++-- .../shop/controller/AdminShopController.java | 5 ++-- .../dto/AdminCreateShopCategoryRequest.java | 1 - .../ShopCategoryNotEmptyException.java | 20 ++++++++++++++++ .../AdminShopCategoryMapRepository.java | 2 ++ .../AdminShopCategoryRepository.java | 8 ++++--- .../admin/shop/service/AdminShopService.java | 17 +++++++++---- .../domain/shop/model/shop/ShopCategory.java | 15 +----------- .../V82__delete_shop_categories_isdeleted.sql | 1 + .../admin/acceptance/AdminShopApiTest.java | 24 ++++++++++++++----- .../koin/fixture/ShopCategoryFixture.java | 2 -- 11 files changed, 63 insertions(+), 36 deletions(-) create mode 100644 src/main/java/in/koreatech/koin/admin/shop/exception/ShopCategoryNotEmptyException.java create mode 100644 src/main/resources/db/migration/V82__delete_shop_categories_isdeleted.sql diff --git a/src/main/java/in/koreatech/koin/admin/shop/controller/AdminShopApi.java b/src/main/java/in/koreatech/koin/admin/shop/controller/AdminShopApi.java index 0ed8a93c3..cf16ce094 100644 --- a/src/main/java/in/koreatech/koin/admin/shop/controller/AdminShopApi.java +++ b/src/main/java/in/koreatech/koin/admin/shop/controller/AdminShopApi.java @@ -71,7 +71,6 @@ ResponseEntity getShop( ResponseEntity getShopCategories( @RequestParam(name = "page", defaultValue = "1") Integer page, @RequestParam(name = "limit", defaultValue = "10", required = false) Integer limit, - @RequestParam(name = "is_deleted", defaultValue = "false") Boolean isDeleted, @Auth(permit = {ADMIN}) Integer adminId ); @@ -295,7 +294,8 @@ ResponseEntity deleteShop( @ApiResponses( value = { - @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "204"), + @ApiResponse(responseCode = "400", content = @Content(schema = @Schema(hidden = true))), @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), diff --git a/src/main/java/in/koreatech/koin/admin/shop/controller/AdminShopController.java b/src/main/java/in/koreatech/koin/admin/shop/controller/AdminShopController.java index d29477cef..da6807a72 100644 --- a/src/main/java/in/koreatech/koin/admin/shop/controller/AdminShopController.java +++ b/src/main/java/in/koreatech/koin/admin/shop/controller/AdminShopController.java @@ -52,10 +52,9 @@ public ResponseEntity getShop( public ResponseEntity getShopCategories( @RequestParam(name = "page", defaultValue = "1") Integer page, @RequestParam(name = "limit", defaultValue = "10", required = false) Integer limit, - @RequestParam(name = "is_deleted", defaultValue = "false") Boolean isDeleted, @Auth(permit = {ADMIN}) Integer adminId ) { - AdminShopCategoriesResponse response = adminShopService.getShopCategories(page, limit, isDeleted); + AdminShopCategoriesResponse response = adminShopService.getShopCategories(page, limit); return ResponseEntity.ok(response); } @@ -199,7 +198,7 @@ public ResponseEntity deleteShopCategory( @Auth(permit = {ADMIN}) Integer adminId ) { adminShopService.deleteShopCategory(id); - return ResponseEntity.ok().build(); + return ResponseEntity.noContent().build(); } @DeleteMapping("/admin/shops/{shopId}/menus/categories/{categoryId}") diff --git a/src/main/java/in/koreatech/koin/admin/shop/dto/AdminCreateShopCategoryRequest.java b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminCreateShopCategoryRequest.java index b664bba7d..f9f146345 100644 --- a/src/main/java/in/koreatech/koin/admin/shop/dto/AdminCreateShopCategoryRequest.java +++ b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminCreateShopCategoryRequest.java @@ -26,7 +26,6 @@ public ShopCategory toShopCategory() { return ShopCategory.builder() .imageUrl(imageUrl) .name(name) - .isDeleted(false) .build(); } } diff --git a/src/main/java/in/koreatech/koin/admin/shop/exception/ShopCategoryNotEmptyException.java b/src/main/java/in/koreatech/koin/admin/shop/exception/ShopCategoryNotEmptyException.java new file mode 100644 index 000000000..8defcaac9 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/shop/exception/ShopCategoryNotEmptyException.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.admin.shop.exception; + +import in.koreatech.koin.global.exception.KoinIllegalArgumentException; + +public class ShopCategoryNotEmptyException extends KoinIllegalArgumentException { + + private static final String DEFAULT_MESSAGE = "카테고리에 상점이 남아있어 삭제할 수 없습니다"; + + public ShopCategoryNotEmptyException(String message) { + super(message); + } + + public ShopCategoryNotEmptyException(String message, String detail) { + super(message, detail); + } + + public static ShopCategoryNotEmptyException withDetail(String detail) { + return new ShopCategoryNotEmptyException(DEFAULT_MESSAGE, detail); + } +} diff --git a/src/main/java/in/koreatech/koin/admin/shop/repository/AdminShopCategoryMapRepository.java b/src/main/java/in/koreatech/koin/admin/shop/repository/AdminShopCategoryMapRepository.java index 294193512..e441a6bd0 100644 --- a/src/main/java/in/koreatech/koin/admin/shop/repository/AdminShopCategoryMapRepository.java +++ b/src/main/java/in/koreatech/koin/admin/shop/repository/AdminShopCategoryMapRepository.java @@ -11,4 +11,6 @@ public interface AdminShopCategoryMapRepository extends Repository findAllByShopId(Integer shopId); + + boolean existsByShopCategoryId(Integer categoryId); } diff --git a/src/main/java/in/koreatech/koin/admin/shop/repository/AdminShopCategoryRepository.java b/src/main/java/in/koreatech/koin/admin/shop/repository/AdminShopCategoryRepository.java index 4a115a863..12e68ada2 100644 --- a/src/main/java/in/koreatech/koin/admin/shop/repository/AdminShopCategoryRepository.java +++ b/src/main/java/in/koreatech/koin/admin/shop/repository/AdminShopCategoryRepository.java @@ -14,9 +14,7 @@ public interface AdminShopCategoryRepository extends Repository { - Page findAllByIsDeleted(boolean isDeleted, Pageable pageable); - - Integer countAllByIsDeleted(boolean isDeleted); + Page findAll(Pageable pageable); @Query(value = "SELECT * FROM shop_categories WHERE id = :shopCategoryId", nativeQuery = true) Optional findById(@Param("shopCategoryId") Integer shopCategoryId); @@ -33,4 +31,8 @@ default ShopCategory getById(Integer shopCategoryId) { return findById(shopCategoryId) .orElseThrow(() -> ShopCategoryNotFoundException.withDetail("shopCategoryId: " + shopCategoryId)); } + + Integer count(); + + void deleteById(Integer shopCategoryId); } diff --git a/src/main/java/in/koreatech/koin/admin/shop/service/AdminShopService.java b/src/main/java/in/koreatech/koin/admin/shop/service/AdminShopService.java index b63b33e81..359d3eb50 100644 --- a/src/main/java/in/koreatech/koin/admin/shop/service/AdminShopService.java +++ b/src/main/java/in/koreatech/koin/admin/shop/service/AdminShopService.java @@ -10,6 +10,7 @@ import java.util.Optional; import in.koreatech.koin.admin.shop.dto.*; +import in.koreatech.koin.admin.shop.exception.ShopCategoryNotEmptyException; import in.koreatech.koin.admin.shop.repository.*; import in.koreatech.koin.domain.shop.exception.ReviewNotFoundException; @@ -76,12 +77,12 @@ public AdminShopResponse getShop(Integer shopId) { return AdminShopResponse.from(shop, eventDuration); } - public AdminShopCategoriesResponse getShopCategories(Integer page, Integer limit, Boolean isDeleted) { - Integer total = adminShopCategoryRepository.countAllByIsDeleted(isDeleted); + public AdminShopCategoriesResponse getShopCategories(Integer page, Integer limit) { + Integer total = adminShopCategoryRepository.count(); Criteria criteria = Criteria.of(page, limit, total); PageRequest pageRequest = PageRequest.of(criteria.getPage(), criteria.getLimit(), Sort.by(Sort.Direction.ASC, "id")); - Page result = adminShopCategoryRepository.findAllByIsDeleted(isDeleted, pageRequest); + Page result = adminShopCategoryRepository.findAll(pageRequest); return AdminShopCategoriesResponse.of(result, criteria); } @@ -288,8 +289,14 @@ public void deleteShop(Integer shopId) { @Transactional public void deleteShopCategory(Integer categoryId) { - ShopCategory shopCategory = adminShopCategoryRepository.getById(categoryId); - shopCategory.delete(); + if (hasShops(categoryId)) { + throw ShopCategoryNotEmptyException.withDetail("category: " + categoryId); + } + adminShopCategoryRepository.deleteById(categoryId); + } + + private boolean hasShops(Integer categoryId) { + return adminShopCategoryMapRepository.existsByShopCategoryId(categoryId); } @Transactional diff --git a/src/main/java/in/koreatech/koin/domain/shop/model/shop/ShopCategory.java b/src/main/java/in/koreatech/koin/domain/shop/model/shop/ShopCategory.java index 38a2980d6..bce64fbc1 100644 --- a/src/main/java/in/koreatech/koin/domain/shop/model/shop/ShopCategory.java +++ b/src/main/java/in/koreatech/koin/domain/shop/model/shop/ShopCategory.java @@ -7,8 +7,6 @@ import java.util.ArrayList; import java.util.List; -import org.hibernate.annotations.Where; - import in.koreatech.koin.global.domain.BaseEntity; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -16,7 +14,6 @@ import jakarta.persistence.Id; import jakarta.persistence.OneToMany; import jakarta.persistence.Table; -import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; import lombok.AccessLevel; import lombok.Builder; @@ -27,7 +24,6 @@ @Entity @NoArgsConstructor(access = AccessLevel.PROTECTED) @Table(name = "shop_categories") -@Where(clause = "is_deleted=0") public class ShopCategory extends BaseEntity { @Id @@ -42,26 +38,17 @@ public class ShopCategory extends BaseEntity { @Column(name = "image_url") private String imageUrl; - @NotNull - @Column(name = "is_deleted", nullable = false) - private boolean isDeleted = false; - @OneToMany(mappedBy = "shopCategory", orphanRemoval = true, cascade = {PERSIST, REMOVE}) private List shopCategoryMaps = new ArrayList<>(); @Builder - private ShopCategory(String name, String imageUrl, Boolean isDeleted) { + private ShopCategory(String name, String imageUrl) { this.name = name; this.imageUrl = imageUrl; - this.isDeleted = isDeleted; } public void modifyShopCategory(String name, String imageUrl) { this.name = name; this.imageUrl = imageUrl; } - - public void delete() { - this.isDeleted = true; - } } diff --git a/src/main/resources/db/migration/V82__delete_shop_categories_isdeleted.sql b/src/main/resources/db/migration/V82__delete_shop_categories_isdeleted.sql new file mode 100644 index 000000000..8802fdbcc --- /dev/null +++ b/src/main/resources/db/migration/V82__delete_shop_categories_isdeleted.sql @@ -0,0 +1 @@ +ALTER TABLE shop_categories DROP is_deleted; diff --git a/src/test/java/in/koreatech/koin/admin/acceptance/AdminShopApiTest.java b/src/test/java/in/koreatech/koin/admin/acceptance/AdminShopApiTest.java index 18238d288..b31762d75 100644 --- a/src/test/java/in/koreatech/koin/admin/acceptance/AdminShopApiTest.java +++ b/src/test/java/in/koreatech/koin/admin/acceptance/AdminShopApiTest.java @@ -202,7 +202,6 @@ void setUp() { for (int i = 0; i < 12; i++) { ShopCategory request = ShopCategory.builder() .name("카테고리" + i) - .isDeleted(false) .build(); adminShopCategoryRepository.save(request); } @@ -458,7 +457,6 @@ void setUp() { softly -> { softly.assertThat(result.getImageUrl()).isEqualTo("https://image.png"); softly.assertThat(result.getName()).isEqualTo("새로운 카테고리"); - softly.assertThat(result.isDeleted()).isEqualTo(false); } ); }); @@ -889,16 +887,30 @@ void setUp() { @Test void 어드민이_상점_카테고리를_삭제한다() throws Exception { - ShopCategory shopCategory = shopCategoryFixture.카테고리_일반음식(); + ShopCategory shopCategory = shopCategoryFixture.카테고리_치킨(); mockMvc.perform( delete("/admin/shops/categories/{id}", shopCategory.getId()) .header("Authorization", "Bearer " + token_admin) ) - .andExpect(status().isOk()); + .andExpect(status().isNoContent()); + + assertThat(adminMenuCategoryRepository.findById(shopCategory.getId())).isNotPresent(); + } - ShopCategory deletedCategory = adminShopCategoryRepository.getById(shopCategory.getId()); - assertSoftly(softly -> softly.assertThat(deletedCategory.isDeleted()).isTrue()); + @Test + void 어드민이_상점_카테고리_삭제시_카테고리에_상점이_남아있으면_400() throws Exception { + ShopCategoryMap shopCategoryMap = ShopCategoryMap.builder() + .shop(shop_마슬랜) + .shopCategory(shopCategory_치킨) + .build(); + entityManager.persist(shopCategoryMap); + + mockMvc.perform( + delete("/admin/shops/categories/{id}", shopCategory_치킨.getId()) + .header("Authorization", "Bearer " + token_admin) + ) + .andExpect(status().isBadRequest()); } @Test diff --git a/src/test/java/in/koreatech/koin/fixture/ShopCategoryFixture.java b/src/test/java/in/koreatech/koin/fixture/ShopCategoryFixture.java index e30b4d8ae..c02fb73e2 100644 --- a/src/test/java/in/koreatech/koin/fixture/ShopCategoryFixture.java +++ b/src/test/java/in/koreatech/koin/fixture/ShopCategoryFixture.java @@ -18,7 +18,6 @@ public ShopCategoryFixture(ShopCategoryRepository categoryRepository) { public ShopCategory 카테고리_치킨() { return categoryRepository.save( ShopCategory.builder() - .isDeleted(false) .name("치킨") .imageUrl("https://test-image.com/ckicken.jpg") .build() @@ -28,7 +27,6 @@ public ShopCategoryFixture(ShopCategoryRepository categoryRepository) { public ShopCategory 카테고리_일반음식() { return categoryRepository.save( ShopCategory.builder() - .isDeleted(false) .name("일반음식점") .imageUrl("https://test-image.com/normal.jpg") .build() From 6ca0730b5daf42ce23ef1fd32541f4b19669fc7a Mon Sep 17 00:00:00 2001 From: krSeonghyeon <149303551+krSeonghyeon@users.noreply.github.com> Date: Fri, 1 Nov 2024 22:07:50 +0900 Subject: [PATCH 05/12] =?UTF-8?q?feat:=20VersionType=EC=97=90=20ANDROID=5F?= =?UTF-8?q?OWNER=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95=20(#988)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: VersionType에 AND_OWNER 추가 및 관련 코드 수정 * refactor: AND_OWNER를 ANDROID_OWNER로 수정 * refactor: VersionType에 category 필드 추가 및 타입체크 방식 변경 * fix: isPlatform 로직 오류 수정 * refactor: isPlatform 메소드 VersionType에 캡슐화 * refactor: 커스텀 예외로 변경 --- .../version/controller/AdminVersionApi.java | 12 ++++++++--- .../VersionNotSupportedException.java | 20 +++++++++++++++++++ .../version/service/AdminVersionService.java | 5 +++-- .../domain/version/controller/VersionApi.java | 10 ++++++++-- .../domain/version/model/VersionType.java | 18 +++++++++++------ .../migration/V83__insert_owner_version.sql | 6 ++++++ 6 files changed, 58 insertions(+), 13 deletions(-) create mode 100644 src/main/java/in/koreatech/koin/admin/version/exception/VersionNotSupportedException.java create mode 100644 src/main/resources/db/migration/V83__insert_owner_version.sql diff --git a/src/main/java/in/koreatech/koin/admin/version/controller/AdminVersionApi.java b/src/main/java/in/koreatech/koin/admin/version/controller/AdminVersionApi.java index a87ebfad7..cb12c092e 100644 --- a/src/main/java/in/koreatech/koin/admin/version/controller/AdminVersionApi.java +++ b/src/main/java/in/koreatech/koin/admin/version/controller/AdminVersionApi.java @@ -58,7 +58,10 @@ ResponseEntity getVersions( @SecurityRequirement(name = "Jwt Authentication") @GetMapping("/{type}") ResponseEntity getVersion( - @Parameter(description = "android, ios, timetable, express_bus_timetable, shuttle_bus_timetable, city_bus_timetable") + @Parameter(description = """ + android, ios, android_owner, timetable, express_bus_timetable, + shuttle_bus_timetable, city_bus_timetable + """) @PathVariable("type") String type, @Auth(permit = {ADMIN}) Integer adminId ); @@ -75,7 +78,7 @@ ResponseEntity getVersion( @SecurityRequirement(name = "Jwt Authentication") @PutMapping("/{type}") ResponseEntity updateVersion( - @Parameter(description = "android, ios") @PathVariable("type") String type, + @Parameter(description = "android, ios, android_owner") @PathVariable("type") String type, @RequestBody @Valid AdminVersionUpdateRequest adminVersionUpdateRequest, @Auth(permit = {ADMIN}) Integer adminId ); @@ -94,7 +97,10 @@ ResponseEntity updateVersion( ResponseEntity getHistory( @RequestParam(name = "page", defaultValue = "1") Integer page, @RequestParam(name = "limit", defaultValue = "10", required = false) Integer limit, - @Parameter(description = "android, ios, timetable, express_bus_timetable, shuttle_bus_timetable, city_bus_timetable") + @Parameter(description = """ + android, ios, android_owner, timetable, express_bus_timetable, + shuttle_bus_timetable, city_bus_timetable + """) @PathVariable("type") String type, @Auth(permit = {ADMIN}) Integer adminId ); diff --git a/src/main/java/in/koreatech/koin/admin/version/exception/VersionNotSupportedException.java b/src/main/java/in/koreatech/koin/admin/version/exception/VersionNotSupportedException.java new file mode 100644 index 000000000..daa796eae --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/version/exception/VersionNotSupportedException.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.admin.version.exception; + +import in.koreatech.koin.global.exception.KoinIllegalArgumentException; + +public class VersionNotSupportedException extends KoinIllegalArgumentException { + + private static final String DEFAULT_MESSAGE = "지원하지 않는 타입입니다"; + + public VersionNotSupportedException(String message) { + super(message); + } + + public VersionNotSupportedException(String message, String detail) { + super(message, detail); + } + + public static VersionNotSupportedException withDetail(String detail) { + return new VersionNotSupportedException(DEFAULT_MESSAGE, detail); + } +} diff --git a/src/main/java/in/koreatech/koin/admin/version/service/AdminVersionService.java b/src/main/java/in/koreatech/koin/admin/version/service/AdminVersionService.java index 7fbd931dd..7df162875 100644 --- a/src/main/java/in/koreatech/koin/admin/version/service/AdminVersionService.java +++ b/src/main/java/in/koreatech/koin/admin/version/service/AdminVersionService.java @@ -10,6 +10,7 @@ import in.koreatech.koin.admin.version.dto.AdminVersionResponse; import in.koreatech.koin.admin.version.dto.AdminVersionUpdateRequest; import in.koreatech.koin.admin.version.dto.AdminVersionsResponse; +import in.koreatech.koin.admin.version.exception.VersionNotSupportedException; import in.koreatech.koin.admin.version.repository.AdminVersionRepository; import in.koreatech.koin.domain.version.model.Version; import in.koreatech.koin.domain.version.model.VersionType; @@ -44,8 +45,8 @@ public AdminVersionResponse getVersion(String type) { @Transactional public void updateVersion(String type, AdminVersionUpdateRequest request) { VersionType versionType = VersionType.from(type); - if (versionType != VersionType.ANDROID && versionType != VersionType.IOS) { - throw new KoinIllegalArgumentException("unsupported type", "type: " + versionType); + if (!versionType.isPlatform()) { + throw VersionNotSupportedException.withDetail("type: " + versionType); } Version currentVersion = adminVersionRepository.getByTypeAndIsPrevious(versionType, false); diff --git a/src/main/java/in/koreatech/koin/domain/version/controller/VersionApi.java b/src/main/java/in/koreatech/koin/domain/version/controller/VersionApi.java index 5200218ec..22f941c0b 100644 --- a/src/main/java/in/koreatech/koin/domain/version/controller/VersionApi.java +++ b/src/main/java/in/koreatech/koin/domain/version/controller/VersionApi.java @@ -26,7 +26,10 @@ public interface VersionApi { @Operation(summary = "특정 타입의 버전 조회") @GetMapping("/versions/{type}") ResponseEntity getVersion( - @Parameter(description = "android, ios, timetable, express_bus_timetable, shuttle_bus_timetable, city_bus_timetable") + @Parameter(description = """ + android, ios, android_owner, timetable, express_bus_timetable, + shuttle_bus_timetable, city_bus_timetable + """) @PathVariable(value = "type") String type ); @@ -39,7 +42,10 @@ ResponseEntity getVersion( @Operation(summary = "업데이트를 위한 특정 타입의 버전 조회") @GetMapping("/version/{type}") ResponseEntity getVersionWithMessage( - @Parameter(description = "android, ios, timetable, express_bus_timetable, shuttle_bus_timetable, city_bus_timetable") + @Parameter(description = """ + android, ios, android_owner, timetable, express_bus_timetable, + shuttle_bus_timetable, city_bus_timetable + """) @PathVariable(value = "type") String type ); } diff --git a/src/main/java/in/koreatech/koin/domain/version/model/VersionType.java b/src/main/java/in/koreatech/koin/domain/version/model/VersionType.java index 84dcc9b7a..d725d7948 100644 --- a/src/main/java/in/koreatech/koin/domain/version/model/VersionType.java +++ b/src/main/java/in/koreatech/koin/domain/version/model/VersionType.java @@ -11,15 +11,17 @@ @Getter @AllArgsConstructor public enum VersionType { - ANDROID("android"), - IOS("ios"), - TIMETABLE("timetable"), - SHUTTLE("shuttle_bus_timetable"), - CITY("city_bus_timetable"), - EXPRESS("express_bus_timetable"), + ANDROID("android", "PLATFORM"), + IOS("ios", "PLATFORM"), + ANDROID_OWNER("android_owner", "PLATFORM"), + TIMETABLE("timetable", "OTHER"), + SHUTTLE("shuttle_bus_timetable", "OTHER"), + CITY("city_bus_timetable", "OTHER"), + EXPRESS("express_bus_timetable", "OTHER"), ; private final String value; + private final String category; @JsonCreator public static VersionType from(String value) { @@ -28,4 +30,8 @@ public static VersionType from(String value) { .findAny() .orElseThrow(() -> VersionTypeNotFoundException.withDetail("versionType: " + value)); } + + public boolean isPlatform() { + return this.category.equals("PLATFORM"); + } } diff --git a/src/main/resources/db/migration/V83__insert_owner_version.sql b/src/main/resources/db/migration/V83__insert_owner_version.sql new file mode 100644 index 000000000..92d11d89c --- /dev/null +++ b/src/main/resources/db/migration/V83__insert_owner_version.sql @@ -0,0 +1,6 @@ +INSERT INTO `versions` (`type`, `version`, `is_previous`, `title`, `content`) +VALUES ('android_owner', + '0.0.1', + 0, + '코인 사장님을 사용하기 위해\n업데이트가 꼭 필요해요.', + '코인 사장님 앱을 실행해 주셔서 감사합니다!\n앱을 사용하기 위해 아래 버튼을 눌러\n스토어에서 업데이트를 진행해 주세요.'); From 8bd4b4058ae3de0f9854334d517ddb444524334e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EA=B4=80=EA=B7=9C?= Date: Sun, 3 Nov 2024 19:52:17 +0900 Subject: [PATCH 06/12] =?UTF-8?q?feat:=20=EC=96=B4=EB=93=9C=EB=AF=BC=20?= =?UTF-8?q?=EA=B3=84=EC=A0=95=20=EB=B6=84=ED=95=A0=20API=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#981)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 어드민 회원가입 API 추가 * feat: 어드민 isAuthed 인증 여부 확인 로직 추가 * chore: AdminUserApi 접근 제어자 삭제 * feat: 어드민 계정 정보 조회 api * chore: 어드민 회원가입 ApiResponses 추가 * chore: AdminResponse 내부 메소드 명 변경 * chore: teamName 오타 수정 * chore: teamName 오타 수정 * feat: 어드민 리스트 조회 API 추가 * feat: 어드민 비밀번호 변경 API 추가 * feat: 어드민 인증 상태 변경 API 추가 * fix: 비밀번호 변경 API 버그 수정 * feat: 어드민 계정 업데이트 API 추가 * chore: 어드민 계정 업데이트 API ApiResponse 추가 * feat: 어드민 계정 권한 수정 API 추가 * chore: 어드민 인증 상태 변경 API 메소드 명 변경 * test: 테스트 코드에 있는 코인 어드민 변경 * test: 어드민 API 테스트 코드 추가 * chore: 미사용 import 문 제거 * refactor: 어드민 로그인 API 리펙토링 * chore: 초기 슈퍼어드민 세팅 * style: 코드 포멧팅 * chore: flyway 수정 * style: 코드 포멧팅 * chore: createAdmin 변수 이름 변경 * test: createAdmin 변수명 변경으로 인한 테스트 코드 수정 * chore: 리뷰 반영 * test: 테스트 코드 수정 * chore: flyway 버전 변경 * test: 테스트 오류 수정 * chore: 리뷰 반영 --- .../admin/user/controller/AdminUserApi.java | 140 ++++++++- .../user/controller/AdminUserController.java | 82 +++++- .../user/dto/AdminPasswordChangeRequest.java | 21 ++ .../dto/AdminPermissionUpdateRequest.java | 21 ++ .../koin/admin/user/dto/AdminResponse.java | 48 ++++ .../admin/user/dto/AdminUpdateRequest.java | 28 ++ .../koin/admin/user/dto/AdminsCondition.java | 40 +++ .../koin/admin/user/dto/AdminsResponse.java | 83 ++++++ .../admin/user/dto/CreateAdminRequest.java | 59 ++++ .../koin/admin/user/enums/TeamType.java | 18 ++ .../koin/admin/user/enums/TrackType.java | 22 ++ .../exception/AdminNotFoundException.java | 20 ++ .../exception/AdminTeamNotValidException.java | 20 ++ .../AdminTrackNotValidException.java | 20 ++ .../koin/admin/user/model/Admin.java | 73 +++++ .../user/repository/AdminRepository.java | 35 +++ .../admin/user/service/AdminUserService.java | 176 +++++++++--- .../koin/domain/user/model/User.java | 4 + .../global/auth/AuthArgumentResolver.java | 6 +- .../domain/email/model/EmailAddress.java | 12 +- .../db/migration/V84__update_admins_table.sql | 12 + .../V85__insert_admin_account_date.sql | 5 + .../koin/acceptance/AbtestApiTest.java | 6 +- .../koin/acceptance/KeywordApiTest.java | 6 +- .../admin/acceptance/AdminBenefitApiTest.java | 6 +- .../acceptance/AdminCoopShopApiTest.java | 6 +- .../admin/acceptance/AdminLandApiTest.java | 26 +- .../admin/acceptance/AdminMemberApiTest.java | 33 +-- .../admin/acceptance/AdminNoticeApiTest.java | 28 +- .../admin/acceptance/AdminShopApiTest.java | 6 +- .../acceptance/AdminShopReviewApiTest.java | 12 +- .../admin/acceptance/AdminTrackApiTest.java | 45 +-- .../admin/acceptance/AdminUserApiTest.java | 269 +++++++++++++++--- .../admin/acceptance/AdminVersionApiTest.java | 6 +- .../koreatech/koin/fixture/UserFixture.java | 91 +++++- 35 files changed, 1294 insertions(+), 191 deletions(-) create mode 100644 src/main/java/in/koreatech/koin/admin/user/dto/AdminPasswordChangeRequest.java create mode 100644 src/main/java/in/koreatech/koin/admin/user/dto/AdminPermissionUpdateRequest.java create mode 100644 src/main/java/in/koreatech/koin/admin/user/dto/AdminResponse.java create mode 100644 src/main/java/in/koreatech/koin/admin/user/dto/AdminUpdateRequest.java create mode 100644 src/main/java/in/koreatech/koin/admin/user/dto/AdminsCondition.java create mode 100644 src/main/java/in/koreatech/koin/admin/user/dto/AdminsResponse.java create mode 100644 src/main/java/in/koreatech/koin/admin/user/dto/CreateAdminRequest.java create mode 100644 src/main/java/in/koreatech/koin/admin/user/enums/TeamType.java create mode 100644 src/main/java/in/koreatech/koin/admin/user/enums/TrackType.java create mode 100644 src/main/java/in/koreatech/koin/admin/user/exception/AdminNotFoundException.java create mode 100644 src/main/java/in/koreatech/koin/admin/user/exception/AdminTeamNotValidException.java create mode 100644 src/main/java/in/koreatech/koin/admin/user/exception/AdminTrackNotValidException.java create mode 100644 src/main/java/in/koreatech/koin/admin/user/model/Admin.java create mode 100644 src/main/java/in/koreatech/koin/admin/user/repository/AdminRepository.java create mode 100644 src/main/resources/db/migration/V84__update_admins_table.sql create mode 100644 src/main/resources/db/migration/V85__insert_admin_account_date.sql diff --git a/src/main/java/in/koreatech/koin/admin/user/controller/AdminUserApi.java b/src/main/java/in/koreatech/koin/admin/user/controller/AdminUserApi.java index faaa39b66..b1bb87f0e 100644 --- a/src/main/java/in/koreatech/koin/admin/user/controller/AdminUserApi.java +++ b/src/main/java/in/koreatech/koin/admin/user/controller/AdminUserApi.java @@ -13,21 +13,29 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; -import in.koreatech.koin.admin.user.dto.AdminOwnerUpdateRequest; -import in.koreatech.koin.admin.user.dto.AdminOwnerUpdateResponse; -import in.koreatech.koin.admin.user.dto.AdminOwnersResponse; import in.koreatech.koin.admin.user.dto.AdminLoginRequest; import in.koreatech.koin.admin.user.dto.AdminLoginResponse; -import in.koreatech.koin.admin.user.dto.AdminStudentResponse; import in.koreatech.koin.admin.user.dto.AdminNewOwnersResponse; import in.koreatech.koin.admin.user.dto.AdminOwnerResponse; +import in.koreatech.koin.admin.user.dto.AdminOwnerUpdateRequest; +import in.koreatech.koin.admin.user.dto.AdminOwnerUpdateResponse; +import in.koreatech.koin.admin.user.dto.AdminOwnersResponse; +import in.koreatech.koin.admin.user.dto.AdminPasswordChangeRequest; +import in.koreatech.koin.admin.user.dto.AdminPermissionUpdateRequest; +import in.koreatech.koin.admin.user.dto.AdminResponse; +import in.koreatech.koin.admin.user.dto.AdminStudentResponse; import in.koreatech.koin.admin.user.dto.AdminStudentUpdateRequest; import in.koreatech.koin.admin.user.dto.AdminStudentUpdateResponse; -import in.koreatech.koin.admin.user.dto.OwnersCondition; -import in.koreatech.koin.domain.user.model.User; import in.koreatech.koin.admin.user.dto.AdminStudentsResponse; import in.koreatech.koin.admin.user.dto.AdminTokenRefreshRequest; import in.koreatech.koin.admin.user.dto.AdminTokenRefreshResponse; +import in.koreatech.koin.admin.user.dto.AdminUpdateRequest; +import in.koreatech.koin.admin.user.dto.AdminsResponse; +import in.koreatech.koin.admin.user.dto.CreateAdminRequest; +import in.koreatech.koin.admin.user.dto.OwnersCondition; +import in.koreatech.koin.admin.user.enums.TeamType; +import in.koreatech.koin.admin.user.enums.TrackType; +import in.koreatech.koin.domain.user.model.User; import in.koreatech.koin.global.auth.Auth; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; @@ -51,7 +59,7 @@ public interface AdminUserApi { @Operation(summary = "학생 리스트 조회(페이지네이션)") @SecurityRequirement(name = "Jwt Authentication") @GetMapping("/admin/students") - public ResponseEntity getStudents( + ResponseEntity getStudents( @RequestParam(required = false) Integer page, @RequestParam(required = false) Integer limit, @RequestParam(required = false) Boolean isAuthed, @@ -60,6 +68,23 @@ public ResponseEntity getStudents( @Auth(permit = {ADMIN}) Integer adminId ); + @ApiResponses( + value = { + @ApiResponse(responseCode = "201"), + @ApiResponse(responseCode = "400", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "409", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "어드민 회원가입") + @PostMapping("/admin") + ResponseEntity createAdmin( + @RequestBody @Valid CreateAdminRequest request, + @Auth(permit = {ADMIN}) Integer adminId + ); + @ApiResponses( value = { @ApiResponse(responseCode = "201"), @@ -74,6 +99,22 @@ ResponseEntity adminLogin( @RequestBody @Valid AdminLoginRequest request ); + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "400", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "어드민 비밀번호 변경") + @PutMapping("/admin/password") + ResponseEntity adminPasswordChange( + @RequestBody @Valid AdminPasswordChangeRequest request, + @Auth(permit = {ADMIN}) Integer adminId + ); + @ApiResponses( value = { @ApiResponse(responseCode = "201"), @@ -99,10 +140,93 @@ ResponseEntity logout( ) @Operation(summary = "어드민 액세스 토큰 재발급") @PostMapping("/admin/user/refresh") - public ResponseEntity refresh( + ResponseEntity refresh( @RequestBody @Valid AdminTokenRefreshRequest request ); + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "어드민 계정 정보 조회") + @GetMapping("/admin/{id}") + ResponseEntity getAdmin( + @PathVariable("id") Integer id, + @Auth(permit = {ADMIN}) Integer adminId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "어드민 계정 리스트 정보 조회") + @GetMapping("/admins") + ResponseEntity getAdmins( + @RequestParam(required = false) Integer page, + @RequestParam(required = false) Integer limit, + @RequestParam(required = false) Boolean isAuthed, + @RequestParam(required = false) TrackType trackName, + @RequestParam(required = false) TeamType teamName, + @Auth(permit = {ADMIN}) Integer adminId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "어드민 계정 인증 상태 변경") + @PutMapping("/admin/{id}/authed") + ResponseEntity adminAuthenticate( + @PathVariable Integer id, + @Auth(permit = {ADMIN}) Integer adminId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "400", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "어드민 계정 정보 수정") + @PutMapping("/admin/{id}") + ResponseEntity updateAdmin( + @RequestBody @Valid AdminUpdateRequest request, + @PathVariable Integer id, + @Auth(permit = {ADMIN}) Integer adminId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "400", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "어드민 계정 권한 수정") + @PutMapping("/admin/{id}/permission") + ResponseEntity updateAdminPermission( + @RequestBody @Valid AdminPermissionUpdateRequest request, + @PathVariable Integer id, + @Auth(permit = {ADMIN}) Integer adminId + ); + @ApiResponses( value = { @ApiResponse(responseCode = "200"), diff --git a/src/main/java/in/koreatech/koin/admin/user/controller/AdminUserController.java b/src/main/java/in/koreatech/koin/admin/user/controller/AdminUserController.java index 600756488..8c1a50eae 100644 --- a/src/main/java/in/koreatech/koin/admin/user/controller/AdminUserController.java +++ b/src/main/java/in/koreatech/koin/admin/user/controller/AdminUserController.java @@ -2,9 +2,9 @@ import static in.koreatech.koin.domain.user.model.UserType.ADMIN; -import org.springdoc.core.annotations.ParameterObject; import java.net.URI; +import org.springdoc.core.annotations.ParameterObject; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; @@ -23,14 +23,23 @@ import in.koreatech.koin.admin.user.dto.AdminOwnerUpdateRequest; import in.koreatech.koin.admin.user.dto.AdminOwnerUpdateResponse; import in.koreatech.koin.admin.user.dto.AdminOwnersResponse; +import in.koreatech.koin.admin.user.dto.AdminPasswordChangeRequest; +import in.koreatech.koin.admin.user.dto.AdminPermissionUpdateRequest; +import in.koreatech.koin.admin.user.dto.AdminResponse; import in.koreatech.koin.admin.user.dto.AdminStudentResponse; import in.koreatech.koin.admin.user.dto.AdminStudentUpdateRequest; import in.koreatech.koin.admin.user.dto.AdminStudentUpdateResponse; -import in.koreatech.koin.admin.user.dto.OwnersCondition; import in.koreatech.koin.admin.user.dto.AdminStudentsResponse; import in.koreatech.koin.admin.user.dto.AdminTokenRefreshRequest; import in.koreatech.koin.admin.user.dto.AdminTokenRefreshResponse; +import in.koreatech.koin.admin.user.dto.AdminUpdateRequest; +import in.koreatech.koin.admin.user.dto.AdminsCondition; +import in.koreatech.koin.admin.user.dto.AdminsResponse; +import in.koreatech.koin.admin.user.dto.CreateAdminRequest; +import in.koreatech.koin.admin.user.dto.OwnersCondition; import in.koreatech.koin.admin.user.dto.StudentsCondition; +import in.koreatech.koin.admin.user.enums.TeamType; +import in.koreatech.koin.admin.user.enums.TrackType; import in.koreatech.koin.admin.user.service.AdminUserService; import in.koreatech.koin.domain.user.model.User; import in.koreatech.koin.global.auth.Auth; @@ -65,6 +74,24 @@ public ResponseEntity getStudents( return ResponseEntity.ok().body(adminStudentsResponse); } + @PostMapping("/admin") + public ResponseEntity createAdmin( + @RequestBody @Valid CreateAdminRequest request, + @Auth(permit = {ADMIN}) Integer adminId + ) { + AdminResponse response = adminUserService.createAdmin(request, adminId); + return ResponseEntity.created(URI.create("/" + response.id())).build(); + } + + @PutMapping("/admin/password") + public ResponseEntity adminPasswordChange( + @RequestBody @Valid AdminPasswordChangeRequest request, + @Auth(permit = {ADMIN}) Integer adminId + ) { + adminUserService.adminPasswordChange(request, adminId); + return ResponseEntity.ok().build(); + } + @PostMapping("/admin/user/login") public ResponseEntity adminLogin( @RequestBody @Valid AdminLoginRequest request @@ -91,6 +118,57 @@ public ResponseEntity refresh( .body(tokenGroupResponse); } + @GetMapping("/admin/{id}") + public ResponseEntity getAdmin( + @PathVariable("id") Integer id, + @Auth(permit = {ADMIN}) Integer adminId + ) { + AdminResponse adminResponse = adminUserService.getAdmin(id); + return ResponseEntity.ok(adminResponse); + } + + @GetMapping("/admins") + public ResponseEntity getAdmins( + @RequestParam(required = false) Integer page, + @RequestParam(required = false) Integer limit, + @RequestParam(required = false) Boolean isAuthed, + @RequestParam(required = false) TrackType trackName, + @RequestParam(required = false) TeamType teamName, + @Auth(permit = {ADMIN}) Integer adminId + ) { + AdminsCondition adminsCondition = new AdminsCondition(page, limit, isAuthed, trackName, teamName); + AdminsResponse adminsResponse = adminUserService.getAdmins(adminsCondition); + return ResponseEntity.ok(adminsResponse); + } + + @PutMapping("/admin/{id}/authed") + public ResponseEntity adminAuthenticate( + @PathVariable Integer id, + @Auth(permit = {ADMIN}) Integer adminId + ) { + adminUserService.adminAuthenticate(id, adminId); + return ResponseEntity.ok().build(); + } + + @PutMapping("/admin/{id}") + public ResponseEntity updateAdmin( + @RequestBody @Valid AdminUpdateRequest request, + @PathVariable Integer id, + @Auth(permit = {ADMIN}) Integer adminId + ) { + adminUserService.updateAdmin(request, id); + return ResponseEntity.ok().build(); + } + + @PutMapping("/admin/{id}/permission") + public ResponseEntity updateAdminPermission( + @RequestBody @Valid AdminPermissionUpdateRequest request, + @PathVariable Integer id, + @Auth(permit = {ADMIN}) Integer adminId + ) { + adminUserService.updateAdminPermission(request, id, adminId); + return ResponseEntity.ok().build(); + } @GetMapping("/admin/users/student/{id}") public ResponseEntity getStudent( diff --git a/src/main/java/in/koreatech/koin/admin/user/dto/AdminPasswordChangeRequest.java b/src/main/java/in/koreatech/koin/admin/user/dto/AdminPasswordChangeRequest.java new file mode 100644 index 000000000..cb283b83e --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/user/dto/AdminPasswordChangeRequest.java @@ -0,0 +1,21 @@ +package in.koreatech.koin.admin.user.dto; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record AdminPasswordChangeRequest( + @Schema(description = "256 SHA 알고리즘으로 암호화된 현재 비밀번호", example = "cd06f8c2b0dd065faf6ef910c7f15934363df71c33740fd245590665286ed268", requiredMode = REQUIRED) + @NotBlank(message = "현재 비밀번호는 필수 입력사항 입니다.") + String oldPassword, + + @Schema(description = "256 SHA 알고리즘으로 암호화된 새로운 비밀번호", example = "cd06f8c2b0dd065faf6ef910c7f15934363df71c33740fd245590665286ed268", requiredMode = REQUIRED) + @NotBlank(message = "새로운 비밀번호는 필수 입력사항 입니다.") + String newPassword +) { +} diff --git a/src/main/java/in/koreatech/koin/admin/user/dto/AdminPermissionUpdateRequest.java b/src/main/java/in/koreatech/koin/admin/user/dto/AdminPermissionUpdateRequest.java new file mode 100644 index 000000000..039c9f6ff --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/user/dto/AdminPermissionUpdateRequest.java @@ -0,0 +1,21 @@ +package in.koreatech.koin.admin.user.dto; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record AdminPermissionUpdateRequest( + @Schema(description = "어드민 생성 권한", example = "false", requiredMode = REQUIRED) + @NotNull(message = "어드민 생성 권한은 필수 입력 사항입니다") + Boolean canCreateAdmin, + + @Schema(description = "슈퍼 어드민 권한", example = "false", requiredMode = REQUIRED) + @NotNull(message = "슈퍼 어드민 권한은 필수 입력 사항입니다") + Boolean superAdmin +) { +} diff --git a/src/main/java/in/koreatech/koin/admin/user/dto/AdminResponse.java b/src/main/java/in/koreatech/koin/admin/user/dto/AdminResponse.java new file mode 100644 index 000000000..f89e0dbd8 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/user/dto/AdminResponse.java @@ -0,0 +1,48 @@ +package in.koreatech.koin.admin.user.dto; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.admin.user.model.Admin; +import in.koreatech.koin.domain.user.model.User; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record AdminResponse( + @Schema(description = "고유 id", example = "1", requiredMode = REQUIRED) + Integer id, + + @Schema(description = "이메일", example = "koin00001@koreatech.ac.kr", requiredMode = REQUIRED) + String email, + + @Schema(description = "이름", example = "신관규", requiredMode = REQUIRED) + String name, + + @Schema(description = "트랙 이름", example = "Backend", requiredMode = REQUIRED) + String trackName, + + @Schema(description = "팀 이름", example = "User", requiredMode = REQUIRED) + String teamName, + + @Schema(description = "어드민 생성 권한", example = "false", requiredMode = REQUIRED) + Boolean canCreateAdmin, + + @Schema(description = "슈퍼 어드민 권한", example = "false", requiredMode = REQUIRED) + Boolean superAdmin +) { + public static AdminResponse from(Admin admin) { + User user = admin.getUser(); + + return new AdminResponse( + admin.getId(), + user.getEmail(), + user.getName(), + admin.getTrackType().getValue(), + admin.getTeamType().getValue(), + admin.isCanCreateAdmin(), + admin.isSuperAdmin() + ); + } +} diff --git a/src/main/java/in/koreatech/koin/admin/user/dto/AdminUpdateRequest.java b/src/main/java/in/koreatech/koin/admin/user/dto/AdminUpdateRequest.java new file mode 100644 index 000000000..f48a5aeef --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/user/dto/AdminUpdateRequest.java @@ -0,0 +1,28 @@ +package in.koreatech.koin.admin.user.dto; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.admin.user.enums.TeamType; +import in.koreatech.koin.admin.user.enums.TrackType; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record AdminUpdateRequest( + @Schema(description = "이름", example = "신관규", requiredMode = REQUIRED) + @NotBlank(message = "이름은 필수 입력 사항입니다.") + String name, + + @Schema(description = "트랙 타입", example = "BACKEND", requiredMode = REQUIRED) + @NotNull(message = "트랙 타입은 필수 입력 사항입니다.") + TrackType trackType, + + @Schema(description = "팀 타입", example = "USER", requiredMode = REQUIRED) + @NotNull(message = "팀 타입은 필수 입력 사항입니다.") + TeamType teamType +) { +} diff --git a/src/main/java/in/koreatech/koin/admin/user/dto/AdminsCondition.java b/src/main/java/in/koreatech/koin/admin/user/dto/AdminsCondition.java new file mode 100644 index 000000000..cdbe4a2a5 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/user/dto/AdminsCondition.java @@ -0,0 +1,40 @@ +package in.koreatech.koin.admin.user.dto; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; + +import java.util.Objects; + +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.admin.user.enums.TeamType; +import in.koreatech.koin.admin.user.enums.TrackType; +import in.koreatech.koin.global.model.Criteria; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record AdminsCondition( + @Schema(description = "페이지", example = "1", defaultValue = "1", requiredMode = NOT_REQUIRED) + Integer page, + + @Schema(description = "페이지당 조회할 최대 개수", example = "10", defaultValue = "10") + Integer limit, + + @Schema(description = "인증 여부", requiredMode = NOT_REQUIRED) + Boolean isAuthed, + + @Schema(description = "트랙 타입", requiredMode = NOT_REQUIRED) + TrackType trackType, + + @Schema(description = "팀 타입", requiredMode = NOT_REQUIRED) + TeamType teamType +) { + public AdminsCondition { + if (Objects.isNull(page)) { + page = Criteria.DEFAULT_PAGE; + } + if (Objects.isNull(limit)) { + limit = Criteria.DEFAULT_LIMIT; + } + } +} diff --git a/src/main/java/in/koreatech/koin/admin/user/dto/AdminsResponse.java b/src/main/java/in/koreatech/koin/admin/user/dto/AdminsResponse.java new file mode 100644 index 000000000..0c5e40ffc --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/user/dto/AdminsResponse.java @@ -0,0 +1,83 @@ +package in.koreatech.koin.admin.user.dto; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.List; +import java.util.stream.Collectors; + +import org.springframework.data.domain.Page; + +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.admin.user.model.Admin; +import in.koreatech.koin.domain.user.model.User; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record AdminsResponse( + @Schema(description = "조건에 해당하는 어드민 계정 수", example = "10", requiredMode = REQUIRED) + Long totalCount, + + @Schema(description = "조건에 해당하는 어드민 계정 중 현재 페이지에서 조회된 수", example = "5", requiredMode = REQUIRED) + Integer currentCount, + + @Schema(description = "조건에 해당하는 어드민 계정을 조회할 수 있는 최대 페이지", example = "2", requiredMode = REQUIRED) + Integer totalPage, + + @Schema(description = "현재 페이지", example = "1", requiredMode = REQUIRED) + Integer currentPage, + + @Schema(description = "어드민 계정 리스트", requiredMode = REQUIRED) + List admins +) { + @JsonNaming(value = SnakeCaseStrategy.class) + public record InnerAdminsResponse( + @Schema(description = "고유 id", example = "1", requiredMode = REQUIRED) + Integer id, + + @Schema(description = "이메일", example = "koin00001@koreatech.ac.kr", requiredMode = REQUIRED) + String email, + + @Schema(description = "이름", example = "신관규", requiredMode = REQUIRED) + String name, + + @Schema(description = "트랙 이름", example = "Backend", requiredMode = REQUIRED) + String trackName, + + @Schema(description = "팀 이름", example = "User", requiredMode = REQUIRED) + String teamName, + + @Schema(description = "어드민 생성 권한", example = "false", requiredMode = REQUIRED) + Boolean canCreateAdmin, + + @Schema(description = "슈퍼 어드민 권한", example = "false", requiredMode = REQUIRED) + Boolean superAdmin + ) { + public static InnerAdminsResponse from(Admin admin) { + User user = admin.getUser(); + + return new InnerAdminsResponse( + admin.getId(), + user.getEmail(), + user.getName(), + admin.getTrackType().getValue(), + admin.getTeamType().getValue(), + admin.isCanCreateAdmin(), + admin.isSuperAdmin() + ); + } + } + + public static AdminsResponse of(Page adminsPage) { + return new AdminsResponse( + adminsPage.getTotalElements(), + adminsPage.getContent().size(), + adminsPage.getTotalPages(), + adminsPage.getNumber() + 1, + adminsPage.getContent().stream() + .map(InnerAdminsResponse::from) + .collect(Collectors.toList()) + ); + } +} diff --git a/src/main/java/in/koreatech/koin/admin/user/dto/CreateAdminRequest.java b/src/main/java/in/koreatech/koin/admin/user/dto/CreateAdminRequest.java new file mode 100644 index 000000000..b3ec31ea9 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/user/dto/CreateAdminRequest.java @@ -0,0 +1,59 @@ +package in.koreatech.koin.admin.user.dto; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import static in.koreatech.koin.domain.user.model.UserType.ADMIN; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import org.springframework.security.crypto.password.PasswordEncoder; + +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.admin.user.enums.TeamType; +import in.koreatech.koin.admin.user.enums.TrackType; +import in.koreatech.koin.admin.user.model.Admin; +import in.koreatech.koin.domain.user.model.User; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record CreateAdminRequest( + @Schema(description = "이메일", example = "koin00001@koreatech.ac.kr", requiredMode = REQUIRED) + @NotBlank(message = "이메일을 입력해주세요.") + String email, + + @Schema(description = "SHA 256 해시 알고리즘으로 암호화된 비밀번호", example = "cd06f8c2b0dd065faf6ef910c7f15934363df71c33740fd245590665286ed268", requiredMode = REQUIRED) + @NotBlank(message = "비밀번호를 입력해주세요.") + String password, + + @Schema(description = "이름", example = "신관규", requiredMode = REQUIRED) + @Size(max = 50, message = "이름은 50자 이내여야 합니다.") + @NotBlank(message = "이름을 입력해주세요.") + String name, + + @Schema(description = "트랙 타입", example = "BACKEND", requiredMode = REQUIRED) + @NotNull(message = "트랙 타입을 입력해주세요.") + TrackType trackType, + + @Schema(description = "팀 타입", example = "USER", requiredMode = REQUIRED) + @NotNull(message = "팀 타입을 입력해주세요.") + TeamType teamType +) { + public Admin toEntity(PasswordEncoder passwordEncoder) { + User user = User.builder() + .email(email) + .password(passwordEncoder.encode(password)) + .name(name) + .userType(ADMIN) + .isAuthed(false) + .isDeleted(false) + .build(); + + return Admin.builder() + .user(user) + .trackType(trackType) + .teamType(teamType) + .build(); + } +} diff --git a/src/main/java/in/koreatech/koin/admin/user/enums/TeamType.java b/src/main/java/in/koreatech/koin/admin/user/enums/TeamType.java new file mode 100644 index 000000000..24cb7b46c --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/user/enums/TeamType.java @@ -0,0 +1,18 @@ +package in.koreatech.koin.admin.user.enums; + +import lombok.Getter; + +@Getter +public enum TeamType { + KOIN("Koin"), + BUSINESS("Business"), + CAMPUS("Campus"), + USER("User"), + ; + + private final String value; + + TeamType(String value) { + this.value = value; + } +} diff --git a/src/main/java/in/koreatech/koin/admin/user/enums/TrackType.java b/src/main/java/in/koreatech/koin/admin/user/enums/TrackType.java new file mode 100644 index 000000000..2d4a70c92 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/user/enums/TrackType.java @@ -0,0 +1,22 @@ +package in.koreatech.koin.admin.user.enums; + +import lombok.Getter; + +@Getter +public enum TrackType { + ANDROID("Android"), + BACKEND("Backend"), + FRONTEND("Frontend"), + GAME("Game"), + PM("PM"), + PL("PL"), + DESIGN("Design"), + IOS("iOS"), + DA("DA"); + + private final String value; + + TrackType(String value) { + this.value = value; + } +} diff --git a/src/main/java/in/koreatech/koin/admin/user/exception/AdminNotFoundException.java b/src/main/java/in/koreatech/koin/admin/user/exception/AdminNotFoundException.java new file mode 100644 index 000000000..e0b5c8dbc --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/user/exception/AdminNotFoundException.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.admin.user.exception; + +import in.koreatech.koin.global.exception.DataNotFoundException; + +public class AdminNotFoundException extends DataNotFoundException { + + private static final String DEFAULT_MESSAGE = "존재하지 않는 어드민 입니다."; + + public AdminNotFoundException(String message) { + super(message); + } + + public AdminNotFoundException(String message, String detail) { + super(message, detail); + } + + public static AdminNotFoundException withDetail(String detail) { + return new AdminNotFoundException(DEFAULT_MESSAGE, detail); + } +} diff --git a/src/main/java/in/koreatech/koin/admin/user/exception/AdminTeamNotValidException.java b/src/main/java/in/koreatech/koin/admin/user/exception/AdminTeamNotValidException.java new file mode 100644 index 000000000..755bf2b30 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/user/exception/AdminTeamNotValidException.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.admin.user.exception; + +import in.koreatech.koin.global.exception.KoinIllegalArgumentException; + +public class AdminTeamNotValidException extends KoinIllegalArgumentException { + + private static final String DEFAULT_MESSAGE = "팀 형식이 아닙니다."; + + public AdminTeamNotValidException(String message) { + super(message); + } + + public AdminTeamNotValidException(String message, String detail) { + super(message, detail); + } + + public static AdminTeamNotValidException withDetail(String detail) { + return new AdminTeamNotValidException(DEFAULT_MESSAGE, detail); + } +} diff --git a/src/main/java/in/koreatech/koin/admin/user/exception/AdminTrackNotValidException.java b/src/main/java/in/koreatech/koin/admin/user/exception/AdminTrackNotValidException.java new file mode 100644 index 000000000..8f2841003 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/user/exception/AdminTrackNotValidException.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.admin.user.exception; + +import in.koreatech.koin.global.exception.KoinIllegalArgumentException; + +public class AdminTrackNotValidException extends KoinIllegalArgumentException { + + private static final String DEFAULT_MESSAGE = "트랙 형식이 아닙니다."; + + public AdminTrackNotValidException(String message) { + super(message); + } + + public AdminTrackNotValidException(String message, String detail) { + super(message, detail); + } + + public static AdminTrackNotValidException withDetail(String detail) { + return new AdminTrackNotValidException(DEFAULT_MESSAGE, detail); + } +} diff --git a/src/main/java/in/koreatech/koin/admin/user/model/Admin.java b/src/main/java/in/koreatech/koin/admin/user/model/Admin.java new file mode 100644 index 000000000..37aaee711 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/user/model/Admin.java @@ -0,0 +1,73 @@ +package in.koreatech.koin.admin.user.model; + +import static lombok.AccessLevel.PROTECTED; + +import in.koreatech.koin.admin.user.dto.AdminPermissionUpdateRequest; +import in.koreatech.koin.admin.user.enums.TeamType; +import in.koreatech.koin.admin.user.enums.TrackType; +import in.koreatech.koin.domain.user.model.User; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.MapsId; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Table(name = "admins") +@NoArgsConstructor(access = PROTECTED) +public class Admin { + + @Id + @Column(name = "user_id", nullable = false) + private Integer id; + + @MapsId + @OneToOne + @JoinColumn(name = "user_id", referencedColumnName = "id") + private User user; + + @NotNull + @Enumerated(EnumType.STRING) + @Column(name = "team_type", nullable = false) + private TeamType teamType; + + @NotNull + @Enumerated(EnumType.STRING) + @Column(name = "track_type", nullable = false) + private TrackType trackType; + + @Column(name = "can_create_admin", columnDefinition = "TINYINT") + private boolean canCreateAdmin = false; + + @Column(name = "super_admin", columnDefinition = "TINYINT") + private boolean superAdmin = false; + + @Builder + public Admin(User user, TeamType teamType, TrackType trackType, boolean canCreateAdmin, boolean superAdmin) { + this.user = user; + this.teamType = teamType; + this.trackType = trackType; + this.canCreateAdmin = canCreateAdmin; + this.superAdmin = superAdmin; + } + + public void update(TeamType teamName, TrackType trackName) { + this.teamType = teamName; + this.trackType = trackName; + } + + /* 어드민 권한이 추가 되면, 해당 메소드에도 추가해야 합니다. */ + public void updatePermission(AdminPermissionUpdateRequest request) { + this.canCreateAdmin = request.canCreateAdmin(); + this.superAdmin = request.superAdmin(); + } +} diff --git a/src/main/java/in/koreatech/koin/admin/user/repository/AdminRepository.java b/src/main/java/in/koreatech/koin/admin/user/repository/AdminRepository.java new file mode 100644 index 000000000..27c813298 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/user/repository/AdminRepository.java @@ -0,0 +1,35 @@ +package in.koreatech.koin.admin.user.repository; + +import java.util.Optional; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.Repository; +import org.springframework.data.repository.query.Param; + +import in.koreatech.koin.admin.user.dto.AdminsCondition; +import in.koreatech.koin.admin.user.exception.AdminNotFoundException; +import in.koreatech.koin.admin.user.model.Admin; + +public interface AdminRepository extends Repository { + Admin save(Admin admin); + + Optional findById(Integer id); + + default Admin getById(Integer id) { + return findById(id) + .orElseThrow(() -> AdminNotFoundException.withDetail("adminId : " + id)); + } + + @Query("SELECT COUNT(*) FROM Admin") + Integer countAdmins(); + + @Query(""" + SELECT a FROM Admin a WHERE + (:#{#condition.isAuthed} IS NULL OR a.user.isAuthed = :#{#condition.isAuthed}) AND + (:#{#condition.trackType?.name()} IS NULL OR a.trackType = :#{#condition.trackType}) AND + (:#{#condition.teamType?.name()} IS NULL OR a.teamType = :#{#condition.teamType}) + """) + Page findByConditions(@Param("condition") AdminsCondition adminsCondition, Pageable pageable); +} diff --git a/src/main/java/in/koreatech/koin/admin/user/service/AdminUserService.java b/src/main/java/in/koreatech/koin/admin/user/service/AdminUserService.java index a0066084b..287b2e30e 100644 --- a/src/main/java/in/koreatech/koin/admin/user/service/AdminUserService.java +++ b/src/main/java/in/koreatech/koin/admin/user/service/AdminUserService.java @@ -2,18 +2,16 @@ import static in.koreatech.koin.domain.user.model.UserType.ADMIN; -import java.util.List; - -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Sort; - import java.time.LocalDateTime; -import java.util.Optional; +import java.util.List; import java.util.Objects; +import java.util.Optional; import java.util.UUID; import java.util.stream.Collectors; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -26,16 +24,25 @@ import in.koreatech.koin.admin.user.dto.AdminOwnerUpdateRequest; import in.koreatech.koin.admin.user.dto.AdminOwnerUpdateResponse; import in.koreatech.koin.admin.user.dto.AdminOwnersResponse; +import in.koreatech.koin.admin.user.dto.AdminPasswordChangeRequest; +import in.koreatech.koin.admin.user.dto.AdminPermissionUpdateRequest; +import in.koreatech.koin.admin.user.dto.AdminResponse; import in.koreatech.koin.admin.user.dto.AdminStudentResponse; import in.koreatech.koin.admin.user.dto.AdminStudentUpdateRequest; import in.koreatech.koin.admin.user.dto.AdminStudentUpdateResponse; -import in.koreatech.koin.admin.user.dto.OwnersCondition; import in.koreatech.koin.admin.user.dto.AdminStudentsResponse; import in.koreatech.koin.admin.user.dto.AdminTokenRefreshRequest; import in.koreatech.koin.admin.user.dto.AdminTokenRefreshResponse; +import in.koreatech.koin.admin.user.dto.AdminUpdateRequest; +import in.koreatech.koin.admin.user.dto.AdminsCondition; +import in.koreatech.koin.admin.user.dto.AdminsResponse; +import in.koreatech.koin.admin.user.dto.CreateAdminRequest; +import in.koreatech.koin.admin.user.dto.OwnersCondition; import in.koreatech.koin.admin.user.dto.StudentsCondition; +import in.koreatech.koin.admin.user.model.Admin; import in.koreatech.koin.admin.user.repository.AdminOwnerRepository; import in.koreatech.koin.admin.user.repository.AdminOwnerShopRedisRepository; +import in.koreatech.koin.admin.user.repository.AdminRepository; import in.koreatech.koin.admin.user.repository.AdminStudentRepository; import in.koreatech.koin.admin.user.repository.AdminTokenRepository; import in.koreatech.koin.admin.user.repository.AdminUserRepository; @@ -43,17 +50,19 @@ import in.koreatech.koin.domain.owner.model.OwnerIncludingShop; import in.koreatech.koin.domain.owner.model.OwnerShop; import in.koreatech.koin.domain.shop.model.shop.Shop; -import in.koreatech.koin.domain.user.exception.DuplicationNicknameException; import in.koreatech.koin.domain.student.exception.StudentDepartmentNotValidException; -import in.koreatech.koin.domain.user.exception.UserNotFoundException; import in.koreatech.koin.domain.student.model.Student; import in.koreatech.koin.domain.student.model.StudentDepartment; +import in.koreatech.koin.domain.user.exception.DuplicationNicknameException; +import in.koreatech.koin.domain.user.exception.UserNotFoundException; import in.koreatech.koin.domain.user.model.User; import in.koreatech.koin.domain.user.model.UserGender; import in.koreatech.koin.domain.user.model.UserToken; import in.koreatech.koin.domain.user.model.UserType; import in.koreatech.koin.global.auth.JwtProvider; import in.koreatech.koin.global.auth.exception.AuthorizationException; +import in.koreatech.koin.global.domain.email.exception.DuplicationEmailException; +import in.koreatech.koin.global.domain.email.model.EmailAddress; import in.koreatech.koin.global.exception.KoinIllegalArgumentException; import in.koreatech.koin.global.model.Criteria; import lombok.RequiredArgsConstructor; @@ -71,6 +80,7 @@ public class AdminUserService { private final AdminShopRepository adminShopRepository; private final PasswordEncoder passwordEncoder; private final AdminTokenRepository adminTokenRepository; + private final AdminRepository adminRepository; public AdminStudentsResponse getStudents(StudentsCondition studentsCondition) { Integer totalStudents = adminStudentRepository.findAllStudentCount(); @@ -82,25 +92,74 @@ public AdminStudentsResponse getStudents(StudentsCondition studentsCondition) { return AdminStudentsResponse.from(studentsPage); } + @Transactional + public AdminResponse createAdmin(CreateAdminRequest request, Integer adminId) { + Admin admin = adminRepository.getById(adminId); + if (!admin.isCanCreateAdmin() || !admin.isSuperAdmin()) { + throw new AuthorizationException("어드민 계정 생성 권한이 없습니다."); + } + + validateAdminCreate(request); + Admin createAdmin = adminRepository.save(request.toEntity(passwordEncoder)); + + return AdminResponse.from(createAdmin); + } + + private void validateAdminCreate(CreateAdminRequest request) { + EmailAddress emailAddress = EmailAddress.from(request.email()); + emailAddress.validateKoreatechEmail(); + emailAddress.validateAdminEmail(); + + validateDuplicateEmail(request); + } + + private void validateDuplicateEmail(CreateAdminRequest request) { + adminUserRepository.findByEmail(request.email()) + .ifPresent(user -> { + throw DuplicationEmailException.withDetail("email: " + request.email()); + }); + } + + @Transactional + public void adminPasswordChange(AdminPasswordChangeRequest request, Integer adminId) { + Admin admin = adminRepository.getById(adminId); + User user = admin.getUser(); + if (!user.isSamePassword(passwordEncoder, request.oldPassword())) { + throw new KoinIllegalArgumentException("비밀번호가 틀렸습니다."); + } + user.updatePassword(passwordEncoder, request.newPassword()); + } + @Transactional public AdminLoginResponse adminLogin(AdminLoginRequest request) { User user = adminUserRepository.getByEmail(request.email()); + validateAdminLogin(user, request); + String accessToken = jwtProvider.createToken(user); + String refreshToken = String.format("%s-%d", UUID.randomUUID(), user.getId()); + UserToken savedtoken = adminTokenRepository.save(UserToken.create(user.getId(), refreshToken)); + user.updateLastLoggedTime(LocalDateTime.now()); + + return AdminLoginResponse.of(accessToken, savedtoken.getRefreshToken()); + } + + private void validateAdminLogin(User user, AdminLoginRequest request) { /* 어드민 권한이 없으면 없는 회원으로 간주 */ if (user.getUserType() != ADMIN) { throw UserNotFoundException.withDetail("email" + request.email()); } + if (adminRepository.findById(user.getId()).isEmpty()) { + throw UserNotFoundException.withDetail("email" + request.email()); + } + if (!user.isSamePassword(passwordEncoder, request.password())) { throw new KoinIllegalArgumentException("비밀번호가 틀렸습니다."); } - String accessToken = jwtProvider.createToken(user); - String refreshToken = String.format("%s-%d", UUID.randomUUID(), user.getId()); - UserToken savedtoken = adminTokenRepository.save(UserToken.create(user.getId(), refreshToken)); - user.updateLastLoggedTime(LocalDateTime.now()); - - return AdminLoginResponse.of(accessToken, savedtoken.getRefreshToken()); + if (!user.isAuthed()) { + throw new AuthorizationException("PL 인증 대기중입니다."); + } } @Transactional @@ -128,6 +187,51 @@ private String getAdminId(String refreshToken) { return split[split.length - 1]; } + public AdminResponse getAdmin(Integer id) { + Admin admin = adminRepository.getById(id); + return AdminResponse.from(admin); + } + + public AdminsResponse getAdmins(AdminsCondition adminsCondition) { + Integer totalAdmins = adminRepository.countAdmins(); + Criteria criteria = Criteria.of(adminsCondition.page(), adminsCondition.limit(), totalAdmins); + + PageRequest pageRequest = PageRequest.of(criteria.getPage(), criteria.getLimit()); + Page adminsPage = adminRepository.findByConditions(adminsCondition, pageRequest); + + return AdminsResponse.of(adminsPage); + } + + @Transactional + public void adminAuthenticate(Integer id, Integer adminId) { + Admin admin = adminRepository.getById(adminId); + if (!admin.isSuperAdmin()) { + throw new AuthorizationException("어드민 승인 권한이 없습니다."); + } + + User user = adminRepository.getById(id).getUser(); + user.auth(); + } + + @Transactional + public void updateAdmin(AdminUpdateRequest request, Integer id) { + Admin admin = adminRepository.getById(id); + User user = admin.getUser(); + + user.updateName(request.name()); + admin.update(request.teamType(), request.trackType()); + } + + @Transactional + public void updateAdminPermission(AdminPermissionUpdateRequest request, Integer id, Integer adminId) { + Admin admin = adminRepository.getById(adminId); + if (!admin.isSuperAdmin()) { + throw new AuthorizationException("슈퍼 어드민 권한이 없습니다."); + } + + adminRepository.getById(id).updatePermission(request); + } + @Transactional public void allowOwnerPermission(Integer id) { Owner owner = adminOwnerRepository.getById(id); @@ -156,7 +260,7 @@ public AdminStudentUpdateResponse updateStudent(Integer id, AdminStudentUpdateRe validateNicknameDuplication(adminRequest.nickname(), id); validateDepartmentValid(adminRequest.major()); user.update(adminRequest.nickname(), adminRequest.name(), - adminRequest.phoneNumber(), UserGender.from(adminRequest.gender())); + adminRequest.phoneNumber(), UserGender.from(adminRequest.gender())); user.updateStudentPassword(passwordEncoder, adminRequest.password()); student.update(adminRequest.studentNumber(), adminRequest.major()); adminStudentRepository.save(student); @@ -174,17 +278,16 @@ public AdminNewOwnersResponse getNewOwners(OwnersCondition ownersCondition) { Page result = getNewOwnersResultPage(ownersCondition, criteria, direction); List ownerIncludingShops = result.getContent().stream() - .map(owner -> { - Optional ownerShop = adminOwnerShopRedisRepository.findById(owner.getId()); - return ownerShop - .map(os -> { - Shop shop = adminShopRepository.findById(os.getShopId()).orElse(null); - return OwnerIncludingShop.of(owner, shop); - }) - .orElseGet(() -> OwnerIncludingShop.of(owner, null)); - }) - .collect(Collectors.toList()); - + .map(owner -> { + Optional ownerShop = adminOwnerShopRedisRepository.findById(owner.getId()); + return ownerShop + .map(os -> { + Shop shop = adminShopRepository.findById(os.getShopId()).orElse(null); + return OwnerIncludingShop.of(owner, shop); + }) + .orElseGet(() -> OwnerIncludingShop.of(owner, null)); + }) + .collect(Collectors.toList()); return AdminNewOwnersResponse.of(ownerIncludingShops, result, criteria); } @@ -202,9 +305,9 @@ public AdminOwnersResponse getOwners(OwnersCondition ownersCondition) { } private Page getOwnersResultPage(OwnersCondition ownersCondition, Criteria criteria, - Sort.Direction direction) { + Sort.Direction direction) { PageRequest pageRequest = PageRequest.of(criteria.getPage(), criteria.getLimit(), - Sort.by(direction, "user.createdAt")); + Sort.by(direction, "user.createdAt")); Page result; @@ -220,9 +323,9 @@ private Page getOwnersResultPage(OwnersCondition ownersCondition, Criteri } private Page getNewOwnersResultPage(OwnersCondition ownersCondition, Criteria criteria, - Sort.Direction direction) { + Sort.Direction direction) { PageRequest pageRequest = PageRequest.of(criteria.getPage(), criteria.getLimit(), - Sort.by(direction, "user.createdAt")); + Sort.by(direction, "user.createdAt")); Page result; @@ -237,10 +340,9 @@ private Page getNewOwnersResultPage(OwnersCondition ownersCondition, Crit return result; } - private void validateNicknameDuplication(String nickname, Integer userId) { if (nickname != null && - adminUserRepository.existsByNicknameAndIdNot(nickname, userId)) { + adminUserRepository.existsByNicknameAndIdNot(nickname, userId)) { throw DuplicationNicknameException.withDetail("nickname : " + nickname); } } @@ -255,9 +357,9 @@ public AdminOwnerResponse getOwner(Integer ownerId) { Owner owner = adminOwnerRepository.getById(ownerId); List shopsId = adminShopRepository.findAllByOwnerId(ownerId) - .stream() - .map(Shop::getId) - .toList(); + .stream() + .map(Shop::getId) + .toList(); return AdminOwnerResponse.of(owner, shopsId); } diff --git a/src/main/java/in/koreatech/koin/domain/user/model/User.java b/src/main/java/in/koreatech/koin/domain/user/model/User.java index 6632f4b20..5f885be92 100644 --- a/src/main/java/in/koreatech/koin/domain/user/model/User.java +++ b/src/main/java/in/koreatech/koin/domain/user/model/User.java @@ -166,6 +166,10 @@ public void auth() { this.isAuthed = true; } + public void updateName(String name) { + this.name = name; + } + public void validateResetToken() { if (resetExpiredAt.isBefore(LocalDateTime.now())) { throw UserResetTokenExpiredException.withDetail("resetToken: " + resetToken); diff --git a/src/main/java/in/koreatech/koin/global/auth/AuthArgumentResolver.java b/src/main/java/in/koreatech/koin/global/auth/AuthArgumentResolver.java index c66981ebd..33c5ab2ea 100644 --- a/src/main/java/in/koreatech/koin/global/auth/AuthArgumentResolver.java +++ b/src/main/java/in/koreatech/koin/global/auth/AuthArgumentResolver.java @@ -1,7 +1,6 @@ package in.koreatech.koin.global.auth; -import static in.koreatech.koin.domain.user.model.UserType.OWNER; -import static in.koreatech.koin.domain.user.model.UserType.STUDENT; +import static in.koreatech.koin.domain.user.model.UserType.*; import static java.util.Objects.requireNonNull; import java.util.Arrays; @@ -54,6 +53,9 @@ public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer m if (user.getUserType() == STUDENT) { throw new AuthorizationException("미인증 상태입니다. 아우누리에서 인증메일을 확인해주세요"); } + if (user.getUserType() == ADMIN) { + throw new AuthorizationException("PL 인증 대기중입니다."); + } throw AuthorizationException.withDetail("userId: " + user.getId()); } return user.getId(); diff --git a/src/main/java/in/koreatech/koin/global/domain/email/model/EmailAddress.java b/src/main/java/in/koreatech/koin/global/domain/email/model/EmailAddress.java index ad4aac29b..8710125fd 100644 --- a/src/main/java/in/koreatech/koin/global/domain/email/model/EmailAddress.java +++ b/src/main/java/in/koreatech/koin/global/domain/email/model/EmailAddress.java @@ -7,10 +7,10 @@ public record EmailAddress( @Email(message = "이메일 형식을 지켜주세요.", regexp = EmailAddress.EMAIL_PATTERN) String email ) { - private static final String LOCAL_PARTS_PATTERN = "^(?=.{1,64}@)[A-Za-z0-9\\+_-]+(\\.[A-Za-z0-9\\+_-]+)*@"; private static final String DOMAIN_PATTERN = "[^-][A-Za-z0-9\\+-]+(\\.[A-Za-z0-9\\+-]+)*(\\.[A-Za-z]{2,})$"; private static final String EMAIL_PATTERN = LOCAL_PARTS_PATTERN + DOMAIN_PATTERN; + private static final String ADMIN_EMAIL_PATTERN = "^koin\\d{5}$"; private static final String DOMAIN_SEPARATOR = "@"; private static final String KOREATECH_DOMAIN = "koreatech.ac.kr"; @@ -25,6 +25,12 @@ public void validateKoreatechEmail() { } } + public void validateAdminEmail() { + if (!addressForm().matches(ADMIN_EMAIL_PATTERN)) { + throw new EmailAddressInvalidException("어드민 계정 양식에 맞지 않습니다", "email: " + email); + } + } + private String domainForm() { return email.substring(getSeparateIndex() + DOMAIN_SEPARATOR.length()); } @@ -32,4 +38,8 @@ private String domainForm() { private int getSeparateIndex() { return email.lastIndexOf(DOMAIN_SEPARATOR); } + + private String addressForm() { + return email.substring(0, email.lastIndexOf("@")); + } } diff --git a/src/main/resources/db/migration/V84__update_admins_table.sql b/src/main/resources/db/migration/V84__update_admins_table.sql new file mode 100644 index 000000000..706d9a4cd --- /dev/null +++ b/src/main/resources/db/migration/V84__update_admins_table.sql @@ -0,0 +1,12 @@ +DROP TABLE IF EXISTS `koin`.`admins`; + +CREATE TABLE `koin`.`admins` +( + `user_id` INT UNSIGNED NOT NULL COMMENT 'user 고유 id', + `team_type` VARCHAR(255) NOT NULL COMMENT '팀 타입', + `track_type` VARCHAR(255) NOT NULL COMMENT '트랙 타입', + `can_create_admin` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '어드민 계정 생성 권한', + `super_admin` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '슈퍼 어드민 권한', + PRIMARY KEY (`user_id`), + CONSTRAINT FK_ADMIN_ON_USER FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +); diff --git a/src/main/resources/db/migration/V85__insert_admin_account_date.sql b/src/main/resources/db/migration/V85__insert_admin_account_date.sql new file mode 100644 index 000000000..49b550cd1 --- /dev/null +++ b/src/main/resources/db/migration/V85__insert_admin_account_date.sql @@ -0,0 +1,5 @@ +INSERT INTO `koin`.`admins` (user_id, team_type, track_type, can_create_admin, super_admin) +SELECT u.id, 'KOIN', 'PL', 1, 1 +FROM `koin`.`users` u +WHERE u.user_type = 'ADMIN' +ORDER BY u.id ASC LIMIT 1; diff --git a/src/test/java/in/koreatech/koin/acceptance/AbtestApiTest.java b/src/test/java/in/koreatech/koin/acceptance/AbtestApiTest.java index 58c6da47d..8ed036889 100644 --- a/src/test/java/in/koreatech/koin/acceptance/AbtestApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/AbtestApiTest.java @@ -25,9 +25,9 @@ import in.koreatech.koin.admin.abtest.model.Device; import in.koreatech.koin.admin.abtest.repository.AbtestRepository; import in.koreatech.koin.admin.abtest.repository.DeviceRepository; +import in.koreatech.koin.admin.user.model.Admin; import in.koreatech.koin.domain.owner.model.Owner; import in.koreatech.koin.domain.student.model.Student; -import in.koreatech.koin.domain.user.model.User; import in.koreatech.koin.fixture.AbtestFixture; import in.koreatech.koin.fixture.DeviceFixture; import in.koreatech.koin.fixture.UserFixture; @@ -54,14 +54,14 @@ class AbtestApiTest extends AcceptanceTest { @Autowired private DeviceRepository deviceRepository; - private User admin; + private Admin admin; private String adminToken; @BeforeAll void setUp() { clear(); admin = userFixture.코인_운영자(); - adminToken = userFixture.getToken(admin); + adminToken = userFixture.getToken(admin.getUser()); } @Test diff --git a/src/test/java/in/koreatech/koin/acceptance/KeywordApiTest.java b/src/test/java/in/koreatech/koin/acceptance/KeywordApiTest.java index 66d5c4906..5695b1234 100644 --- a/src/test/java/in/koreatech/koin/acceptance/KeywordApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/KeywordApiTest.java @@ -19,6 +19,7 @@ import org.springframework.transaction.annotation.Transactional; import in.koreatech.koin.AcceptanceTest; +import in.koreatech.koin.admin.user.model.Admin; import in.koreatech.koin.domain.community.article.model.Article; import in.koreatech.koin.domain.community.article.model.Board; import in.koreatech.koin.domain.community.keyword.model.ArticleKeywordSuggestCache; @@ -27,7 +28,6 @@ import in.koreatech.koin.domain.community.keyword.repository.ArticleKeywordSuggestRepository; import in.koreatech.koin.domain.community.keyword.repository.ArticleKeywordUserMapRepository; import in.koreatech.koin.domain.student.model.Student; -import in.koreatech.koin.domain.user.model.User; import in.koreatech.koin.fixture.ArticleFixture; import in.koreatech.koin.fixture.BoardFixture; import in.koreatech.koin.fixture.KeywordFixture; @@ -60,7 +60,7 @@ public class KeywordApiTest extends AcceptanceTest { private ArticleFixture articleFixture; private Student 준호_학생; - private User 관리자; + private Admin 관리자; private String token; private String adminToken; @@ -70,7 +70,7 @@ void setup() { 준호_학생 = userFixture.준호_학생(); 관리자 = userFixture.코인_운영자(); token = userFixture.getToken(준호_학생.getUser()); - adminToken = userFixture.getToken(관리자); + adminToken = userFixture.getToken(관리자.getUser()); } @Test diff --git a/src/test/java/in/koreatech/koin/admin/acceptance/AdminBenefitApiTest.java b/src/test/java/in/koreatech/koin/admin/acceptance/AdminBenefitApiTest.java index e8d315c84..8c64a448a 100644 --- a/src/test/java/in/koreatech/koin/admin/acceptance/AdminBenefitApiTest.java +++ b/src/test/java/in/koreatech/koin/admin/acceptance/AdminBenefitApiTest.java @@ -17,11 +17,11 @@ import in.koreatech.koin.AcceptanceTest; import in.koreatech.koin.admin.benefit.repository.AdminBenefitCategoryMapRepository; import in.koreatech.koin.admin.benefit.repository.AdminBenefitCategoryRepository; +import in.koreatech.koin.admin.user.model.Admin; import in.koreatech.koin.domain.benefit.model.BenefitCategory; import in.koreatech.koin.domain.benefit.model.BenefitCategoryMap; import in.koreatech.koin.domain.owner.model.Owner; import in.koreatech.koin.domain.shop.model.shop.Shop; -import in.koreatech.koin.domain.user.model.User; import in.koreatech.koin.fixture.BenefitCategoryFixture; import in.koreatech.koin.fixture.BenefitCategoryMapFixture; import in.koreatech.koin.fixture.ShopFixture; @@ -49,7 +49,7 @@ public class AdminBenefitApiTest extends AcceptanceTest { @Autowired UserFixture userFixture; - User admin; + Admin admin; String token_admin; Owner 현수_사장님; @@ -67,7 +67,7 @@ public class AdminBenefitApiTest extends AcceptanceTest { void setup() { clear(); admin = userFixture.코인_운영자(); - token_admin = userFixture.getToken(admin); + token_admin = userFixture.getToken(admin.getUser()); 배달비_무료 = benefitCategoryFixture.배달비_무료(); 최소주문금액_무료 = benefitCategoryFixture.최소주문금액_무료(); diff --git a/src/test/java/in/koreatech/koin/admin/acceptance/AdminCoopShopApiTest.java b/src/test/java/in/koreatech/koin/admin/acceptance/AdminCoopShopApiTest.java index 132b17f18..d93e7f8a0 100644 --- a/src/test/java/in/koreatech/koin/admin/acceptance/AdminCoopShopApiTest.java +++ b/src/test/java/in/koreatech/koin/admin/acceptance/AdminCoopShopApiTest.java @@ -11,8 +11,8 @@ import org.springframework.transaction.annotation.Transactional; import in.koreatech.koin.AcceptanceTest; +import in.koreatech.koin.admin.user.model.Admin; import in.koreatech.koin.domain.coopshop.repository.CoopShopRepository; -import in.koreatech.koin.domain.user.model.User; import in.koreatech.koin.fixture.CoopShopFixture; import in.koreatech.koin.fixture.UserFixture; @@ -30,7 +30,7 @@ class AdminCoopShopApiTest extends AcceptanceTest { @Autowired private UserFixture userFixture; - private User admin; + private Admin admin; private String token_admin; @BeforeAll @@ -39,7 +39,7 @@ void setUp() { coopShopFixture._23_2학기(); coopShopFixture._23_겨울학기(); admin = userFixture.코인_운영자(); - token_admin = userFixture.getToken(admin); + token_admin = userFixture.getToken(admin.getUser()); } @Test diff --git a/src/test/java/in/koreatech/koin/admin/acceptance/AdminLandApiTest.java b/src/test/java/in/koreatech/koin/admin/acceptance/AdminLandApiTest.java index 55e48daf8..ac5ac345d 100644 --- a/src/test/java/in/koreatech/koin/admin/acceptance/AdminLandApiTest.java +++ b/src/test/java/in/koreatech/koin/admin/acceptance/AdminLandApiTest.java @@ -15,8 +15,8 @@ import in.koreatech.koin.AcceptanceTest; import in.koreatech.koin.admin.land.repository.AdminLandRepository; +import in.koreatech.koin.admin.user.model.Admin; import in.koreatech.koin.domain.land.model.Land; -import in.koreatech.koin.domain.user.model.User; import in.koreatech.koin.fixture.LandFixture; import in.koreatech.koin.fixture.UserFixture; @@ -55,8 +55,8 @@ void setup() { adminLandRepository.save(request); } - User adminUser = userFixture.코인_운영자(); - String token = userFixture.getToken(adminUser); + Admin adminUser = userFixture.코인_운영자(); + String token = userFixture.getToken(adminUser.getUser()); mockMvc.perform( get("/admin/lands") @@ -102,8 +102,8 @@ void setup() { } """; - User adminUser = userFixture.코인_운영자(); - String token = userFixture.getToken(adminUser); + Admin adminUser = userFixture.코인_운영자(); + String token = userFixture.getToken(adminUser.getUser()); mockMvc.perform( post("/admin/lands") @@ -141,8 +141,8 @@ void setup() { Land savedLand = adminLandRepository.save(request); Integer landId = savedLand.getId(); - User adminUser = userFixture.코인_운영자(); - String token = userFixture.getToken(adminUser); + Admin adminUser = userFixture.코인_운영자(); + String token = userFixture.getToken(adminUser.getUser()); mockMvc.perform( delete("/admin/lands/{id}", landId) @@ -177,8 +177,8 @@ void setup() { Land savedLand = adminLandRepository.save(request); Integer landId = savedLand.getId(); - User adminUser = userFixture.코인_운영자(); - String token = userFixture.getToken(adminUser); + Admin adminUser = userFixture.코인_운영자(); + String token = userFixture.getToken(adminUser.getUser()); mockMvc.perform( get("/admin/lands/{id}", landId) @@ -229,8 +229,8 @@ void setup() { Land land = landFixture.신안빌(); Integer landId = land.getId(); - User adminUser = userFixture.코인_운영자(); - String token = userFixture.getToken(adminUser); + Admin adminUser = userFixture.코인_운영자(); + String token = userFixture.getToken(adminUser.getUser()); String jsonBody = """ { @@ -303,8 +303,8 @@ void setup() { Land deletedLand = landFixture.삭제된_복덕방(); Integer landId = deletedLand.getId(); - User adminUser = userFixture.코인_운영자(); - String token = userFixture.getToken(adminUser); + Admin adminUser = userFixture.코인_운영자(); + String token = userFixture.getToken(adminUser.getUser()); mockMvc.perform( post("/admin/lands/{id}/undelete", landId) diff --git a/src/test/java/in/koreatech/koin/admin/acceptance/AdminMemberApiTest.java b/src/test/java/in/koreatech/koin/admin/acceptance/AdminMemberApiTest.java index e706914ee..e1a570fe0 100644 --- a/src/test/java/in/koreatech/koin/admin/acceptance/AdminMemberApiTest.java +++ b/src/test/java/in/koreatech/koin/admin/acceptance/AdminMemberApiTest.java @@ -1,7 +1,8 @@ package in.koreatech.koin.admin.acceptance; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import org.assertj.core.api.SoftAssertions; import org.junit.jupiter.api.BeforeAll; @@ -13,8 +14,8 @@ import in.koreatech.koin.AcceptanceTest; import in.koreatech.koin.admin.member.repository.AdminMemberRepository; +import in.koreatech.koin.admin.user.model.Admin; import in.koreatech.koin.domain.member.model.Member; -import in.koreatech.koin.domain.user.model.User; import in.koreatech.koin.fixture.MemberFixture; import in.koreatech.koin.fixture.TrackFixture; import in.koreatech.koin.fixture.UserFixture; @@ -45,8 +46,8 @@ void setup() { void BCSDLab_회원들의_정보를_조회한다() throws Exception { memberFixture.최준호(trackFixture.backend()); - User adminUser = userFixture.코인_운영자(); - String token = userFixture.getToken(adminUser); + Admin adminUser = userFixture.코인_운영자(); + String token = userFixture.getToken(adminUser.getUser()); mockMvc.perform( get("/admin/members") @@ -82,8 +83,8 @@ void setup() { void 관리자_권한으로_BCSDLab_회원을_추가한다() throws Exception { trackFixture.backend(); - User adminUser = userFixture.코인_운영자(); - String token = userFixture.getToken(adminUser); + Admin adminUser = userFixture.코인_운영자(); + String token = userFixture.getToken(adminUser.getUser()); String jsonBody = """ { @@ -121,8 +122,8 @@ void setup() { void BCSDLab_회원_정보를_조회한다() throws Exception { memberFixture.최준호(trackFixture.backend()); - User adminUser = userFixture.코인_운영자(); - String token = userFixture.getToken(adminUser); + Admin adminUser = userFixture.코인_운영자(); + String token = userFixture.getToken(adminUser.getUser()); mockMvc.perform( get("/admin/members/{id}", 1) @@ -148,8 +149,8 @@ void setup() { Member member = memberFixture.최준호(trackFixture.backend()); Integer memberId = member.getId(); - User adminUser = userFixture.코인_운영자(); - String token = userFixture.getToken(adminUser); + Admin adminUser = userFixture.코인_운영자(); + String token = userFixture.getToken(adminUser.getUser()); mockMvc.perform( delete("/admin/members/{id}", memberId) @@ -175,8 +176,8 @@ void setup() { Member member = memberFixture.최준호(trackFixture.backend()); Integer memberId = member.getId(); - User adminUser = userFixture.코인_운영자(); - String token = userFixture.getToken(adminUser); + Admin adminUser = userFixture.코인_운영자(); + String token = userFixture.getToken(adminUser.getUser()); String jsonBody = """ { @@ -216,8 +217,8 @@ void setup() { trackFixture.frontend(); Integer memberId = member.getId(); - User adminUser = userFixture.코인_운영자(); - String token = userFixture.getToken(adminUser); + Admin adminUser = userFixture.코인_운영자(); + String token = userFixture.getToken(adminUser.getUser()); String jsonBody = """ { @@ -256,8 +257,8 @@ void setup() { Member member = memberFixture.최준호_삭제(trackFixture.backend()); Integer memberId = member.getId(); - User adminUser = userFixture.코인_운영자(); - String token = userFixture.getToken(adminUser); + Admin adminUser = userFixture.코인_운영자(); + String token = userFixture.getToken(adminUser.getUser()); mockMvc.perform( post("/admin/members/{id}/undelete", memberId) diff --git a/src/test/java/in/koreatech/koin/admin/acceptance/AdminNoticeApiTest.java b/src/test/java/in/koreatech/koin/admin/acceptance/AdminNoticeApiTest.java index 1211b3238..55ca22a0e 100644 --- a/src/test/java/in/koreatech/koin/admin/acceptance/AdminNoticeApiTest.java +++ b/src/test/java/in/koreatech/koin/admin/acceptance/AdminNoticeApiTest.java @@ -12,11 +12,11 @@ import org.springframework.transaction.annotation.Transactional; import in.koreatech.koin.AcceptanceTest; +import in.koreatech.koin.admin.user.model.Admin; import in.koreatech.koin.domain.community.article.model.Article; import in.koreatech.koin.domain.community.article.model.Board; import in.koreatech.koin.domain.community.article.model.KoinArticle; import in.koreatech.koin.domain.community.article.repository.ArticleRepository; -import in.koreatech.koin.domain.user.model.User; import in.koreatech.koin.fixture.ArticleFixture; import in.koreatech.koin.fixture.BoardFixture; import in.koreatech.koin.fixture.UserFixture; @@ -38,7 +38,7 @@ public class AdminNoticeApiTest extends AcceptanceTest { @Autowired private ArticleRepository articleRepository; - User adminUser; + Admin adminUser; Board boardId1, boardId2, boardId3, boardId4, boardId5, boardId6, boardId7, boardId8, boardId9; Article article1, article2, article3, article4; @@ -57,13 +57,13 @@ void setup() { boardId9 = boardFixture.코인공지(); article1 = - articleFixture.코인_공지_게시글("[캠퍼스팀] 공지 테스트", "

내용

", boardId9, adminUser); + articleFixture.코인_공지_게시글("[캠퍼스팀] 공지 테스트", "

내용

", boardId9, adminUser.getUser()); article2 = - articleFixture.코인_공지_게시글("[유저팀] 공지 테스트", "

내용

", boardId9, adminUser); + articleFixture.코인_공지_게시글("[유저팀] 공지 테스트", "

내용

", boardId9, adminUser.getUser()); article3 = - articleFixture.코인_공지_게시글("[비즈니스팀] 공지 테스트", "

내용

", boardId9, adminUser); + articleFixture.코인_공지_게시글("[비즈니스팀] 공지 테스트", "

내용

", boardId9, adminUser.getUser()); article4 = - articleFixture.코인_공지_게시글("[KOIN] 공지 테스트", "

내용

", boardId9, adminUser); + articleFixture.코인_공지_게시글("[KOIN] 공지 테스트", "

내용

", boardId9, adminUser.getUser()); } @Test @@ -75,7 +75,7 @@ void setup() { } """; - String token = userFixture.getToken(adminUser); + String token = userFixture.getToken(adminUser.getUser()); mockMvc.perform( post("/admin/notice") @@ -88,7 +88,7 @@ void setup() { @Test void 관리자_권한으로_삭제_되지_않은_코인_공지사항_목록을_조회한다() throws Exception { - String token = userFixture.getToken(adminUser); + String token = userFixture.getToken(adminUser.getUser()); mockMvc.perform( get("/admin/notice") @@ -108,7 +108,7 @@ void setup() { void 관리자_권한으로_코인_공지사항_게시글을_단건_조회한다() throws Exception { // 코인 공지사항 게시글 생성 KoinArticle koinArticle = KoinArticle.builder() - .user(adminUser) + .user(adminUser.getUser()) .isDeleted(false) .build(); @@ -127,7 +127,7 @@ void setup() { Article saveNotice = articleRepository.save(adminNoticeArticle); Integer noticeId = saveNotice.getId(); - String token = userFixture.getToken(adminUser); + String token = userFixture.getToken(adminUser.getUser()); mockMvc.perform( get("/admin/notice/{id}", noticeId) @@ -150,7 +150,7 @@ void setup() { void 관리자_권한으로_코인_공지사항_게시글을_삭제한다() throws Exception { // 코인 공지사항 게시글 생성 KoinArticle koinArticle = KoinArticle.builder() - .user(adminUser) + .user(adminUser.getUser()) .isDeleted(false) .build(); @@ -169,7 +169,7 @@ void setup() { Article saveNotice = articleRepository.save(adminNoticeArticle); Integer noticeId = saveNotice.getId(); - String token = userFixture.getToken(adminUser); + String token = userFixture.getToken(adminUser.getUser()); mockMvc.perform( delete("/admin/notice/{id}", noticeId) @@ -189,7 +189,7 @@ void setup() { void 관리자_권한으로_코인_공지사항_게시글을_수정한다() throws Exception { // 코인 공지사항 게시글 생성 KoinArticle koinArticle = KoinArticle.builder() - .user(adminUser) + .user(adminUser.getUser()) .isDeleted(false) .build(); @@ -208,7 +208,7 @@ void setup() { Article saveNotice = articleRepository.save(adminNoticeArticle); Integer noticeId = saveNotice.getId(); - String token = userFixture.getToken(adminUser); + String token = userFixture.getToken(adminUser.getUser()); // 코인 공지사항 게시글 수정 String jsonBody = """ diff --git a/src/test/java/in/koreatech/koin/admin/acceptance/AdminShopApiTest.java b/src/test/java/in/koreatech/koin/admin/acceptance/AdminShopApiTest.java index b31762d75..c72b8434b 100644 --- a/src/test/java/in/koreatech/koin/admin/acceptance/AdminShopApiTest.java +++ b/src/test/java/in/koreatech/koin/admin/acceptance/AdminShopApiTest.java @@ -22,6 +22,7 @@ import in.koreatech.koin.admin.shop.repository.AdminMenuRepository; import in.koreatech.koin.admin.shop.repository.AdminShopCategoryRepository; import in.koreatech.koin.admin.shop.repository.AdminShopRepository; +import in.koreatech.koin.admin.user.model.Admin; import in.koreatech.koin.domain.owner.model.Owner; import in.koreatech.koin.domain.shop.model.menu.Menu; import in.koreatech.koin.domain.shop.model.menu.MenuCategory; @@ -33,7 +34,6 @@ import in.koreatech.koin.domain.shop.model.shop.ShopCategoryMap; import in.koreatech.koin.domain.shop.model.shop.ShopImage; import in.koreatech.koin.domain.shop.model.shop.ShopOpen; -import in.koreatech.koin.domain.user.model.User; import in.koreatech.koin.fixture.MenuCategoryFixture; import in.koreatech.koin.fixture.MenuFixture; import in.koreatech.koin.fixture.ShopCategoryFixture; @@ -82,7 +82,7 @@ class AdminShopApiTest extends AcceptanceTest { private Owner owner_현수; private Owner owner_준영; private Shop shop_마슬랜; - private User admin; + private Admin admin; private String token_admin; private ShopCategory shopCategory_치킨; private ShopCategory shopCategory_일반; @@ -93,7 +93,7 @@ class AdminShopApiTest extends AcceptanceTest { void setUp() { clear(); admin = userFixture.코인_운영자(); - token_admin = userFixture.getToken(admin); + token_admin = userFixture.getToken(admin.getUser()); owner_현수 = userFixture.현수_사장님(); owner_준영 = userFixture.준영_사장님(); shop_마슬랜 = shopFixture.마슬랜(owner_현수); diff --git a/src/test/java/in/koreatech/koin/admin/acceptance/AdminShopReviewApiTest.java b/src/test/java/in/koreatech/koin/admin/acceptance/AdminShopReviewApiTest.java index 55926f0ea..e8227ab1e 100644 --- a/src/test/java/in/koreatech/koin/admin/acceptance/AdminShopReviewApiTest.java +++ b/src/test/java/in/koreatech/koin/admin/acceptance/AdminShopReviewApiTest.java @@ -1,11 +1,11 @@ package in.koreatech.koin.admin.acceptance; -import static in.koreatech.koin.domain.shop.model.review.ReportStatus.*; +import static in.koreatech.koin.domain.shop.model.review.ReportStatus.DELETED; +import static in.koreatech.koin.domain.shop.model.review.ReportStatus.UNHANDLED; import static org.assertj.core.api.Assertions.assertThat; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import java.util.List; import java.util.Optional; @@ -19,12 +19,12 @@ import in.koreatech.koin.AcceptanceTest; import in.koreatech.koin.admin.shop.repository.AdminShopReviewRepository; +import in.koreatech.koin.admin.user.model.Admin; import in.koreatech.koin.domain.owner.model.Owner; import in.koreatech.koin.domain.shop.model.review.ShopReview; import in.koreatech.koin.domain.shop.model.review.ShopReviewReport; import in.koreatech.koin.domain.shop.model.shop.Shop; import in.koreatech.koin.domain.student.model.Student; -import in.koreatech.koin.domain.user.model.User; import in.koreatech.koin.fixture.ShopFixture; import in.koreatech.koin.fixture.ShopReviewFixture; import in.koreatech.koin.fixture.ShopReviewReportFixture; @@ -53,7 +53,7 @@ class AdminShopReviewApiTest extends AcceptanceTest { @Autowired private AdminShopReviewRepository adminShopReviewRepository; - private User admin; + private Admin admin; private Owner owner_현수; private Student student_익명; private ShopReview 준호_리뷰; @@ -65,7 +65,7 @@ void setUp() { clear(); admin = userFixture.코인_운영자(); student_익명 = userFixture.익명_학생(); - token_admin = userFixture.getToken(admin); + token_admin = userFixture.getToken(admin.getUser()); owner_현수 = userFixture.현수_사장님(); shop_마슬랜 = shopFixture.마슬랜(owner_현수); 준호_리뷰 = shopReviewFixture.리뷰_4점(student_익명, shop_마슬랜); diff --git a/src/test/java/in/koreatech/koin/admin/acceptance/AdminTrackApiTest.java b/src/test/java/in/koreatech/koin/admin/acceptance/AdminTrackApiTest.java index 69275b720..f569ae0ac 100644 --- a/src/test/java/in/koreatech/koin/admin/acceptance/AdminTrackApiTest.java +++ b/src/test/java/in/koreatech/koin/admin/acceptance/AdminTrackApiTest.java @@ -1,7 +1,8 @@ package in.koreatech.koin.admin.acceptance; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import org.assertj.core.api.SoftAssertions; import org.junit.jupiter.api.BeforeAll; @@ -14,10 +15,10 @@ import in.koreatech.koin.AcceptanceTest; import in.koreatech.koin.admin.member.repository.AdminTechStackRepository; import in.koreatech.koin.admin.member.repository.AdminTrackRepository; +import in.koreatech.koin.admin.user.model.Admin; import in.koreatech.koin.domain.member.model.TechStack; import in.koreatech.koin.domain.member.model.Track; import in.koreatech.koin.domain.student.model.Student; -import in.koreatech.koin.domain.user.model.User; import in.koreatech.koin.fixture.MemberFixture; import in.koreatech.koin.fixture.TechStackFixture; import in.koreatech.koin.fixture.TrackFixture; @@ -65,8 +66,8 @@ void setup() { @Test void 관리자가_BCSDLab_트랙_정보를_조회한다() throws Exception { - User adminUser = userFixture.코인_운영자(); - String token = userFixture.getToken(adminUser); + Admin adminUser = userFixture.코인_운영자(); + String token = userFixture.getToken(adminUser.getUser()); trackFixture.backend(); trackFixture.frontend(); @@ -109,8 +110,8 @@ void setup() { @Test void 관리자가_BCSDLab_트랙_정보를_생성한다() throws Exception { - User adminUser = userFixture.코인_운영자(); - String token = userFixture.getToken(adminUser); + Admin adminUser = userFixture.코인_운영자(); + String token = userFixture.getToken(adminUser.getUser()); mockMvc.perform( post("/admin/tracks") @@ -138,8 +139,8 @@ void setup() { @Test void 관리자가_BCSDLab_트랙_정보를_생성한다_이미_있는_트랙명이면_409_반환() throws Exception { - User adminUser = userFixture.코인_운영자(); - String token = userFixture.getToken(adminUser); + Admin adminUser = userFixture.코인_운영자(); + String token = userFixture.getToken(adminUser.getUser()); trackFixture.backend(); @@ -159,8 +160,8 @@ void setup() { @Test void 관리자가_BCSDLab_트랙_단거_정보를_조회한다() throws Exception { - User adminUser = userFixture.코인_운영자(); - String token = userFixture.getToken(adminUser); + Admin adminUser = userFixture.코인_운영자(); + String token = userFixture.getToken(adminUser.getUser()); Track backend = trackFixture.backend(); trackFixture.ai(); // 삭제된 트랙 @@ -231,8 +232,8 @@ void setup() { @Test void 관리자가_BCSDLab_트랙_정보를_수정한다() throws Exception { - User adminUser = userFixture.코인_운영자(); - String token = userFixture.getToken(adminUser); + Admin adminUser = userFixture.코인_운영자(); + String token = userFixture.getToken(adminUser.getUser()); Track backEnd = trackFixture.backend(); @@ -262,8 +263,8 @@ void setup() { @Test void 관리자가_BCSDLab_트랙_정보를_수정한다_이미_있는_트랙명이면_409_반환() throws Exception { - User adminUser = userFixture.코인_운영자(); - String token = userFixture.getToken(adminUser); + Admin adminUser = userFixture.코인_운영자(); + String token = userFixture.getToken(adminUser.getUser()); Track backEnd = trackFixture.backend(); @@ -283,8 +284,8 @@ void setup() { @Test void 관리자가_BCSDLab_트랙_정보를_삭제한다() throws Exception { - User adminUser = userFixture.코인_운영자(); - String token = userFixture.getToken(adminUser); + Admin adminUser = userFixture.코인_운영자(); + String token = userFixture.getToken(adminUser.getUser()); Track backEnd = trackFixture.backend(); @@ -305,8 +306,8 @@ void setup() { @Test void 관리자가_BCSDLab_기술스택_정보를_생성한다() throws Exception { - User adminUser = userFixture.코인_운영자(); - String token = userFixture.getToken(adminUser); + Admin adminUser = userFixture.코인_운영자(); + String token = userFixture.getToken(adminUser.getUser()); trackFixture.frontend(); Track backEnd = trackFixture.backend(); @@ -341,8 +342,8 @@ void setup() { @Test void 관리자가_BCSDLab_기술스택_정보를_수정한다() throws Exception { - User adminUser = userFixture.코인_운영자(); - String token = userFixture.getToken(adminUser); + Admin adminUser = userFixture.코인_운영자(); + String token = userFixture.getToken(adminUser.getUser()); TechStack java = techStackFixture.java(trackFixture.frontend()); Track backEnd = trackFixture.backend(); @@ -378,8 +379,8 @@ void setup() { @Test void 관리자가_기술스택_정보를_삭제한다() throws Exception { - User adminUser = userFixture.코인_운영자(); - String token = userFixture.getToken(adminUser); + Admin adminUser = userFixture.코인_운영자(); + String token = userFixture.getToken(adminUser.getUser()); Track backEnd = trackFixture.backend(); TechStack java = techStackFixture.java(backEnd); diff --git a/src/test/java/in/koreatech/koin/admin/acceptance/AdminUserApiTest.java b/src/test/java/in/koreatech/koin/admin/acceptance/AdminUserApiTest.java index cd068e84c..f28b46827 100644 --- a/src/test/java/in/koreatech/koin/admin/acceptance/AdminUserApiTest.java +++ b/src/test/java/in/koreatech/koin/admin/acceptance/AdminUserApiTest.java @@ -3,8 +3,8 @@ import static in.koreatech.koin.domain.user.model.UserGender.MAN; import static in.koreatech.koin.domain.user.model.UserIdentity.UNDERGRADUATE; import static in.koreatech.koin.domain.user.model.UserType.OWNER; -import static org.assertj.core.api.Assertions.assertThat; import static in.koreatech.koin.domain.user.model.UserType.STUDENT; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.SoftAssertions.assertSoftly; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @@ -25,8 +25,10 @@ import com.fasterxml.jackson.databind.JsonNode; import in.koreatech.koin.AcceptanceTest; +import in.koreatech.koin.admin.user.model.Admin; import in.koreatech.koin.admin.user.repository.AdminOwnerRepository; import in.koreatech.koin.admin.user.repository.AdminOwnerShopRedisRepository; +import in.koreatech.koin.admin.user.repository.AdminRepository; import in.koreatech.koin.admin.user.repository.AdminStudentRepository; import in.koreatech.koin.admin.user.repository.AdminUserRepository; import in.koreatech.koin.domain.owner.model.Owner; @@ -61,6 +63,9 @@ public class AdminUserApiTest extends AcceptanceTest { @Autowired private OwnerShopRedisRepository ownerShopRedisRepository; + @Autowired + private AdminRepository adminRepository; + @Autowired private TransactionTemplate transactionTemplate; @@ -81,9 +86,9 @@ void setup() { @Test void 관리자가_학생_리스트를_파라미터가_없이_조회한다_페이지네이션() throws Exception { Student student = userFixture.준호_학생(); - User adminUser = userFixture.코인_운영자(); + Admin adminUser = userFixture.코인_운영자(); - String token = userFixture.getToken(adminUser); + String token = userFixture.getToken(adminUser.getUser()); mockMvc.perform( get("/admin/students") @@ -138,9 +143,8 @@ void setup() { adminStudentRepository.save(student); } - User adminUser = userFixture.코인_운영자(); - - String token = userFixture.getToken(adminUser); + Admin adminUser = userFixture.코인_운영자(); + String token = userFixture.getToken(adminUser.getUser()); mockMvc.perform( get("/admin/students") @@ -173,9 +177,8 @@ void setup() { void 관리자가_학생_리스트를_닉네임으로_조회한다_페이지네이션() throws Exception { Student student1 = userFixture.성빈_학생(); Student student2 = userFixture.준호_학생(); - User adminUser = userFixture.코인_운영자(); - - String token = userFixture.getToken(adminUser); + Admin adminUser = userFixture.코인_운영자(); + String token = userFixture.getToken(adminUser.getUser()); mockMvc.perform( get("/admin/students") @@ -206,8 +209,8 @@ void setup() { @Test void 관리자가_로그인_한다() throws Exception { - User adminUser = userFixture.코인_운영자(); - String email = adminUser.getEmail(); + Admin adminUser = userFixture.코인_운영자(); + String email = adminUser.getUser().getEmail(); String password = "1234"; mockMvc.perform( @@ -243,10 +246,28 @@ void setup() { } @Test - void 관리자가_로그아웃한다() throws Exception { - User adminUser = userFixture.코인_운영자(); + void 인증_받지_못한_관리자가_로그인_한다() throws Exception { + Admin admin = userFixture.진구_운영자(); + String email = admin.getUser().getEmail(); + String password = "1234"; + + mockMvc.perform( + post("/admin/user/login") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "email" : "%s", + "password" : "%s" + } + """.formatted(email, password)) + ) + .andExpect(status().isForbidden()); + } - String token = userFixture.getToken(adminUser); + @Test + void 관리자가_로그아웃한다() throws Exception { + Admin adminUser = userFixture.코인_운영자(); + String token = userFixture.getToken(adminUser.getUser()); mockMvc.perform( post("/admin/user/logout") @@ -258,11 +279,11 @@ void setup() { @Test void 관리자가_액세스_토큰_재발급_한다() throws Exception { - User adminUser = userFixture.코인_운영자(); - String email = adminUser.getEmail(); + Admin adminUser = userFixture.코인_운영자(); + User user = adminUser.getUser(); + String email = user.getEmail(); String password = "1234"; - - String token = userFixture.getToken(adminUser); + String token = userFixture.getToken(user); MvcResult result = mockMvc.perform( post("/admin/user/login") @@ -297,8 +318,8 @@ void setup() { Owner owner = userFixture.철수_사장님(); Shop shop = shopFixture.마슬랜(null); - User adminUser = userFixture.코인_운영자(); - String token = userFixture.getToken(adminUser); + Admin adminUser = userFixture.코인_운영자(); + String token = userFixture.getToken(adminUser.getUser()); OwnerShop ownerShop = OwnerShop.builder() .ownerId(owner.getId()) @@ -343,8 +364,8 @@ void setup() { void 관리자가_특정_학생_정보를_조회한다() throws Exception { Student student = userFixture.준호_학생(); - User adminUser = userFixture.코인_운영자(); - String token = userFixture.getToken(adminUser); + Admin adminUser = userFixture.코인_운영자(); + String token = userFixture.getToken(adminUser.getUser()); mockMvc.perform( get("/admin/users/student/{id}", student.getUser().getId()) @@ -377,8 +398,8 @@ void setup() { void 관리자가_특정_학생_정보를_수정한다() throws Exception { Student student = userFixture.준호_학생(); - User adminUser = userFixture.코인_운영자(); - String token = userFixture.getToken(adminUser); + Admin adminUser = userFixture.코인_운영자(); + String token = userFixture.getToken(adminUser.getUser()); mockMvc.perform( put("/admin/users/student/{id}", student.getUser().getId()) @@ -428,8 +449,8 @@ void setup() { Owner owner = userFixture.현수_사장님(); Shop shop = shopFixture.마슬랜(owner); - User adminUser = userFixture.코인_운영자(); - String token = userFixture.getToken(adminUser); + Admin adminUser = userFixture.코인_운영자(); + String token = userFixture.getToken(adminUser.getUser()); mockMvc.perform( get("/admin/users/owner/{id}", owner.getUser().getId()) @@ -466,8 +487,8 @@ void setup() { Owner owner = userFixture.현수_사장님(); Shop shop = shopFixture.마슬랜(owner); - User adminUser = userFixture.코인_운영자(); - String token = userFixture.getToken(adminUser); + Admin adminUser = userFixture.코인_운영자(); + String token = userFixture.getToken(adminUser.getUser()); mockMvc.perform( put("/admin/users/owner/{id}", owner.getUser().getId()) @@ -501,8 +522,8 @@ void setup() { Owner owner = userFixture.철수_사장님(); Shop shop = shopFixture.마슬랜(null); - User adminUser = userFixture.코인_운영자(); - String token = userFixture.getToken(adminUser); + Admin adminUser = userFixture.코인_운영자(); + String token = userFixture.getToken(adminUser.getUser()); OwnerShop ownerShop = OwnerShop.builder() .ownerId(owner.getId()) @@ -581,8 +602,8 @@ void setup() { adminOwnerRepository.save(owner); } - User adminUser = userFixture.코인_운영자(); - String token = userFixture.getToken(adminUser); + Admin adminUser = userFixture.코인_운영자(); + String token = userFixture.getToken(adminUser.getUser()); mockMvc.perform( get("/admin/users/new-owners") @@ -637,8 +658,8 @@ void setup() { adminOwnerRepository.save(owner); } - User adminUser = userFixture.코인_운영자(); - String token = userFixture.getToken(adminUser); + Admin adminUser = userFixture.코인_운영자(); + String token = userFixture.getToken(adminUser.getUser()); mockMvc.perform( get("/admin/users/owners") @@ -657,8 +678,8 @@ void setup() { void 관리자가_회원을_조회한다() throws Exception { Student student = userFixture.준호_학생(); - User adminUser = userFixture.코인_운영자(); - String token = userFixture.getToken(adminUser); + Admin adminUser = userFixture.코인_운영자(); + String token = userFixture.getToken(adminUser.getUser()); mockMvc.perform( get("/admin/users/{id}", student.getUser().getId()) @@ -675,8 +696,8 @@ void setup() { void 관리자가_회원을_삭제한다() throws Exception { Student student = userFixture.준호_학생(); - User adminUser = userFixture.코인_운영자(); - String token = userFixture.getToken(adminUser); + Admin adminUser = userFixture.코인_운영자(); + String token = userFixture.getToken(adminUser.getUser()); mockMvc.perform( delete("/admin/users/{id}", student.getUser().getId()) @@ -687,4 +708,176 @@ void setup() { assertThat(adminUserRepository.findById(student.getId())).isNotPresent(); } + + @Test + void 관리자_계정_생성_권한이_있는_관리자가_관리자_계정을_만든다() throws Exception { + Admin admin = userFixture.코인_운영자(); + String token = userFixture.getToken(admin.getUser()); + + mockMvc.perform( + post("/admin") + .header("Authorization", "Bearer " + token) + .content(""" + { + "email": "koin01234@koreatech.ac.kr", + "password": "cd06f8c2b0dd065faf6ef910c7f15934363df71c33740fd245590665286ed268", + "name": "신관규", + "track_type": "BACKEND", + "team_type": "USER" + } + """) + .contentType(MediaType.APPLICATION_JSON) + ) + .andExpect(status().isCreated()); + } + + @Test + void 관리자_계정_생성_권한이_없는_관리자가_관리자_계정을_만든다() throws Exception { + Admin admin = userFixture.영희_운영자(); + String token = userFixture.getToken(admin.getUser()); + + mockMvc.perform( + post("/admin") + .header("Authorization", "Bearer " + token) + .content(""" + { + "email": "koin12345@koreatech.ac.kr", + "password": "cd06f8c2b0dd065faf6ef910c7f15934363df71c33740fd245590665286ed268", + "name": "신관규", + "track_type": "BACKEND", + "team_type": "USER" + } + """) + .contentType(MediaType.APPLICATION_JSON) + ) + .andExpect(status().isForbidden()); + } + + @Test + void 조건에_맞지_않는_이메일로_관리자_계정을_만든다() throws Exception { + Admin admin = userFixture.코인_운영자(); + String token = userFixture.getToken(admin.getUser()); + + mockMvc.perform( + post("/admin") + .header("Authorization", "Bearer " + token) + .content(""" + { + "email": "admin123456@koreatech.ac.kr", + "password": "cd06f8c2b0dd065faf6ef910c7f15934363df71c33740fd245590665286ed268", + "name": "신관규", + "track_type": "BACKEND", + "team_type": "USER" + } + """) + .contentType(MediaType.APPLICATION_JSON) + ) + .andExpect(status().isBadRequest()); + } + + @Test + void 관리자가_특정_관리자의_계정을_조회한다() throws Exception { + Admin admin = userFixture.코인_운영자(); + String token = userFixture.getToken(admin.getUser()); + + Admin admin1 = userFixture.영희_운영자(); + + mockMvc.perform( + get("/admin/{id}", admin1.getId()) + .header("Authorization", "Bearer " + token) + ) + .andExpect(status().isOk()) + .andExpect(content().json(String.format(""" + { + "id": %d, + "email": "koinadmin1@koreatech.ac.kr", + "name": "테스트용_코인운영자", + "track_name": "Backend", + "team_name": "Business", + "can_create_admin": false, + "super_admin": false + } + """, admin1.getId()))); + } + + @Test + void 관리자가_관리자_계정_리스트를_조회한다() throws Exception { + Admin admin = userFixture.코인_운영자(); + String token = userFixture.getToken(admin.getUser()); + + Admin admin1 = userFixture.영희_운영자(); + Admin admin2 = userFixture.진구_운영자(); + + mockMvc.perform( + get("/admins") + .header("Authorization", "Bearer " + token) + ) + .andExpect(status().isOk()) + .andExpect(content().json(String.format(""" + { + "total_count": 3, + "current_count": 3, + "total_page": 1, + "current_page": 1, + "admins": [ + { + "id": %d, + "email": "juno@koreatech.ac.kr", + "name": "테스트용_코인운영자", + "team_name": "User", + "track_name": "Backend", + "can_create_admin": true, + "super_admin": true + }, + { + "id": %d, + "email": "koinadmin1@koreatech.ac.kr", + "name": "테스트용_코인운영자", + "team_name": "Business", + "track_name": "Backend", + "can_create_admin": false, + "super_admin": false + }, + { + "id": %d, + "email": "koinadmin2@koreatech.ac.kr", + "name": "테스트용_코인운영자", + "team_name": "Campus", + "track_name": "Backend", + "can_create_admin": true, + "super_admin": false + } + ] + } + """, admin.getId(), admin1.getId(), admin2.getId()))); + } + + @Test + void 슈퍼_관리자가_관리자_계정_권한을_승인한다() throws Exception { + Admin admin = userFixture.코인_운영자(); + String token = userFixture.getToken(admin.getUser()); + Admin admin1 = userFixture.진구_운영자(); + + mockMvc.perform( + put("/admin/{id}/authed", admin1.getId()) + .header("Authorization", "Bearer " + token) + ) + .andExpect(status().isOk()); + + Admin updateAdmin = adminRepository.getById(admin1.getId()); + assertThat(updateAdmin.getUser().isAuthed()).isEqualTo(true); + } + + @Test + void 관리자가_관리자_계정_권한을_승인한다() throws Exception { + Admin admin = userFixture.영희_운영자(); + String token = userFixture.getToken(admin.getUser()); + Admin admin1 = userFixture.진구_운영자(); + + mockMvc.perform( + put("/admin/{id}/authed", admin1.getId()) + .header("Authorization", "Bearer " + token) + ) + .andExpect(status().isForbidden()); + } } diff --git a/src/test/java/in/koreatech/koin/admin/acceptance/AdminVersionApiTest.java b/src/test/java/in/koreatech/koin/admin/acceptance/AdminVersionApiTest.java index c7fc0e7f6..fac6af3e9 100644 --- a/src/test/java/in/koreatech/koin/admin/acceptance/AdminVersionApiTest.java +++ b/src/test/java/in/koreatech/koin/admin/acceptance/AdminVersionApiTest.java @@ -13,8 +13,8 @@ import org.springframework.transaction.annotation.Transactional; import in.koreatech.koin.AcceptanceTest; +import in.koreatech.koin.admin.user.model.Admin; import in.koreatech.koin.admin.version.repository.AdminVersionRepository; -import in.koreatech.koin.domain.user.model.User; import in.koreatech.koin.domain.version.model.Version; import in.koreatech.koin.domain.version.model.VersionType; import in.koreatech.koin.fixture.UserFixture; @@ -34,7 +34,7 @@ public class AdminVersionApiTest extends AcceptanceTest { private UserFixture userFixture; private Version android; - private User admin; + private Admin admin; private String admin_token; @BeforeAll @@ -42,7 +42,7 @@ void setup() { clear(); android = versionFixture.android(); admin = userFixture.코인_운영자(); - admin_token = userFixture.getToken(admin); + admin_token = userFixture.getToken(admin.getUser()); } @Test diff --git a/src/test/java/in/koreatech/koin/fixture/UserFixture.java b/src/test/java/in/koreatech/koin/fixture/UserFixture.java index 93f78cb1e..ecc5f9034 100644 --- a/src/test/java/in/koreatech/koin/fixture/UserFixture.java +++ b/src/test/java/in/koreatech/koin/fixture/UserFixture.java @@ -1,6 +1,9 @@ package in.koreatech.koin.fixture; +import static in.koreatech.koin.admin.user.enums.TeamType.*; +import static in.koreatech.koin.admin.user.enums.TrackType.BACKEND; import static in.koreatech.koin.domain.user.model.UserGender.MAN; +import static in.koreatech.koin.domain.user.model.UserGender.WOMAN; import static in.koreatech.koin.domain.user.model.UserIdentity.UNDERGRADUATE; import static in.koreatech.koin.domain.user.model.UserType.*; @@ -11,16 +14,18 @@ import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Component; +import in.koreatech.koin.admin.user.model.Admin; +import in.koreatech.koin.admin.user.repository.AdminRepository; import in.koreatech.koin.domain.coop.model.Coop; import in.koreatech.koin.domain.coop.repository.CoopRepository; import in.koreatech.koin.domain.owner.model.Owner; import in.koreatech.koin.domain.owner.model.OwnerAttachment; import in.koreatech.koin.domain.owner.repository.OwnerRepository; import in.koreatech.koin.domain.student.model.Student; +import in.koreatech.koin.domain.student.repository.StudentRepository; import in.koreatech.koin.domain.user.model.User; import in.koreatech.koin.domain.user.model.UserGender; import in.koreatech.koin.domain.user.model.UserType; -import in.koreatech.koin.domain.student.repository.StudentRepository; import in.koreatech.koin.domain.user.repository.UserRepository; import in.koreatech.koin.global.auth.JwtProvider; @@ -33,9 +38,9 @@ public final class UserFixture { private final OwnerRepository ownerRepository; private final StudentRepository studentRepository; private final CoopRepository coopRepository; + private final AdminRepository adminRepository; private final JwtProvider jwtProvider; - @Autowired public UserFixture( PasswordEncoder passwordEncoder, @@ -43,6 +48,7 @@ public UserFixture( OwnerRepository ownerRepository, StudentRepository studentRepository, CoopRepository coopRepository, + AdminRepository adminRepository, JwtProvider jwtProvider ) { this.passwordEncoder = passwordEncoder; @@ -50,21 +56,78 @@ public UserFixture( this.ownerRepository = ownerRepository; this.studentRepository = studentRepository; this.coopRepository = coopRepository; + this.adminRepository = adminRepository; this.jwtProvider = jwtProvider; } - public User 코인_운영자() { - return userRepository.save( - User.builder() - .password(passwordEncoder.encode("1234")) - .nickname("코인운영자") - .name("테스트용_코인운영자") - .phoneNumber("01012342344") - .userType(ADMIN) - .gender(MAN) - .email("juno@koreatech.ac.kr") - .isAuthed(true) - .isDeleted(false) + public Admin 코인_운영자() { + return adminRepository.save( + Admin.builder() + .trackType(BACKEND) + .teamType(USER) + .canCreateAdmin(true) + .superAdmin(true) + .user( + User.builder() + .password(passwordEncoder.encode("1234")) + .nickname("코인운영자") + .name("테스트용_코인운영자") + .phoneNumber("01012342344") + .userType(ADMIN) + .gender(MAN) + .email("juno@koreatech.ac.kr") + .isAuthed(true) + .isDeleted(false) + .build() + ) + .build() + ); + } + + public Admin 영희_운영자() { + return adminRepository.save( + Admin.builder() + .trackType(BACKEND) + .teamType(BUSINESS) + .canCreateAdmin(false) + .superAdmin(false) + .user( + User.builder() + .password(passwordEncoder.encode("1234")) + .nickname("코인운영자1") + .name("테스트용_코인운영자") + .phoneNumber("01012342347") + .userType(ADMIN) + .gender(WOMAN) + .email("koinadmin1@koreatech.ac.kr") + .isAuthed(true) + .isDeleted(false) + .build() + ) + .build() + ); + } + + public Admin 진구_운영자() { + return adminRepository.save( + Admin.builder() + .trackType(BACKEND) + .teamType(CAMPUS) + .canCreateAdmin(true) + .superAdmin(false) + .user( + User.builder() + .password(passwordEncoder.encode("1234")) + .nickname("코인운영자2") + .name("테스트용_코인운영자") + .phoneNumber("01012342347") + .userType(ADMIN) + .gender(WOMAN) + .email("koinadmin2@koreatech.ac.kr") + .isAuthed(false) + .isDeleted(false) + .build() + ) .build() ); } From bc5b554ae6d07af9ef6a27354ce0b7e1bc46dde8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EA=B4=80=EA=B7=9C?= Date: Mon, 4 Nov 2024 13:43:28 +0900 Subject: [PATCH 07/12] =?UTF-8?q?feat:=20=EB=AA=A8=EB=B0=94=EC=9D=BC=20?= =?UTF-8?q?=EC=A0=95=EA=B7=9C=20=EA=B0=95=EC=9D=98=20=EC=82=AD=EC=A0=9C=20?= =?UTF-8?q?API=20=EC=B6=94=EA=B0=80=20(#996)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 시간표 프레임 id를 이용해서 정규 강의 정보 삭제 api 추가 * test: 테스트 코드 추가 --- .../controller/TimetableApiV2.java | 16 ++++++++++++++ .../controller/TimetableControllerV2.java | 10 +++++++++ .../TimetableLectureRepositoryV2.java | 12 +++++++++++ .../service/TimetableServiceV2.java | 14 +++++++++++++ .../koin/acceptance/TimetableV2ApiTest.java | 21 +++++++++++++++++++ 5 files changed, 73 insertions(+) diff --git a/src/main/java/in/koreatech/koin/domain/timetableV2/controller/TimetableApiV2.java b/src/main/java/in/koreatech/koin/domain/timetableV2/controller/TimetableApiV2.java index 47b577bdb..dca625755 100644 --- a/src/main/java/in/koreatech/koin/domain/timetableV2/controller/TimetableApiV2.java +++ b/src/main/java/in/koreatech/koin/domain/timetableV2/controller/TimetableApiV2.java @@ -175,4 +175,20 @@ ResponseEntity deleteTimetableLecture( @PathVariable(value = "id") Integer timetableLectureId, @Auth(permit = {STUDENT}) Integer userId ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "204"), + @ApiResponse(responseCode = "400", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))) + } + ) + @Operation(summary = "시간표 프레임 Id를 이용해서 정규 강의 정보 삭제") + @SecurityRequirement(name = "Jwt Authentication") + @DeleteMapping("/v2/timetables/frame/{frameId}/lecture/{lectureId}") + ResponseEntity deleteTimetableLectureByFrameId( + @PathVariable(value = "frameId") Integer frameId, + @PathVariable(value = "lectureId") Integer lectureId, + @Auth(permit = {STUDENT}) Integer userId + ); } diff --git a/src/main/java/in/koreatech/koin/domain/timetableV2/controller/TimetableControllerV2.java b/src/main/java/in/koreatech/koin/domain/timetableV2/controller/TimetableControllerV2.java index ea0becbdd..4b5921041 100644 --- a/src/main/java/in/koreatech/koin/domain/timetableV2/controller/TimetableControllerV2.java +++ b/src/main/java/in/koreatech/koin/domain/timetableV2/controller/TimetableControllerV2.java @@ -115,4 +115,14 @@ public ResponseEntity deleteTimetableLecture( timetableServiceV2.deleteTimetableLecture(userId, timetableLectureId); return ResponseEntity.noContent().build(); } + + @DeleteMapping("/v2/timetables/frame/{frameId}/lecture/{lectureId}") + public ResponseEntity deleteTimetableLectureByFrameId( + @PathVariable(value = "frameId") Integer frameId, + @PathVariable(value = "lectureId") Integer lectureId, + @Auth(permit = {STUDENT}) Integer userId + ) { + timetableServiceV2.deleteTimetableLectureByFrameId(frameId, lectureId, userId); + return ResponseEntity.noContent().build(); + } } diff --git a/src/main/java/in/koreatech/koin/domain/timetableV2/repository/TimetableLectureRepositoryV2.java b/src/main/java/in/koreatech/koin/domain/timetableV2/repository/TimetableLectureRepositoryV2.java index ee0c82047..56ca4d87a 100644 --- a/src/main/java/in/koreatech/koin/domain/timetableV2/repository/TimetableLectureRepositoryV2.java +++ b/src/main/java/in/koreatech/koin/domain/timetableV2/repository/TimetableLectureRepositoryV2.java @@ -3,7 +3,10 @@ import java.util.List; import java.util.Optional; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.Repository; +import org.springframework.data.repository.query.Param; import in.koreatech.koin.domain.timetableV2.exception.TimetableLectureNotFoundException; import in.koreatech.koin.domain.timetableV2.model.TimetableLecture; @@ -20,5 +23,14 @@ default TimetableLecture getById(Integer id) { return findById(id) .orElseThrow(() -> TimetableLectureNotFoundException.withDetail("id: " + id)); } + TimetableLecture save(TimetableLecture timetableLecture); + + @Modifying + @Query(value = """ + DELETE FROM timetable_lecture + WHERE frame_id = :frameId + AND lectures_id = :lectureId + """, nativeQuery = true) + void deleteByFrameIdAndLectureId(@Param("frameId") Integer frameId, @Param("lectureId") Integer lectureId); } diff --git a/src/main/java/in/koreatech/koin/domain/timetableV2/service/TimetableServiceV2.java b/src/main/java/in/koreatech/koin/domain/timetableV2/service/TimetableServiceV2.java index eb6d83821..b16ce79ee 100644 --- a/src/main/java/in/koreatech/koin/domain/timetableV2/service/TimetableServiceV2.java +++ b/src/main/java/in/koreatech/koin/domain/timetableV2/service/TimetableServiceV2.java @@ -10,6 +10,7 @@ import in.koreatech.koin.global.exception.KoinIllegalArgumentException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.PathVariable; import in.koreatech.koin.domain.timetable.model.Lecture; import in.koreatech.koin.domain.timetable.model.Semester; @@ -198,4 +199,17 @@ public void deleteAllTimetablesFrame(Integer userId, String semester) { .orElseThrow(() -> new SemesterNotFoundException("해당하는 시간표 프레임이 없습니다")); timetableFrameRepositoryV2.deleteAllByUserAndSemester(user, userSemester); } + + @Transactional + public void deleteTimetableLectureByFrameId( + @PathVariable(value = "frameId") Integer frameId, + @PathVariable(value = "lectureId") Integer lectureId, + Integer userId + ) { + TimetableFrame timetableFrame = timetableFrameRepositoryV2.getById(frameId); + if (!Objects.equals(timetableFrame.getUser().getId(), userId)) { + throw AuthorizationException.withDetail("userId: " + userId); + } + timetableLectureRepositoryV2.deleteByFrameIdAndLectureId(frameId, lectureId); + } } diff --git a/src/test/java/in/koreatech/koin/acceptance/TimetableV2ApiTest.java b/src/test/java/in/koreatech/koin/acceptance/TimetableV2ApiTest.java index 71351835d..9bdf9cc49 100644 --- a/src/test/java/in/koreatech/koin/acceptance/TimetableV2ApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/TimetableV2ApiTest.java @@ -495,6 +495,27 @@ void setup() { .andExpect(status().isNoContent()); } + @Test + void 시간표에서_특정_강의를_삭제한다_V2() throws Exception { + User user1 = userFixture.준호_학생().getUser(); + String token = userFixture.getToken(user1); + Semester semester = semesterFixture.semester("20192"); + Lecture lecture1 = lectureFixture.HRD_개론("20192"); + Lecture lecture2 = lectureFixture.영어청해("20192"); + TimetableFrame frame = timetableV2Fixture.시간표4(user1, semester, lecture1, lecture2); + + Integer frameId = frame.getId(); + Integer lectureId = lecture1.getId(); + + mockMvc.perform( + delete("/v2/timetables/frame/{frameId}/lecture/{lectureId}", frameId, lectureId) + .header("Authorization", "Bearer " + token) + .param("timetable_frame_id", String.valueOf(frame.getId())) + .contentType(MediaType.APPLICATION_JSON) + ) + .andExpect(status().isNoContent()); + } + /*@Test @Transactional(propagation = Propagation.NOT_SUPPORTED) void isMain이_false인_frame과_true인_frame을_동시에_삭제한다() { From f86a54c82049e1740d468fc9409aac72e9f4fe4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=93=80=ED=9E=88?= <149302959+duehee@users.noreply.github.com> Date: Mon, 4 Nov 2024 13:53:37 +0900 Subject: [PATCH 08/12] =?UTF-8?q?chore=20:=20Swagger=20Schema=20=EC=9D=BC?= =?UTF-8?q?=EA=B4=80=EC=84=B1=EC=9D=84=20=EC=9C=84=ED=95=B4=20name=20->=20?= =?UTF-8?q?description=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD=20(#995)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/timetable/dto/LectureResponse.java | 26 +++++++++---------- .../timetable/dto/TimetableCreateRequest.java | 14 +++++----- .../timetable/dto/TimetableResponse.java | 26 +++++++++---------- .../timetable/dto/TimetableUpdateRequest.java | 14 +++++----- 4 files changed, 40 insertions(+), 40 deletions(-) diff --git a/src/main/java/in/koreatech/koin/domain/timetable/dto/LectureResponse.java b/src/main/java/in/koreatech/koin/domain/timetable/dto/LectureResponse.java index 1c0f8b586..91968b236 100644 --- a/src/main/java/in/koreatech/koin/domain/timetable/dto/LectureResponse.java +++ b/src/main/java/in/koreatech/koin/domain/timetable/dto/LectureResponse.java @@ -15,43 +15,43 @@ @JsonNaming(value = SnakeCaseStrategy.class) public record LectureResponse( - @Schema(name = "과목 id", example = "1", requiredMode = REQUIRED) + @Schema(description = "과목 id", example = "1", requiredMode = REQUIRED) Integer id, - @Schema(name = "과목 코드", example = "ARB244", requiredMode = REQUIRED) + @Schema(description = "과목 코드", example = "ARB244", requiredMode = REQUIRED) String code, - @Schema(name = "과목 이름", example = "건축구조의 이해 및 실습", requiredMode = REQUIRED) + @Schema(description = "과목 이름", example = "건축구조의 이해 및 실습", requiredMode = REQUIRED) String name, - @Schema(name = "대상 학년", example = "3", requiredMode = REQUIRED) + @Schema(description = "대상 학년", example = "3", requiredMode = REQUIRED) String grades, - @Schema(name = "분반", example = "01", requiredMode = REQUIRED) + @Schema(description = "분반", example = "01", requiredMode = REQUIRED) String lectureClass, - @Schema(name = "수강 인원", example = "25", requiredMode = NOT_REQUIRED) + @Schema(description = "수강 인원", example = "25", requiredMode = NOT_REQUIRED) String regularNumber, - @Schema(name = "학부", example = "디자인ㆍ건축공학부", requiredMode = REQUIRED) + @Schema(description = "학부", example = "디자인ㆍ건축공학부", requiredMode = REQUIRED) String department, - @Schema(name = "대상", example = "디자 1 건축", requiredMode = REQUIRED) + @Schema(description = "대상", example = "디자 1 건축", requiredMode = REQUIRED) String target, - @Schema(name = "강의 교수", example = "이돈우", requiredMode = NOT_REQUIRED) + @Schema(description = "강의 교수", example = "이돈우", requiredMode = NOT_REQUIRED) String professor, - @Schema(name = "영어 수업인지", example = "N", requiredMode = REQUIRED) + @Schema(description = "영어 수업인지", example = "N", requiredMode = REQUIRED) String isEnglish, - @Schema(name = "설계 학점", example = "0", requiredMode = REQUIRED) + @Schema(description = "설계 학점", example = "0", requiredMode = REQUIRED) String designScore, - @Schema(name = "이러닝인지", example = "Y", requiredMode = REQUIRED) + @Schema(description = "이러닝인지", example = "Y", requiredMode = REQUIRED) String isElearning, - @Schema(name = "강의 시간", example = "[200,201,202,203,204,205,206,207]", requiredMode = REQUIRED) + @Schema(description = "강의 시간", example = "[200,201,202,203,204,205,206,207]", requiredMode = REQUIRED) List classTime ) { diff --git a/src/main/java/in/koreatech/koin/domain/timetable/dto/TimetableCreateRequest.java b/src/main/java/in/koreatech/koin/domain/timetable/dto/TimetableCreateRequest.java index 75cb9d39c..5ced416e0 100644 --- a/src/main/java/in/koreatech/koin/domain/timetable/dto/TimetableCreateRequest.java +++ b/src/main/java/in/koreatech/koin/domain/timetable/dto/TimetableCreateRequest.java @@ -43,33 +43,33 @@ public record InnerTimetableRequest( @Schema(description = "강의 장소", example = "2공학관", requiredMode = NOT_REQUIRED) String classPlace, - @Schema(name = "강의 교수", example = "이돈우", requiredMode = NOT_REQUIRED) + @Schema(description = "강의 교수", example = "이돈우", requiredMode = NOT_REQUIRED) String professor, @Schema(description = "학점", example = "3", requiredMode = NOT_REQUIRED) String grades, - @Schema(name = "분반", example = "01", requiredMode = NOT_REQUIRED) + @Schema(description = "분반", example = "01", requiredMode = NOT_REQUIRED) @Size(max = 3, message = "분반은 3자 이하로 입력해주세요.") String lectureClass, - @Schema(name = "대상", example = "디자 1 건축", requiredMode = NOT_REQUIRED) + @Schema(description = "대상", example = "디자 1 건축", requiredMode = NOT_REQUIRED) @Size(max = 200, message = "대상은 200자 이하로 입력해주세요.") String target, - @Schema(name = "수강 인원", example = "25", requiredMode = NOT_REQUIRED) + @Schema(description = "수강 인원", example = "25", requiredMode = NOT_REQUIRED) @Size(max = 4, message = "수강 인원은 4자 이하로 입력해주세요.") String regularNumber, - @Schema(name = "설계 학점", example = "0", requiredMode = NOT_REQUIRED) + @Schema(description = "설계 학점", example = "0", requiredMode = NOT_REQUIRED) @Size(max = 4, message = "설계 학점은 4자 이하로 입력해주세요.") String designScore, - @Schema(name = "학부", example = "디자인ㆍ건축공학부", requiredMode = NOT_REQUIRED) + @Schema(description = "학부", example = "디자인ㆍ건축공학부", requiredMode = NOT_REQUIRED) @Size(max = 30, message = "학부는 30자 이하로 입력해주세요.") String department, - @Schema(name = "memo", example = "메모메모", requiredMode = NOT_REQUIRED) + @Schema(description = "memo", example = "메모메모", requiredMode = NOT_REQUIRED) @Size(max = 200, message = "메모는 200자 이하로 입력해주세요.") String memo ) { diff --git a/src/main/java/in/koreatech/koin/domain/timetable/dto/TimetableResponse.java b/src/main/java/in/koreatech/koin/domain/timetable/dto/TimetableResponse.java index e2916800f..aecc2dc19 100644 --- a/src/main/java/in/koreatech/koin/domain/timetable/dto/TimetableResponse.java +++ b/src/main/java/in/koreatech/koin/domain/timetable/dto/TimetableResponse.java @@ -16,28 +16,28 @@ @JsonNaming(value = SnakeCaseStrategy.class) public record TimetableResponse( - @Schema(name = "학기", example = "20241", requiredMode = REQUIRED) + @Schema(description = "학기", example = "20241", requiredMode = REQUIRED) String semester, - @Schema(name = "시간표 상세정보") + @Schema(description = "시간표 상세정보") List timetable, - @Schema(name = "해당 학기 학점", example = "21") + @Schema(description = "해당 학기 학점", example = "21") Integer grades, - @Schema(name = "전체 학기 학점", example = "121") + @Schema(description = "전체 학기 학점", example = "121") Integer totalGrades ) { @JsonNaming(value = SnakeCaseStrategy.class) public record InnerTimetableResponse( - @Schema(name = "시간표 id", example = "1", requiredMode = REQUIRED) + @Schema(description = "시간표 id", example = "1", requiredMode = REQUIRED) Integer id, - @Schema(name = "수강 정원", example = "40", requiredMode = NOT_REQUIRED) + @Schema(description = "수강 정원", example = "40", requiredMode = NOT_REQUIRED) String regularNumber, - @Schema(name = "과목 코드", example = "ARB244", requiredMode = NOT_REQUIRED) + @Schema(description = "과목 코드", example = "ARB244", requiredMode = NOT_REQUIRED) String code, @Schema(description = "설계 학점", example = "0", requiredMode = NOT_REQUIRED) @@ -52,22 +52,22 @@ public record InnerTimetableResponse( @Schema(description = "메모", example = "null", requiredMode = NOT_REQUIRED) String memo, - @Schema(name = "학점", example = "3", requiredMode = REQUIRED) + @Schema(description = "학점", example = "3", requiredMode = REQUIRED) String grades, - @Schema(name = "강의(커스텀) 이름", example = "한국사", requiredMode = REQUIRED) + @Schema(description = "강의(커스텀) 이름", example = "한국사", requiredMode = REQUIRED) String classTitle, - @Schema(name = "분반", example = "01", requiredMode = NOT_REQUIRED) + @Schema(description = "분반", example = "01", requiredMode = NOT_REQUIRED) String lectureClass, - @Schema(name = "대상", example = "디자 1 건축", requiredMode = NOT_REQUIRED) + @Schema(description = "대상", example = "디자 1 건축", requiredMode = NOT_REQUIRED) String target, - @Schema(name = "강의 교수", example = "이돈우", requiredMode = NOT_REQUIRED) + @Schema(description = "강의 교수", example = "이돈우", requiredMode = NOT_REQUIRED) String professor, - @Schema(name = "학부", example = "디자인ㆍ건축공학부", requiredMode = NOT_REQUIRED) + @Schema(description = "학부", example = "디자인ㆍ건축공학부", requiredMode = NOT_REQUIRED) String department ) { diff --git a/src/main/java/in/koreatech/koin/domain/timetable/dto/TimetableUpdateRequest.java b/src/main/java/in/koreatech/koin/domain/timetable/dto/TimetableUpdateRequest.java index 39e75178a..ccb2890f5 100644 --- a/src/main/java/in/koreatech/koin/domain/timetable/dto/TimetableUpdateRequest.java +++ b/src/main/java/in/koreatech/koin/domain/timetable/dto/TimetableUpdateRequest.java @@ -48,33 +48,33 @@ public record InnerTimetableRequest( @Schema(description = "강의 장소", example = "null", requiredMode = NOT_REQUIRED) String classPlace, - @Schema(name = "강의 교수", example = "이돈우", requiredMode = NOT_REQUIRED) + @Schema(description = "강의 교수", example = "이돈우", requiredMode = NOT_REQUIRED) String professor, @Schema(description = "학점", example = "3", requiredMode = NOT_REQUIRED) String grades, - @Schema(name = "분반", example = "01", requiredMode = NOT_REQUIRED) + @Schema(description = "분반", example = "01", requiredMode = NOT_REQUIRED) @Size(max = 3, message = "분반은 3자 이하로 입력해주세요.") String lectureClass, - @Schema(name = "대상", example = "디자 1 건축", requiredMode = NOT_REQUIRED) + @Schema(description = "대상", example = "디자 1 건축", requiredMode = NOT_REQUIRED) @Size(max = 200, message = "대상은 200자 이하로 입력해주세요.") String target, - @Schema(name = "수강 인원", example = "25", requiredMode = NOT_REQUIRED) + @Schema(description = "수강 인원", example = "25", requiredMode = NOT_REQUIRED) @Size(max = 4, message = "수강 인원은 4자 이하로 입력해주세요.") String regularNumber, - @Schema(name = "설계 학점", example = "0", requiredMode = NOT_REQUIRED) + @Schema(description = "설계 학점", example = "0", requiredMode = NOT_REQUIRED) @Size(max = 4, message = "설계 학점은 4자 이하로 입력해주세요.") String designScore, - @Schema(name = "학부", example = "디자인ㆍ건축공학부", requiredMode = NOT_REQUIRED) + @Schema(description = "학부", example = "디자인ㆍ건축공학부", requiredMode = NOT_REQUIRED) @Size(max = 30, message = "학부는 30자 이하로 입력해주세요.") String department, - @Schema(name = "memo", example = "메모메모", requiredMode = NOT_REQUIRED) + @Schema(description = "memo", example = "메모메모", requiredMode = NOT_REQUIRED) @Size(max = 200, message = "메모는 200자 이하로 입력해주세요.") String memo ) { From e09eb7e5fc5220a4bdc0724303384b3058d65d55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EA=B4=80=EA=B7=9C?= Date: Wed, 6 Nov 2024 22:03:02 +0900 Subject: [PATCH 09/12] =?UTF-8?q?feat:=20=EC=96=B4=EB=93=9C=EB=AF=BC=20?= =?UTF-8?q?=ED=9E=88=EC=8A=A4=ED=86=A0=EB=A6=AC=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(#985)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: AdminActivityHistory 테이블 flyway 추가 * feat: AdminActivityHistory 모델 추가 * feat: AdminActivityHistoryAspect 추가 * feat: DomainType 추가 * chore: BaseEntity 상속 * refactor: AdminActivityHistoryAspect 로직 리펙토링 * feat: HistoryApi 추가 * feat: 히스토리 조회 API 추가 * chore: save() 메소드 추가 * chore: response 형식 변경 * chore: JsonNaming 어노테이션 적용 * chore: 내부 클래스 레코드으로 변경 * chore: 카테고리 enum 추가 * refactor: aspect 리펙터링 * chore: 리뷰 반영 * chore: user 코드 admin으로 변경 * feat: 어드민 계정 히스토리 추가 --- .../aop/AdminActivityHistoryAspect.java | 136 ++++++++++++++++++ .../admin/history/controller/HistoryApi.java | 57 ++++++++ .../history/controller/HistoryController.java | 47 ++++++ .../history/dto/AdminHistoryResponse.java | 58 ++++++++ .../history/dto/AdminHistorysCondition.java | 38 +++++ .../history/dto/AdminHistorysResponse.java | 92 ++++++++++++ .../koin/admin/history/enums/DomainType.java | 44 ++++++ .../admin/history/enums/HttpMethodType.java | 17 +++ ...AdminActivityHistoryNotFoundException.java | 20 +++ .../history/model/AdminActivityHistory.java | 60 ++++++++ .../AdminActivityHistoryRepository.java | 36 +++++ .../admin/history/service/HistoryService.java | 37 +++++ .../V86__add_admin_activity_history_table.sql | 13 ++ 13 files changed, 655 insertions(+) create mode 100644 src/main/java/in/koreatech/koin/admin/history/aop/AdminActivityHistoryAspect.java create mode 100644 src/main/java/in/koreatech/koin/admin/history/controller/HistoryApi.java create mode 100644 src/main/java/in/koreatech/koin/admin/history/controller/HistoryController.java create mode 100644 src/main/java/in/koreatech/koin/admin/history/dto/AdminHistoryResponse.java create mode 100644 src/main/java/in/koreatech/koin/admin/history/dto/AdminHistorysCondition.java create mode 100644 src/main/java/in/koreatech/koin/admin/history/dto/AdminHistorysResponse.java create mode 100644 src/main/java/in/koreatech/koin/admin/history/enums/DomainType.java create mode 100644 src/main/java/in/koreatech/koin/admin/history/enums/HttpMethodType.java create mode 100644 src/main/java/in/koreatech/koin/admin/history/exception/AdminActivityHistoryNotFoundException.java create mode 100644 src/main/java/in/koreatech/koin/admin/history/model/AdminActivityHistory.java create mode 100644 src/main/java/in/koreatech/koin/admin/history/repository/AdminActivityHistoryRepository.java create mode 100644 src/main/java/in/koreatech/koin/admin/history/service/HistoryService.java create mode 100644 src/main/resources/db/migration/V86__add_admin_activity_history_table.sql diff --git a/src/main/java/in/koreatech/koin/admin/history/aop/AdminActivityHistoryAspect.java b/src/main/java/in/koreatech/koin/admin/history/aop/AdminActivityHistoryAspect.java new file mode 100644 index 000000000..75e4f9289 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/history/aop/AdminActivityHistoryAspect.java @@ -0,0 +1,136 @@ +package in.koreatech.koin.admin.history.aop; + +import org.apache.commons.lang3.EnumUtils; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Pointcut; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; +import org.springframework.web.util.ContentCachingRequestWrapper; + +import in.koreatech.koin.admin.history.enums.DomainType; +import in.koreatech.koin.admin.history.model.AdminActivityHistory; +import in.koreatech.koin.admin.history.repository.AdminActivityHistoryRepository; +import in.koreatech.koin.admin.user.model.Admin; +import in.koreatech.koin.admin.user.repository.AdminRepository; +import in.koreatech.koin.global.auth.AuthContext; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; + +@Aspect +@Component +@Profile("!test") +@RequiredArgsConstructor +public class AdminActivityHistoryAspect { + private static final String REGEX_NUMERIC = "^[0-9]*$"; + private static final String SEGMENT_SHOPS = "SHOPS"; + private static final String SEGMENT_BENEFIT = "benefit"; + private static final String SEGMENT_CATEGORIES = "CATEGORIES"; + private static final String SEGMENT_CLOSE = "close"; + private static final String SEGMENT_ABTEST = "abtest"; + + private final AuthContext authContext; + private final AdminRepository adminRepository; + private final AdminActivityHistoryRepository adminActivityHistoryRepository; + + @Pointcut("execution(* in.koreatech.koin.admin..controller.*.*(..))") + private void allAdminControllers() { + } + + @Pointcut("!@annotation(org.springframework.web.bind.annotation.GetMapping)") + private void excludeGetMapping() { + } + + @Pointcut("!execution(* in.koreatech.koin.admin.user.controller.AdminUserController.adminLogin(..)) && " + + "!execution(* in.koreatech.koin.admin.user.controller.AdminUserController.logout(..)) && " + + "!execution(* in.koreatech.koin.admin.user.controller.AdminUserController.refresh(..)) && " + + "!execution(* in.koreatech.koin.admin.user.controller.AdminUserController.createAdmin(..)) && " + + "!execution(* in.koreatech.koin.admin.user.controller.AdminUserController.adminPasswordChange(..)) && " + + "!execution(* in.koreatech.koin.admin.abtest.controller.AbtestController.assignOrGetAbtestVariable(..))") + private void excludeSpecificMethods() { + } + + @Around("allAdminControllers() && excludeGetMapping() && excludeSpecificMethods()") + public Object logAdminActivity(ProceedingJoinPoint joinPoint) throws Throwable { + HttpServletRequest request = ((ServletRequestAttributes)RequestContextHolder.currentRequestAttributes()).getRequest(); + String requestURI = request.getRequestURI(); + String requestMethod = request.getMethod(); + + ContentCachingRequestWrapper cachingRequest = (ContentCachingRequestWrapper)request; + String requestMessage = new String(cachingRequest.getContentAsByteArray()); + + Object result = joinPoint.proceed(); + + Admin admin = adminRepository.getById(authContext.getUserId()); + DomainInfo domainInfo = getDomainInfo(requestURI); + + adminActivityHistoryRepository.save(AdminActivityHistory.builder() + .domainId(domainInfo.domainId()) + .admin(admin) + .requestMethod(requestMethod) + .domainName(domainInfo.domainName()) + .requestMessage(requestMessage) + .build()); + + return result; + } + + private DomainInfo getDomainInfo(String requestURI) { + String[] segments = requestURI.split("/"); + Integer domainId = null; + String domainName = null; + + for (int i = segments.length - 1; i >= 0; i--) { + String segment = segments[i]; + + if (isDomainType(segment)) { + domainName = getDomainName(segment, segments, i); + domainId = getDomainId(segments, i); + break; + } + + if (isCloseAbtest(segment, segments, i)) { + domainName = segments[i - 1].toUpperCase(); + domainId = Integer.valueOf(segments[i + 1]); + break; + } + } + + return new DomainInfo(domainId, domainName); + } + + private boolean isDomainType(String segment) { + return EnumUtils.isValidEnumIgnoreCase(DomainType.class, segment); + } + + private String getDomainName(String segment, String[] segments, int index) { + String domainName = segment.toUpperCase(); + + if (SEGMENT_SHOPS.equals(domainName) && SEGMENT_BENEFIT.equals(segments[index - 2])) { + return segments[index - 2].toUpperCase(); + } + + if (SEGMENT_CATEGORIES.equals(domainName)) { + return (segments[index - 1] + domainName).toUpperCase(); + } + + return domainName; + } + + private Integer getDomainId(String[] segments, int index) { + if (index != segments.length - 1 && segments[index + 1].matches(REGEX_NUMERIC)) { + return Integer.valueOf(segments[index + 1]); + } + return null; + } + + private boolean isCloseAbtest(String segment, String[] segments, int index) { + return SEGMENT_CLOSE.equals(segment) && SEGMENT_ABTEST.equals(segments[index - 1]); + } + + private record DomainInfo(Integer domainId, String domainName) { + } +} diff --git a/src/main/java/in/koreatech/koin/admin/history/controller/HistoryApi.java b/src/main/java/in/koreatech/koin/admin/history/controller/HistoryApi.java new file mode 100644 index 000000000..70bdba8fd --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/history/controller/HistoryApi.java @@ -0,0 +1,57 @@ +package in.koreatech.koin.admin.history.controller; + +import static in.koreatech.koin.domain.user.model.UserType.ADMIN; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; + +import in.koreatech.koin.admin.history.dto.AdminHistoryResponse; +import in.koreatech.koin.admin.history.dto.AdminHistorysResponse; +import in.koreatech.koin.global.auth.Auth; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "(Admin) History: 기록", description = "관리자 기록 관련 API") +public interface HistoryApi { + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "400", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "히스토리 리스트 조회") + @GetMapping("/admin/historys") + ResponseEntity getHistorys( + @RequestParam(required = false) Integer page, + @RequestParam(required = false) Integer limit, + @RequestParam(required = false) String requestMethod, + @RequestParam(required = false) String domainName, + @RequestParam(required = false) Integer domainId, + @Auth(permit = {ADMIN}) Integer adminId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "400", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "히스토리 단건 조회") + @GetMapping("/admin/history/{id}") + ResponseEntity getHistory( + @PathVariable(name = "id") Integer id, + @Auth(permit = {ADMIN}) Integer adminId + ); +} diff --git a/src/main/java/in/koreatech/koin/admin/history/controller/HistoryController.java b/src/main/java/in/koreatech/koin/admin/history/controller/HistoryController.java new file mode 100644 index 000000000..efc11bbbf --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/history/controller/HistoryController.java @@ -0,0 +1,47 @@ +package in.koreatech.koin.admin.history.controller; + +import static in.koreatech.koin.domain.user.model.UserType.ADMIN; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import in.koreatech.koin.admin.history.dto.AdminHistoryResponse; +import in.koreatech.koin.admin.history.dto.AdminHistorysCondition; +import in.koreatech.koin.admin.history.dto.AdminHistorysResponse; +import in.koreatech.koin.admin.history.service.HistoryService; +import in.koreatech.koin.global.auth.Auth; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +public class HistoryController implements HistoryApi { + + private final HistoryService historyService; + + @GetMapping("/admin/historys") + public ResponseEntity getHistorys( + @RequestParam(required = false) Integer page, + @RequestParam(required = false) Integer limit, + @RequestParam(required = false) String requestMethod, + @RequestParam(required = false) String domainName, + @RequestParam(required = false) Integer domainId, + @Auth(permit = {ADMIN}) Integer adminId + ) { + AdminHistorysCondition adminHistorysCondition = new AdminHistorysCondition(page, limit, requestMethod, + domainName, domainId); + AdminHistorysResponse historys = historyService.getHistorys(adminHistorysCondition); + return ResponseEntity.ok(historys); + } + + @GetMapping("/admin/history/{id}") + public ResponseEntity getHistory( + @PathVariable(name = "id") Integer id, + @Auth(permit = {ADMIN}) Integer adminId + ) { + AdminHistoryResponse history = historyService.getHistory(id); + return ResponseEntity.ok(history); + } +} diff --git a/src/main/java/in/koreatech/koin/admin/history/dto/AdminHistoryResponse.java b/src/main/java/in/koreatech/koin/admin/history/dto/AdminHistoryResponse.java new file mode 100644 index 000000000..406ce536a --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/history/dto/AdminHistoryResponse.java @@ -0,0 +1,58 @@ +package in.koreatech.koin.admin.history.dto; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.time.LocalDateTime; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.admin.history.enums.DomainType; +import in.koreatech.koin.admin.history.enums.HttpMethodType; +import in.koreatech.koin.admin.history.model.AdminActivityHistory; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record AdminHistoryResponse( + @Schema(description = "고유 id", example = "1", requiredMode = REQUIRED) + Integer id, + + @Schema(description = "도메인 엔티티 id", example = "null", requiredMode = REQUIRED) + Integer domainId, + + @Schema(description = "이름", example = "신관규", requiredMode = REQUIRED) + String name, + + @Schema(description = "도메인 이름", example = "코인 공지", requiredMode = REQUIRED) + String domainName, + + @Schema(description = "HTTP 요청 메소드 종류", example = "생성", requiredMode = REQUIRED) + String requestMethod, + + @Schema(description = "HTTP 요청 메시지 바디", example = """ + { + "title": "제목 예시", + "content": "본문 내용 예시" + } + """, + requiredMode = REQUIRED + ) + String requestMessage, + + @Schema(description = "요청 시간", example = "2019-08-16-23-01-52", requiredMode = REQUIRED) + @JsonFormat(pattern = "yyyy-MM-dd-HH-mm-ss") + LocalDateTime createdAt +) { + public static AdminHistoryResponse from(AdminActivityHistory adminActivityHistory) { + return new AdminHistoryResponse( + adminActivityHistory.getId(), + adminActivityHistory.getDomainId(), + adminActivityHistory.getAdmin().getUser().getName(), + DomainType.valueOf(adminActivityHistory.getDomainName()).getDescription(), + HttpMethodType.valueOf(adminActivityHistory.getRequestMethod()).getValue(), + adminActivityHistory.getRequestMessage(), + adminActivityHistory.getCreatedAt() + ); + } +} diff --git a/src/main/java/in/koreatech/koin/admin/history/dto/AdminHistorysCondition.java b/src/main/java/in/koreatech/koin/admin/history/dto/AdminHistorysCondition.java new file mode 100644 index 000000000..e738e5b10 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/history/dto/AdminHistorysCondition.java @@ -0,0 +1,38 @@ +package in.koreatech.koin.admin.history.dto; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; + +import java.util.Objects; + +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.global.model.Criteria; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record AdminHistorysCondition( + @Schema(description = "페이지", example = "1", defaultValue = "1", requiredMode = NOT_REQUIRED) + Integer page, + + @Schema(description = "페이지당 조회할 최대 개수", example = "10", defaultValue = "10", requiredMode = NOT_REQUIRED) + Integer limit, + + @Schema(description = "HTTP 메소드", example = "POST", requiredMode = NOT_REQUIRED) + String requestMethod, + + @Schema(description = "도메인 이름", example = "NOTICE", requiredMode = NOT_REQUIRED) + String domainName, + + @Schema(description = "특정 엔티티 id", requiredMode = NOT_REQUIRED) + Integer domainId +) { + public AdminHistorysCondition { + if (Objects.isNull(page)) { + page = Criteria.DEFAULT_PAGE; + } + if (Objects.isNull(limit)) { + limit = Criteria.DEFAULT_LIMIT; + } + } +} diff --git a/src/main/java/in/koreatech/koin/admin/history/dto/AdminHistorysResponse.java b/src/main/java/in/koreatech/koin/admin/history/dto/AdminHistorysResponse.java new file mode 100644 index 000000000..26891b308 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/history/dto/AdminHistorysResponse.java @@ -0,0 +1,92 @@ +package in.koreatech.koin.admin.history.dto; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Collectors; + +import org.springframework.data.domain.Page; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.admin.history.enums.DomainType; +import in.koreatech.koin.admin.history.enums.HttpMethodType; +import in.koreatech.koin.admin.history.model.AdminActivityHistory; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record AdminHistorysResponse( + @Schema(description = "조건에 해당하는 히스토리 수", example = "10", requiredMode = REQUIRED) + Long totalCount, + + @Schema(description = "조건에 해당하는 히스토리 중 현재 페이지에서 조회된 수", example = "5", requiredMode = REQUIRED) + Integer currentCount, + + @Schema(description = "조건에 해당하는 히스토리를 조회할 수 있는 최대 페이지", example = "2", requiredMode = REQUIRED) + Integer totalPage, + + @Schema(description = "현재 페이지", example = "1", requiredMode = REQUIRED) + Integer currentPage, + + @Schema(description = "어드민 계정 리스트", requiredMode = REQUIRED) + List historys +) { + @JsonNaming(value = SnakeCaseStrategy.class) + public record InnerAdminHistorysResponse( + @Schema(description = "고유 id", example = "1", requiredMode = REQUIRED) + Integer id, + + @Schema(description = "도메인 엔티티 id", example = "null", requiredMode = REQUIRED) + Integer domainId, + + @Schema(description = "이름", example = "신관규", requiredMode = REQUIRED) + String name, + + @Schema(description = "도메인 이름", example = "코인 공지", requiredMode = REQUIRED) + String domainName, + + @Schema(description = "HTTP 요청 메소드 종류", example = "생성", requiredMode = REQUIRED) + String requestMethod, + + @Schema(description = "HTTP 요청 메시지 바디", example = """ + { + "title": "제목 예시", + "content": "본문 내용 예시" + } + """, + requiredMode = REQUIRED + ) + String requestMessage, + + @Schema(description = "요청 시간", example = "2019-08-16-23-01-52", requiredMode = REQUIRED) + @JsonFormat(pattern = "yyyy-MM-dd-HH-mm-ss") + LocalDateTime createdAt + ) { + public static InnerAdminHistorysResponse from(AdminActivityHistory adminActivityHistory) { + return new InnerAdminHistorysResponse( + adminActivityHistory.getId(), + adminActivityHistory.getDomainId(), + adminActivityHistory.getAdmin().getUser().getName(), + DomainType.valueOf(adminActivityHistory.getDomainName()).getDescription(), + HttpMethodType.valueOf(adminActivityHistory.getRequestMethod()).getValue(), + adminActivityHistory.getRequestMessage(), + adminActivityHistory.getCreatedAt() + ); + } + } + + public static AdminHistorysResponse of(Page adminActivityHistoryPage) { + return new AdminHistorysResponse( + adminActivityHistoryPage.getTotalElements(), + adminActivityHistoryPage.getContent().size(), + adminActivityHistoryPage.getTotalPages(), + adminActivityHistoryPage.getNumber() + 1, + adminActivityHistoryPage.getContent().stream() + .map(InnerAdminHistorysResponse::from) + .collect(Collectors.toList()) + ); + } +} diff --git a/src/main/java/in/koreatech/koin/admin/history/enums/DomainType.java b/src/main/java/in/koreatech/koin/admin/history/enums/DomainType.java new file mode 100644 index 000000000..5967838a7 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/history/enums/DomainType.java @@ -0,0 +1,44 @@ +package in.koreatech.koin.admin.history.enums; + +import lombok.Getter; + +@Getter +public enum DomainType { + MEMBERS("Member", "BCSDLAB 회원"), + TRACKS("Track", "트랙"), + TECHSTACKS("TechStack", "기술 스택"), + NOTICE("Notice", "코인 공지"), + VERSION("Version", "버전관리"), + + CATEGORIES("Categories", "카테고리"), + + BENEFIT("Benefit", "혜택"), + BENEFITCATEGORIES("Benefit Categories", "혜택 카테고리"), + + SHOPS("Shop", "상점"), + SHOPSCATEGORIES("Shop Categories", "상점 카테고리"), + + MENUS("Menu", "메뉴"), + MENUSCATEGORIES("Menu Categroies", "메뉴 카테고리"), + + REVIEWS("Review", "리뷰"), + + ABTEST("Abtest", "AB 테스트"), + + LANDS("Land", "복덕방"), + COOPSHOP("CoopShop", "생협 매장"), + + USERS("User", "회원"), + STUDENT("Student", "학생"), + OWNER("Owner", "사장님"), + ADMIN("Admin", "어드민") + ; + + private final String value; + private final String description; + + DomainType(String value, String description) { + this.value = value; + this.description = description; + } +} diff --git a/src/main/java/in/koreatech/koin/admin/history/enums/HttpMethodType.java b/src/main/java/in/koreatech/koin/admin/history/enums/HttpMethodType.java new file mode 100644 index 000000000..a9916ec5c --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/history/enums/HttpMethodType.java @@ -0,0 +1,17 @@ +package in.koreatech.koin.admin.history.enums; + +import lombok.Getter; + +@Getter +public enum HttpMethodType { + POST("생성"), + PUT("수정"), + DELETE("삭제"), + ; + + private final String value; + + HttpMethodType(String value) { + this.value = value; + } +} diff --git a/src/main/java/in/koreatech/koin/admin/history/exception/AdminActivityHistoryNotFoundException.java b/src/main/java/in/koreatech/koin/admin/history/exception/AdminActivityHistoryNotFoundException.java new file mode 100644 index 000000000..ff601ce57 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/history/exception/AdminActivityHistoryNotFoundException.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.admin.history.exception; + +import in.koreatech.koin.global.exception.DataNotFoundException; + +public class AdminActivityHistoryNotFoundException extends DataNotFoundException { + + private static final String DEFAULT_MESSAGE = "존재하지 않는 히스토리 입니다."; + + public AdminActivityHistoryNotFoundException(String message) { + super(message); + } + + public AdminActivityHistoryNotFoundException(String message, String detail) { + super(message, detail); + } + + public static AdminActivityHistoryNotFoundException withDetail(String detail) { + return new AdminActivityHistoryNotFoundException(DEFAULT_MESSAGE, detail); + } +} diff --git a/src/main/java/in/koreatech/koin/admin/history/model/AdminActivityHistory.java b/src/main/java/in/koreatech/koin/admin/history/model/AdminActivityHistory.java new file mode 100644 index 000000000..3ab600a04 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/history/model/AdminActivityHistory.java @@ -0,0 +1,60 @@ +package in.koreatech.koin.admin.history.model; + +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +import in.koreatech.koin.admin.user.model.Admin; +import in.koreatech.koin.global.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Table(name = "admins_activity_history") +@NoArgsConstructor(access = PROTECTED) +public class AdminActivityHistory extends BaseEntity { + + @Id + @GeneratedValue(strategy = IDENTITY) + private Integer id; + + @Column(name = "domain_id") + private Integer domainId; + + @NotNull + @Size(max = 10) + @Column(name = "request_method", nullable = false, length = 10) + private String requestMethod; + + @NotNull + @Size(max = 20) + @Column(name = "domain_name", nullable = false, length = 20) + private String domainName; + + @Column(name = "request_message", columnDefinition = "TEXT") + private String requestMessage; + + @ManyToOne + @JoinColumn(name = "admin_id") + private Admin admin; + + @Builder + public AdminActivityHistory(Integer domainId, String requestMethod, String domainName, String requestMessage, + Admin admin) { + this.domainId = domainId; + this.requestMethod = requestMethod; + this.domainName = domainName; + this.requestMessage = requestMessage; + this.admin = admin; + } +} diff --git a/src/main/java/in/koreatech/koin/admin/history/repository/AdminActivityHistoryRepository.java b/src/main/java/in/koreatech/koin/admin/history/repository/AdminActivityHistoryRepository.java new file mode 100644 index 000000000..eb0a9721f --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/history/repository/AdminActivityHistoryRepository.java @@ -0,0 +1,36 @@ +package in.koreatech.koin.admin.history.repository; + +import java.util.Optional; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.Repository; +import org.springframework.data.repository.query.Param; + +import in.koreatech.koin.admin.history.dto.AdminHistorysCondition; +import in.koreatech.koin.admin.history.exception.AdminActivityHistoryNotFoundException; +import in.koreatech.koin.admin.history.model.AdminActivityHistory; + +public interface AdminActivityHistoryRepository extends Repository { + AdminActivityHistory save(AdminActivityHistory adminActivityHistory); + + Optional findById(Integer id); + + default AdminActivityHistory getById(Integer id) { + return findById(id) + .orElseThrow(() -> new AdminActivityHistoryNotFoundException("admin_activity_history id: " + id)); + } + + @Query("SELECT COUNT(*) FROM AdminActivityHistory") + Integer countAdminActivityHistory(); + + @Query(""" + SELECT a FROM AdminActivityHistory a WHERE + (:#{#condition.requestMethod} IS NULL OR a.requestMethod = :#{#condition.requestMethod}) AND + (:#{#condition.domainName} IS NULL OR a.domainName = :#{#condition.domainName}) AND + (:#{#condition.domainId} IS NULL OR a.domainId = :#{#condition.domainId}) + """) + Page findByConditions(@Param("condition") AdminHistorysCondition adminsCondition, + Pageable pageable); +} diff --git a/src/main/java/in/koreatech/koin/admin/history/service/HistoryService.java b/src/main/java/in/koreatech/koin/admin/history/service/HistoryService.java new file mode 100644 index 000000000..84706bab5 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/history/service/HistoryService.java @@ -0,0 +1,37 @@ +package in.koreatech.koin.admin.history.service; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; + +import in.koreatech.koin.admin.history.dto.AdminHistoryResponse; +import in.koreatech.koin.admin.history.dto.AdminHistorysCondition; +import in.koreatech.koin.admin.history.dto.AdminHistorysResponse; +import in.koreatech.koin.admin.history.model.AdminActivityHistory; +import in.koreatech.koin.admin.history.repository.AdminActivityHistoryRepository; +import in.koreatech.koin.global.model.Criteria; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class HistoryService { + + private final AdminActivityHistoryRepository adminActivityHistoryRepository; + + public AdminHistorysResponse getHistorys(AdminHistorysCondition condition) { + Integer total = adminActivityHistoryRepository.countAdminActivityHistory(); + Criteria criteria = Criteria.of(condition.page(), condition.limit(), total); + + PageRequest pageRequest = PageRequest.of(criteria.getPage(), criteria.getLimit()); + Page adminActivityHistoryRepositoryPage = adminActivityHistoryRepository.findByConditions( + condition, + pageRequest); + + return AdminHistorysResponse.of(adminActivityHistoryRepositoryPage); + } + + public AdminHistoryResponse getHistory(Integer id) { + AdminActivityHistory adminActivityHistory = adminActivityHistoryRepository.getById(id); + return AdminHistoryResponse.from(adminActivityHistory); + } +} diff --git a/src/main/resources/db/migration/V86__add_admin_activity_history_table.sql b/src/main/resources/db/migration/V86__add_admin_activity_history_table.sql new file mode 100644 index 000000000..7fbefe54a --- /dev/null +++ b/src/main/resources/db/migration/V86__add_admin_activity_history_table.sql @@ -0,0 +1,13 @@ +CREATE TABLE `koin`.`admins_activity_history` +( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '고유 id', + `domain_id` INT UNSIGNED NULL COMMENT '도메인 엔티티 id', + `admin_id` INT UNSIGNED NOT NULL COMMENT '어드민 고유 id', + `request_method` VARCHAR(10) NOT NULL COMMENT 'HTTP 요청 메소드', + `domain_name` VARCHAR(20) NOT NULL COMMENT '도메인 이름', + `request_message` TEXT NULL COMMENT 'HTTP 요청 메시지 바디', + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 일자', + `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '업데이트 일자', + PRIMARY KEY (id), + CONSTRAINT `FK_HISTORY_ON_ADMIN` FOREIGN KEY (`admin_id`) REFERENCES `admins` (`user_id`) ON DELETE CASCADE +) From cb27a9f5d5f4a57760dc700034a48b5165b8e6dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EA=B4=80=EA=B7=9C?= Date: Thu, 7 Nov 2024 09:22:06 +0900 Subject: [PATCH 10/12] =?UTF-8?q?feat:=20timetableLecture=20id=EB=A5=BC=20?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=EB=A1=9C=20=EB=B0=9B=EC=95=84=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=ED=95=98=EB=8A=94=20API=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=20(#1000)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: timetableLecture id 리스트로 강의 삭제 api 추가 * chore: 미사용 어노테이션 삭제 * test: 테스트 코드 추가 * test: 미사용 파라미터 삭제 * style: 코드 포멧팅 --- .../controller/TimetableApiV2.java | 16 ++ .../controller/TimetableControllerV2.java | 13 +- .../dto/TimeTableLecturesDeleteRequest.java | 13 + .../service/TimetableServiceV2.java | 29 +- .../koin/acceptance/TimetableV2ApiTest.java | 272 ++++++++++-------- 5 files changed, 209 insertions(+), 134 deletions(-) create mode 100644 src/main/java/in/koreatech/koin/domain/timetableV2/dto/TimeTableLecturesDeleteRequest.java diff --git a/src/main/java/in/koreatech/koin/domain/timetableV2/controller/TimetableApiV2.java b/src/main/java/in/koreatech/koin/domain/timetableV2/controller/TimetableApiV2.java index dca625755..f9c146cb8 100644 --- a/src/main/java/in/koreatech/koin/domain/timetableV2/controller/TimetableApiV2.java +++ b/src/main/java/in/koreatech/koin/domain/timetableV2/controller/TimetableApiV2.java @@ -13,6 +13,7 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; +import in.koreatech.koin.domain.timetableV2.dto.TimeTableLecturesDeleteRequest; import in.koreatech.koin.domain.timetableV2.dto.TimetableFrameCreateRequest; import in.koreatech.koin.domain.timetableV2.dto.TimetableFrameResponse; import in.koreatech.koin.domain.timetableV2.dto.TimetableFrameUpdateRequest; @@ -176,6 +177,21 @@ ResponseEntity deleteTimetableLecture( @Auth(permit = {STUDENT}) Integer userId ); + @ApiResponses( + value = { + @ApiResponse(responseCode = "204"), + @ApiResponse(responseCode = "400", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))) + } + ) + @Operation(summary = "시간표에서 여러 개의 강의 정보 삭제") + @SecurityRequirement(name = "Jwt Authentication") + @DeleteMapping("/v2/timetables/lectures") + ResponseEntity deleteTimetableLectures( + @RequestParam(name = "timetable_lecture_ids") List request, + @Auth(permit = {STUDENT}) Integer userId + ); + @ApiResponses( value = { @ApiResponse(responseCode = "204"), diff --git a/src/main/java/in/koreatech/koin/domain/timetableV2/controller/TimetableControllerV2.java b/src/main/java/in/koreatech/koin/domain/timetableV2/controller/TimetableControllerV2.java index 4b5921041..7d654e3cc 100644 --- a/src/main/java/in/koreatech/koin/domain/timetableV2/controller/TimetableControllerV2.java +++ b/src/main/java/in/koreatech/koin/domain/timetableV2/controller/TimetableControllerV2.java @@ -14,6 +14,7 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import in.koreatech.koin.domain.timetableV2.dto.TimeTableLecturesDeleteRequest; import in.koreatech.koin.domain.timetableV2.dto.TimetableFrameCreateRequest; import in.koreatech.koin.domain.timetableV2.dto.TimetableFrameResponse; import in.koreatech.koin.domain.timetableV2.dto.TimetableFrameUpdateRequest; @@ -93,7 +94,8 @@ public ResponseEntity updateTimetableLecture( @Valid @RequestBody TimetableLectureUpdateRequest request, @Auth(permit = {STUDENT}) Integer userId ) { - TimetableLectureResponse timetableLectureResponse = timetableServiceV2.updateTimetablesLectures(userId, request); + TimetableLectureResponse timetableLectureResponse = timetableServiceV2.updateTimetablesLectures(userId, + request); return ResponseEntity.ok(timetableLectureResponse); } @@ -116,6 +118,15 @@ public ResponseEntity deleteTimetableLecture( return ResponseEntity.noContent().build(); } + @DeleteMapping("/v2/timetables/lectures") + public ResponseEntity deleteTimetableLectures( + @RequestParam(name = "timetable_lecture_ids") List request, + @Auth(permit = {STUDENT}) Integer userId + ) { + timetableServiceV2.deleteTimetableLectures(request, userId); + return ResponseEntity.noContent().build(); + } + @DeleteMapping("/v2/timetables/frame/{frameId}/lecture/{lectureId}") public ResponseEntity deleteTimetableLectureByFrameId( @PathVariable(value = "frameId") Integer frameId, diff --git a/src/main/java/in/koreatech/koin/domain/timetableV2/dto/TimeTableLecturesDeleteRequest.java b/src/main/java/in/koreatech/koin/domain/timetableV2/dto/TimeTableLecturesDeleteRequest.java new file mode 100644 index 000000000..15942ae2f --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/timetableV2/dto/TimeTableLecturesDeleteRequest.java @@ -0,0 +1,13 @@ +package in.koreatech.koin.domain.timetableV2.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record TimeTableLecturesDeleteRequest( + @Schema(description = "timetableLecture id 리스트", example = "[1, 2, 3]", requiredMode = REQUIRED) + List timetablesLectureIds +) { +} diff --git a/src/main/java/in/koreatech/koin/domain/timetableV2/service/TimetableServiceV2.java b/src/main/java/in/koreatech/koin/domain/timetableV2/service/TimetableServiceV2.java index b16ce79ee..04980e740 100644 --- a/src/main/java/in/koreatech/koin/domain/timetableV2/service/TimetableServiceV2.java +++ b/src/main/java/in/koreatech/koin/domain/timetableV2/service/TimetableServiceV2.java @@ -1,17 +1,15 @@ package in.koreatech.koin.domain.timetableV2.service; -import static in.koreatech.koin.domain.timetableV2.dto.TimetableLectureCreateRequest.*; -import static in.koreatech.koin.domain.timetableV2.dto.TimetableLectureUpdateRequest.*; +import static in.koreatech.koin.domain.timetableV2.dto.TimetableLectureCreateRequest.InnerTimeTableLectureRequest; +import static in.koreatech.koin.domain.timetableV2.dto.TimetableLectureUpdateRequest.InnerTimetableLectureRequest; import java.util.List; import java.util.Objects; -import in.koreatech.koin.domain.timetable.exception.SemesterNotFoundException; -import in.koreatech.koin.global.exception.KoinIllegalArgumentException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.bind.annotation.PathVariable; +import in.koreatech.koin.domain.timetable.exception.SemesterNotFoundException; import in.koreatech.koin.domain.timetable.model.Lecture; import in.koreatech.koin.domain.timetable.model.Semester; import in.koreatech.koin.domain.timetableV2.dto.TimetableFrameCreateRequest; @@ -31,6 +29,7 @@ import in.koreatech.koin.domain.user.repository.UserRepository; import in.koreatech.koin.global.auth.exception.AuthorizationException; import in.koreatech.koin.global.concurrent.ConcurrencyGuard; +import in.koreatech.koin.global.exception.KoinIllegalArgumentException; import lombok.RequiredArgsConstructor; @Service @@ -90,7 +89,8 @@ public void deleteTimetablesFrame(Integer userId, Integer frameId) { if (frame.isMain()) { TimetableFrame nextMainFrame = timetableFrameRepositoryV2. - findFirstByUserIdAndSemesterIdAndIsMainFalseOrderByCreatedAtAsc(userId, frame.getSemester().getId()); + findFirstByUserIdAndSemesterIdAndIsMainFalseOrderByCreatedAtAsc(userId, + frame.getSemester().getId()); if (nextMainFrame != null) { nextMainFrame.updateStatusMain(true); } @@ -201,11 +201,18 @@ public void deleteAllTimetablesFrame(Integer userId, String semester) { } @Transactional - public void deleteTimetableLectureByFrameId( - @PathVariable(value = "frameId") Integer frameId, - @PathVariable(value = "lectureId") Integer lectureId, - Integer userId - ) { + public void deleteTimetableLectures(List request, Integer userId) { + for (int timetablesLectureId : request) { + TimetableLecture timetableLecture = timetableLectureRepositoryV2.getById(timetablesLectureId); + if (!Objects.equals(timetableLecture.getTimetableFrame().getUser().getId(), userId)) { + throw AuthorizationException.withDetail("userId: " + userId); + } + timetableLectureRepositoryV2.deleteById(timetablesLectureId); + } + } + + @Transactional + public void deleteTimetableLectureByFrameId(Integer frameId, Integer lectureId, Integer userId) { TimetableFrame timetableFrame = timetableFrameRepositoryV2.getById(frameId); if (!Objects.equals(timetableFrame.getUser().getId(), userId)) { throw AuthorizationException.withDetail("userId: " + userId); diff --git a/src/test/java/in/koreatech/koin/acceptance/TimetableV2ApiTest.java b/src/test/java/in/koreatech/koin/acceptance/TimetableV2ApiTest.java index 9bdf9cc49..2b041c2c7 100644 --- a/src/test/java/in/koreatech/koin/acceptance/TimetableV2ApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/TimetableV2ApiTest.java @@ -2,7 +2,11 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.List; +import java.util.stream.Collectors; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; @@ -15,6 +19,7 @@ import in.koreatech.koin.domain.timetable.model.Lecture; import in.koreatech.koin.domain.timetable.model.Semester; import in.koreatech.koin.domain.timetableV2.model.TimetableFrame; +import in.koreatech.koin.domain.timetableV2.model.TimetableLecture; import in.koreatech.koin.domain.timetableV2.repository.TimetableFrameRepositoryV2; import in.koreatech.koin.domain.timetableV2.repository.TimetableLectureRepositoryV2; import in.koreatech.koin.domain.user.model.User; @@ -290,46 +295,46 @@ void setup() { ) .andExpect(status().isOk()) .andExpect(content().json(""" - { - "timetable_frame_id": 1, - "timetable": [ - { - "id": 1, - "lecture_id" : null, - "regular_number": null, - "code": null, - "design_score": null, - "class_time": [200, 201], - "class_place": "한기대", - "memo": "메모", - "grades": "2", - "class_title": "커스텀생성1", - "lecture_class": null, - "target": null, - "professor": "서정빈", - "department": null - }, - { - "id": 2, - "lecture_id" : null, - "regular_number": null, - "code": null, - "design_score": null, - "class_time": [202, 203], - "class_place": "참빛관 편의점", - "memo": "메모", - "grades": "1", - "class_title": "커스텀생성2", - "lecture_class": null, - "target": null, - "professor": "감사 서정빈", - "department": null - } - ], - "grades": 3, - "total_grades": 3 - } - """)); + { + "timetable_frame_id": 1, + "timetable": [ + { + "id": 1, + "lecture_id" : null, + "regular_number": null, + "code": null, + "design_score": null, + "class_time": [200, 201], + "class_place": "한기대", + "memo": "메모", + "grades": "2", + "class_title": "커스텀생성1", + "lecture_class": null, + "target": null, + "professor": "서정빈", + "department": null + }, + { + "id": 2, + "lecture_id" : null, + "regular_number": null, + "code": null, + "design_score": null, + "class_time": [202, 203], + "class_place": "참빛관 편의점", + "memo": "메모", + "grades": "1", + "class_title": "커스텀생성2", + "lecture_class": null, + "target": null, + "professor": "감사 서정빈", + "department": null + } + ], + "grades": 3, + "total_grades": 3 + } + """)); } @Test @@ -372,46 +377,46 @@ void setup() { ) .andExpect(status().isOk()) .andExpect(content().json(""" - { - "timetable_frame_id": 1, - "timetable": [ - { - "id": 1, - "lecture_id" : null, - "regular_number": null, - "code": null, - "design_score": null, - "class_time": [200, 201], - "class_place": "한기대", - "memo": "메모한당 히히", - "grades": "0", - "class_title": "커스텀바꿔요1", - "lecture_class": null, - "target": null, - "professor": "서정빈", - "department": null - }, - { - "id": 2, - "lecture_id" : null, - "regular_number": null, - "code": null, - "design_score": null, - "class_time": [202, 203], - "class_place": "참빛관 편의점", - "memo": "메모한당 히히", - "grades": "0", - "class_title": "커스텀바꿔요2", - "lecture_class": null, - "target": null, - "professor": "알바 서정빈", - "department": null - } - ], - "grades": 0, - "total_grades": 0 - } - """)); + { + "timetable_frame_id": 1, + "timetable": [ + { + "id": 1, + "lecture_id" : null, + "regular_number": null, + "code": null, + "design_score": null, + "class_time": [200, 201], + "class_place": "한기대", + "memo": "메모한당 히히", + "grades": "0", + "class_title": "커스텀바꿔요1", + "lecture_class": null, + "target": null, + "professor": "서정빈", + "department": null + }, + { + "id": 2, + "lecture_id" : null, + "regular_number": null, + "code": null, + "design_score": null, + "class_time": [202, 203], + "class_place": "참빛관 편의점", + "memo": "메모한당 히히", + "grades": "0", + "class_title": "커스텀바꿔요2", + "lecture_class": null, + "target": null, + "professor": "알바 서정빈", + "department": null + } + ], + "grades": 0, + "total_grades": 0 + } + """)); } @Test @@ -433,46 +438,46 @@ void setup() { ) .andExpect(status().isOk()) .andExpect(content().json(""" - { - "timetable_frame_id": 1, - "timetable": [ - { - "id" : 1, - "lecture_id" : 1, - "regular_number": "25", - "code": "ARB244", - "design_score": "0", - "class_time": [200, 201, 202, 203, 204, 205, 206, 207], - "class_place": null, - "memo": null, - "grades": "3", - "class_title": "건축구조의 이해 및 실습", - "lecture_class": "01", - "target": "디자 1 건축", - "professor": "황현식", - "department": "디자인ㆍ건축공학부" - }, - { - "id": 2, - "lecture_id": 2, - "regular_number": "22", - "code": "BSM590", - "design_score": "0", - "class_time": [12, 13, 14, 15, 210, 211, 212, 213], - "class_place": null, - "memo": null, - "grades": "3", - "class_title": "컴퓨팅사고", - "lecture_class": "06", - "target": "기공1", - "professor": "박한수,최준호", - "department": "기계공학부" - } - ], - "grades": 6, - "total_grades": 6 - } - """)); + { + "timetable_frame_id": 1, + "timetable": [ + { + "id" : 1, + "lecture_id" : 1, + "regular_number": "25", + "code": "ARB244", + "design_score": "0", + "class_time": [200, 201, 202, 203, 204, 205, 206, 207], + "class_place": null, + "memo": null, + "grades": "3", + "class_title": "건축구조의 이해 및 실습", + "lecture_class": "01", + "target": "디자 1 건축", + "professor": "황현식", + "department": "디자인ㆍ건축공학부" + }, + { + "id": 2, + "lecture_id": 2, + "regular_number": "22", + "code": "BSM590", + "design_score": "0", + "class_time": [12, 13, 14, 15, 210, 211, 212, 213], + "class_place": null, + "memo": null, + "grades": "3", + "class_title": "컴퓨팅사고", + "lecture_class": "06", + "target": "기공1", + "professor": "박한수,최준호", + "department": "기계공학부" + } + ], + "grades": 6, + "total_grades": 6 + } + """)); } @Test @@ -510,7 +515,30 @@ void setup() { mockMvc.perform( delete("/v2/timetables/frame/{frameId}/lecture/{lectureId}", frameId, lectureId) .header("Authorization", "Bearer " + token) - .param("timetable_frame_id", String.valueOf(frame.getId())) + .contentType(MediaType.APPLICATION_JSON) + ) + .andExpect(status().isNoContent()); + } + + @Test + void 시간표에서_여러개의_강의를_한번에_삭제한다_V2() throws Exception { + User user1 = userFixture.준호_학생().getUser(); + String token = userFixture.getToken(user1); + Semester semester = semesterFixture.semester("20192"); + Lecture lecture1 = lectureFixture.HRD_개론("20192"); + Lecture lecture2 = lectureFixture.영어청해("20192"); + TimetableFrame frame = timetableV2Fixture.시간표4(user1, semester, lecture1, lecture2); + + List timetableLectureIds = frame.getTimetableLectures().stream() + .map(TimetableLecture::getId) + .toList(); + + mockMvc.perform( + delete("/v2/timetables/lectures") + .header("Authorization", "Bearer " + token) + .param("timetable_lecture_ids", timetableLectureIds.stream() + .map(String::valueOf) + .collect(Collectors.joining(","))) .contentType(MediaType.APPLICATION_JSON) ) .andExpect(status().isNoContent()); From 46802a9f10e28fa44b23352bad21e97fc44e6915 Mon Sep 17 00:00:00 2001 From: krSeonghyeon <149303551+krSeonghyeon@users.noreply.github.com> Date: Thu, 7 Nov 2024 20:54:29 +0900 Subject: [PATCH 11/12] =?UTF-8?q?feat:=20=EC=A0=84=ED=99=94=ED=95=98?= =?UTF-8?q?=EA=B8=B0=20=EB=A6=AC=EB=B7=B0=20=EC=9C=A0=EB=8F=84=20=ED=91=B8?= =?UTF-8?q?=EC=8B=9C=EC=95=8C=EB=A6=BC=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#992)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 전화하기 리뷰 유도 푸시알림 기능 추가 * feat: 초기 데이터 삽입 추가 * feat: 트랜잭션 추가 및 기본데이터 추가 * chore: flyway 파일 버전 변경 * feat: 테스트 코드 추가 * feat: 테스트 충돌로 인한 로직 이동 및 테스트코드 수정 * chore: flyway 개행 추가 * chore: Repository 메서드 순서 변경 * refactor: 불필요한 @Transactional 제거 * chore: 라인 포맷팅 수정 * chore: 변수 네이밍 수정 * feat: 매칭되는 메세지가 없는 경우 예외처리 추가 * feat: n+1 문제 해결 및 테스트코드 수정 * chore: 메소드명 변경 및 Clock 추가 * refactor: 이벤트 리스너 삭제 * chore: transactional 어노테이션 추가 * fix: 스테이지 프로덕션 DB 불일치로 인한 변경 * chore: 명칭 통일 * refactor: 리뷰 반영 * chore: flyway 버전 변경 --- .../version/service/AdminVersionService.java | 1 - .../koin/domain/shop/controller/ShopApi.java | 15 ++++ .../shop/controller/ShopController.java | 9 +++ .../NotificationMessageNotFoundException.java | 20 ++++++ .../domain/shop/model/shop/ShopCategory.java | 7 ++ .../shop/model/shop/ShopMainCategory.java | 38 ++++++++++ .../model/shop/ShopNotificationBuffer.java | 50 +++++++++++++ .../model/shop/ShopNotificationMessage.java | 33 +++++++++ .../ShopNotificationBufferRepository.java | 38 ++++++++++ .../shop/scheduler/NotificationScheduler.java | 25 +++++++ .../service/NotificationScheduleService.java | 70 +++++++++++++++++++ .../koin/domain/shop/service/ShopService.java | 33 +++++++++ .../student/model/StudentEventListener.java | 5 ++ .../student/model/StudentRegisterEvent.java | 3 +- .../student/service/StudentService.java | 4 +- .../model/NotificationFactory.java | 19 +++++ .../model/NotificationSubscribeType.java | 1 + .../NotificationSubscribeRepository.java | 2 + ..._notification_messages_and_insert_data.sql | 13 ++++ ..._main_categories_table_and_insert_data.sql | 15 ++++ ...categories_add_main_category_id_column.sql | 14 ++++ .../V90__add_shop_notification_queue.sql | 11 +++ ...t_notification_subscribe_review_prompt.sql | 4 ++ .../koin/acceptance/NotificationApiTest.java | 35 ++++++++++ .../koin/acceptance/ShopApiTest.java | 12 ++++ 25 files changed, 474 insertions(+), 3 deletions(-) create mode 100644 src/main/java/in/koreatech/koin/domain/shop/exception/NotificationMessageNotFoundException.java create mode 100644 src/main/java/in/koreatech/koin/domain/shop/model/shop/ShopMainCategory.java create mode 100644 src/main/java/in/koreatech/koin/domain/shop/model/shop/ShopNotificationBuffer.java create mode 100644 src/main/java/in/koreatech/koin/domain/shop/model/shop/ShopNotificationMessage.java create mode 100644 src/main/java/in/koreatech/koin/domain/shop/repository/shop/ShopNotificationBufferRepository.java create mode 100644 src/main/java/in/koreatech/koin/domain/shop/scheduler/NotificationScheduler.java create mode 100644 src/main/java/in/koreatech/koin/domain/shop/service/NotificationScheduleService.java create mode 100644 src/main/resources/db/migration/V87__add_shop_notification_messages_and_insert_data.sql create mode 100644 src/main/resources/db/migration/V88__add_shop_main_categories_table_and_insert_data.sql create mode 100644 src/main/resources/db/migration/V89__alter_shop_categories_add_main_category_id_column.sql create mode 100644 src/main/resources/db/migration/V90__add_shop_notification_queue.sql create mode 100644 src/main/resources/db/migration/V91__insert_notification_subscribe_review_prompt.sql diff --git a/src/main/java/in/koreatech/koin/admin/version/service/AdminVersionService.java b/src/main/java/in/koreatech/koin/admin/version/service/AdminVersionService.java index 7df162875..c2f3083dc 100644 --- a/src/main/java/in/koreatech/koin/admin/version/service/AdminVersionService.java +++ b/src/main/java/in/koreatech/koin/admin/version/service/AdminVersionService.java @@ -14,7 +14,6 @@ import in.koreatech.koin.admin.version.repository.AdminVersionRepository; import in.koreatech.koin.domain.version.model.Version; import in.koreatech.koin.domain.version.model.VersionType; -import in.koreatech.koin.global.exception.KoinIllegalArgumentException; import in.koreatech.koin.global.model.Criteria; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/in/koreatech/koin/domain/shop/controller/ShopApi.java b/src/main/java/in/koreatech/koin/domain/shop/controller/ShopApi.java index d6fb056ec..b6e4f336e 100644 --- a/src/main/java/in/koreatech/koin/domain/shop/controller/ShopApi.java +++ b/src/main/java/in/koreatech/koin/domain/shop/controller/ShopApi.java @@ -285,4 +285,19 @@ ResponseEntity getShopsV2( @RequestParam(name = "sorter", defaultValue = "NONE") ShopsSortCriteria sortBy, @RequestParam(name = "filter") List shopsFilterCriterias ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "전화하기 리뷰 유도 푸시알림 요청") + @PostMapping("/shops/{shopId}/call-notification") + ResponseEntity createCallNotification( + @Parameter(in = PATH) @PathVariable("shopId") Integer shopId, + @Auth(permit = {STUDENT}) Integer studentId + ); } diff --git a/src/main/java/in/koreatech/koin/domain/shop/controller/ShopController.java b/src/main/java/in/koreatech/koin/domain/shop/controller/ShopController.java index e6ded5a71..363a07b01 100644 --- a/src/main/java/in/koreatech/koin/domain/shop/controller/ShopController.java +++ b/src/main/java/in/koreatech/koin/domain/shop/controller/ShopController.java @@ -200,4 +200,13 @@ public ResponseEntity getReview( ShopReviewResponse shopReviewResponse = shopReviewService.getReviewByReviewId(shopId, reviewId); return ResponseEntity.ok(shopReviewResponse); } + + @PostMapping("/shops/{shopId}/call-notification") + public ResponseEntity createCallNotification( + @Parameter(in = PATH) @PathVariable("shopId") Integer shopId, + @Auth(permit = {STUDENT}) Integer studentId + ) { + shopService.publishCallNotification(shopId, studentId); + return ResponseEntity.ok().build(); + } } diff --git a/src/main/java/in/koreatech/koin/domain/shop/exception/NotificationMessageNotFoundException.java b/src/main/java/in/koreatech/koin/domain/shop/exception/NotificationMessageNotFoundException.java new file mode 100644 index 000000000..cb66ffb9a --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/shop/exception/NotificationMessageNotFoundException.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.domain.shop.exception; + +import in.koreatech.koin.global.exception.DataNotFoundException; + +public class NotificationMessageNotFoundException extends DataNotFoundException { + + private static final String DEFAULT_MESSAGE = "해당 상점에 해당하는 알림 메세지를 찾지 못했습니다."; + + public NotificationMessageNotFoundException(String message) { + super(message); + } + + public NotificationMessageNotFoundException(String message, String detail) { + super(message, detail); + } + + public static NotificationMessageNotFoundException withDetail(String detail) { + return new NotificationMessageNotFoundException(DEFAULT_MESSAGE, detail); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/shop/model/shop/ShopCategory.java b/src/main/java/in/koreatech/koin/domain/shop/model/shop/ShopCategory.java index bce64fbc1..05a4de29e 100644 --- a/src/main/java/in/koreatech/koin/domain/shop/model/shop/ShopCategory.java +++ b/src/main/java/in/koreatech/koin/domain/shop/model/shop/ShopCategory.java @@ -10,8 +10,11 @@ import in.koreatech.koin.global.domain.BaseEntity; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; import jakarta.persistence.OneToMany; import jakarta.persistence.Table; import jakarta.validation.constraints.Size; @@ -41,6 +44,10 @@ public class ShopCategory extends BaseEntity { @OneToMany(mappedBy = "shopCategory", orphanRemoval = true, cascade = {PERSIST, REMOVE}) private List shopCategoryMaps = new ArrayList<>(); + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "main_category_id", referencedColumnName = "id") + private ShopMainCategory mainCategory; + @Builder private ShopCategory(String name, String imageUrl) { this.name = name; diff --git a/src/main/java/in/koreatech/koin/domain/shop/model/shop/ShopMainCategory.java b/src/main/java/in/koreatech/koin/domain/shop/model/shop/ShopMainCategory.java new file mode 100644 index 000000000..3602b96a4 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/shop/model/shop/ShopMainCategory.java @@ -0,0 +1,38 @@ +package in.koreatech.koin.domain.shop.model.shop; + +import static lombok.AccessLevel.PROTECTED; + +import in.koreatech.koin.global.domain.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 jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@NoArgsConstructor(access = PROTECTED) +@Table(name = "shop_main_categories") +public class ShopMainCategory extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + @NotNull + @Size(max = 255) + @Column(name = "name", nullable = false) + private String name; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "notification_message_id", referencedColumnName = "id", nullable = false) + private ShopNotificationMessage notificationMessage; +} diff --git a/src/main/java/in/koreatech/koin/domain/shop/model/shop/ShopNotificationBuffer.java b/src/main/java/in/koreatech/koin/domain/shop/model/shop/ShopNotificationBuffer.java new file mode 100644 index 000000000..a74807a6e --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/shop/model/shop/ShopNotificationBuffer.java @@ -0,0 +1,50 @@ +package in.koreatech.koin.domain.shop.model.shop; + +import static lombok.AccessLevel.PROTECTED; + +import java.time.LocalDateTime; + +import in.koreatech.koin.domain.user.model.User; +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 jakarta.validation.constraints.NotNull; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@NoArgsConstructor(access = PROTECTED) +@Table(name = "shop_notification_buffer") +public class ShopNotificationBuffer { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "shop_id", referencedColumnName = "id", nullable = false) + private Shop shop; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", referencedColumnName = "id", nullable = false) + private User user; + + @NotNull + @Column(name = "notification_time", columnDefinition = "TIMESTAMP", nullable = false) + private LocalDateTime notificationTime; + + @Builder + private ShopNotificationBuffer(Shop shop, User user, LocalDateTime notificationTime) { + this.shop = shop; + this.user = user; + this.notificationTime = notificationTime; + } +} diff --git a/src/main/java/in/koreatech/koin/domain/shop/model/shop/ShopNotificationMessage.java b/src/main/java/in/koreatech/koin/domain/shop/model/shop/ShopNotificationMessage.java new file mode 100644 index 000000000..0f629e0fb --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/shop/model/shop/ShopNotificationMessage.java @@ -0,0 +1,33 @@ +package in.koreatech.koin.domain.shop.model.shop; + +import static lombok.AccessLevel.PROTECTED; + +import in.koreatech.koin.global.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@NoArgsConstructor(access = PROTECTED) +@Table(name = "shop_notification_messages") +public class ShopNotificationMessage extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + @Size(max = 255) + @Column(name = "title", nullable = false) + private String title; + + @Size(max = 255) + @Column(name = "content", nullable = false) + private String content; +} diff --git a/src/main/java/in/koreatech/koin/domain/shop/repository/shop/ShopNotificationBufferRepository.java b/src/main/java/in/koreatech/koin/domain/shop/repository/shop/ShopNotificationBufferRepository.java new file mode 100644 index 000000000..f4ccd8c24 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/shop/repository/shop/ShopNotificationBufferRepository.java @@ -0,0 +1,38 @@ +package in.koreatech.koin.domain.shop.repository.shop; + +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.Repository; +import org.springframework.data.repository.query.Param; + +import in.koreatech.koin.domain.shop.model.shop.ShopNotificationBuffer; + +public interface ShopNotificationBufferRepository extends Repository { + + ShopNotificationBuffer save(ShopNotificationBuffer shopNotificationBuffer); + + @Query(""" + SELECT b FROM ShopNotificationBuffer b + JOIN FETCH b.shop s + JOIN FETCH b.user u + JOIN FETCH s.shopCategories scm + JOIN FETCH scm.shopCategory sc + JOIN FETCH sc.mainCategory smc + JOIN FETCH smc.notificationMessage m + WHERE b.notificationTime < :now + AND sc.id = ( + SELECT MIN(sc2.id) + FROM ShopCategory sc2 + JOIN ShopCategoryMap scm2 ON scm2.shopCategory = sc2 + WHERE scm2.shop = s + AND sc2.name != '전체보기' + ) + """) + List findByNotificationTimeBefore(@Param("now") LocalDateTime now); + + List findAll(); + + int deleteByNotificationTimeBefore(LocalDateTime now); +} diff --git a/src/main/java/in/koreatech/koin/domain/shop/scheduler/NotificationScheduler.java b/src/main/java/in/koreatech/koin/domain/shop/scheduler/NotificationScheduler.java new file mode 100644 index 000000000..689d9fb7e --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/shop/scheduler/NotificationScheduler.java @@ -0,0 +1,25 @@ +package in.koreatech.koin.domain.shop.scheduler; + +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import in.koreatech.koin.domain.shop.service.NotificationScheduleService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class NotificationScheduler { + + private final NotificationScheduleService notificationScheduleService; + + @Scheduled(cron = "0 * * * * *") + public void sendDueNotifications() { + try { + notificationScheduleService.sendDueNotifications(); + } catch (Exception e) { + log.warn("리뷰유도 알림 전송 과정에서 오류가 발생했습니다."); + } + } +} diff --git a/src/main/java/in/koreatech/koin/domain/shop/service/NotificationScheduleService.java b/src/main/java/in/koreatech/koin/domain/shop/service/NotificationScheduleService.java new file mode 100644 index 000000000..c7731f4bb --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/shop/service/NotificationScheduleService.java @@ -0,0 +1,70 @@ +package in.koreatech.koin.domain.shop.service; + +import static in.koreatech.koin.global.fcm.MobileAppPath.SHOP; + +import java.time.Clock; +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import in.koreatech.koin.domain.shop.exception.NotificationMessageNotFoundException; +import in.koreatech.koin.domain.shop.model.shop.Shop; +import in.koreatech.koin.domain.shop.model.shop.ShopCategoryMap; +import in.koreatech.koin.domain.shop.model.shop.ShopNotificationBuffer; +import in.koreatech.koin.domain.shop.model.shop.ShopNotificationMessage; +import in.koreatech.koin.domain.shop.repository.shop.ShopNotificationBufferRepository; +import in.koreatech.koin.domain.user.model.User; + +import in.koreatech.koin.global.domain.notification.model.Notification; +import in.koreatech.koin.global.domain.notification.model.NotificationFactory; +import in.koreatech.koin.global.domain.notification.service.NotificationService; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class NotificationScheduleService { + + private final Clock clock; + private final ShopNotificationBufferRepository shopNotificationBufferRepository; + private final NotificationFactory notificationFactory; + private final NotificationService notificationService; + + @Transactional + public void sendDueNotifications() { + LocalDateTime now = LocalDateTime.now(clock); + List dueNotifications = shopNotificationBufferRepository.findByNotificationTimeBefore(now); + if (dueNotifications.isEmpty()) { + return; + } + + List notifications = dueNotifications.stream() + .map(this::createNotification) + .toList(); + shopNotificationBufferRepository.deleteByNotificationTimeBefore(now); + + notificationService.push(notifications); + } + + private Notification createNotification(ShopNotificationBuffer dueNotification) { + Shop shop = dueNotification.getShop(); + User user = dueNotification.getUser(); + + ShopNotificationMessage shopNotificationMessage = shop.getShopCategories().stream() + .findFirst() + .map(ShopCategoryMap::getShopCategory) + .map(shopCategory -> shopCategory.getMainCategory().getNotificationMessage()) + .orElseThrow(() -> NotificationMessageNotFoundException.withDetail("shopId: " + shop.getId())); + + return notificationFactory.generateReviewPromptNotification( + SHOP, + dueNotification.getShop().getId(), + shop.getName(), + shopNotificationMessage.getTitle(), + shopNotificationMessage.getContent(), + user + ); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/shop/service/ShopService.java b/src/main/java/in/koreatech/koin/domain/shop/service/ShopService.java index 08dd8b776..24feaaad6 100644 --- a/src/main/java/in/koreatech/koin/domain/shop/service/ShopService.java +++ b/src/main/java/in/koreatech/koin/domain/shop/service/ShopService.java @@ -1,5 +1,7 @@ package in.koreatech.koin.domain.shop.service; +import static in.koreatech.koin.global.domain.notification.model.NotificationSubscribeType.REVIEW_PROMPT; + import java.time.Clock; import java.time.LocalDate; import java.time.LocalDateTime; @@ -25,14 +27,19 @@ import in.koreatech.koin.domain.shop.model.menu.MenuCategoryMap; import in.koreatech.koin.domain.shop.model.shop.Shop; import in.koreatech.koin.domain.shop.model.shop.ShopCategory; +import in.koreatech.koin.domain.shop.model.shop.ShopNotificationBuffer; import in.koreatech.koin.domain.shop.repository.event.EventArticleRepository; import in.koreatech.koin.domain.shop.repository.menu.MenuCategoryRepository; import in.koreatech.koin.domain.shop.repository.menu.MenuRepository; import in.koreatech.koin.domain.shop.repository.shop.ShopCategoryRepository; +import in.koreatech.koin.domain.shop.repository.shop.ShopNotificationBufferRepository; import in.koreatech.koin.domain.shop.repository.shop.ShopRepository; import in.koreatech.koin.domain.shop.repository.shop.dto.ShopCustomRepository; import in.koreatech.koin.domain.shop.repository.shop.dto.ShopInfoV1; import in.koreatech.koin.domain.shop.repository.shop.dto.ShopInfoV2; +import in.koreatech.koin.domain.user.model.User; +import in.koreatech.koin.domain.user.repository.UserRepository; +import in.koreatech.koin.global.domain.notification.repository.NotificationSubscribeRepository; import in.koreatech.koin.global.exception.KoinIllegalArgumentException; import lombok.RequiredArgsConstructor; @@ -48,6 +55,9 @@ public class ShopService { private final ShopCategoryRepository shopCategoryRepository; private final EventArticleRepository eventArticleRepository; private final ShopCustomRepository shopCustomRepository; + private final NotificationSubscribeRepository notificationSubscribeRepository; + private final ShopNotificationBufferRepository shopNotificationBufferRepository; + private final UserRepository userRepository; public MenuDetailResponse findMenu(Integer menuId) { Menu menu = menuRepository.getById(menuId); @@ -111,4 +121,27 @@ public ShopsResponseV2 getShopsV2(ShopsSortCriteria sortBy, List shopInfoMap = shopCustomRepository.findAllShopInfo(now); return ShopsResponseV2.from(shops, shopInfoMap, sortBy, shopsFilterCriterias, now); } + + @Transactional + public void publishCallNotification(Integer shopId, Integer studentId) { + shopRepository.getById(shopId); + + if (isSubscribeReviewNotification(studentId)) { + Shop shop = shopRepository.getById(shopId); + User user = userRepository.getById(studentId); + + ShopNotificationBuffer shopNotificationBuffer = ShopNotificationBuffer.builder() + .shop(shop) + .user(user) + .notificationTime(LocalDateTime.now().plusHours(1)) + .build(); + + shopNotificationBufferRepository.save(shopNotificationBuffer); + } + } + + private boolean isSubscribeReviewNotification(Integer studentId) { + return notificationSubscribeRepository + .existsByUserIdAndSubscribeType(studentId, REVIEW_PROMPT); + } } diff --git a/src/main/java/in/koreatech/koin/domain/student/model/StudentEventListener.java b/src/main/java/in/koreatech/koin/domain/student/model/StudentEventListener.java index 0a1018cf3..dc1d8e35c 100644 --- a/src/main/java/in/koreatech/koin/domain/student/model/StudentEventListener.java +++ b/src/main/java/in/koreatech/koin/domain/student/model/StudentEventListener.java @@ -7,6 +7,8 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.event.TransactionalEventListener; +import in.koreatech.koin.global.domain.notification.model.NotificationSubscribeType; +import in.koreatech.koin.global.domain.notification.service.NotificationService; import in.koreatech.koin.global.domain.slack.SlackClient; import in.koreatech.koin.global.domain.slack.model.SlackNotificationFactory; import lombok.RequiredArgsConstructor; @@ -18,6 +20,7 @@ public class StudentEventListener { private final SlackClient slackClient; private final SlackNotificationFactory slackNotificationFactory; + private final NotificationService notificationService; @TransactionalEventListener(phase = AFTER_COMMIT) public void onStudentEmailRequest(StudentEmailRequestEvent event) { @@ -29,5 +32,7 @@ public void onStudentEmailRequest(StudentEmailRequestEvent event) { public void onStudentRegister(StudentRegisterEvent event) { var notification = slackNotificationFactory.generateStudentRegisterCompleteNotification(event.email()); slackClient.sendMessage(notification); + + notificationService.permitNotificationSubscribe(event.studentId(), NotificationSubscribeType.REVIEW_PROMPT); } } diff --git a/src/main/java/in/koreatech/koin/domain/student/model/StudentRegisterEvent.java b/src/main/java/in/koreatech/koin/domain/student/model/StudentRegisterEvent.java index 4fe083c00..01092f04a 100644 --- a/src/main/java/in/koreatech/koin/domain/student/model/StudentRegisterEvent.java +++ b/src/main/java/in/koreatech/koin/domain/student/model/StudentRegisterEvent.java @@ -1,7 +1,8 @@ package in.koreatech.koin.domain.student.model; public record StudentRegisterEvent( - String email + String email, + Integer studentId ) { } diff --git a/src/main/java/in/koreatech/koin/domain/student/service/StudentService.java b/src/main/java/in/koreatech/koin/domain/student/service/StudentService.java index 06b6f10f3..e2b6c3b47 100644 --- a/src/main/java/in/koreatech/koin/domain/student/service/StudentService.java +++ b/src/main/java/in/koreatech/koin/domain/student/service/StudentService.java @@ -43,6 +43,8 @@ import in.koreatech.koin.global.domain.email.form.StudentRegistrationData; import in.koreatech.koin.global.domain.email.model.EmailAddress; import in.koreatech.koin.global.domain.email.service.MailService; +import in.koreatech.koin.global.domain.notification.model.NotificationSubscribeType; +import in.koreatech.koin.global.domain.notification.service.NotificationService; import in.koreatech.koin.global.exception.KoinIllegalArgumentException; import lombok.RequiredArgsConstructor; @@ -134,7 +136,7 @@ public ModelAndView authenticate(AuthTokenRequest request) { userRepository.save(student.getUser()); studentRedisRepository.deleteById(student.getUser().getEmail()); - eventPublisher.publishEvent(new StudentRegisterEvent(student.getUser().getEmail())); + eventPublisher.publishEvent(new StudentRegisterEvent(student.getUser().getEmail(), student.getId())); return new ModelAndView("success_register_config"); } diff --git a/src/main/java/in/koreatech/koin/global/domain/notification/model/NotificationFactory.java b/src/main/java/in/koreatech/koin/global/domain/notification/model/NotificationFactory.java index 3d5602aa8..9b53b4fbe 100644 --- a/src/main/java/in/koreatech/koin/global/domain/notification/model/NotificationFactory.java +++ b/src/main/java/in/koreatech/koin/global/domain/notification/model/NotificationFactory.java @@ -8,6 +8,25 @@ @Component public class NotificationFactory { + public Notification generateReviewPromptNotification( + MobileAppPath path, + Integer eventShopId, + String shopName, + String title, + String message, + User target + ) { + return new Notification( + path, + generateSchemeUri(path, eventShopId), + String.format("%s%s", shopName, title), + message, + null, + NotificationType.MESSAGE, + target + ); + } + public Notification generateShopEventCreateNotification( MobileAppPath path, Integer eventShopId, diff --git a/src/main/java/in/koreatech/koin/global/domain/notification/model/NotificationSubscribeType.java b/src/main/java/in/koreatech/koin/global/domain/notification/model/NotificationSubscribeType.java index 3bde7b51a..cfe9ac223 100644 --- a/src/main/java/in/koreatech/koin/global/domain/notification/model/NotificationSubscribeType.java +++ b/src/main/java/in/koreatech/koin/global/domain/notification/model/NotificationSubscribeType.java @@ -13,6 +13,7 @@ @Getter public enum NotificationSubscribeType { SHOP_EVENT(List.of()), + REVIEW_PROMPT(List.of()), DINING_SOLD_OUT(List.of(BREAKFAST, LUNCH, DINNER)), DINING_IMAGE_UPLOAD(List.of()), ARTICLE_KEYWORD(List.of()) diff --git a/src/main/java/in/koreatech/koin/global/domain/notification/repository/NotificationSubscribeRepository.java b/src/main/java/in/koreatech/koin/global/domain/notification/repository/NotificationSubscribeRepository.java index d4890a903..a13eca02e 100644 --- a/src/main/java/in/koreatech/koin/global/domain/notification/repository/NotificationSubscribeRepository.java +++ b/src/main/java/in/koreatech/koin/global/domain/notification/repository/NotificationSubscribeRepository.java @@ -36,4 +36,6 @@ List findByUserIdAndSubscribeType( Integer userId, NotificationSubscribeType type ); + + boolean existsByUserIdAndSubscribeType(Integer userId, NotificationSubscribeType type); } diff --git a/src/main/resources/db/migration/V87__add_shop_notification_messages_and_insert_data.sql b/src/main/resources/db/migration/V87__add_shop_notification_messages_and_insert_data.sql new file mode 100644 index 000000000..9f2e5fc01 --- /dev/null +++ b/src/main/resources/db/migration/V87__add_shop_notification_messages_and_insert_data.sql @@ -0,0 +1,13 @@ +CREATE TABLE `shop_notification_messages` +( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY COMMENT 'shop_notification_messages 고유 id', + title VARCHAR(255) NOT NULL COMMENT '메세지 제목', + content VARCHAR(255) NOT NULL COMMENT '메세지 내용', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL COMMENT '생성 일자', + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 일자' +); + +INSERT INTO `shop_notification_messages` (title, content) +VALUES (', 맛있게 드셨나요?', '드신 메뉴에 대한 리뷰를 작성해보세요!'), + (', 편안하게 이동하셨나요?', '승차하신 콜벤에 대한 리뷰를 작성해보세요!'), + (', 어떠셨나요?', '이용하신 샵에 대한 리뷰를 작성해보세요!'); diff --git a/src/main/resources/db/migration/V88__add_shop_main_categories_table_and_insert_data.sql b/src/main/resources/db/migration/V88__add_shop_main_categories_table_and_insert_data.sql new file mode 100644 index 000000000..878f8c369 --- /dev/null +++ b/src/main/resources/db/migration/V88__add_shop_main_categories_table_and_insert_data.sql @@ -0,0 +1,15 @@ +CREATE TABLE `shop_main_categories` +( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY COMMENT 'shop_main_categories 고유 id', + name VARCHAR(255) NOT NULL COMMENT '메인 카테고리 이름', + notification_message_id INT UNSIGNED NOT NULL COMMENT '알림 메시지 id', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL COMMENT '생성 일자', + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 일자', + CONSTRAINT `FK_MAIN_CATEGORIES_ON_SHOP_NOTIFICATION_MESSAGES` + FOREIGN KEY (`notification_message_id`) REFERENCES `shop_notification_messages` (`id`) +); + +INSERT INTO `shop_main_categories` (name, notification_message_id) +VALUES ('가게', 1), + ('콜벤', 2), + ('뷰티', 3); diff --git a/src/main/resources/db/migration/V89__alter_shop_categories_add_main_category_id_column.sql b/src/main/resources/db/migration/V89__alter_shop_categories_add_main_category_id_column.sql new file mode 100644 index 000000000..d511b7b60 --- /dev/null +++ b/src/main/resources/db/migration/V89__alter_shop_categories_add_main_category_id_column.sql @@ -0,0 +1,14 @@ +ALTER TABLE `shop_categories` + ADD COLUMN `main_category_id` INT UNSIGNED COMMENT '메인 카테고리 id', + ADD CONSTRAINT `FK_SHOP_CATEGORIES_ON_SHOP_MAIN_CATEGORIES` + FOREIGN KEY (`main_category_id`) + REFERENCES `shop_main_categories` (`id`); + +UPDATE shop_categories +SET main_category_id = + CASE + WHEN name IN ('기타/콜밴', '콜벤') THEN 2 + WHEN name IN ('기타', '뷰티') THEN 3 + ELSE 1 + END +WHERE id != 1; \ No newline at end of file diff --git a/src/main/resources/db/migration/V90__add_shop_notification_queue.sql b/src/main/resources/db/migration/V90__add_shop_notification_queue.sql new file mode 100644 index 000000000..47959db15 --- /dev/null +++ b/src/main/resources/db/migration/V90__add_shop_notification_queue.sql @@ -0,0 +1,11 @@ +CREATE TABLE `shop_notification_buffer` +( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY COMMENT 'shop_notification_buffer 고유 id', + shop_id INT UNSIGNED NOT NULL COMMENT '상점 ID', + user_id INT UNSIGNED NOT NULL COMMENT '사용자 ID', + notification_time TIMESTAMP NOT NULL COMMENT '알림 시간', + CONSTRAINT `FK_SHOP_NOTIFICATION_BUFFER_ON_SHOPS` + FOREIGN KEY (`shop_id`) REFERENCES `shops` (`id`), + CONSTRAINT `FK_SHOP_NOTIFICATION_BUFFER_ON_USERS` + FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) +); diff --git a/src/main/resources/db/migration/V91__insert_notification_subscribe_review_prompt.sql b/src/main/resources/db/migration/V91__insert_notification_subscribe_review_prompt.sql new file mode 100644 index 000000000..f0e14f27a --- /dev/null +++ b/src/main/resources/db/migration/V91__insert_notification_subscribe_review_prompt.sql @@ -0,0 +1,4 @@ +INSERT INTO notification_subscribe (created_at, updated_at, subscribe_type, user_id) +SELECT NOW(), NOW(), 'REVIEW_PROMPT', s.user_id +FROM students s + JOIN users u ON s.user_id = u.id; diff --git a/src/test/java/in/koreatech/koin/acceptance/NotificationApiTest.java b/src/test/java/in/koreatech/koin/acceptance/NotificationApiTest.java index 1dbff099b..46a65d21d 100644 --- a/src/test/java/in/koreatech/koin/acceptance/NotificationApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/NotificationApiTest.java @@ -108,6 +108,13 @@ void setUp() { "detail_subscribes": [ \s ] + }, + { + "type": "REVIEW_PROMPT", + "is_permit": false, + "detail_subscribes": [ + \s + ] } ] } @@ -212,6 +219,13 @@ void setUp() { "detail_subscribes": [ \s ] + }, + { + "type": "REVIEW_PROMPT", + "is_permit": false, + "detail_subscribes": [ + \s + ] } ] } @@ -316,6 +330,13 @@ void setUp() { "detail_subscribes": [ \s ] + }, + { + "type": "REVIEW_PROMPT", + "is_permit": false, + "detail_subscribes": [ + \s + ] } ] } @@ -428,6 +449,13 @@ void setUp() { "detail_subscribes": [ \s ] + }, + { + "type": "REVIEW_PROMPT", + "is_permit": false, + "detail_subscribes": [ + \s + ] } ] } @@ -537,6 +565,13 @@ void setUp() { "detail_subscribes": [ \s ] + }, + { + "type": "REVIEW_PROMPT", + "is_permit": false, + "detail_subscribes": [ + \s + ] } ] } diff --git a/src/test/java/in/koreatech/koin/acceptance/ShopApiTest.java b/src/test/java/in/koreatech/koin/acceptance/ShopApiTest.java index 7a91dd5ad..ff291d402 100644 --- a/src/test/java/in/koreatech/koin/acceptance/ShopApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/ShopApiTest.java @@ -3,6 +3,7 @@ import static in.koreatech.koin.domain.shop.model.review.ReportStatus.DISMISSED; import static in.koreatech.koin.domain.shop.model.review.ReportStatus.UNHANDLED; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -62,6 +63,7 @@ class ShopApiTest extends AcceptanceTest { private Owner owner; private Student 익명_학생; + private String token_익명; @BeforeAll void setUp() { @@ -69,6 +71,7 @@ void setUp() { owner = userFixture.준영_사장님(); 마슬랜 = shopFixture.마슬랜(owner); 익명_학생 = userFixture.익명_학생(); + token_익명 = userFixture.getToken(익명_학생.getUser()); } @Test @@ -1017,4 +1020,13 @@ void setUp() { } """, 티바_영업여부, 마슬랜_영업여부))); } + + @Test + void 전화하기_발생시_정보가_알림큐에_저장된다() throws Exception { + mockMvc.perform( + post("/shops/{shopId}/call-notification", 마슬랜.getId()) + .header("Authorization", "Bearer " + token_익명) + ) + .andExpect(status().isOk()); + } } From 9e81d6d7fae6722869160cc119930f67f4691640 Mon Sep 17 00:00:00 2001 From: krSeonghyeon <149303551+krSeonghyeon@users.noreply.github.com> Date: Thu, 7 Nov 2024 22:25:10 +0900 Subject: [PATCH 12/12] =?UTF-8?q?fix:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EC=8B=9C=20=EA=B0=95=EC=A0=9C=20=EA=B5=AC=EB=8F=85?= =?UTF-8?q?=20=EC=82=AD=EC=A0=9C=20(#1002)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 회원가입 시 강제 구독 삭제 * fix: 모든 회원이 아닌 device 토큰이 존재하는 회원만 추가하도록 변경 * chore: 미사용 코드 삭제 * chore: 개행추가 --- .../koin/domain/student/model/StudentEventListener.java | 3 --- .../koin/domain/student/model/StudentRegisterEvent.java | 3 +-- .../koin/domain/student/service/StudentService.java | 2 +- .../V92__delete_notification_subscribe_review_prompt.sql | 8 ++++++++ 4 files changed, 10 insertions(+), 6 deletions(-) create mode 100644 src/main/resources/db/migration/V92__delete_notification_subscribe_review_prompt.sql diff --git a/src/main/java/in/koreatech/koin/domain/student/model/StudentEventListener.java b/src/main/java/in/koreatech/koin/domain/student/model/StudentEventListener.java index dc1d8e35c..cfedb433d 100644 --- a/src/main/java/in/koreatech/koin/domain/student/model/StudentEventListener.java +++ b/src/main/java/in/koreatech/koin/domain/student/model/StudentEventListener.java @@ -20,7 +20,6 @@ public class StudentEventListener { private final SlackClient slackClient; private final SlackNotificationFactory slackNotificationFactory; - private final NotificationService notificationService; @TransactionalEventListener(phase = AFTER_COMMIT) public void onStudentEmailRequest(StudentEmailRequestEvent event) { @@ -32,7 +31,5 @@ public void onStudentEmailRequest(StudentEmailRequestEvent event) { public void onStudentRegister(StudentRegisterEvent event) { var notification = slackNotificationFactory.generateStudentRegisterCompleteNotification(event.email()); slackClient.sendMessage(notification); - - notificationService.permitNotificationSubscribe(event.studentId(), NotificationSubscribeType.REVIEW_PROMPT); } } diff --git a/src/main/java/in/koreatech/koin/domain/student/model/StudentRegisterEvent.java b/src/main/java/in/koreatech/koin/domain/student/model/StudentRegisterEvent.java index 01092f04a..4fe083c00 100644 --- a/src/main/java/in/koreatech/koin/domain/student/model/StudentRegisterEvent.java +++ b/src/main/java/in/koreatech/koin/domain/student/model/StudentRegisterEvent.java @@ -1,8 +1,7 @@ package in.koreatech.koin.domain.student.model; public record StudentRegisterEvent( - String email, - Integer studentId + String email ) { } diff --git a/src/main/java/in/koreatech/koin/domain/student/service/StudentService.java b/src/main/java/in/koreatech/koin/domain/student/service/StudentService.java index e2b6c3b47..cdc41a261 100644 --- a/src/main/java/in/koreatech/koin/domain/student/service/StudentService.java +++ b/src/main/java/in/koreatech/koin/domain/student/service/StudentService.java @@ -136,7 +136,7 @@ public ModelAndView authenticate(AuthTokenRequest request) { userRepository.save(student.getUser()); studentRedisRepository.deleteById(student.getUser().getEmail()); - eventPublisher.publishEvent(new StudentRegisterEvent(student.getUser().getEmail(), student.getId())); + eventPublisher.publishEvent(new StudentRegisterEvent(student.getUser().getEmail())); return new ModelAndView("success_register_config"); } diff --git a/src/main/resources/db/migration/V92__delete_notification_subscribe_review_prompt.sql b/src/main/resources/db/migration/V92__delete_notification_subscribe_review_prompt.sql new file mode 100644 index 000000000..9175d1fcd --- /dev/null +++ b/src/main/resources/db/migration/V92__delete_notification_subscribe_review_prompt.sql @@ -0,0 +1,8 @@ +DELETE FROM notification_subscribe +WHERE subscribe_type = 'REVIEW_PROMPT'; + +INSERT INTO notification_subscribe (created_at, updated_at, subscribe_type, user_id) +SELECT NOW(), NOW(), 'REVIEW_PROMPT', s.user_id +FROM students s + JOIN users u ON s.user_id = u.id +WHERE u.device_token IS NOT NULL;