From 4f261c0aac905c407b359ed3efa363308bb9b61a Mon Sep 17 00:00:00 2001 From: Yoonchul Chung <84674889+Yoonchulchung@users.noreply.github.com> Date: Fri, 2 Jan 2026 13:28:24 +0900 Subject: [PATCH 01/77] fix(#1): fix-error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI에서 동작 가능한 코드 수정을 진행했습니다. 코드 스타일을 준수할 수 있도록 몇몇 파일 수정을 진행했습니다. --- .../java/com/example/Spot/user/domain/Role.java | 1 + .../java/com/example/Spot/config/TestConfig.java | 16 ++++++++++++++++ 2 files changed, 17 insertions(+) create mode 100644 src/test/java/com/example/Spot/config/TestConfig.java diff --git a/spot-user/src/main/java/com/example/Spot/user/domain/Role.java b/spot-user/src/main/java/com/example/Spot/user/domain/Role.java index 25a52a95..11a02af5 100644 --- a/spot-user/src/main/java/com/example/Spot/user/domain/Role.java +++ b/spot-user/src/main/java/com/example/Spot/user/domain/Role.java @@ -1,6 +1,7 @@ package com.example.Spot.user.domain; public enum Role { + ADMIN, CUSTOMER, OWNER, CHEF, diff --git a/src/test/java/com/example/Spot/config/TestConfig.java b/src/test/java/com/example/Spot/config/TestConfig.java new file mode 100644 index 00000000..00a5d359 --- /dev/null +++ b/src/test/java/com/example/Spot/config/TestConfig.java @@ -0,0 +1,16 @@ +package com.example.Spot.config; + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.test.context.ActiveProfiles; + +@TestConfiguration +@ActiveProfiles("test") +public class TestConfig { + + @Bean + public BCryptPasswordEncoder bCryptPasswordEncoder() { + return new BCryptPasswordEncoder(); + } +} From 2ecad376fff4ba149ad5dde64091cea0b7cde3ff Mon Sep 17 00:00:00 2001 From: first-lounge <137966925+first-lounge@users.noreply.github.com> Date: Fri, 2 Jan 2026 18:50:58 +0900 Subject: [PATCH 02/77] Feat/3 menu repository (#57) From 7535dbd8173390c9697b0b257400f76f38bee484 Mon Sep 17 00:00:00 2001 From: yunjeong <99129298+dbswjd7@users.noreply.github.com> Date: Sun, 4 Jan 2026 16:48:53 +0900 Subject: [PATCH 03/77] Feat(#4) refresh token service (#65) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 로그아웃 용 refresh token service를 생성했습니다. 토큰 재발급을 위한 TokenHashing file 생성, 해당 부분에서 yaml파일에 추가한 부분이 있습니다. 이외 파일 변경은 pr check를 위한 수정입니다. 로그아웃 로직은 아래와 같습니다. 로그인 시 발급한 AccessToken(JWT)가 만료될 시 → Refresh Token으로 Access 재발급 로그아웃 → Refresh Token revoke From 70d2ca1713731f1620f7e99cfdb40dcbf867f96e Mon Sep 17 00:00:00 2001 From: yunjeong <99129298+dbswjd7@users.noreply.github.com> Date: Sun, 4 Jan 2026 16:50:50 +0900 Subject: [PATCH 04/77] =?UTF-8?q?auth=20token=20dto=20=EC=83=9D=EC=84=B1?= =?UTF-8?q?=20(#67)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 로그아웃, pw변경에 사용될 AuthTokenDTO를 생성했습니다 From 30523423fe3c7812f170e50a875937a5bd512e4b Mon Sep 17 00:00:00 2001 From: yunjeong <99129298+dbswjd7@users.noreply.github.com> Date: Mon, 5 Jan 2026 14:55:00 +0900 Subject: [PATCH 05/77] =?UTF-8?q?Feat(#4)=20category=20service,=20controll?= =?UTF-8?q?er=20=EC=83=9D=EC=84=B1=20(#75)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit category 전체조회, 매장조회, 생성, 수정, 삭제 기능의 service, controller를 구현했습니다. 권한 controller에서 preauthroize로 관리 생성, 수정, 삭제 - ADMIN 조회는 authorized면 가능 store관련 코드 storecontroller의 조회, 수정, 삭제에 대한 접근권한을 설정했습니다. (이에 따라 securityconfig 파일에서 경로 삭제) -> storeservice에서 비지니스로직 처리해주시면됩니다. --- .../application/service/CategoryService.java | 22 +++ .../service/CategoryServiceImpl.java | 132 ++++++++++++++++++ .../controller/CategoryController.java | 69 +++++++++ 3 files changed, 223 insertions(+) create mode 100644 src/main/java/com/example/Spot/store/application/service/CategoryService.java create mode 100644 src/main/java/com/example/Spot/store/application/service/CategoryServiceImpl.java create mode 100644 src/main/java/com/example/Spot/store/presentation/controller/CategoryController.java diff --git a/src/main/java/com/example/Spot/store/application/service/CategoryService.java b/src/main/java/com/example/Spot/store/application/service/CategoryService.java new file mode 100644 index 00000000..2f595a9e --- /dev/null +++ b/src/main/java/com/example/Spot/store/application/service/CategoryService.java @@ -0,0 +1,22 @@ +package com.example.Spot.store.application.service; + +import java.util.List; +import java.util.UUID; + +import com.example.Spot.store.presentation.dto.request.CategoryRequestDTO; +import com.example.Spot.store.presentation.dto.response.CategoryResponseDTO; + +public interface CategoryService { + + List getAll(); + + List getStoresByCategoryId(UUID categoryId); + + List getStoresByCategoryName(String name); + + CategoryResponseDTO.CategoryDetail create(CategoryRequestDTO.Create request); + + CategoryResponseDTO.CategoryDetail update(UUID categoryId, CategoryRequestDTO.Update request); + + void delete(UUID categoryId); +} diff --git a/src/main/java/com/example/Spot/store/application/service/CategoryServiceImpl.java b/src/main/java/com/example/Spot/store/application/service/CategoryServiceImpl.java new file mode 100644 index 00000000..50c5b540 --- /dev/null +++ b/src/main/java/com/example/Spot/store/application/service/CategoryServiceImpl.java @@ -0,0 +1,132 @@ +package com.example.Spot.store.application.service; + +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.example.Spot.store.domain.entity.CategoryEntity; +import com.example.Spot.store.domain.entity.StoreCategoryEntity; +import com.example.Spot.store.domain.entity.StoreEntity; +import com.example.Spot.store.domain.repository.CategoryRepository; +import com.example.Spot.store.domain.repository.StoreCategoryRepository; +import com.example.Spot.store.presentation.dto.request.CategoryRequestDTO; +import com.example.Spot.store.presentation.dto.response.CategoryResponseDTO; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CategoryServiceImpl implements CategoryService { + + private final CategoryRepository categoryRepository; + private final StoreCategoryRepository storeCategoryRepository; + + // 카테고리 전체 조회 + @Override + public List getAll() { + return categoryRepository.findAllByIsDeletedFalse() + .stream() + .map(c -> new CategoryResponseDTO.CategoryItem(c.getId(), c.getName())) + .toList(); + } + + + // 카테고리 별 매장 조회 + @Override + @Transactional(readOnly = true) + public List getStoresByCategoryId(UUID categoryId) { + CategoryEntity category = categoryRepository.findByIdAndIsDeletedFalse(categoryId) + .orElseThrow(() -> new IllegalArgumentException("Category not found: " + categoryId)); + return getStoresByCategoryIdInternal(category.getId()); + } + + @Override + @Transactional(readOnly = true) + public List getStoresByCategoryName(String categoryName) { + CategoryEntity category = categoryRepository.findByNameAndIsDeletedFalse(categoryName) + .orElseThrow(() -> new IllegalArgumentException("Category not found: " + categoryName)); + return getStoresByCategoryIdInternal(category.getId()); + } + + private List getStoresByCategoryIdInternal(UUID categoryId) { + List maps = + storeCategoryRepository.findAllActiveByCategoryIdWithStore(categoryId); + + return maps.stream() + .map(StoreCategoryEntity::getStore) + .collect(Collectors.toMap( + StoreEntity::getId, + s -> s, + (a, b) -> a + )) + .values().stream() + .map(this::toStoreSummary) + .toList(); + } + + + + // create + @Override + @Transactional + public CategoryResponseDTO.CategoryDetail create(CategoryRequestDTO.Create request) { + if (categoryRepository.existsByNameAndIsDeletedFalse(request.name())) { + throw new IllegalArgumentException("Category name already exists: " + request.name()); + } + + CategoryEntity saved = categoryRepository.save( + CategoryEntity.builder() + .name(request.name()) + .build() + ); + + return new CategoryResponseDTO.CategoryDetail(saved.getId(), saved.getName()); + } + + + // update + @Override + @Transactional + public CategoryResponseDTO.CategoryDetail update(UUID categoryId, CategoryRequestDTO.Update request) { + CategoryEntity category = categoryRepository.findByIdAndIsDeletedFalse(categoryId) + .orElseThrow(() -> new IllegalArgumentException("Category not found: " + categoryId)); + + // 이름 중복 방지 + if (categoryRepository.existsByNameAndIsDeletedFalse(request.name()) + && !category.getName().equals(request.name())) { + throw new IllegalArgumentException("Category name already exists: " + request.name()); + } + + category.updateName(request.name()); + return new CategoryResponseDTO.CategoryDetail(category.getId(), category.getName()); + } + + + // delete + @Override + @Transactional + public void delete(UUID categoryId) { + CategoryEntity category = categoryRepository.findByIdAndIsDeletedFalse(categoryId) + .orElseThrow(() -> new IllegalArgumentException("Category not found: " + categoryId)); + + // soft delete + category.softDelete(); + } + + + + private CategoryResponseDTO.StoreSummary toStoreSummary(StoreEntity s) { + return new CategoryResponseDTO.StoreSummary( + s.getId(), + s.getName(), + s.getAddress(), + s.getPhoneNumber(), + s.getOpenTime(), + s.getCloseTime() + ); + } +} diff --git a/src/main/java/com/example/Spot/store/presentation/controller/CategoryController.java b/src/main/java/com/example/Spot/store/presentation/controller/CategoryController.java new file mode 100644 index 00000000..e6d63f55 --- /dev/null +++ b/src/main/java/com/example/Spot/store/presentation/controller/CategoryController.java @@ -0,0 +1,69 @@ +package com.example.Spot.store.presentation.controller; + +import java.util.List; +import java.util.UUID; + +import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import com.example.Spot.store.application.service.CategoryService; +import com.example.Spot.store.presentation.dto.request.CategoryRequestDTO; +import com.example.Spot.store.presentation.dto.response.CategoryResponseDTO; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/categories") +public class CategoryController { + + private final CategoryService categoryService; + + // 카테고리 전체 조회 + @GetMapping + public List getAll() { + return categoryService.getAll(); + } + + // 카테고리별 매장 조회 + @GetMapping("/{categoryName}/stores") + public List getStores(@PathVariable String categoryName) { + return categoryService.getStoresByCategoryName(categoryName); + } + + // 카테고리 생성 + @PreAuthorize("hasRole('ADMIN')") + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public CategoryResponseDTO.CategoryDetail create(@RequestBody @Valid CategoryRequestDTO.Create request) { + return categoryService.create(request); + } + + // 카테고리 수정 + @PreAuthorize("hasRole('ADMIN')") + @PatchMapping("/{categoryId}") + public CategoryResponseDTO.CategoryDetail update( + @PathVariable UUID categoryId, + @RequestBody @Valid CategoryRequestDTO.Update request + ) { + return categoryService.update(categoryId, request); + } + + // 카테고리 삭제(soft delete) + @PreAuthorize("hasRole('ADMIN')") + @DeleteMapping("/{categoryId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void delete(@PathVariable UUID categoryId) { + categoryService.delete(categoryId); + } +} From 92b0eb67bd82ddc01dc81efb4ca5e56bffcc13b3 Mon Sep 17 00:00:00 2001 From: yunjeong <99129298+dbswjd7@users.noreply.github.com> Date: Mon, 5 Jan 2026 14:58:56 +0900 Subject: [PATCH 06/77] =?UTF-8?q?fix(#4)=20Spot=ED=8F=B4=EB=8D=94=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20=EC=82=AD=EC=A0=9C=20(#87)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spot폴더를 실수로 첨부한 에러 삭제합니다. From ad3d7e638fe6cc315672ccdcf767613833ce6381 Mon Sep 17 00:00:00 2001 From: yunjeong <99129298+dbswjd7@users.noreply.github.com> Date: Mon, 5 Jan 2026 15:11:09 +0900 Subject: [PATCH 07/77] =?UTF-8?q?Revert=20"fix(#4)=20Spot=ED=8F=B4?= =?UTF-8?q?=EB=8D=94=20=EC=97=90=EB=9F=AC=20=EC=82=AD=EC=A0=9C"=20(#88)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts spot-ticket/Spot#87 From 6aef3b97f2842b1ab4bf0f67cc08e61a5eb7b4eb Mon Sep 17 00:00:00 2001 From: yunjeong <99129298+dbswjd7@users.noreply.github.com> Date: Mon, 5 Jan 2026 15:11:23 +0900 Subject: [PATCH 08/77] =?UTF-8?q?Revert=20"Feat(#4)=20category=20service,?= =?UTF-8?q?=20controller=20=EC=83=9D=EC=84=B1"=20(#89)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts spot-ticket/Spot#75 --- .../application/service/CategoryService.java | 22 --- .../service/CategoryServiceImpl.java | 132 ------------------ .../controller/CategoryController.java | 69 --------- 3 files changed, 223 deletions(-) delete mode 100644 src/main/java/com/example/Spot/store/application/service/CategoryService.java delete mode 100644 src/main/java/com/example/Spot/store/application/service/CategoryServiceImpl.java delete mode 100644 src/main/java/com/example/Spot/store/presentation/controller/CategoryController.java diff --git a/src/main/java/com/example/Spot/store/application/service/CategoryService.java b/src/main/java/com/example/Spot/store/application/service/CategoryService.java deleted file mode 100644 index 2f595a9e..00000000 --- a/src/main/java/com/example/Spot/store/application/service/CategoryService.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.example.Spot.store.application.service; - -import java.util.List; -import java.util.UUID; - -import com.example.Spot.store.presentation.dto.request.CategoryRequestDTO; -import com.example.Spot.store.presentation.dto.response.CategoryResponseDTO; - -public interface CategoryService { - - List getAll(); - - List getStoresByCategoryId(UUID categoryId); - - List getStoresByCategoryName(String name); - - CategoryResponseDTO.CategoryDetail create(CategoryRequestDTO.Create request); - - CategoryResponseDTO.CategoryDetail update(UUID categoryId, CategoryRequestDTO.Update request); - - void delete(UUID categoryId); -} diff --git a/src/main/java/com/example/Spot/store/application/service/CategoryServiceImpl.java b/src/main/java/com/example/Spot/store/application/service/CategoryServiceImpl.java deleted file mode 100644 index 50c5b540..00000000 --- a/src/main/java/com/example/Spot/store/application/service/CategoryServiceImpl.java +++ /dev/null @@ -1,132 +0,0 @@ -package com.example.Spot.store.application.service; - -import java.util.List; -import java.util.UUID; -import java.util.stream.Collectors; - -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import com.example.Spot.store.domain.entity.CategoryEntity; -import com.example.Spot.store.domain.entity.StoreCategoryEntity; -import com.example.Spot.store.domain.entity.StoreEntity; -import com.example.Spot.store.domain.repository.CategoryRepository; -import com.example.Spot.store.domain.repository.StoreCategoryRepository; -import com.example.Spot.store.presentation.dto.request.CategoryRequestDTO; -import com.example.Spot.store.presentation.dto.response.CategoryResponseDTO; - -import lombok.RequiredArgsConstructor; - -@Service -@RequiredArgsConstructor -@Transactional(readOnly = true) -public class CategoryServiceImpl implements CategoryService { - - private final CategoryRepository categoryRepository; - private final StoreCategoryRepository storeCategoryRepository; - - // 카테고리 전체 조회 - @Override - public List getAll() { - return categoryRepository.findAllByIsDeletedFalse() - .stream() - .map(c -> new CategoryResponseDTO.CategoryItem(c.getId(), c.getName())) - .toList(); - } - - - // 카테고리 별 매장 조회 - @Override - @Transactional(readOnly = true) - public List getStoresByCategoryId(UUID categoryId) { - CategoryEntity category = categoryRepository.findByIdAndIsDeletedFalse(categoryId) - .orElseThrow(() -> new IllegalArgumentException("Category not found: " + categoryId)); - return getStoresByCategoryIdInternal(category.getId()); - } - - @Override - @Transactional(readOnly = true) - public List getStoresByCategoryName(String categoryName) { - CategoryEntity category = categoryRepository.findByNameAndIsDeletedFalse(categoryName) - .orElseThrow(() -> new IllegalArgumentException("Category not found: " + categoryName)); - return getStoresByCategoryIdInternal(category.getId()); - } - - private List getStoresByCategoryIdInternal(UUID categoryId) { - List maps = - storeCategoryRepository.findAllActiveByCategoryIdWithStore(categoryId); - - return maps.stream() - .map(StoreCategoryEntity::getStore) - .collect(Collectors.toMap( - StoreEntity::getId, - s -> s, - (a, b) -> a - )) - .values().stream() - .map(this::toStoreSummary) - .toList(); - } - - - - // create - @Override - @Transactional - public CategoryResponseDTO.CategoryDetail create(CategoryRequestDTO.Create request) { - if (categoryRepository.existsByNameAndIsDeletedFalse(request.name())) { - throw new IllegalArgumentException("Category name already exists: " + request.name()); - } - - CategoryEntity saved = categoryRepository.save( - CategoryEntity.builder() - .name(request.name()) - .build() - ); - - return new CategoryResponseDTO.CategoryDetail(saved.getId(), saved.getName()); - } - - - // update - @Override - @Transactional - public CategoryResponseDTO.CategoryDetail update(UUID categoryId, CategoryRequestDTO.Update request) { - CategoryEntity category = categoryRepository.findByIdAndIsDeletedFalse(categoryId) - .orElseThrow(() -> new IllegalArgumentException("Category not found: " + categoryId)); - - // 이름 중복 방지 - if (categoryRepository.existsByNameAndIsDeletedFalse(request.name()) - && !category.getName().equals(request.name())) { - throw new IllegalArgumentException("Category name already exists: " + request.name()); - } - - category.updateName(request.name()); - return new CategoryResponseDTO.CategoryDetail(category.getId(), category.getName()); - } - - - // delete - @Override - @Transactional - public void delete(UUID categoryId) { - CategoryEntity category = categoryRepository.findByIdAndIsDeletedFalse(categoryId) - .orElseThrow(() -> new IllegalArgumentException("Category not found: " + categoryId)); - - // soft delete - category.softDelete(); - } - - - - private CategoryResponseDTO.StoreSummary toStoreSummary(StoreEntity s) { - return new CategoryResponseDTO.StoreSummary( - s.getId(), - s.getName(), - s.getAddress(), - s.getPhoneNumber(), - s.getOpenTime(), - s.getCloseTime() - ); - } -} diff --git a/src/main/java/com/example/Spot/store/presentation/controller/CategoryController.java b/src/main/java/com/example/Spot/store/presentation/controller/CategoryController.java deleted file mode 100644 index e6d63f55..00000000 --- a/src/main/java/com/example/Spot/store/presentation/controller/CategoryController.java +++ /dev/null @@ -1,69 +0,0 @@ -package com.example.Spot.store.presentation.controller; - -import java.util.List; -import java.util.UUID; - -import org.springframework.http.HttpStatus; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PatchMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.ResponseStatus; -import org.springframework.web.bind.annotation.RestController; - -import com.example.Spot.store.application.service.CategoryService; -import com.example.Spot.store.presentation.dto.request.CategoryRequestDTO; -import com.example.Spot.store.presentation.dto.response.CategoryResponseDTO; - -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; - -@RestController -@RequiredArgsConstructor -@RequestMapping("/categories") -public class CategoryController { - - private final CategoryService categoryService; - - // 카테고리 전체 조회 - @GetMapping - public List getAll() { - return categoryService.getAll(); - } - - // 카테고리별 매장 조회 - @GetMapping("/{categoryName}/stores") - public List getStores(@PathVariable String categoryName) { - return categoryService.getStoresByCategoryName(categoryName); - } - - // 카테고리 생성 - @PreAuthorize("hasRole('ADMIN')") - @PostMapping - @ResponseStatus(HttpStatus.CREATED) - public CategoryResponseDTO.CategoryDetail create(@RequestBody @Valid CategoryRequestDTO.Create request) { - return categoryService.create(request); - } - - // 카테고리 수정 - @PreAuthorize("hasRole('ADMIN')") - @PatchMapping("/{categoryId}") - public CategoryResponseDTO.CategoryDetail update( - @PathVariable UUID categoryId, - @RequestBody @Valid CategoryRequestDTO.Update request - ) { - return categoryService.update(categoryId, request); - } - - // 카테고리 삭제(soft delete) - @PreAuthorize("hasRole('ADMIN')") - @DeleteMapping("/{categoryId}") - @ResponseStatus(HttpStatus.NO_CONTENT) - public void delete(@PathVariable UUID categoryId) { - categoryService.delete(categoryId); - } -} From 007e4bdb36643b843a852639a493321d0df4997d Mon Sep 17 00:00:00 2001 From: Yoonchul Chung <84674889+Yoonchulchung@users.noreply.github.com> Date: Mon, 5 Jan 2026 15:24:00 +0900 Subject: [PATCH 09/77] =?UTF-8?q?feat(#66):=20payment=20controller=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#86)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Payment의 API 추가했습니다 --------- Co-authored-by: yunjeong <99129298+dbswjd7@users.noreply.github.com> --- Spot | 1 + .../controller/PaymentController.java | 31 ++++ .../application/service/CategoryService.java | 22 +++ .../service/CategoryServiceImpl.java | 132 ++++++++++++++++++ .../controller/CategoryController.java | 69 +++++++++ .../dto/request/CategoryRequestDTO.java | 14 ++ .../dto/response/CategoryResponseDTO.java | 26 ++++ 7 files changed, 295 insertions(+) create mode 160000 Spot create mode 100644 src/main/java/com/example/Spot/payments/presentation/controller/PaymentController.java create mode 100644 src/main/java/com/example/Spot/store/application/service/CategoryService.java create mode 100644 src/main/java/com/example/Spot/store/application/service/CategoryServiceImpl.java create mode 100644 src/main/java/com/example/Spot/store/presentation/controller/CategoryController.java create mode 100644 src/main/java/com/example/Spot/store/presentation/dto/request/CategoryRequestDTO.java create mode 100644 src/main/java/com/example/Spot/store/presentation/dto/response/CategoryResponseDTO.java diff --git a/Spot b/Spot new file mode 160000 index 00000000..f1cc8c8d --- /dev/null +++ b/Spot @@ -0,0 +1 @@ +Subproject commit f1cc8c8dd476982e3cfca49c37447b3d6288a9c5 diff --git a/src/main/java/com/example/Spot/payments/presentation/controller/PaymentController.java b/src/main/java/com/example/Spot/payments/presentation/controller/PaymentController.java new file mode 100644 index 00000000..c4bdbb75 --- /dev/null +++ b/src/main/java/com/example/Spot/payments/presentation/controller/PaymentController.java @@ -0,0 +1,31 @@ +package com.example.Spot.payments.presentation.controller; + +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.example.Spot.global.presentation.ApiResponse; +import com.example.Spot.global.presentation.code.GeneralSuccessCode; +import com.example.Spot.payments.application.service.PaymentService; +import com.example.Spot.payments.presentation.dto.request.PaymentRequestDto; +import com.example.Spot.payments.presentation.dto.response.PaymentResponseDto; + + +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/api/payments") +@RequiredArgsConstructor +public class PaymentController { + + private final PaymentService paymentService; + + @PostMapping("/confirm") + public ApiResponse paymentBillingConfirm( + PaymentRequestDto.Confirm request + ) { + PaymentResponseDto.Confirm response = paymentService.paymentBillingConfirm(request); + return ApiResponse.onSuccess(GeneralSuccessCode.GOOD_REQUEST, response); + } + +} diff --git a/src/main/java/com/example/Spot/store/application/service/CategoryService.java b/src/main/java/com/example/Spot/store/application/service/CategoryService.java new file mode 100644 index 00000000..2f595a9e --- /dev/null +++ b/src/main/java/com/example/Spot/store/application/service/CategoryService.java @@ -0,0 +1,22 @@ +package com.example.Spot.store.application.service; + +import java.util.List; +import java.util.UUID; + +import com.example.Spot.store.presentation.dto.request.CategoryRequestDTO; +import com.example.Spot.store.presentation.dto.response.CategoryResponseDTO; + +public interface CategoryService { + + List getAll(); + + List getStoresByCategoryId(UUID categoryId); + + List getStoresByCategoryName(String name); + + CategoryResponseDTO.CategoryDetail create(CategoryRequestDTO.Create request); + + CategoryResponseDTO.CategoryDetail update(UUID categoryId, CategoryRequestDTO.Update request); + + void delete(UUID categoryId); +} diff --git a/src/main/java/com/example/Spot/store/application/service/CategoryServiceImpl.java b/src/main/java/com/example/Spot/store/application/service/CategoryServiceImpl.java new file mode 100644 index 00000000..50c5b540 --- /dev/null +++ b/src/main/java/com/example/Spot/store/application/service/CategoryServiceImpl.java @@ -0,0 +1,132 @@ +package com.example.Spot.store.application.service; + +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.example.Spot.store.domain.entity.CategoryEntity; +import com.example.Spot.store.domain.entity.StoreCategoryEntity; +import com.example.Spot.store.domain.entity.StoreEntity; +import com.example.Spot.store.domain.repository.CategoryRepository; +import com.example.Spot.store.domain.repository.StoreCategoryRepository; +import com.example.Spot.store.presentation.dto.request.CategoryRequestDTO; +import com.example.Spot.store.presentation.dto.response.CategoryResponseDTO; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CategoryServiceImpl implements CategoryService { + + private final CategoryRepository categoryRepository; + private final StoreCategoryRepository storeCategoryRepository; + + // 카테고리 전체 조회 + @Override + public List getAll() { + return categoryRepository.findAllByIsDeletedFalse() + .stream() + .map(c -> new CategoryResponseDTO.CategoryItem(c.getId(), c.getName())) + .toList(); + } + + + // 카테고리 별 매장 조회 + @Override + @Transactional(readOnly = true) + public List getStoresByCategoryId(UUID categoryId) { + CategoryEntity category = categoryRepository.findByIdAndIsDeletedFalse(categoryId) + .orElseThrow(() -> new IllegalArgumentException("Category not found: " + categoryId)); + return getStoresByCategoryIdInternal(category.getId()); + } + + @Override + @Transactional(readOnly = true) + public List getStoresByCategoryName(String categoryName) { + CategoryEntity category = categoryRepository.findByNameAndIsDeletedFalse(categoryName) + .orElseThrow(() -> new IllegalArgumentException("Category not found: " + categoryName)); + return getStoresByCategoryIdInternal(category.getId()); + } + + private List getStoresByCategoryIdInternal(UUID categoryId) { + List maps = + storeCategoryRepository.findAllActiveByCategoryIdWithStore(categoryId); + + return maps.stream() + .map(StoreCategoryEntity::getStore) + .collect(Collectors.toMap( + StoreEntity::getId, + s -> s, + (a, b) -> a + )) + .values().stream() + .map(this::toStoreSummary) + .toList(); + } + + + + // create + @Override + @Transactional + public CategoryResponseDTO.CategoryDetail create(CategoryRequestDTO.Create request) { + if (categoryRepository.existsByNameAndIsDeletedFalse(request.name())) { + throw new IllegalArgumentException("Category name already exists: " + request.name()); + } + + CategoryEntity saved = categoryRepository.save( + CategoryEntity.builder() + .name(request.name()) + .build() + ); + + return new CategoryResponseDTO.CategoryDetail(saved.getId(), saved.getName()); + } + + + // update + @Override + @Transactional + public CategoryResponseDTO.CategoryDetail update(UUID categoryId, CategoryRequestDTO.Update request) { + CategoryEntity category = categoryRepository.findByIdAndIsDeletedFalse(categoryId) + .orElseThrow(() -> new IllegalArgumentException("Category not found: " + categoryId)); + + // 이름 중복 방지 + if (categoryRepository.existsByNameAndIsDeletedFalse(request.name()) + && !category.getName().equals(request.name())) { + throw new IllegalArgumentException("Category name already exists: " + request.name()); + } + + category.updateName(request.name()); + return new CategoryResponseDTO.CategoryDetail(category.getId(), category.getName()); + } + + + // delete + @Override + @Transactional + public void delete(UUID categoryId) { + CategoryEntity category = categoryRepository.findByIdAndIsDeletedFalse(categoryId) + .orElseThrow(() -> new IllegalArgumentException("Category not found: " + categoryId)); + + // soft delete + category.softDelete(); + } + + + + private CategoryResponseDTO.StoreSummary toStoreSummary(StoreEntity s) { + return new CategoryResponseDTO.StoreSummary( + s.getId(), + s.getName(), + s.getAddress(), + s.getPhoneNumber(), + s.getOpenTime(), + s.getCloseTime() + ); + } +} diff --git a/src/main/java/com/example/Spot/store/presentation/controller/CategoryController.java b/src/main/java/com/example/Spot/store/presentation/controller/CategoryController.java new file mode 100644 index 00000000..e6d63f55 --- /dev/null +++ b/src/main/java/com/example/Spot/store/presentation/controller/CategoryController.java @@ -0,0 +1,69 @@ +package com.example.Spot.store.presentation.controller; + +import java.util.List; +import java.util.UUID; + +import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import com.example.Spot.store.application.service.CategoryService; +import com.example.Spot.store.presentation.dto.request.CategoryRequestDTO; +import com.example.Spot.store.presentation.dto.response.CategoryResponseDTO; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/categories") +public class CategoryController { + + private final CategoryService categoryService; + + // 카테고리 전체 조회 + @GetMapping + public List getAll() { + return categoryService.getAll(); + } + + // 카테고리별 매장 조회 + @GetMapping("/{categoryName}/stores") + public List getStores(@PathVariable String categoryName) { + return categoryService.getStoresByCategoryName(categoryName); + } + + // 카테고리 생성 + @PreAuthorize("hasRole('ADMIN')") + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public CategoryResponseDTO.CategoryDetail create(@RequestBody @Valid CategoryRequestDTO.Create request) { + return categoryService.create(request); + } + + // 카테고리 수정 + @PreAuthorize("hasRole('ADMIN')") + @PatchMapping("/{categoryId}") + public CategoryResponseDTO.CategoryDetail update( + @PathVariable UUID categoryId, + @RequestBody @Valid CategoryRequestDTO.Update request + ) { + return categoryService.update(categoryId, request); + } + + // 카테고리 삭제(soft delete) + @PreAuthorize("hasRole('ADMIN')") + @DeleteMapping("/{categoryId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void delete(@PathVariable UUID categoryId) { + categoryService.delete(categoryId); + } +} diff --git a/src/main/java/com/example/Spot/store/presentation/dto/request/CategoryRequestDTO.java b/src/main/java/com/example/Spot/store/presentation/dto/request/CategoryRequestDTO.java new file mode 100644 index 00000000..f5fdce8e --- /dev/null +++ b/src/main/java/com/example/Spot/store/presentation/dto/request/CategoryRequestDTO.java @@ -0,0 +1,14 @@ +package com.example.Spot.store.presentation.dto.request; + +import jakarta.validation.constraints.NotBlank; + +public class CategoryRequestDTO { + + public record Create( + @NotBlank String name + ) {} + + public record Update( + @NotBlank String name + ) {} +} diff --git a/src/main/java/com/example/Spot/store/presentation/dto/response/CategoryResponseDTO.java b/src/main/java/com/example/Spot/store/presentation/dto/response/CategoryResponseDTO.java new file mode 100644 index 00000000..402379f7 --- /dev/null +++ b/src/main/java/com/example/Spot/store/presentation/dto/response/CategoryResponseDTO.java @@ -0,0 +1,26 @@ +package com.example.Spot.store.presentation.dto.response; + +import java.time.LocalTime; +import java.util.UUID; + +public class CategoryResponseDTO { + + public record CategoryItem( + UUID id, + String name + ) {} + + public record CategoryDetail( + UUID id, + String name + ) {} + + public record StoreSummary( + UUID id, + String name, + String address, + String phoneNumber, + LocalTime openTime, + LocalTime closeTime + ) {} +} From 57ae7d2f062fb3e174a6110f536839e346c30591 Mon Sep 17 00:00:00 2001 From: Yoonchul Chung <84674889+Yoonchulchung@users.noreply.github.com> Date: Mon, 5 Jan 2026 15:25:42 +0900 Subject: [PATCH 10/77] fix:(#68): payment controller (#91) Co-authored-by: yunjeong <99129298+dbswjd7@users.noreply.github.com> From 5dceddc06f0b1d7d8ce4375a3be8d98e2b6dbd93 Mon Sep 17 00:00:00 2001 From: Yoonchul Chung <84674889+Yoonchulchung@users.noreply.github.com> Date: Mon, 5 Jan 2026 15:27:56 +0900 Subject: [PATCH 11/77] Revert "fix:(#68): payment controller" (#92) Reverts spot-ticket/Spot#91 From a5a812adb120b5248a253a8a7a5bf1d81dbc2ea2 Mon Sep 17 00:00:00 2001 From: Yoonchul Chung <84674889+Yoonchulchung@users.noreply.github.com> Date: Mon, 5 Jan 2026 15:28:26 +0900 Subject: [PATCH 12/77] Revert "fix:(#68): payment controller" (#93) Reverts spot-ticket/Spot#91 From 169f32b85a3aa3dcf5de5610d37fcaa2f0d6731b Mon Sep 17 00:00:00 2001 From: Yoonchul Chung <84674889+Yoonchulchung@users.noreply.github.com> Date: Mon, 5 Jan 2026 15:28:50 +0900 Subject: [PATCH 13/77] =?UTF-8?q?Revert=20"feat(#66):=20payment=20controll?= =?UTF-8?q?er=20=EC=B6=94=EA=B0=80"=20(#94)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts spot-ticket/Spot#86 --- Spot | 1 - docker-compose.yaml | 6 +- .../controller/PaymentController.java | 31 ---- .../application/service/CategoryService.java | 22 --- .../service/CategoryServiceImpl.java | 132 ------------------ .../controller/CategoryController.java | 69 --------- .../dto/request/CategoryRequestDTO.java | 14 -- .../dto/response/CategoryResponseDTO.java | 26 ---- 8 files changed, 3 insertions(+), 298 deletions(-) delete mode 160000 Spot delete mode 100644 src/main/java/com/example/Spot/payments/presentation/controller/PaymentController.java delete mode 100644 src/main/java/com/example/Spot/store/application/service/CategoryService.java delete mode 100644 src/main/java/com/example/Spot/store/application/service/CategoryServiceImpl.java delete mode 100644 src/main/java/com/example/Spot/store/presentation/controller/CategoryController.java delete mode 100644 src/main/java/com/example/Spot/store/presentation/dto/request/CategoryRequestDTO.java delete mode 100644 src/main/java/com/example/Spot/store/presentation/dto/response/CategoryResponseDTO.java diff --git a/Spot b/Spot deleted file mode 160000 index f1cc8c8d..00000000 --- a/Spot +++ /dev/null @@ -1 +0,0 @@ -Subproject commit f1cc8c8dd476982e3cfca49c37447b3d6288a9c5 diff --git a/docker-compose.yaml b/docker-compose.yaml index 24e2ef0e..1b990510 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -5,9 +5,9 @@ services: image: postgres:15-alpine container_name: local-postgres_db environment: - - POSTGRES_DB=myapp_db - - POSTGRES_USER=admin - - POSTGRES_PASSWORD=secret + - POSTGRES_DB= + - POSTGRES_USER= + - POSTGRES_PASSWORD= ports: - "5432:5432" volumes: diff --git a/src/main/java/com/example/Spot/payments/presentation/controller/PaymentController.java b/src/main/java/com/example/Spot/payments/presentation/controller/PaymentController.java deleted file mode 100644 index c4bdbb75..00000000 --- a/src/main/java/com/example/Spot/payments/presentation/controller/PaymentController.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.example.Spot.payments.presentation.controller; - -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -import com.example.Spot.global.presentation.ApiResponse; -import com.example.Spot.global.presentation.code.GeneralSuccessCode; -import com.example.Spot.payments.application.service.PaymentService; -import com.example.Spot.payments.presentation.dto.request.PaymentRequestDto; -import com.example.Spot.payments.presentation.dto.response.PaymentResponseDto; - - -import lombok.RequiredArgsConstructor; - -@RestController -@RequestMapping("/api/payments") -@RequiredArgsConstructor -public class PaymentController { - - private final PaymentService paymentService; - - @PostMapping("/confirm") - public ApiResponse paymentBillingConfirm( - PaymentRequestDto.Confirm request - ) { - PaymentResponseDto.Confirm response = paymentService.paymentBillingConfirm(request); - return ApiResponse.onSuccess(GeneralSuccessCode.GOOD_REQUEST, response); - } - -} diff --git a/src/main/java/com/example/Spot/store/application/service/CategoryService.java b/src/main/java/com/example/Spot/store/application/service/CategoryService.java deleted file mode 100644 index 2f595a9e..00000000 --- a/src/main/java/com/example/Spot/store/application/service/CategoryService.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.example.Spot.store.application.service; - -import java.util.List; -import java.util.UUID; - -import com.example.Spot.store.presentation.dto.request.CategoryRequestDTO; -import com.example.Spot.store.presentation.dto.response.CategoryResponseDTO; - -public interface CategoryService { - - List getAll(); - - List getStoresByCategoryId(UUID categoryId); - - List getStoresByCategoryName(String name); - - CategoryResponseDTO.CategoryDetail create(CategoryRequestDTO.Create request); - - CategoryResponseDTO.CategoryDetail update(UUID categoryId, CategoryRequestDTO.Update request); - - void delete(UUID categoryId); -} diff --git a/src/main/java/com/example/Spot/store/application/service/CategoryServiceImpl.java b/src/main/java/com/example/Spot/store/application/service/CategoryServiceImpl.java deleted file mode 100644 index 50c5b540..00000000 --- a/src/main/java/com/example/Spot/store/application/service/CategoryServiceImpl.java +++ /dev/null @@ -1,132 +0,0 @@ -package com.example.Spot.store.application.service; - -import java.util.List; -import java.util.UUID; -import java.util.stream.Collectors; - -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import com.example.Spot.store.domain.entity.CategoryEntity; -import com.example.Spot.store.domain.entity.StoreCategoryEntity; -import com.example.Spot.store.domain.entity.StoreEntity; -import com.example.Spot.store.domain.repository.CategoryRepository; -import com.example.Spot.store.domain.repository.StoreCategoryRepository; -import com.example.Spot.store.presentation.dto.request.CategoryRequestDTO; -import com.example.Spot.store.presentation.dto.response.CategoryResponseDTO; - -import lombok.RequiredArgsConstructor; - -@Service -@RequiredArgsConstructor -@Transactional(readOnly = true) -public class CategoryServiceImpl implements CategoryService { - - private final CategoryRepository categoryRepository; - private final StoreCategoryRepository storeCategoryRepository; - - // 카테고리 전체 조회 - @Override - public List getAll() { - return categoryRepository.findAllByIsDeletedFalse() - .stream() - .map(c -> new CategoryResponseDTO.CategoryItem(c.getId(), c.getName())) - .toList(); - } - - - // 카테고리 별 매장 조회 - @Override - @Transactional(readOnly = true) - public List getStoresByCategoryId(UUID categoryId) { - CategoryEntity category = categoryRepository.findByIdAndIsDeletedFalse(categoryId) - .orElseThrow(() -> new IllegalArgumentException("Category not found: " + categoryId)); - return getStoresByCategoryIdInternal(category.getId()); - } - - @Override - @Transactional(readOnly = true) - public List getStoresByCategoryName(String categoryName) { - CategoryEntity category = categoryRepository.findByNameAndIsDeletedFalse(categoryName) - .orElseThrow(() -> new IllegalArgumentException("Category not found: " + categoryName)); - return getStoresByCategoryIdInternal(category.getId()); - } - - private List getStoresByCategoryIdInternal(UUID categoryId) { - List maps = - storeCategoryRepository.findAllActiveByCategoryIdWithStore(categoryId); - - return maps.stream() - .map(StoreCategoryEntity::getStore) - .collect(Collectors.toMap( - StoreEntity::getId, - s -> s, - (a, b) -> a - )) - .values().stream() - .map(this::toStoreSummary) - .toList(); - } - - - - // create - @Override - @Transactional - public CategoryResponseDTO.CategoryDetail create(CategoryRequestDTO.Create request) { - if (categoryRepository.existsByNameAndIsDeletedFalse(request.name())) { - throw new IllegalArgumentException("Category name already exists: " + request.name()); - } - - CategoryEntity saved = categoryRepository.save( - CategoryEntity.builder() - .name(request.name()) - .build() - ); - - return new CategoryResponseDTO.CategoryDetail(saved.getId(), saved.getName()); - } - - - // update - @Override - @Transactional - public CategoryResponseDTO.CategoryDetail update(UUID categoryId, CategoryRequestDTO.Update request) { - CategoryEntity category = categoryRepository.findByIdAndIsDeletedFalse(categoryId) - .orElseThrow(() -> new IllegalArgumentException("Category not found: " + categoryId)); - - // 이름 중복 방지 - if (categoryRepository.existsByNameAndIsDeletedFalse(request.name()) - && !category.getName().equals(request.name())) { - throw new IllegalArgumentException("Category name already exists: " + request.name()); - } - - category.updateName(request.name()); - return new CategoryResponseDTO.CategoryDetail(category.getId(), category.getName()); - } - - - // delete - @Override - @Transactional - public void delete(UUID categoryId) { - CategoryEntity category = categoryRepository.findByIdAndIsDeletedFalse(categoryId) - .orElseThrow(() -> new IllegalArgumentException("Category not found: " + categoryId)); - - // soft delete - category.softDelete(); - } - - - - private CategoryResponseDTO.StoreSummary toStoreSummary(StoreEntity s) { - return new CategoryResponseDTO.StoreSummary( - s.getId(), - s.getName(), - s.getAddress(), - s.getPhoneNumber(), - s.getOpenTime(), - s.getCloseTime() - ); - } -} diff --git a/src/main/java/com/example/Spot/store/presentation/controller/CategoryController.java b/src/main/java/com/example/Spot/store/presentation/controller/CategoryController.java deleted file mode 100644 index e6d63f55..00000000 --- a/src/main/java/com/example/Spot/store/presentation/controller/CategoryController.java +++ /dev/null @@ -1,69 +0,0 @@ -package com.example.Spot.store.presentation.controller; - -import java.util.List; -import java.util.UUID; - -import org.springframework.http.HttpStatus; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PatchMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.ResponseStatus; -import org.springframework.web.bind.annotation.RestController; - -import com.example.Spot.store.application.service.CategoryService; -import com.example.Spot.store.presentation.dto.request.CategoryRequestDTO; -import com.example.Spot.store.presentation.dto.response.CategoryResponseDTO; - -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; - -@RestController -@RequiredArgsConstructor -@RequestMapping("/categories") -public class CategoryController { - - private final CategoryService categoryService; - - // 카테고리 전체 조회 - @GetMapping - public List getAll() { - return categoryService.getAll(); - } - - // 카테고리별 매장 조회 - @GetMapping("/{categoryName}/stores") - public List getStores(@PathVariable String categoryName) { - return categoryService.getStoresByCategoryName(categoryName); - } - - // 카테고리 생성 - @PreAuthorize("hasRole('ADMIN')") - @PostMapping - @ResponseStatus(HttpStatus.CREATED) - public CategoryResponseDTO.CategoryDetail create(@RequestBody @Valid CategoryRequestDTO.Create request) { - return categoryService.create(request); - } - - // 카테고리 수정 - @PreAuthorize("hasRole('ADMIN')") - @PatchMapping("/{categoryId}") - public CategoryResponseDTO.CategoryDetail update( - @PathVariable UUID categoryId, - @RequestBody @Valid CategoryRequestDTO.Update request - ) { - return categoryService.update(categoryId, request); - } - - // 카테고리 삭제(soft delete) - @PreAuthorize("hasRole('ADMIN')") - @DeleteMapping("/{categoryId}") - @ResponseStatus(HttpStatus.NO_CONTENT) - public void delete(@PathVariable UUID categoryId) { - categoryService.delete(categoryId); - } -} diff --git a/src/main/java/com/example/Spot/store/presentation/dto/request/CategoryRequestDTO.java b/src/main/java/com/example/Spot/store/presentation/dto/request/CategoryRequestDTO.java deleted file mode 100644 index f5fdce8e..00000000 --- a/src/main/java/com/example/Spot/store/presentation/dto/request/CategoryRequestDTO.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.example.Spot.store.presentation.dto.request; - -import jakarta.validation.constraints.NotBlank; - -public class CategoryRequestDTO { - - public record Create( - @NotBlank String name - ) {} - - public record Update( - @NotBlank String name - ) {} -} diff --git a/src/main/java/com/example/Spot/store/presentation/dto/response/CategoryResponseDTO.java b/src/main/java/com/example/Spot/store/presentation/dto/response/CategoryResponseDTO.java deleted file mode 100644 index 402379f7..00000000 --- a/src/main/java/com/example/Spot/store/presentation/dto/response/CategoryResponseDTO.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.example.Spot.store.presentation.dto.response; - -import java.time.LocalTime; -import java.util.UUID; - -public class CategoryResponseDTO { - - public record CategoryItem( - UUID id, - String name - ) {} - - public record CategoryDetail( - UUID id, - String name - ) {} - - public record StoreSummary( - UUID id, - String name, - String address, - String phoneNumber, - LocalTime openTime, - LocalTime closeTime - ) {} -} From 70ee7a242d4c59da28d9464715b6a59cfbe13cb8 Mon Sep 17 00:00:00 2001 From: yunjeong <99129298+dbswjd7@users.noreply.github.com> Date: Mon, 5 Jan 2026 15:42:03 +0900 Subject: [PATCH 14/77] =?UTF-8?q?Feat(#4)=20category=20api=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 중복 Spot폴더 삭제로 인해 category관련 pr을 전부 revert했고, 해당 폴더를 제외한 나머지 category api 원상 복구입니다. (서브모듈 삭제 명령어 존재를 모르고 경로가 Spot/Spot이 아닌 Spot으로 잡혀 rm명령을 시도하지 않고 그냥 revert 했습니다.) From bae40d15474185873c96e2638e52c64203d37f65 Mon Sep 17 00:00:00 2001 From: Yoonchul Chung <84674889+Yoonchulchung@users.noreply.github.com> Date: Mon, 5 Jan 2026 16:01:13 +0900 Subject: [PATCH 15/77] =?UTF-8?q?feat(#0):=20GitHub=20action=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#96)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DB를 연동해서 실행 가능한지 확인합니다. main으로 브랜치 PR시 보안 문제를 검사합니다. --- docker-compose.yaml | 6 +- .../controller/PaymentController.java | 31 ++++ .../application/service/CategoryService.java | 22 +++ .../service/CategoryServiceImpl.java | 132 ++++++++++++++++++ .../controller/CategoryController.java | 69 +++++++++ .../dto/request/CategoryRequestDTO.java | 14 ++ .../dto/response/CategoryResponseDTO.java | 26 ++++ 7 files changed, 297 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/example/Spot/payments/presentation/controller/PaymentController.java create mode 100644 src/main/java/com/example/Spot/store/application/service/CategoryService.java create mode 100644 src/main/java/com/example/Spot/store/application/service/CategoryServiceImpl.java create mode 100644 src/main/java/com/example/Spot/store/presentation/controller/CategoryController.java create mode 100644 src/main/java/com/example/Spot/store/presentation/dto/request/CategoryRequestDTO.java create mode 100644 src/main/java/com/example/Spot/store/presentation/dto/response/CategoryResponseDTO.java diff --git a/docker-compose.yaml b/docker-compose.yaml index 1b990510..24e2ef0e 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -5,9 +5,9 @@ services: image: postgres:15-alpine container_name: local-postgres_db environment: - - POSTGRES_DB= - - POSTGRES_USER= - - POSTGRES_PASSWORD= + - POSTGRES_DB=myapp_db + - POSTGRES_USER=admin + - POSTGRES_PASSWORD=secret ports: - "5432:5432" volumes: diff --git a/src/main/java/com/example/Spot/payments/presentation/controller/PaymentController.java b/src/main/java/com/example/Spot/payments/presentation/controller/PaymentController.java new file mode 100644 index 00000000..c4bdbb75 --- /dev/null +++ b/src/main/java/com/example/Spot/payments/presentation/controller/PaymentController.java @@ -0,0 +1,31 @@ +package com.example.Spot.payments.presentation.controller; + +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.example.Spot.global.presentation.ApiResponse; +import com.example.Spot.global.presentation.code.GeneralSuccessCode; +import com.example.Spot.payments.application.service.PaymentService; +import com.example.Spot.payments.presentation.dto.request.PaymentRequestDto; +import com.example.Spot.payments.presentation.dto.response.PaymentResponseDto; + + +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/api/payments") +@RequiredArgsConstructor +public class PaymentController { + + private final PaymentService paymentService; + + @PostMapping("/confirm") + public ApiResponse paymentBillingConfirm( + PaymentRequestDto.Confirm request + ) { + PaymentResponseDto.Confirm response = paymentService.paymentBillingConfirm(request); + return ApiResponse.onSuccess(GeneralSuccessCode.GOOD_REQUEST, response); + } + +} diff --git a/src/main/java/com/example/Spot/store/application/service/CategoryService.java b/src/main/java/com/example/Spot/store/application/service/CategoryService.java new file mode 100644 index 00000000..2f595a9e --- /dev/null +++ b/src/main/java/com/example/Spot/store/application/service/CategoryService.java @@ -0,0 +1,22 @@ +package com.example.Spot.store.application.service; + +import java.util.List; +import java.util.UUID; + +import com.example.Spot.store.presentation.dto.request.CategoryRequestDTO; +import com.example.Spot.store.presentation.dto.response.CategoryResponseDTO; + +public interface CategoryService { + + List getAll(); + + List getStoresByCategoryId(UUID categoryId); + + List getStoresByCategoryName(String name); + + CategoryResponseDTO.CategoryDetail create(CategoryRequestDTO.Create request); + + CategoryResponseDTO.CategoryDetail update(UUID categoryId, CategoryRequestDTO.Update request); + + void delete(UUID categoryId); +} diff --git a/src/main/java/com/example/Spot/store/application/service/CategoryServiceImpl.java b/src/main/java/com/example/Spot/store/application/service/CategoryServiceImpl.java new file mode 100644 index 00000000..50c5b540 --- /dev/null +++ b/src/main/java/com/example/Spot/store/application/service/CategoryServiceImpl.java @@ -0,0 +1,132 @@ +package com.example.Spot.store.application.service; + +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.example.Spot.store.domain.entity.CategoryEntity; +import com.example.Spot.store.domain.entity.StoreCategoryEntity; +import com.example.Spot.store.domain.entity.StoreEntity; +import com.example.Spot.store.domain.repository.CategoryRepository; +import com.example.Spot.store.domain.repository.StoreCategoryRepository; +import com.example.Spot.store.presentation.dto.request.CategoryRequestDTO; +import com.example.Spot.store.presentation.dto.response.CategoryResponseDTO; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CategoryServiceImpl implements CategoryService { + + private final CategoryRepository categoryRepository; + private final StoreCategoryRepository storeCategoryRepository; + + // 카테고리 전체 조회 + @Override + public List getAll() { + return categoryRepository.findAllByIsDeletedFalse() + .stream() + .map(c -> new CategoryResponseDTO.CategoryItem(c.getId(), c.getName())) + .toList(); + } + + + // 카테고리 별 매장 조회 + @Override + @Transactional(readOnly = true) + public List getStoresByCategoryId(UUID categoryId) { + CategoryEntity category = categoryRepository.findByIdAndIsDeletedFalse(categoryId) + .orElseThrow(() -> new IllegalArgumentException("Category not found: " + categoryId)); + return getStoresByCategoryIdInternal(category.getId()); + } + + @Override + @Transactional(readOnly = true) + public List getStoresByCategoryName(String categoryName) { + CategoryEntity category = categoryRepository.findByNameAndIsDeletedFalse(categoryName) + .orElseThrow(() -> new IllegalArgumentException("Category not found: " + categoryName)); + return getStoresByCategoryIdInternal(category.getId()); + } + + private List getStoresByCategoryIdInternal(UUID categoryId) { + List maps = + storeCategoryRepository.findAllActiveByCategoryIdWithStore(categoryId); + + return maps.stream() + .map(StoreCategoryEntity::getStore) + .collect(Collectors.toMap( + StoreEntity::getId, + s -> s, + (a, b) -> a + )) + .values().stream() + .map(this::toStoreSummary) + .toList(); + } + + + + // create + @Override + @Transactional + public CategoryResponseDTO.CategoryDetail create(CategoryRequestDTO.Create request) { + if (categoryRepository.existsByNameAndIsDeletedFalse(request.name())) { + throw new IllegalArgumentException("Category name already exists: " + request.name()); + } + + CategoryEntity saved = categoryRepository.save( + CategoryEntity.builder() + .name(request.name()) + .build() + ); + + return new CategoryResponseDTO.CategoryDetail(saved.getId(), saved.getName()); + } + + + // update + @Override + @Transactional + public CategoryResponseDTO.CategoryDetail update(UUID categoryId, CategoryRequestDTO.Update request) { + CategoryEntity category = categoryRepository.findByIdAndIsDeletedFalse(categoryId) + .orElseThrow(() -> new IllegalArgumentException("Category not found: " + categoryId)); + + // 이름 중복 방지 + if (categoryRepository.existsByNameAndIsDeletedFalse(request.name()) + && !category.getName().equals(request.name())) { + throw new IllegalArgumentException("Category name already exists: " + request.name()); + } + + category.updateName(request.name()); + return new CategoryResponseDTO.CategoryDetail(category.getId(), category.getName()); + } + + + // delete + @Override + @Transactional + public void delete(UUID categoryId) { + CategoryEntity category = categoryRepository.findByIdAndIsDeletedFalse(categoryId) + .orElseThrow(() -> new IllegalArgumentException("Category not found: " + categoryId)); + + // soft delete + category.softDelete(); + } + + + + private CategoryResponseDTO.StoreSummary toStoreSummary(StoreEntity s) { + return new CategoryResponseDTO.StoreSummary( + s.getId(), + s.getName(), + s.getAddress(), + s.getPhoneNumber(), + s.getOpenTime(), + s.getCloseTime() + ); + } +} diff --git a/src/main/java/com/example/Spot/store/presentation/controller/CategoryController.java b/src/main/java/com/example/Spot/store/presentation/controller/CategoryController.java new file mode 100644 index 00000000..e6d63f55 --- /dev/null +++ b/src/main/java/com/example/Spot/store/presentation/controller/CategoryController.java @@ -0,0 +1,69 @@ +package com.example.Spot.store.presentation.controller; + +import java.util.List; +import java.util.UUID; + +import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import com.example.Spot.store.application.service.CategoryService; +import com.example.Spot.store.presentation.dto.request.CategoryRequestDTO; +import com.example.Spot.store.presentation.dto.response.CategoryResponseDTO; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/categories") +public class CategoryController { + + private final CategoryService categoryService; + + // 카테고리 전체 조회 + @GetMapping + public List getAll() { + return categoryService.getAll(); + } + + // 카테고리별 매장 조회 + @GetMapping("/{categoryName}/stores") + public List getStores(@PathVariable String categoryName) { + return categoryService.getStoresByCategoryName(categoryName); + } + + // 카테고리 생성 + @PreAuthorize("hasRole('ADMIN')") + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public CategoryResponseDTO.CategoryDetail create(@RequestBody @Valid CategoryRequestDTO.Create request) { + return categoryService.create(request); + } + + // 카테고리 수정 + @PreAuthorize("hasRole('ADMIN')") + @PatchMapping("/{categoryId}") + public CategoryResponseDTO.CategoryDetail update( + @PathVariable UUID categoryId, + @RequestBody @Valid CategoryRequestDTO.Update request + ) { + return categoryService.update(categoryId, request); + } + + // 카테고리 삭제(soft delete) + @PreAuthorize("hasRole('ADMIN')") + @DeleteMapping("/{categoryId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void delete(@PathVariable UUID categoryId) { + categoryService.delete(categoryId); + } +} diff --git a/src/main/java/com/example/Spot/store/presentation/dto/request/CategoryRequestDTO.java b/src/main/java/com/example/Spot/store/presentation/dto/request/CategoryRequestDTO.java new file mode 100644 index 00000000..f5fdce8e --- /dev/null +++ b/src/main/java/com/example/Spot/store/presentation/dto/request/CategoryRequestDTO.java @@ -0,0 +1,14 @@ +package com.example.Spot.store.presentation.dto.request; + +import jakarta.validation.constraints.NotBlank; + +public class CategoryRequestDTO { + + public record Create( + @NotBlank String name + ) {} + + public record Update( + @NotBlank String name + ) {} +} diff --git a/src/main/java/com/example/Spot/store/presentation/dto/response/CategoryResponseDTO.java b/src/main/java/com/example/Spot/store/presentation/dto/response/CategoryResponseDTO.java new file mode 100644 index 00000000..402379f7 --- /dev/null +++ b/src/main/java/com/example/Spot/store/presentation/dto/response/CategoryResponseDTO.java @@ -0,0 +1,26 @@ +package com.example.Spot.store.presentation.dto.response; + +import java.time.LocalTime; +import java.util.UUID; + +public class CategoryResponseDTO { + + public record CategoryItem( + UUID id, + String name + ) {} + + public record CategoryDetail( + UUID id, + String name + ) {} + + public record StoreSummary( + UUID id, + String name, + String address, + String phoneNumber, + LocalTime openTime, + LocalTime closeTime + ) {} +} From 67a1706bad8f16bf4b13e1ac51d3a882f643aa2e Mon Sep 17 00:00:00 2001 From: Yoonchul Chung <84674889+Yoonchulchung@users.noreply.github.com> Date: Wed, 7 Jan 2026 16:03:12 +0900 Subject: [PATCH 16/77] fix: resolve merge conflict with main branch (#135) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - main 브랜치와의 merge conflict 해결 - `deploy.yml`의 Dockerfile 경로를 `./Docker/Dockerfile`로 유지 Co-authored-by: yunjeong <99129298+dbswjd7@users.noreply.github.com> Co-authored-by: Yeojun <52143231+Yun024@users.noreply.github.com> Co-authored-by: first-lounge <137966925+first-lounge@users.noreply.github.com> From a15fb78cff0a3a0f62c23451fe49d560ba6c7446 Mon Sep 17 00:00:00 2001 From: first-lounge <137966925+first-lounge@users.noreply.github.com> Date: Thu, 8 Jan 2026 20:07:14 +0900 Subject: [PATCH 17/77] feat(#0): menu new (#160) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 메뉴 main 수정 --------- Co-authored-by: yoonchulchung Co-authored-by: dbswjd7 Co-authored-by: yeojun Co-authored-by: eqqmayo Co-authored-by: Yoonchul Chung <84674889+Yoonchulchung@users.noreply.github.com> Co-authored-by: Yeojun <52143231+Yun024@users.noreply.github.com> Co-authored-by: yunjeong <99129298+dbswjd7@users.noreply.github.com> Co-authored-by: eqqmayo <144116848+eqqmayo@users.noreply.github.com> --- .../controller/PaymentControllerTest.java | 705 ++++++++++++++++++ .../controller/TestSecurityConfig.java | 25 + 2 files changed, 730 insertions(+) create mode 100644 src/test/java/com/example/Spot/payments/presentation/controller/PaymentControllerTest.java create mode 100644 src/test/java/com/example/Spot/payments/presentation/controller/TestSecurityConfig.java diff --git a/src/test/java/com/example/Spot/payments/presentation/controller/PaymentControllerTest.java b/src/test/java/com/example/Spot/payments/presentation/controller/PaymentControllerTest.java new file mode 100644 index 00000000..22157a98 --- /dev/null +++ b/src/test/java/com/example/Spot/payments/presentation/controller/PaymentControllerTest.java @@ -0,0 +1,705 @@ +package com.example.Spot.payments.presentation.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willDoNothing; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +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.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.web.servlet.MockMvc; + +import com.example.Spot.infra.auth.security.CustomUserDetails; +import com.example.Spot.payments.application.service.PaymentService; +import com.example.Spot.payments.domain.entity.PaymentEntity.PaymentMethod; +import com.example.Spot.payments.presentation.dto.request.PaymentRequestDto; +import com.example.Spot.payments.presentation.dto.response.PaymentResponseDto; +import com.example.Spot.user.domain.Role; +import com.example.Spot.user.domain.entity.UserEntity; +import com.fasterxml.jackson.databind.ObjectMapper; + +@WebMvcTest(PaymentController.class) +@Import(TestSecurityConfig.class) +class PaymentControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockBean + private PaymentService paymentService; + + private UUID orderId; + private UUID paymentId; + private Integer userId; + + @BeforeEach + void setUp() { + orderId = UUID.randomUUID(); + paymentId = UUID.randomUUID(); + userId = 1; + } + + private CustomUserDetails createUserDetails(Role role) { + UserEntity user = UserEntity.forAuthentication(userId, role); + return new CustomUserDetails(user); + } + + private void setSecurityContext(Role role) { + CustomUserDetails userDetails = createUserDetails(role); + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + @Nested + @DisplayName("POST /api/payments/{order_id}/confirm - 결제 승인") + class ConfirmPaymentTest { + + @Test + @DisplayName("정상: CUSTOMER가 결제를 승인한다") + void CUSTOMER_결제_승인_성공() throws Exception { + setSecurityContext(Role.CUSTOMER); + + PaymentRequestDto.Confirm request = PaymentRequestDto.Confirm.builder() + .title("테스트 결제") + .content("테스트 내용") + .userId(userId) + .orderId(orderId) + .paymentMethod(PaymentMethod.CREDIT_CARD) + .paymentAmount(10000L) + .build(); + + PaymentResponseDto.Confirm response = PaymentResponseDto.Confirm.builder() + .paymentId(paymentId) + .status("DONE") + .amount(10000L) + .approvedAt(LocalDateTime.now()) + .build(); + + willDoNothing().given(paymentService).validateOrderOwnership(orderId, userId); + given(paymentService.preparePayment(any(PaymentRequestDto.Confirm.class))).willReturn(paymentId); + given(paymentService.executePaymentBilling(paymentId)).willReturn(response); + + mockMvc.perform(post("/api/payments/{order_id}/confirm", orderId) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.result.paymentId").value(paymentId.toString())) + .andExpect(jsonPath("$.result.status").value("DONE")) + .andExpect(jsonPath("$.result.amount").value(10000)); + + verify(paymentService, times(1)).validateOrderOwnership(orderId, userId); + verify(paymentService, times(1)).preparePayment(any(PaymentRequestDto.Confirm.class)); + verify(paymentService, times(1)).executePaymentBilling(paymentId); + } + + @Test + @DisplayName("정상: OWNER가 결제를 승인한다") + void OWNER_결제_승인_성공() throws Exception { + setSecurityContext(Role.OWNER); + + PaymentRequestDto.Confirm request = PaymentRequestDto.Confirm.builder() + .title("테스트 결제") + .content("테스트 내용") + .userId(userId) + .orderId(orderId) + .paymentMethod(PaymentMethod.CREDIT_CARD) + .paymentAmount(10000L) + .build(); + + PaymentResponseDto.Confirm response = PaymentResponseDto.Confirm.builder() + .paymentId(paymentId) + .status("DONE") + .amount(10000L) + .build(); + + willDoNothing().given(paymentService).validateOrderStoreOwnership(orderId, userId); + given(paymentService.preparePayment(any(PaymentRequestDto.Confirm.class))).willReturn(paymentId); + given(paymentService.executePaymentBilling(paymentId)).willReturn(response); + + mockMvc.perform(post("/api/payments/{order_id}/confirm", orderId) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)); + + verify(paymentService, times(1)).validateOrderStoreOwnership(orderId, userId); + } + + @Test + @DisplayName("정상: MANAGER가 결제를 승인한다 (소유권 검증 없음)") + void MANAGER_결제_승인_성공() throws Exception { + setSecurityContext(Role.MANAGER); + + PaymentRequestDto.Confirm request = PaymentRequestDto.Confirm.builder() + .title("테스트 결제") + .content("테스트 내용") + .userId(userId) + .orderId(orderId) + .paymentMethod(PaymentMethod.CREDIT_CARD) + .paymentAmount(10000L) + .build(); + + PaymentResponseDto.Confirm response = PaymentResponseDto.Confirm.builder() + .paymentId(paymentId) + .status("DONE") + .amount(10000L) + .build(); + + given(paymentService.preparePayment(any(PaymentRequestDto.Confirm.class))).willReturn(paymentId); + given(paymentService.executePaymentBilling(paymentId)).willReturn(response); + + mockMvc.perform(post("/api/payments/{order_id}/confirm", orderId) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)); + + verify(paymentService, never()).validateOrderOwnership(any(), any()); + verify(paymentService, never()).validateOrderStoreOwnership(any(), any()); + } + + @Test + @DisplayName("예외: 다른 사용자의 주문에 결제를 시도하면 실패한다") + void 소유권_없음_결제_승인_실패() throws Exception { + setSecurityContext(Role.CUSTOMER); + + PaymentRequestDto.Confirm request = PaymentRequestDto.Confirm.builder() + .title("테스트 결제") + .content("테스트 내용") + .userId(userId) + .orderId(orderId) + .paymentMethod(PaymentMethod.CREDIT_CARD) + .paymentAmount(10000L) + .build(); + + willThrow(new IllegalStateException("해당 주문에 대한 접근 권한이 없습니다.")) + .given(paymentService).validateOrderOwnership(orderId, userId); + + mockMvc.perform(post("/api/payments/{order_id}/confirm", orderId) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().is5xxServerError()); + + verify(paymentService, never()).preparePayment(any()); + } + + @Test + @DisplayName("정상: 결제 금액이 포함된 결제 승인 요청") + void 결제_금액_포함_승인_요청() throws Exception { + setSecurityContext(Role.CUSTOMER); + + PaymentRequestDto.Confirm request = PaymentRequestDto.Confirm.builder() + .title("고가 결제") + .content("프리미엄 상품") + .userId(userId) + .orderId(orderId) + .paymentMethod(PaymentMethod.BANK_TRANSFER) + .paymentAmount(1000000L) + .build(); + + PaymentResponseDto.Confirm response = PaymentResponseDto.Confirm.builder() + .paymentId(paymentId) + .status("DONE") + .amount(1000000L) + .build(); + + willDoNothing().given(paymentService).validateOrderOwnership(orderId, userId); + given(paymentService.preparePayment(any(PaymentRequestDto.Confirm.class))).willReturn(paymentId); + given(paymentService.executePaymentBilling(paymentId)).willReturn(response); + + mockMvc.perform(post("/api/payments/{order_id}/confirm", orderId) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result.amount").value(1000000)); + } + } + + @Nested + @DisplayName("POST /api/payments/{order_id}/cancel - 결제 취소") + class CancelPaymentTest { + + @Test + @DisplayName("정상: CUSTOMER가 결제를 취소한다") + void CUSTOMER_결제_취소_성공() throws Exception { + setSecurityContext(Role.CUSTOMER); + + PaymentRequestDto.Cancel request = PaymentRequestDto.Cancel.builder() + .paymentId(paymentId) + .cancelReason("고객 요청") + .build(); + + PaymentResponseDto.Cancel response = PaymentResponseDto.Cancel.builder() + .paymentId(paymentId) + .cancelAmount(10000L) + .cancelReason("고객 요청") + .canceledAt(LocalDateTime.now()) + .build(); + + willDoNothing().given(paymentService).validateOrderOwnership(orderId, userId); + given(paymentService.executeCancel(any(PaymentRequestDto.Cancel.class))).willReturn(response); + + mockMvc.perform(post("/api/payments/{order_id}/cancel", orderId) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.result.paymentId").value(paymentId.toString())) + .andExpect(jsonPath("$.result.cancelReason").value("고객 요청")); + + verify(paymentService, times(1)).executeCancel(any(PaymentRequestDto.Cancel.class)); + } + + @Test + @DisplayName("정상: MASTER가 결제를 취소한다 (소유권 검증 없음)") + void MASTER_결제_취소_성공() throws Exception { + setSecurityContext(Role.MASTER); + + PaymentRequestDto.Cancel request = PaymentRequestDto.Cancel.builder() + .paymentId(paymentId) + .cancelReason("관리자 취소") + .build(); + + PaymentResponseDto.Cancel response = PaymentResponseDto.Cancel.builder() + .paymentId(paymentId) + .cancelAmount(10000L) + .cancelReason("관리자 취소") + .canceledAt(LocalDateTime.now()) + .build(); + + given(paymentService.executeCancel(any(PaymentRequestDto.Cancel.class))).willReturn(response); + + mockMvc.perform(post("/api/payments/{order_id}/cancel", orderId) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)); + + verify(paymentService, never()).validateOrderOwnership(any(), any()); + } + + @Test + @DisplayName("정상: OWNER가 가게 주문의 결제를 취소한다") + void OWNER_결제_취소_성공() throws Exception { + setSecurityContext(Role.OWNER); + + PaymentRequestDto.Cancel request = PaymentRequestDto.Cancel.builder() + .paymentId(paymentId) + .cancelReason("가게 사정") + .build(); + + PaymentResponseDto.Cancel response = PaymentResponseDto.Cancel.builder() + .paymentId(paymentId) + .cancelAmount(15000L) + .cancelReason("가게 사정") + .canceledAt(LocalDateTime.now()) + .build(); + + willDoNothing().given(paymentService).validateOrderStoreOwnership(orderId, userId); + given(paymentService.executeCancel(any(PaymentRequestDto.Cancel.class))).willReturn(response); + + mockMvc.perform(post("/api/payments/{order_id}/cancel", orderId) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result.cancelAmount").value(15000)); + + verify(paymentService, times(1)).validateOrderStoreOwnership(orderId, userId); + } + } + + @Nested + @DisplayName("GET /api/payments - 전체 결제 목록 조회") + class GetAllPaymentTest { + + @Test + @DisplayName("정상: MANAGER가 전체 결제 목록을 조회한다") + void MANAGER_전체_결제_목록_조회_성공() throws Exception { + setSecurityContext(Role.MANAGER); + + List payments = java.util.Arrays.asList( + PaymentResponseDto.PaymentDetail.builder() + .paymentId(UUID.randomUUID()) + .title("결제1") + .content("내용1") + .paymentMethod(PaymentMethod.CREDIT_CARD) + .totalAmount(10000L) + .status("DONE") + .createdAt(LocalDateTime.now()) + .build(), + PaymentResponseDto.PaymentDetail.builder() + .paymentId(UUID.randomUUID()) + .title("결제2") + .content("내용2") + .paymentMethod(PaymentMethod.BANK_TRANSFER) + .totalAmount(20000L) + .status("READY") + .createdAt(LocalDateTime.now()) + .build() + ); + + PaymentResponseDto.PaymentList response = PaymentResponseDto.PaymentList.builder() + .payments(payments) + .totalCount(2) + .build(); + + given(paymentService.getAllPayment()).willReturn(response); + + mockMvc.perform(get("/api/payments")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.result.totalCount").value(2)) + .andExpect(jsonPath("$.result.payments").isArray()) + .andExpect(jsonPath("$.result.payments.length()").value(2)); + + verify(paymentService, times(1)).getAllPayment(); + } + + @Test + @DisplayName("정상: MASTER가 전체 결제 목록을 조회한다") + void MASTER_전체_결제_목록_조회_성공() throws Exception { + setSecurityContext(Role.MASTER); + + PaymentResponseDto.PaymentList response = PaymentResponseDto.PaymentList.builder() + .payments(java.util.Collections.emptyList()) + .totalCount(0) + .build(); + + given(paymentService.getAllPayment()).willReturn(response); + + mockMvc.perform(get("/api/payments")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.result.totalCount").value(0)); + } + + @Test + @DisplayName("정상: 결제 목록이 비어있을 때") + void 빈_결제_목록_조회() throws Exception { + setSecurityContext(Role.MANAGER); + + PaymentResponseDto.PaymentList response = PaymentResponseDto.PaymentList.builder() + .payments(java.util.Collections.emptyList()) + .totalCount(0) + .build(); + + given(paymentService.getAllPayment()).willReturn(response); + + mockMvc.perform(get("/api/payments")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result.payments").isArray()) + .andExpect(jsonPath("$.result.payments").isEmpty()); + } + } + + @Nested + @DisplayName("GET /api/payments/{paymentId} - 결제 상세 조회") + class GetDetailPaymentTest { + + @Test + @DisplayName("정상: CUSTOMER가 본인 결제 상세를 조회한다") + void CUSTOMER_결제_상세_조회_성공() throws Exception { + setSecurityContext(Role.CUSTOMER); + + PaymentResponseDto.PaymentDetail response = PaymentResponseDto.PaymentDetail.builder() + .paymentId(paymentId) + .title("테스트 결제") + .content("테스트 내용") + .paymentMethod(PaymentMethod.CREDIT_CARD) + .totalAmount(10000L) + .status("DONE") + .createdAt(LocalDateTime.now()) + .build(); + + willDoNothing().given(paymentService).validatePaymentOwnership(paymentId, userId); + given(paymentService.getDetailPayment(paymentId)).willReturn(response); + + mockMvc.perform(get("/api/payments/{paymentId}", paymentId)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.result.paymentId").value(paymentId.toString())) + .andExpect(jsonPath("$.result.title").value("테스트 결제")) + .andExpect(jsonPath("$.result.totalAmount").value(10000)); + + verify(paymentService, times(1)).validatePaymentOwnership(paymentId, userId); + } + + @Test + @DisplayName("정상: OWNER가 가게 결제 상세를 조회한다") + void OWNER_결제_상세_조회_성공() throws Exception { + setSecurityContext(Role.OWNER); + + PaymentResponseDto.PaymentDetail response = PaymentResponseDto.PaymentDetail.builder() + .paymentId(paymentId) + .title("가게 결제") + .content("가게 주문 내용") + .paymentMethod(PaymentMethod.CREDIT_CARD) + .totalAmount(25000L) + .status("DONE") + .createdAt(LocalDateTime.now()) + .build(); + + willDoNothing().given(paymentService).validatePaymentStoreOwnership(paymentId, userId); + given(paymentService.getDetailPayment(paymentId)).willReturn(response); + + mockMvc.perform(get("/api/payments/{paymentId}", paymentId)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.result.totalAmount").value(25000)); + + verify(paymentService, times(1)).validatePaymentStoreOwnership(paymentId, userId); + } + + @Test + @DisplayName("예외: 다른 사용자의 결제 상세 조회시 실패한다") + void 타인_결제_상세_조회_실패() throws Exception { + setSecurityContext(Role.CUSTOMER); + + willThrow(new IllegalStateException("해당 결제에 대한 접근 권한이 없습니다.")) + .given(paymentService).validatePaymentOwnership(paymentId, userId); + + mockMvc.perform(get("/api/payments/{paymentId}", paymentId)) + .andDo(print()) + .andExpect(status().is5xxServerError()); + + verify(paymentService, never()).getDetailPayment(any()); + } + + @Test + @DisplayName("정상: MANAGER가 모든 결제 상세를 조회한다") + void MANAGER_결제_상세_조회_성공() throws Exception { + setSecurityContext(Role.MANAGER); + + PaymentResponseDto.PaymentDetail response = PaymentResponseDto.PaymentDetail.builder() + .paymentId(paymentId) + .title("관리자 조회") + .content("관리자가 조회하는 결제") + .paymentMethod(PaymentMethod.CREDIT_CARD) + .totalAmount(50000L) + .status("DONE") + .createdAt(LocalDateTime.now()) + .build(); + + given(paymentService.getDetailPayment(paymentId)).willReturn(response); + + mockMvc.perform(get("/api/payments/{paymentId}", paymentId)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)); + + verify(paymentService, never()).validatePaymentOwnership(any(), any()); + verify(paymentService, never()).validatePaymentStoreOwnership(any(), any()); + } + } + + @Nested + @DisplayName("GET /api/payments/cancel - 전체 취소 목록 조회") + class GetAllPaymentCancelTest { + + @Test + @DisplayName("정상: MANAGER가 전체 취소 목록을 조회한다") + void MANAGER_전체_취소_목록_조회_성공() throws Exception { + setSecurityContext(Role.MANAGER); + + List cancellations = java.util.Collections.singletonList( + PaymentResponseDto.CancelDetail.builder() + .cancelId(UUID.randomUUID()) + .paymentId(UUID.randomUUID()) + .cancelAmount(10000L) + .cancelReason("고객 요청") + .canceledAt(LocalDateTime.now()) + .build() + ); + + PaymentResponseDto.CancelList response = PaymentResponseDto.CancelList.builder() + .cancellations(cancellations) + .totalCount(1) + .build(); + + given(paymentService.getAllPaymentCancel()).willReturn(response); + + mockMvc.perform(get("/api/payments/cancel")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.result.totalCount").value(1)); + + verify(paymentService, times(1)).getAllPaymentCancel(); + } + + @Test + @DisplayName("정상: MASTER가 전체 취소 목록을 조회한다") + void MASTER_전체_취소_목록_조회_성공() throws Exception { + setSecurityContext(Role.MASTER); + + List cancellations = java.util.Arrays.asList( + PaymentResponseDto.CancelDetail.builder() + .cancelId(UUID.randomUUID()) + .paymentId(UUID.randomUUID()) + .cancelAmount(20000L) + .cancelReason("관리자 취소") + .canceledAt(LocalDateTime.now()) + .build(), + PaymentResponseDto.CancelDetail.builder() + .cancelId(UUID.randomUUID()) + .paymentId(UUID.randomUUID()) + .cancelAmount(15000L) + .cancelReason("고객 요청") + .canceledAt(LocalDateTime.now()) + .build() + ); + + PaymentResponseDto.CancelList response = PaymentResponseDto.CancelList.builder() + .cancellations(cancellations) + .totalCount(2) + .build(); + + given(paymentService.getAllPaymentCancel()).willReturn(response); + + mockMvc.perform(get("/api/payments/cancel")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result.totalCount").value(2)) + .andExpect(jsonPath("$.result.cancellations.length()").value(2)); + } + } + + @Nested + @DisplayName("GET /api/payments/{paymentId}/cancel - 특정 결제 취소 내역 조회") + class GetDetailPaymentCancelTest { + + @Test + @DisplayName("정상: CUSTOMER가 본인 결제의 취소 내역을 조회한다") + void CUSTOMER_결제_취소_내역_조회_성공() throws Exception { + setSecurityContext(Role.CUSTOMER); + + List cancellations = java.util.Collections.singletonList( + PaymentResponseDto.CancelDetail.builder() + .cancelId(UUID.randomUUID()) + .paymentId(paymentId) + .cancelAmount(10000L) + .cancelReason("고객 요청") + .canceledAt(LocalDateTime.now()) + .build() + ); + + PaymentResponseDto.CancelList response = PaymentResponseDto.CancelList.builder() + .cancellations(cancellations) + .totalCount(1) + .build(); + + willDoNothing().given(paymentService).validatePaymentOwnership(paymentId, userId); + given(paymentService.getDetailPaymentCancel(paymentId)).willReturn(response); + + mockMvc.perform(get("/api/payments/{paymentId}/cancel", paymentId)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.result.totalCount").value(1)) + .andExpect(jsonPath("$.result.cancellations[0].paymentId").value(paymentId.toString())); + + verify(paymentService, times(1)).validatePaymentOwnership(paymentId, userId); + verify(paymentService, times(1)).getDetailPaymentCancel(paymentId); + } + + @Test + @DisplayName("정상: MASTER가 모든 결제의 취소 내역을 조회한다") + void MASTER_결제_취소_내역_조회_성공() throws Exception { + setSecurityContext(Role.MASTER); + + PaymentResponseDto.CancelList response = PaymentResponseDto.CancelList.builder() + .cancellations(java.util.Collections.emptyList()) + .totalCount(0) + .build(); + + given(paymentService.getDetailPaymentCancel(paymentId)).willReturn(response); + + mockMvc.perform(get("/api/payments/{paymentId}/cancel", paymentId)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)); + + verify(paymentService, never()).validatePaymentOwnership(any(), any()); + verify(paymentService, times(1)).getDetailPaymentCancel(paymentId); + } + + @Test + @DisplayName("정상: OWNER가 가게 결제의 취소 내역을 조회한다") + void OWNER_결제_취소_내역_조회_성공() throws Exception { + setSecurityContext(Role.OWNER); + + List cancellations = java.util.Collections.singletonList( + PaymentResponseDto.CancelDetail.builder() + .cancelId(UUID.randomUUID()) + .paymentId(paymentId) + .cancelAmount(30000L) + .cancelReason("재고 부족") + .canceledAt(LocalDateTime.now()) + .build() + ); + + PaymentResponseDto.CancelList response = PaymentResponseDto.CancelList.builder() + .cancellations(cancellations) + .totalCount(1) + .build(); + + willDoNothing().given(paymentService).validatePaymentStoreOwnership(paymentId, userId); + given(paymentService.getDetailPaymentCancel(paymentId)).willReturn(response); + + mockMvc.perform(get("/api/payments/{paymentId}/cancel", paymentId)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result.cancellations[0].cancelReason").value("재고 부족")); + + verify(paymentService, times(1)).validatePaymentStoreOwnership(paymentId, userId); + } + } +} diff --git a/src/test/java/com/example/Spot/payments/presentation/controller/TestSecurityConfig.java b/src/test/java/com/example/Spot/payments/presentation/controller/TestSecurityConfig.java new file mode 100644 index 00000000..8f537e46 --- /dev/null +++ b/src/test/java/com/example/Spot/payments/presentation/controller/TestSecurityConfig.java @@ -0,0 +1,25 @@ +package com.example.Spot.payments.presentation.controller; + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.web.SecurityFilterChain; + +@TestConfiguration +@EnableWebSecurity +@EnableMethodSecurity(prePostEnabled = true) +public class TestSecurityConfig { + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .authorizeHttpRequests(auth -> auth + .anyRequest().authenticated() + ); + return http.build(); + } +} From ca51414d5034397e0b8a46f039a3188903694877 Mon Sep 17 00:00:00 2001 From: Yoonchul Chung <84674889+Yoonchulchung@users.noreply.github.com> Date: Fri, 9 Jan 2026 21:45:00 +0900 Subject: [PATCH 18/77] Revert "fix(#173):pr-alert" (#184) Reverts spot-ticket/Spot#181 From 59c848d43b5122794edbdf2b0cc5a4be2327b994 Mon Sep 17 00:00:00 2001 From: Yeojun <52143231+Yun024@users.noreply.github.com> Date: Fri, 9 Jan 2026 21:45:35 +0900 Subject: [PATCH 19/77] Revert "fix(#173):pr-alert-check" (#185) Reverts spot-ticket/Spot#179 From cab3898817680c4e4474199e0e9bc94993cf29ac Mon Sep 17 00:00:00 2001 From: Yeojun <52143231+Yun024@users.noreply.github.com> Date: Fri, 9 Jan 2026 22:03:13 +0900 Subject: [PATCH 20/77] Revert "Enhance PR workflow with Discord notifications" (#188) Reverts spot-ticket/Spot#174 From 6e8f7ae9b985628958cb8722d06497ba6f294fc4 Mon Sep 17 00:00:00 2001 From: Yoonchul Chung <84674889+Yoonchulchung@users.noreply.github.com> Date: Sun, 11 Jan 2026 00:48:27 +0900 Subject: [PATCH 21/77] =?UTF-8?q?fix(#199)=20backend=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=EC=88=98=EC=A0=95=20(#200)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FE와 연동하는 과정에서 조회, 생성 과정에서 오류가 있어 해결했습니다. --- .../service/AdminStatsService.java | 109 ++++++++++++++++++ .../service/AdminStoreService.java | 43 +++++++ .../application/service/AdminUserService.java | 54 +++++++++ .../controller/AdminStatsController.java | 31 +++++ .../controller/AdminStoreController.java | 69 +++++++++++ .../controller/AdminUserController.java | 66 +++++++++++ .../dto/request/UserRoleUpdateRequestDto.java | 14 +++ .../dto/response/AdminStatsResponseDto.java | 40 +++++++ src/test/resources/.keep | 0 9 files changed, 426 insertions(+) create mode 100644 src/main/java/com/example/Spot/admin/application/service/AdminStatsService.java create mode 100644 src/main/java/com/example/Spot/admin/application/service/AdminStoreService.java create mode 100644 src/main/java/com/example/Spot/admin/application/service/AdminUserService.java create mode 100644 src/main/java/com/example/Spot/admin/presentation/controller/AdminStatsController.java create mode 100644 src/main/java/com/example/Spot/admin/presentation/controller/AdminStoreController.java create mode 100644 src/main/java/com/example/Spot/admin/presentation/controller/AdminUserController.java create mode 100644 src/main/java/com/example/Spot/admin/presentation/dto/request/UserRoleUpdateRequestDto.java create mode 100644 src/main/java/com/example/Spot/admin/presentation/dto/response/AdminStatsResponseDto.java create mode 100644 src/test/resources/.keep diff --git a/src/main/java/com/example/Spot/admin/application/service/AdminStatsService.java b/src/main/java/com/example/Spot/admin/application/service/AdminStatsService.java new file mode 100644 index 00000000..d5face8f --- /dev/null +++ b/src/main/java/com/example/Spot/admin/application/service/AdminStatsService.java @@ -0,0 +1,109 @@ +package com.example.Spot.admin.application.service; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.example.Spot.admin.presentation.dto.response.AdminStatsResponseDto; +import com.example.Spot.order.domain.entity.OrderEntity; +import com.example.Spot.order.domain.repository.OrderRepository; +import com.example.Spot.order.presentation.dto.response.OrderResponseDto; +import com.example.Spot.store.domain.repository.StoreRepository; +import com.example.Spot.user.domain.repository.UserRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AdminStatsService { + + private final UserRepository userRepository; + private final OrderRepository orderRepository; + private final StoreRepository storeRepository; + + public AdminStatsResponseDto getStats() { + // 전체 사용자 수 + Long totalUsers = userRepository.count(); + + // 전체 주문 수 + Long totalOrders = orderRepository.count(); + + // 전체 가게 수 + Long totalStores = storeRepository.count(); + + // 총 매출 (모든 완료된 주문의 총합) + List completedOrders = orderRepository.findAll(); + BigDecimal totalRevenue = completedOrders.stream() + .flatMap(order -> order.getOrderItems().stream()) + .map(item -> item.getMenuPrice().multiply(BigDecimal.valueOf(item.getQuantity()))) + .reduce(BigDecimal.ZERO, BigDecimal::add); + + // 최근 주문 10개 + Pageable recentPageable = PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "createdAt")); + List recentOrders = orderRepository.findAll(recentPageable) + .stream() + .map(OrderResponseDto::from) + .collect(Collectors.toList()); + + // 사용자 증가 통계 (최근 7일) + List userGrowth = calculateUserGrowth(7); + + // 주문 상태별 통계 + List orderStats = calculateOrderStatusStats(); + + return AdminStatsResponseDto.builder() + .totalUsers(totalUsers) + .totalOrders(totalOrders) + .totalStores(totalStores) + .totalRevenue(totalRevenue) + .recentOrders(recentOrders) + .userGrowth(userGrowth) + .orderStats(orderStats) + .build(); + } + + private List calculateUserGrowth(int days) { + List growth = new ArrayList<>(); + LocalDateTime now = LocalDateTime.now(); + + for (int i = days - 1; i >= 0; i--) { + LocalDate date = now.minusDays(i).toLocalDate(); + + // 간단히 날짜별 사용자 수를 0으로 설정 (실제 구현 시 적절한 쿼리 필요) + Long count = 0L; + growth.add(AdminStatsResponseDto.UserGrowthDto.builder() + .date(date.toString()) + .count(count) + .build()); + } + + return growth; + } + + private List calculateOrderStatusStats() { + List orders = orderRepository.findAll(); + Map statusCounts = orders.stream() + .collect(Collectors.groupingBy( + order -> order.getOrderStatus().name(), + Collectors.counting() + )); + + return statusCounts.entrySet().stream() + .map(entry -> AdminStatsResponseDto.OrderStatusStatsDto.builder() + .status(entry.getKey()) + .count(entry.getValue()) + .build()) + .collect(Collectors.toList()); + } +} diff --git a/src/main/java/com/example/Spot/admin/application/service/AdminStoreService.java b/src/main/java/com/example/Spot/admin/application/service/AdminStoreService.java new file mode 100644 index 00000000..2b2ee2a9 --- /dev/null +++ b/src/main/java/com/example/Spot/admin/application/service/AdminStoreService.java @@ -0,0 +1,43 @@ +package com.example.Spot.admin.application.service; + +import java.util.UUID; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.example.Spot.store.domain.StoreStatus; +import com.example.Spot.store.domain.entity.StoreEntity; +import com.example.Spot.store.domain.repository.StoreRepository; +import com.example.Spot.store.presentation.dto.response.StoreListResponse; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AdminStoreService { + + private final StoreRepository storeRepository; + + public Page getAllStores(Pageable pageable) { + Page stores = storeRepository.findAll(pageable); + return stores.map(StoreListResponse::fromEntity); + } + + @Transactional + public void approveStore(UUID storeId) { + StoreEntity store = storeRepository.findById(storeId) + .orElseThrow(() -> new IllegalArgumentException("가게를 찾을 수 없습니다.")); + // 가게 승인 로직 (필요시 승인 상태 필드 추가) + store.updateStatus(StoreStatus.APPROVED); + } + + @Transactional + public void deleteStore(UUID storeId, Integer userId) { + StoreEntity store = storeRepository.findById(storeId) + .orElseThrow(() -> new IllegalArgumentException("가게를 찾을 수 없습니다.")); + store.softDelete(userId); + } +} diff --git a/src/main/java/com/example/Spot/admin/application/service/AdminUserService.java b/src/main/java/com/example/Spot/admin/application/service/AdminUserService.java new file mode 100644 index 00000000..9449b031 --- /dev/null +++ b/src/main/java/com/example/Spot/admin/application/service/AdminUserService.java @@ -0,0 +1,54 @@ +package com.example.Spot.admin.application.service; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.example.Spot.user.domain.Role; +import com.example.Spot.user.domain.entity.UserEntity; +import com.example.Spot.user.domain.repository.UserRepository; +import com.example.Spot.user.presentation.dto.response.UserResponseDTO; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AdminUserService { + + private final UserRepository userRepository; + + public Page getAllUsers(Pageable pageable) { + Page users = userRepository.findAll(pageable); + return users.map(this::toResponse); + } + + @Transactional + public void updateUserRole(Integer userId, Role role) { + UserEntity user = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다.")); + user.setRole(role); + } + + @Transactional + public void deleteUser(Integer userId) { + UserEntity user = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다.")); + userRepository.delete(user); + } + + private UserResponseDTO toResponse(UserEntity user) { + return new UserResponseDTO( + user.getId(), + user.getUsername(), + user.getRole(), + user.getNickname(), + user.getEmail(), + user.getRoadAddress(), + user.getAddressDetail(), + user.getAge(), + user.isMale() + ); + } +} diff --git a/src/main/java/com/example/Spot/admin/presentation/controller/AdminStatsController.java b/src/main/java/com/example/Spot/admin/presentation/controller/AdminStatsController.java new file mode 100644 index 00000000..adbaace2 --- /dev/null +++ b/src/main/java/com/example/Spot/admin/presentation/controller/AdminStatsController.java @@ -0,0 +1,31 @@ +package com.example.Spot.admin.presentation.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.example.Spot.admin.application.service.AdminStatsService; +import com.example.Spot.admin.presentation.dto.response.AdminStatsResponseDto; +import com.example.Spot.global.presentation.ApiResponse; +import com.example.Spot.global.presentation.code.GeneralSuccessCode; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/api/admin/stats") +@RequiredArgsConstructor +@PreAuthorize("hasAnyRole('MASTER', 'MANAGER')") +public class AdminStatsController { + + private final AdminStatsService adminStatsService; + + @GetMapping + public ResponseEntity> getStats() { + AdminStatsResponseDto stats = adminStatsService.getStats(); + + return ResponseEntity + .ok(ApiResponse.onSuccess(GeneralSuccessCode.GOOD_REQUEST, stats)); + } +} diff --git a/src/main/java/com/example/Spot/admin/presentation/controller/AdminStoreController.java b/src/main/java/com/example/Spot/admin/presentation/controller/AdminStoreController.java new file mode 100644 index 00000000..7110833e --- /dev/null +++ b/src/main/java/com/example/Spot/admin/presentation/controller/AdminStoreController.java @@ -0,0 +1,69 @@ +package com.example.Spot.admin.presentation.controller; + +import java.util.UUID; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.example.Spot.admin.application.service.AdminStoreService; +import com.example.Spot.global.presentation.ApiResponse; +import com.example.Spot.global.presentation.code.GeneralSuccessCode; +import com.example.Spot.infra.auth.security.CustomUserDetails; +import com.example.Spot.store.presentation.dto.response.StoreListResponse; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/api/admin/stores") +@RequiredArgsConstructor +@PreAuthorize("hasAnyRole('MASTER', 'MANAGER')") +public class AdminStoreController { + + private final AdminStoreService adminStoreService; + + @GetMapping + public ResponseEntity>> getAllStores( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + @RequestParam(defaultValue = "name") String sortBy, + @RequestParam(defaultValue = "ASC") Sort.Direction direction) { + + Pageable pageable = PageRequest.of(page, size, Sort.by(direction, sortBy)); + Page stores = adminStoreService.getAllStores(pageable); + + return ResponseEntity + .ok(ApiResponse.onSuccess(GeneralSuccessCode.GOOD_REQUEST, stores)); + } + + @PatchMapping("/{storeId}/approve") + public ResponseEntity> approveStore(@PathVariable UUID storeId) { + adminStoreService.approveStore(storeId); + + return ResponseEntity + .ok(ApiResponse.onSuccess(GeneralSuccessCode.GOOD_REQUEST, null)); + } + + @DeleteMapping("/{storeId}") + public ResponseEntity> deleteStore( + @PathVariable UUID storeId, + @AuthenticationPrincipal CustomUserDetails principal) { + + // To Do: 관리자 페이지에서 삭제를 했을 때, 삭제된 것들에 대한 조치가 없음. 해결할 것. + adminStoreService.deleteStore(storeId, principal.getUserId()); + + return ResponseEntity + .ok(ApiResponse.onSuccess(GeneralSuccessCode.GOOD_REQUEST, null)); + } +} diff --git a/src/main/java/com/example/Spot/admin/presentation/controller/AdminUserController.java b/src/main/java/com/example/Spot/admin/presentation/controller/AdminUserController.java new file mode 100644 index 00000000..caa32ed0 --- /dev/null +++ b/src/main/java/com/example/Spot/admin/presentation/controller/AdminUserController.java @@ -0,0 +1,66 @@ +package com.example.Spot.admin.presentation.controller; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.example.Spot.admin.application.service.AdminUserService; +import com.example.Spot.admin.presentation.dto.request.UserRoleUpdateRequestDto; +import com.example.Spot.global.presentation.ApiResponse; +import com.example.Spot.global.presentation.code.GeneralSuccessCode; +import com.example.Spot.user.presentation.dto.response.UserResponseDTO; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/api/admin/users") +@RequiredArgsConstructor +@PreAuthorize("hasAnyRole('MASTER', 'MANAGER')") +public class AdminUserController { + + private final AdminUserService adminUserService; + + @GetMapping + public ResponseEntity>> getAllUsers( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + @RequestParam(defaultValue = "id") String sortBy, + @RequestParam(defaultValue = "ASC") Sort.Direction direction) { + + Pageable pageable = PageRequest.of(page, size, Sort.by(direction, sortBy)); + Page users = adminUserService.getAllUsers(pageable); + + return ResponseEntity + .ok(ApiResponse.onSuccess(GeneralSuccessCode.GOOD_REQUEST, users)); + } + + @PatchMapping("/{userId}/role") + public ResponseEntity> updateUserRole( + @PathVariable Integer userId, + @RequestBody UserRoleUpdateRequestDto request) { + + adminUserService.updateUserRole(userId, request.getRole()); + + return ResponseEntity + .ok(ApiResponse.onSuccess(GeneralSuccessCode.GOOD_REQUEST, null)); + } + + @DeleteMapping("/{userId}") + public ResponseEntity> deleteUser(@PathVariable Integer userId) { + adminUserService.deleteUser(userId); + + return ResponseEntity + .ok(ApiResponse.onSuccess(GeneralSuccessCode.GOOD_REQUEST, null)); + } +} diff --git a/src/main/java/com/example/Spot/admin/presentation/dto/request/UserRoleUpdateRequestDto.java b/src/main/java/com/example/Spot/admin/presentation/dto/request/UserRoleUpdateRequestDto.java new file mode 100644 index 00000000..7228c056 --- /dev/null +++ b/src/main/java/com/example/Spot/admin/presentation/dto/request/UserRoleUpdateRequestDto.java @@ -0,0 +1,14 @@ +package com.example.Spot.admin.presentation.dto.request; + +import com.example.Spot.user.domain.Role; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class UserRoleUpdateRequestDto { + private Role role; +} diff --git a/src/main/java/com/example/Spot/admin/presentation/dto/response/AdminStatsResponseDto.java b/src/main/java/com/example/Spot/admin/presentation/dto/response/AdminStatsResponseDto.java new file mode 100644 index 00000000..158d7236 --- /dev/null +++ b/src/main/java/com/example/Spot/admin/presentation/dto/response/AdminStatsResponseDto.java @@ -0,0 +1,40 @@ +package com.example.Spot.admin.presentation.dto.response; + +import java.math.BigDecimal; +import java.util.List; + +import com.example.Spot.order.presentation.dto.response.OrderResponseDto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@AllArgsConstructor +public class AdminStatsResponseDto { + + private Long totalUsers; + private Long totalOrders; + private Long totalStores; + private BigDecimal totalRevenue; + private List recentOrders; + private List userGrowth; + private List orderStats; + + @Getter + @Builder + @AllArgsConstructor + public static class UserGrowthDto { + private String date; + private Long count; + } + + @Getter + @Builder + @AllArgsConstructor + public static class OrderStatusStatsDto { + private String status; + private Long count; + } +} diff --git a/src/test/resources/.keep b/src/test/resources/.keep new file mode 100644 index 00000000..e69de29b From f27004fef6c517b2d118860f6024b98dbf292e1e Mon Sep 17 00:00:00 2001 From: first-lounge <137966925+first-lounge@users.noreply.github.com> Date: Sun, 11 Jan 2026 21:40:25 +0900 Subject: [PATCH 22/77] fix(#165): menu conflict (#201) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 메뉴 Service 로직 수정 메뉴 및 메뉴 옵션 DTO 수정 메뉴 및 메뉴 옵션 업데이트 관련 DTO 파일 삭제 메뉴 Controller 수정 메뉴 및 메뉴 옵션 Repository 수정 메뉴 api 수정 --------- Co-authored-by: yoonchulchung Co-authored-by: Yoonchul Chung <84674889+Yoonchulchung@users.noreply.github.com> --- .../dto/response/CreateMenuResponseDto.java | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/spot-mono/src/main/java/com/example/Spot/menu/presentation/dto/response/CreateMenuResponseDto.java b/spot-mono/src/main/java/com/example/Spot/menu/presentation/dto/response/CreateMenuResponseDto.java index e5d9bb1f..3cc40283 100644 --- a/spot-mono/src/main/java/com/example/Spot/menu/presentation/dto/response/CreateMenuResponseDto.java +++ b/spot-mono/src/main/java/com/example/Spot/menu/presentation/dto/response/CreateMenuResponseDto.java @@ -4,15 +4,24 @@ import java.util.UUID; import com.example.Spot.menu.domain.entity.MenuEntity; +import com.fasterxml.jackson.annotation.JsonProperty; public record CreateMenuResponseDto( - + @JsonProperty("menu_id") UUID id, + String name, + String category, + Integer price, + String description, + + @JsonProperty("image_url") String imageUrl, + + @JsonProperty("created_at") LocalDateTime createdAt ) { // Entity를 DTO로 변환하는 생성자 From 674677875a40928c0ea75986091c3d1475e197e9 Mon Sep 17 00:00:00 2001 From: yunjeong <99129298+dbswjd7@users.noreply.github.com> Date: Tue, 20 Jan 2026 11:11:33 +0900 Subject: [PATCH 23/77] =?UTF-8?q?Chore(#212):=20UserEntity=EC=97=90?= =?UTF-8?q?=EC=84=9C=20Store=20=EC=A1=B0=EC=9D=B8=20=ED=95=B4=EC=A0=9C=20(?= =?UTF-8?q?#214)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UserEntity에서 Store 조인 해제 --------- Co-authored-by: Yoonchul Chung <84674889+Yoonchulchung@users.noreply.github.com> From c2a953d686c63b1b319003900015a77b6f85d868 Mon Sep 17 00:00:00 2001 From: dbswjd7 Date: Fri, 9 Jan 2026 18:23:11 +0900 Subject: [PATCH 24/77] =?UTF-8?q?feat(#169):=20menucontroller,=20menuoptio?= =?UTF-8?q?ncontroller,=20paymentcontroller=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../example/Spot/infra/auth/security/CustomUserDetails.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/spot-mono/src/main/java/com/example/Spot/infra/auth/security/CustomUserDetails.java b/spot-mono/src/main/java/com/example/Spot/infra/auth/security/CustomUserDetails.java index cb85d80b..a463f485 100644 --- a/spot-mono/src/main/java/com/example/Spot/infra/auth/security/CustomUserDetails.java +++ b/spot-mono/src/main/java/com/example/Spot/infra/auth/security/CustomUserDetails.java @@ -79,7 +79,8 @@ public boolean isEnabled() { return true; } - public Integer getUserId() { + public Integer getUserId() + { return userEntity.getId(); } From f9a09d55789e8e7480482d0b5b4db46692df2f7b Mon Sep 17 00:00:00 2001 From: dbswjd7 Date: Fri, 9 Jan 2026 18:30:38 +0900 Subject: [PATCH 25/77] =?UTF-8?q?feat(#169):=20menu,=20payment=20controlle?= =?UTF-8?q?r=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../example/Spot/infra/auth/security/CustomUserDetails.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/spot-mono/src/main/java/com/example/Spot/infra/auth/security/CustomUserDetails.java b/spot-mono/src/main/java/com/example/Spot/infra/auth/security/CustomUserDetails.java index a463f485..cb85d80b 100644 --- a/spot-mono/src/main/java/com/example/Spot/infra/auth/security/CustomUserDetails.java +++ b/spot-mono/src/main/java/com/example/Spot/infra/auth/security/CustomUserDetails.java @@ -79,8 +79,7 @@ public boolean isEnabled() { return true; } - public Integer getUserId() - { + public Integer getUserId() { return userEntity.getId(); } From 4b94653e9f457e36cad16512ba7c322be8036291 Mon Sep 17 00:00:00 2001 From: dbswjd7 Date: Tue, 20 Jan 2026 14:51:46 +0900 Subject: [PATCH 26/77] =?UTF-8?q?feat(#212):=20openfeign=20=EA=B8=B0?= =?UTF-8?q?=EB=B3=B8=ED=8C=8C=EC=9D=BC=20=EC=83=9D=EC=84=B1=20=EB=B0=8F=20?= =?UTF-8?q?resilience4j=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- spot-mono/build.gradle | 22 +++++++- .../com/example/Spot/SpotApplication.java | 4 +- .../client/feign/FeignCommonConfig.java | 24 +++++++++ .../client/feign/RemoteErrorDecoder.java | 51 +++++++++++++++++++ .../client/feign/RequestIdInterceptor.java | 21 ++++++++ .../exception/RemoteCallFailedException.java | 7 +++ .../exception/RemoteConflictException.java | 7 +++ .../exception/RemoteNotFoundException.java | 7 +++ .../RemoteServiceUnavailableException.java | 7 +++ 9 files changed, 146 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com/example/Spot/global/infrastructure/client/feign/FeignCommonConfig.java create mode 100644 src/main/java/com/example/Spot/global/infrastructure/client/feign/RemoteErrorDecoder.java create mode 100644 src/main/java/com/example/Spot/global/infrastructure/client/feign/RequestIdInterceptor.java create mode 100644 src/main/java/com/example/Spot/global/infrastructure/client/feign/exception/RemoteCallFailedException.java create mode 100644 src/main/java/com/example/Spot/global/infrastructure/client/feign/exception/RemoteConflictException.java create mode 100644 src/main/java/com/example/Spot/global/infrastructure/client/feign/exception/RemoteNotFoundException.java create mode 100644 src/main/java/com/example/Spot/global/infrastructure/client/feign/exception/RemoteServiceUnavailableException.java diff --git a/spot-mono/build.gradle b/spot-mono/build.gradle index 7e0c49c8..7e9483b7 100644 --- a/spot-mono/build.gradle +++ b/spot-mono/build.gradle @@ -15,6 +15,16 @@ java { } } +// spring cloud +ext { + set('springCloudVersion', "2025.0.1") +} +dependencyManagement { + imports { + mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}" + } +} + repositories { mavenCentral() } @@ -49,8 +59,16 @@ dependencies { implementation 'io.jsonwebtoken:jjwt-impl:0.12.6' implementation 'io.jsonwebtoken:jjwt-jackson:0.12.6' - // postgreSQL - implementation 'org.postgresql:postgresql' + + // openFeign + implementation 'org.springframework.cloud:spring-cloud-starter-openfeign' + // resilience4J + implementation 'io.github.resilience4j:resilience4j-spring-boot3:2.2.0' + implementation 'io.github.resilience4j:resilience4j-timelimiter:2.2.0' + implementation 'io.github.resilience4j:resilience4j-circuitbreaker:2.2.0' + implementation 'io.github.resilience4j:resilience4j-bulkhead:2.2.0' + implementation 'io.github.resilience4j:resilience4j-retry:2.2.0' + implementation 'io.github.resilience4j:resilience4j-ratelimiter:2.2.0' } tasks.named('test') { diff --git a/spot-mono/src/main/java/com/example/Spot/SpotApplication.java b/spot-mono/src/main/java/com/example/Spot/SpotApplication.java index e255fe8b..88434182 100644 --- a/spot-mono/src/main/java/com/example/Spot/SpotApplication.java +++ b/spot-mono/src/main/java/com/example/Spot/SpotApplication.java @@ -2,9 +2,9 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cloud.openfeign.EnableFeignClients; -@EnableCaching +@EnableFeignClients @SpringBootApplication public class SpotApplication { diff --git a/src/main/java/com/example/Spot/global/infrastructure/client/feign/FeignCommonConfig.java b/src/main/java/com/example/Spot/global/infrastructure/client/feign/FeignCommonConfig.java new file mode 100644 index 00000000..b8947437 --- /dev/null +++ b/src/main/java/com/example/Spot/global/infrastructure/client/feign/FeignCommonConfig.java @@ -0,0 +1,24 @@ +package com.example.Spot.global.infrastructure.client.feign; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import feign.codec.ErrorDecoder; + +@Configuration +public class FeignCommonConfig { + + @Bean + public RequestIdInterceptor requestIdInterceptor() { + return new RequestIdInterceptor(); + } + + @Bean + public ErrorDecoder errorDecoder() { + return new RemoteErrorDecoder(); + } + + + + +} diff --git a/src/main/java/com/example/Spot/global/infrastructure/client/feign/RemoteErrorDecoder.java b/src/main/java/com/example/Spot/global/infrastructure/client/feign/RemoteErrorDecoder.java new file mode 100644 index 00000000..09d25e84 --- /dev/null +++ b/src/main/java/com/example/Spot/global/infrastructure/client/feign/RemoteErrorDecoder.java @@ -0,0 +1,51 @@ +package com.example.Spot.global.infrastructure.client.feign; + +import com.example.Spot.global.infrastructure.client.feign.exception.RemoteCallFailedException; +import com.example.Spot.global.infrastructure.client.feign.exception.RemoteConflictException; +import com.example.Spot.global.infrastructure.client.feign.exception.RemoteNotFoundException; +import com.example.Spot.global.infrastructure.client.feign.exception.RemoteServiceUnavailableException; +import feign.Response; +import feign.Util; +import feign.codec.ErrorDecoder; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +public class RemoteErrorDecoder implements ErrorDecoder { + private static final int MAX_BODY_CHARS = 2000; + + @Override + public Exception decode(String methodKey, Response response) { + int status = response.status(); + + if (status == 404) { + return new RemoteNotFoundException(methodKey); + } + if (status == 409) { + return new RemoteConflictException(methodKey); + } + if (status >= 500) { + return new RemoteServiceUnavailableException(methodKey); + } + return new RemoteCallFailedException(methodKey, status); + } + + // 디버깅: 로그용 (로직추가필요) + private String readBody(Response response) { + if (response == null || response.body() == null) { + return null; + } + try { + String raw = Util.toString(response.body().asReader(StandardCharsets.UTF_8)); + if (raw == null) { + return null; + } + if (raw.length() <= MAX_BODY_CHARS) { + return raw; + } + return raw.substring(0, MAX_BODY_CHARS); + } catch (IOException e) { + return null; + } + } +} diff --git a/src/main/java/com/example/Spot/global/infrastructure/client/feign/RequestIdInterceptor.java b/src/main/java/com/example/Spot/global/infrastructure/client/feign/RequestIdInterceptor.java new file mode 100644 index 00000000..c98d32e5 --- /dev/null +++ b/src/main/java/com/example/Spot/global/infrastructure/client/feign/RequestIdInterceptor.java @@ -0,0 +1,21 @@ +package com.example.Spot.global.infrastructure.client.feign; + +import feign.RequestInterceptor; +import feign.RequestTemplate; +import org.slf4j.MDC; + +import java.util.UUID; + +public class RequestIdInterceptor implements RequestInterceptor { + + public static final String HEADER_REQUEST_ID = "X-Request-Id"; + + @Override + public void apply(RequestTemplate template) { + String requestId = MDC.get(HEADER_REQUEST_ID); + if (requestId == null) { + requestId = UUID.randomUUID().toString(); + } + template.header(HEADER_REQUEST_ID, requestId); + } +} diff --git a/src/main/java/com/example/Spot/global/infrastructure/client/feign/exception/RemoteCallFailedException.java b/src/main/java/com/example/Spot/global/infrastructure/client/feign/exception/RemoteCallFailedException.java new file mode 100644 index 00000000..6ab07800 --- /dev/null +++ b/src/main/java/com/example/Spot/global/infrastructure/client/feign/exception/RemoteCallFailedException.java @@ -0,0 +1,7 @@ +package com.example.Spot.global.infrastructure.client.feign.exception; + +public class RemoteCallFailedException extends RuntimeException { + public RemoteCallFailedException(String methodKey, int status) { + super("Remote call failed: " + methodKey + " status=" + status); + } +} diff --git a/src/main/java/com/example/Spot/global/infrastructure/client/feign/exception/RemoteConflictException.java b/src/main/java/com/example/Spot/global/infrastructure/client/feign/exception/RemoteConflictException.java new file mode 100644 index 00000000..d2f4c5a0 --- /dev/null +++ b/src/main/java/com/example/Spot/global/infrastructure/client/feign/exception/RemoteConflictException.java @@ -0,0 +1,7 @@ +package com.example.Spot.global.infrastructure.client.feign.exception; + +public class RemoteConflictException extends RuntimeException { + public RemoteConflictException(String methodKey) { + super("Remote conflict: " + methodKey); + } +} diff --git a/src/main/java/com/example/Spot/global/infrastructure/client/feign/exception/RemoteNotFoundException.java b/src/main/java/com/example/Spot/global/infrastructure/client/feign/exception/RemoteNotFoundException.java new file mode 100644 index 00000000..34a5b11d --- /dev/null +++ b/src/main/java/com/example/Spot/global/infrastructure/client/feign/exception/RemoteNotFoundException.java @@ -0,0 +1,7 @@ +package com.example.Spot.global.infrastructure.client.feign.exception; + +public class RemoteNotFoundException extends RuntimeException { + public RemoteNotFoundException(String methodKey) { + super("Remote resource not found: " + methodKey); + } +} diff --git a/src/main/java/com/example/Spot/global/infrastructure/client/feign/exception/RemoteServiceUnavailableException.java b/src/main/java/com/example/Spot/global/infrastructure/client/feign/exception/RemoteServiceUnavailableException.java new file mode 100644 index 00000000..b7205df2 --- /dev/null +++ b/src/main/java/com/example/Spot/global/infrastructure/client/feign/exception/RemoteServiceUnavailableException.java @@ -0,0 +1,7 @@ +package com.example.Spot.global.infrastructure.client.feign.exception; + +public class RemoteServiceUnavailableException extends RuntimeException { + public RemoteServiceUnavailableException(String methodKey) { + super("Remote service unavailable: " + methodKey); + } +} From 16511b7f0667af1e26e279d887b062a7980115c2 Mon Sep 17 00:00:00 2001 From: dbswjd7 Date: Wed, 21 Jan 2026 17:17:05 +0900 Subject: [PATCH 27/77] feat(#212): merge dev --- .../client/feign/FeignCommonConfig.java | 0 .../client/feign/RemoteErrorDecoder.java | 0 .../client/feign/RequestIdInterceptor.java | 0 .../exception/RemoteCallFailedException.java | 0 .../exception/RemoteConflictException.java | 0 .../exception/RemoteNotFoundException.java | 0 .../RemoteServiceUnavailableException.java | 0 .../service/AdminStatsService.java | 109 --- .../service/AdminStoreService.java | 43 -- .../application/service/AdminUserService.java | 54 -- .../controller/AdminStatsController.java | 31 - .../controller/AdminStoreController.java | 69 -- .../controller/AdminUserController.java | 66 -- .../dto/request/UserRoleUpdateRequestDto.java | 14 - .../dto/response/AdminStatsResponseDto.java | 40 - .../controller/PaymentController.java | 31 - .../application/service/CategoryService.java | 22 - .../service/CategoryServiceImpl.java | 132 ---- .../controller/CategoryController.java | 69 -- .../dto/request/CategoryRequestDTO.java | 14 - .../dto/response/CategoryResponseDTO.java | 26 - .../com/example/Spot/config/TestConfig.java | 16 - .../controller/PaymentControllerTest.java | 705 ------------------ .../controller/TestSecurityConfig.java | 25 - src/test/resources/.keep | 0 25 files changed, 1466 deletions(-) rename {src/main/java/com/example/Spot/global/infrastructure => infra}/client/feign/FeignCommonConfig.java (100%) rename {src/main/java/com/example/Spot/global/infrastructure => infra}/client/feign/RemoteErrorDecoder.java (100%) rename {src/main/java/com/example/Spot/global/infrastructure => infra}/client/feign/RequestIdInterceptor.java (100%) rename {src/main/java/com/example/Spot/global/infrastructure => infra}/client/feign/exception/RemoteCallFailedException.java (100%) rename {src/main/java/com/example/Spot/global/infrastructure => infra}/client/feign/exception/RemoteConflictException.java (100%) rename {src/main/java/com/example/Spot/global/infrastructure => infra}/client/feign/exception/RemoteNotFoundException.java (100%) rename {src/main/java/com/example/Spot/global/infrastructure => infra}/client/feign/exception/RemoteServiceUnavailableException.java (100%) delete mode 100644 src/main/java/com/example/Spot/admin/application/service/AdminStatsService.java delete mode 100644 src/main/java/com/example/Spot/admin/application/service/AdminStoreService.java delete mode 100644 src/main/java/com/example/Spot/admin/application/service/AdminUserService.java delete mode 100644 src/main/java/com/example/Spot/admin/presentation/controller/AdminStatsController.java delete mode 100644 src/main/java/com/example/Spot/admin/presentation/controller/AdminStoreController.java delete mode 100644 src/main/java/com/example/Spot/admin/presentation/controller/AdminUserController.java delete mode 100644 src/main/java/com/example/Spot/admin/presentation/dto/request/UserRoleUpdateRequestDto.java delete mode 100644 src/main/java/com/example/Spot/admin/presentation/dto/response/AdminStatsResponseDto.java delete mode 100644 src/main/java/com/example/Spot/payments/presentation/controller/PaymentController.java delete mode 100644 src/main/java/com/example/Spot/store/application/service/CategoryService.java delete mode 100644 src/main/java/com/example/Spot/store/application/service/CategoryServiceImpl.java delete mode 100644 src/main/java/com/example/Spot/store/presentation/controller/CategoryController.java delete mode 100644 src/main/java/com/example/Spot/store/presentation/dto/request/CategoryRequestDTO.java delete mode 100644 src/main/java/com/example/Spot/store/presentation/dto/response/CategoryResponseDTO.java delete mode 100644 src/test/java/com/example/Spot/config/TestConfig.java delete mode 100644 src/test/java/com/example/Spot/payments/presentation/controller/PaymentControllerTest.java delete mode 100644 src/test/java/com/example/Spot/payments/presentation/controller/TestSecurityConfig.java delete mode 100644 src/test/resources/.keep diff --git a/src/main/java/com/example/Spot/global/infrastructure/client/feign/FeignCommonConfig.java b/infra/client/feign/FeignCommonConfig.java similarity index 100% rename from src/main/java/com/example/Spot/global/infrastructure/client/feign/FeignCommonConfig.java rename to infra/client/feign/FeignCommonConfig.java diff --git a/src/main/java/com/example/Spot/global/infrastructure/client/feign/RemoteErrorDecoder.java b/infra/client/feign/RemoteErrorDecoder.java similarity index 100% rename from src/main/java/com/example/Spot/global/infrastructure/client/feign/RemoteErrorDecoder.java rename to infra/client/feign/RemoteErrorDecoder.java diff --git a/src/main/java/com/example/Spot/global/infrastructure/client/feign/RequestIdInterceptor.java b/infra/client/feign/RequestIdInterceptor.java similarity index 100% rename from src/main/java/com/example/Spot/global/infrastructure/client/feign/RequestIdInterceptor.java rename to infra/client/feign/RequestIdInterceptor.java diff --git a/src/main/java/com/example/Spot/global/infrastructure/client/feign/exception/RemoteCallFailedException.java b/infra/client/feign/exception/RemoteCallFailedException.java similarity index 100% rename from src/main/java/com/example/Spot/global/infrastructure/client/feign/exception/RemoteCallFailedException.java rename to infra/client/feign/exception/RemoteCallFailedException.java diff --git a/src/main/java/com/example/Spot/global/infrastructure/client/feign/exception/RemoteConflictException.java b/infra/client/feign/exception/RemoteConflictException.java similarity index 100% rename from src/main/java/com/example/Spot/global/infrastructure/client/feign/exception/RemoteConflictException.java rename to infra/client/feign/exception/RemoteConflictException.java diff --git a/src/main/java/com/example/Spot/global/infrastructure/client/feign/exception/RemoteNotFoundException.java b/infra/client/feign/exception/RemoteNotFoundException.java similarity index 100% rename from src/main/java/com/example/Spot/global/infrastructure/client/feign/exception/RemoteNotFoundException.java rename to infra/client/feign/exception/RemoteNotFoundException.java diff --git a/src/main/java/com/example/Spot/global/infrastructure/client/feign/exception/RemoteServiceUnavailableException.java b/infra/client/feign/exception/RemoteServiceUnavailableException.java similarity index 100% rename from src/main/java/com/example/Spot/global/infrastructure/client/feign/exception/RemoteServiceUnavailableException.java rename to infra/client/feign/exception/RemoteServiceUnavailableException.java diff --git a/src/main/java/com/example/Spot/admin/application/service/AdminStatsService.java b/src/main/java/com/example/Spot/admin/application/service/AdminStatsService.java deleted file mode 100644 index d5face8f..00000000 --- a/src/main/java/com/example/Spot/admin/application/service/AdminStatsService.java +++ /dev/null @@ -1,109 +0,0 @@ -package com.example.Spot.admin.application.service; - -import java.math.BigDecimal; -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; - -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import com.example.Spot.admin.presentation.dto.response.AdminStatsResponseDto; -import com.example.Spot.order.domain.entity.OrderEntity; -import com.example.Spot.order.domain.repository.OrderRepository; -import com.example.Spot.order.presentation.dto.response.OrderResponseDto; -import com.example.Spot.store.domain.repository.StoreRepository; -import com.example.Spot.user.domain.repository.UserRepository; - -import lombok.RequiredArgsConstructor; - -@Service -@RequiredArgsConstructor -@Transactional(readOnly = true) -public class AdminStatsService { - - private final UserRepository userRepository; - private final OrderRepository orderRepository; - private final StoreRepository storeRepository; - - public AdminStatsResponseDto getStats() { - // 전체 사용자 수 - Long totalUsers = userRepository.count(); - - // 전체 주문 수 - Long totalOrders = orderRepository.count(); - - // 전체 가게 수 - Long totalStores = storeRepository.count(); - - // 총 매출 (모든 완료된 주문의 총합) - List completedOrders = orderRepository.findAll(); - BigDecimal totalRevenue = completedOrders.stream() - .flatMap(order -> order.getOrderItems().stream()) - .map(item -> item.getMenuPrice().multiply(BigDecimal.valueOf(item.getQuantity()))) - .reduce(BigDecimal.ZERO, BigDecimal::add); - - // 최근 주문 10개 - Pageable recentPageable = PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "createdAt")); - List recentOrders = orderRepository.findAll(recentPageable) - .stream() - .map(OrderResponseDto::from) - .collect(Collectors.toList()); - - // 사용자 증가 통계 (최근 7일) - List userGrowth = calculateUserGrowth(7); - - // 주문 상태별 통계 - List orderStats = calculateOrderStatusStats(); - - return AdminStatsResponseDto.builder() - .totalUsers(totalUsers) - .totalOrders(totalOrders) - .totalStores(totalStores) - .totalRevenue(totalRevenue) - .recentOrders(recentOrders) - .userGrowth(userGrowth) - .orderStats(orderStats) - .build(); - } - - private List calculateUserGrowth(int days) { - List growth = new ArrayList<>(); - LocalDateTime now = LocalDateTime.now(); - - for (int i = days - 1; i >= 0; i--) { - LocalDate date = now.minusDays(i).toLocalDate(); - - // 간단히 날짜별 사용자 수를 0으로 설정 (실제 구현 시 적절한 쿼리 필요) - Long count = 0L; - growth.add(AdminStatsResponseDto.UserGrowthDto.builder() - .date(date.toString()) - .count(count) - .build()); - } - - return growth; - } - - private List calculateOrderStatusStats() { - List orders = orderRepository.findAll(); - Map statusCounts = orders.stream() - .collect(Collectors.groupingBy( - order -> order.getOrderStatus().name(), - Collectors.counting() - )); - - return statusCounts.entrySet().stream() - .map(entry -> AdminStatsResponseDto.OrderStatusStatsDto.builder() - .status(entry.getKey()) - .count(entry.getValue()) - .build()) - .collect(Collectors.toList()); - } -} diff --git a/src/main/java/com/example/Spot/admin/application/service/AdminStoreService.java b/src/main/java/com/example/Spot/admin/application/service/AdminStoreService.java deleted file mode 100644 index 2b2ee2a9..00000000 --- a/src/main/java/com/example/Spot/admin/application/service/AdminStoreService.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.example.Spot.admin.application.service; - -import java.util.UUID; - -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import com.example.Spot.store.domain.StoreStatus; -import com.example.Spot.store.domain.entity.StoreEntity; -import com.example.Spot.store.domain.repository.StoreRepository; -import com.example.Spot.store.presentation.dto.response.StoreListResponse; - -import lombok.RequiredArgsConstructor; - -@Service -@RequiredArgsConstructor -@Transactional(readOnly = true) -public class AdminStoreService { - - private final StoreRepository storeRepository; - - public Page getAllStores(Pageable pageable) { - Page stores = storeRepository.findAll(pageable); - return stores.map(StoreListResponse::fromEntity); - } - - @Transactional - public void approveStore(UUID storeId) { - StoreEntity store = storeRepository.findById(storeId) - .orElseThrow(() -> new IllegalArgumentException("가게를 찾을 수 없습니다.")); - // 가게 승인 로직 (필요시 승인 상태 필드 추가) - store.updateStatus(StoreStatus.APPROVED); - } - - @Transactional - public void deleteStore(UUID storeId, Integer userId) { - StoreEntity store = storeRepository.findById(storeId) - .orElseThrow(() -> new IllegalArgumentException("가게를 찾을 수 없습니다.")); - store.softDelete(userId); - } -} diff --git a/src/main/java/com/example/Spot/admin/application/service/AdminUserService.java b/src/main/java/com/example/Spot/admin/application/service/AdminUserService.java deleted file mode 100644 index 9449b031..00000000 --- a/src/main/java/com/example/Spot/admin/application/service/AdminUserService.java +++ /dev/null @@ -1,54 +0,0 @@ -package com.example.Spot.admin.application.service; - -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import com.example.Spot.user.domain.Role; -import com.example.Spot.user.domain.entity.UserEntity; -import com.example.Spot.user.domain.repository.UserRepository; -import com.example.Spot.user.presentation.dto.response.UserResponseDTO; - -import lombok.RequiredArgsConstructor; - -@Service -@RequiredArgsConstructor -@Transactional(readOnly = true) -public class AdminUserService { - - private final UserRepository userRepository; - - public Page getAllUsers(Pageable pageable) { - Page users = userRepository.findAll(pageable); - return users.map(this::toResponse); - } - - @Transactional - public void updateUserRole(Integer userId, Role role) { - UserEntity user = userRepository.findById(userId) - .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다.")); - user.setRole(role); - } - - @Transactional - public void deleteUser(Integer userId) { - UserEntity user = userRepository.findById(userId) - .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다.")); - userRepository.delete(user); - } - - private UserResponseDTO toResponse(UserEntity user) { - return new UserResponseDTO( - user.getId(), - user.getUsername(), - user.getRole(), - user.getNickname(), - user.getEmail(), - user.getRoadAddress(), - user.getAddressDetail(), - user.getAge(), - user.isMale() - ); - } -} diff --git a/src/main/java/com/example/Spot/admin/presentation/controller/AdminStatsController.java b/src/main/java/com/example/Spot/admin/presentation/controller/AdminStatsController.java deleted file mode 100644 index adbaace2..00000000 --- a/src/main/java/com/example/Spot/admin/presentation/controller/AdminStatsController.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.example.Spot.admin.presentation.controller; - -import org.springframework.http.ResponseEntity; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -import com.example.Spot.admin.application.service.AdminStatsService; -import com.example.Spot.admin.presentation.dto.response.AdminStatsResponseDto; -import com.example.Spot.global.presentation.ApiResponse; -import com.example.Spot.global.presentation.code.GeneralSuccessCode; - -import lombok.RequiredArgsConstructor; - -@RestController -@RequestMapping("/api/admin/stats") -@RequiredArgsConstructor -@PreAuthorize("hasAnyRole('MASTER', 'MANAGER')") -public class AdminStatsController { - - private final AdminStatsService adminStatsService; - - @GetMapping - public ResponseEntity> getStats() { - AdminStatsResponseDto stats = adminStatsService.getStats(); - - return ResponseEntity - .ok(ApiResponse.onSuccess(GeneralSuccessCode.GOOD_REQUEST, stats)); - } -} diff --git a/src/main/java/com/example/Spot/admin/presentation/controller/AdminStoreController.java b/src/main/java/com/example/Spot/admin/presentation/controller/AdminStoreController.java deleted file mode 100644 index 7110833e..00000000 --- a/src/main/java/com/example/Spot/admin/presentation/controller/AdminStoreController.java +++ /dev/null @@ -1,69 +0,0 @@ -package com.example.Spot.admin.presentation.controller; - -import java.util.UUID; - -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; -import org.springframework.http.ResponseEntity; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PatchMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -import com.example.Spot.admin.application.service.AdminStoreService; -import com.example.Spot.global.presentation.ApiResponse; -import com.example.Spot.global.presentation.code.GeneralSuccessCode; -import com.example.Spot.infra.auth.security.CustomUserDetails; -import com.example.Spot.store.presentation.dto.response.StoreListResponse; - -import lombok.RequiredArgsConstructor; - -@RestController -@RequestMapping("/api/admin/stores") -@RequiredArgsConstructor -@PreAuthorize("hasAnyRole('MASTER', 'MANAGER')") -public class AdminStoreController { - - private final AdminStoreService adminStoreService; - - @GetMapping - public ResponseEntity>> getAllStores( - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "20") int size, - @RequestParam(defaultValue = "name") String sortBy, - @RequestParam(defaultValue = "ASC") Sort.Direction direction) { - - Pageable pageable = PageRequest.of(page, size, Sort.by(direction, sortBy)); - Page stores = adminStoreService.getAllStores(pageable); - - return ResponseEntity - .ok(ApiResponse.onSuccess(GeneralSuccessCode.GOOD_REQUEST, stores)); - } - - @PatchMapping("/{storeId}/approve") - public ResponseEntity> approveStore(@PathVariable UUID storeId) { - adminStoreService.approveStore(storeId); - - return ResponseEntity - .ok(ApiResponse.onSuccess(GeneralSuccessCode.GOOD_REQUEST, null)); - } - - @DeleteMapping("/{storeId}") - public ResponseEntity> deleteStore( - @PathVariable UUID storeId, - @AuthenticationPrincipal CustomUserDetails principal) { - - // To Do: 관리자 페이지에서 삭제를 했을 때, 삭제된 것들에 대한 조치가 없음. 해결할 것. - adminStoreService.deleteStore(storeId, principal.getUserId()); - - return ResponseEntity - .ok(ApiResponse.onSuccess(GeneralSuccessCode.GOOD_REQUEST, null)); - } -} diff --git a/src/main/java/com/example/Spot/admin/presentation/controller/AdminUserController.java b/src/main/java/com/example/Spot/admin/presentation/controller/AdminUserController.java deleted file mode 100644 index caa32ed0..00000000 --- a/src/main/java/com/example/Spot/admin/presentation/controller/AdminUserController.java +++ /dev/null @@ -1,66 +0,0 @@ -package com.example.Spot.admin.presentation.controller; - -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; -import org.springframework.http.ResponseEntity; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PatchMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -import com.example.Spot.admin.application.service.AdminUserService; -import com.example.Spot.admin.presentation.dto.request.UserRoleUpdateRequestDto; -import com.example.Spot.global.presentation.ApiResponse; -import com.example.Spot.global.presentation.code.GeneralSuccessCode; -import com.example.Spot.user.presentation.dto.response.UserResponseDTO; - -import lombok.RequiredArgsConstructor; - -@RestController -@RequestMapping("/api/admin/users") -@RequiredArgsConstructor -@PreAuthorize("hasAnyRole('MASTER', 'MANAGER')") -public class AdminUserController { - - private final AdminUserService adminUserService; - - @GetMapping - public ResponseEntity>> getAllUsers( - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "20") int size, - @RequestParam(defaultValue = "id") String sortBy, - @RequestParam(defaultValue = "ASC") Sort.Direction direction) { - - Pageable pageable = PageRequest.of(page, size, Sort.by(direction, sortBy)); - Page users = adminUserService.getAllUsers(pageable); - - return ResponseEntity - .ok(ApiResponse.onSuccess(GeneralSuccessCode.GOOD_REQUEST, users)); - } - - @PatchMapping("/{userId}/role") - public ResponseEntity> updateUserRole( - @PathVariable Integer userId, - @RequestBody UserRoleUpdateRequestDto request) { - - adminUserService.updateUserRole(userId, request.getRole()); - - return ResponseEntity - .ok(ApiResponse.onSuccess(GeneralSuccessCode.GOOD_REQUEST, null)); - } - - @DeleteMapping("/{userId}") - public ResponseEntity> deleteUser(@PathVariable Integer userId) { - adminUserService.deleteUser(userId); - - return ResponseEntity - .ok(ApiResponse.onSuccess(GeneralSuccessCode.GOOD_REQUEST, null)); - } -} diff --git a/src/main/java/com/example/Spot/admin/presentation/dto/request/UserRoleUpdateRequestDto.java b/src/main/java/com/example/Spot/admin/presentation/dto/request/UserRoleUpdateRequestDto.java deleted file mode 100644 index 7228c056..00000000 --- a/src/main/java/com/example/Spot/admin/presentation/dto/request/UserRoleUpdateRequestDto.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.example.Spot.admin.presentation.dto.request; - -import com.example.Spot.user.domain.Role; - -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Getter -@NoArgsConstructor -@AllArgsConstructor -public class UserRoleUpdateRequestDto { - private Role role; -} diff --git a/src/main/java/com/example/Spot/admin/presentation/dto/response/AdminStatsResponseDto.java b/src/main/java/com/example/Spot/admin/presentation/dto/response/AdminStatsResponseDto.java deleted file mode 100644 index 158d7236..00000000 --- a/src/main/java/com/example/Spot/admin/presentation/dto/response/AdminStatsResponseDto.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.example.Spot.admin.presentation.dto.response; - -import java.math.BigDecimal; -import java.util.List; - -import com.example.Spot.order.presentation.dto.response.OrderResponseDto; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; - -@Getter -@Builder -@AllArgsConstructor -public class AdminStatsResponseDto { - - private Long totalUsers; - private Long totalOrders; - private Long totalStores; - private BigDecimal totalRevenue; - private List recentOrders; - private List userGrowth; - private List orderStats; - - @Getter - @Builder - @AllArgsConstructor - public static class UserGrowthDto { - private String date; - private Long count; - } - - @Getter - @Builder - @AllArgsConstructor - public static class OrderStatusStatsDto { - private String status; - private Long count; - } -} diff --git a/src/main/java/com/example/Spot/payments/presentation/controller/PaymentController.java b/src/main/java/com/example/Spot/payments/presentation/controller/PaymentController.java deleted file mode 100644 index c4bdbb75..00000000 --- a/src/main/java/com/example/Spot/payments/presentation/controller/PaymentController.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.example.Spot.payments.presentation.controller; - -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -import com.example.Spot.global.presentation.ApiResponse; -import com.example.Spot.global.presentation.code.GeneralSuccessCode; -import com.example.Spot.payments.application.service.PaymentService; -import com.example.Spot.payments.presentation.dto.request.PaymentRequestDto; -import com.example.Spot.payments.presentation.dto.response.PaymentResponseDto; - - -import lombok.RequiredArgsConstructor; - -@RestController -@RequestMapping("/api/payments") -@RequiredArgsConstructor -public class PaymentController { - - private final PaymentService paymentService; - - @PostMapping("/confirm") - public ApiResponse paymentBillingConfirm( - PaymentRequestDto.Confirm request - ) { - PaymentResponseDto.Confirm response = paymentService.paymentBillingConfirm(request); - return ApiResponse.onSuccess(GeneralSuccessCode.GOOD_REQUEST, response); - } - -} diff --git a/src/main/java/com/example/Spot/store/application/service/CategoryService.java b/src/main/java/com/example/Spot/store/application/service/CategoryService.java deleted file mode 100644 index 2f595a9e..00000000 --- a/src/main/java/com/example/Spot/store/application/service/CategoryService.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.example.Spot.store.application.service; - -import java.util.List; -import java.util.UUID; - -import com.example.Spot.store.presentation.dto.request.CategoryRequestDTO; -import com.example.Spot.store.presentation.dto.response.CategoryResponseDTO; - -public interface CategoryService { - - List getAll(); - - List getStoresByCategoryId(UUID categoryId); - - List getStoresByCategoryName(String name); - - CategoryResponseDTO.CategoryDetail create(CategoryRequestDTO.Create request); - - CategoryResponseDTO.CategoryDetail update(UUID categoryId, CategoryRequestDTO.Update request); - - void delete(UUID categoryId); -} diff --git a/src/main/java/com/example/Spot/store/application/service/CategoryServiceImpl.java b/src/main/java/com/example/Spot/store/application/service/CategoryServiceImpl.java deleted file mode 100644 index 50c5b540..00000000 --- a/src/main/java/com/example/Spot/store/application/service/CategoryServiceImpl.java +++ /dev/null @@ -1,132 +0,0 @@ -package com.example.Spot.store.application.service; - -import java.util.List; -import java.util.UUID; -import java.util.stream.Collectors; - -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import com.example.Spot.store.domain.entity.CategoryEntity; -import com.example.Spot.store.domain.entity.StoreCategoryEntity; -import com.example.Spot.store.domain.entity.StoreEntity; -import com.example.Spot.store.domain.repository.CategoryRepository; -import com.example.Spot.store.domain.repository.StoreCategoryRepository; -import com.example.Spot.store.presentation.dto.request.CategoryRequestDTO; -import com.example.Spot.store.presentation.dto.response.CategoryResponseDTO; - -import lombok.RequiredArgsConstructor; - -@Service -@RequiredArgsConstructor -@Transactional(readOnly = true) -public class CategoryServiceImpl implements CategoryService { - - private final CategoryRepository categoryRepository; - private final StoreCategoryRepository storeCategoryRepository; - - // 카테고리 전체 조회 - @Override - public List getAll() { - return categoryRepository.findAllByIsDeletedFalse() - .stream() - .map(c -> new CategoryResponseDTO.CategoryItem(c.getId(), c.getName())) - .toList(); - } - - - // 카테고리 별 매장 조회 - @Override - @Transactional(readOnly = true) - public List getStoresByCategoryId(UUID categoryId) { - CategoryEntity category = categoryRepository.findByIdAndIsDeletedFalse(categoryId) - .orElseThrow(() -> new IllegalArgumentException("Category not found: " + categoryId)); - return getStoresByCategoryIdInternal(category.getId()); - } - - @Override - @Transactional(readOnly = true) - public List getStoresByCategoryName(String categoryName) { - CategoryEntity category = categoryRepository.findByNameAndIsDeletedFalse(categoryName) - .orElseThrow(() -> new IllegalArgumentException("Category not found: " + categoryName)); - return getStoresByCategoryIdInternal(category.getId()); - } - - private List getStoresByCategoryIdInternal(UUID categoryId) { - List maps = - storeCategoryRepository.findAllActiveByCategoryIdWithStore(categoryId); - - return maps.stream() - .map(StoreCategoryEntity::getStore) - .collect(Collectors.toMap( - StoreEntity::getId, - s -> s, - (a, b) -> a - )) - .values().stream() - .map(this::toStoreSummary) - .toList(); - } - - - - // create - @Override - @Transactional - public CategoryResponseDTO.CategoryDetail create(CategoryRequestDTO.Create request) { - if (categoryRepository.existsByNameAndIsDeletedFalse(request.name())) { - throw new IllegalArgumentException("Category name already exists: " + request.name()); - } - - CategoryEntity saved = categoryRepository.save( - CategoryEntity.builder() - .name(request.name()) - .build() - ); - - return new CategoryResponseDTO.CategoryDetail(saved.getId(), saved.getName()); - } - - - // update - @Override - @Transactional - public CategoryResponseDTO.CategoryDetail update(UUID categoryId, CategoryRequestDTO.Update request) { - CategoryEntity category = categoryRepository.findByIdAndIsDeletedFalse(categoryId) - .orElseThrow(() -> new IllegalArgumentException("Category not found: " + categoryId)); - - // 이름 중복 방지 - if (categoryRepository.existsByNameAndIsDeletedFalse(request.name()) - && !category.getName().equals(request.name())) { - throw new IllegalArgumentException("Category name already exists: " + request.name()); - } - - category.updateName(request.name()); - return new CategoryResponseDTO.CategoryDetail(category.getId(), category.getName()); - } - - - // delete - @Override - @Transactional - public void delete(UUID categoryId) { - CategoryEntity category = categoryRepository.findByIdAndIsDeletedFalse(categoryId) - .orElseThrow(() -> new IllegalArgumentException("Category not found: " + categoryId)); - - // soft delete - category.softDelete(); - } - - - - private CategoryResponseDTO.StoreSummary toStoreSummary(StoreEntity s) { - return new CategoryResponseDTO.StoreSummary( - s.getId(), - s.getName(), - s.getAddress(), - s.getPhoneNumber(), - s.getOpenTime(), - s.getCloseTime() - ); - } -} diff --git a/src/main/java/com/example/Spot/store/presentation/controller/CategoryController.java b/src/main/java/com/example/Spot/store/presentation/controller/CategoryController.java deleted file mode 100644 index e6d63f55..00000000 --- a/src/main/java/com/example/Spot/store/presentation/controller/CategoryController.java +++ /dev/null @@ -1,69 +0,0 @@ -package com.example.Spot.store.presentation.controller; - -import java.util.List; -import java.util.UUID; - -import org.springframework.http.HttpStatus; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PatchMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.ResponseStatus; -import org.springframework.web.bind.annotation.RestController; - -import com.example.Spot.store.application.service.CategoryService; -import com.example.Spot.store.presentation.dto.request.CategoryRequestDTO; -import com.example.Spot.store.presentation.dto.response.CategoryResponseDTO; - -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; - -@RestController -@RequiredArgsConstructor -@RequestMapping("/categories") -public class CategoryController { - - private final CategoryService categoryService; - - // 카테고리 전체 조회 - @GetMapping - public List getAll() { - return categoryService.getAll(); - } - - // 카테고리별 매장 조회 - @GetMapping("/{categoryName}/stores") - public List getStores(@PathVariable String categoryName) { - return categoryService.getStoresByCategoryName(categoryName); - } - - // 카테고리 생성 - @PreAuthorize("hasRole('ADMIN')") - @PostMapping - @ResponseStatus(HttpStatus.CREATED) - public CategoryResponseDTO.CategoryDetail create(@RequestBody @Valid CategoryRequestDTO.Create request) { - return categoryService.create(request); - } - - // 카테고리 수정 - @PreAuthorize("hasRole('ADMIN')") - @PatchMapping("/{categoryId}") - public CategoryResponseDTO.CategoryDetail update( - @PathVariable UUID categoryId, - @RequestBody @Valid CategoryRequestDTO.Update request - ) { - return categoryService.update(categoryId, request); - } - - // 카테고리 삭제(soft delete) - @PreAuthorize("hasRole('ADMIN')") - @DeleteMapping("/{categoryId}") - @ResponseStatus(HttpStatus.NO_CONTENT) - public void delete(@PathVariable UUID categoryId) { - categoryService.delete(categoryId); - } -} diff --git a/src/main/java/com/example/Spot/store/presentation/dto/request/CategoryRequestDTO.java b/src/main/java/com/example/Spot/store/presentation/dto/request/CategoryRequestDTO.java deleted file mode 100644 index f5fdce8e..00000000 --- a/src/main/java/com/example/Spot/store/presentation/dto/request/CategoryRequestDTO.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.example.Spot.store.presentation.dto.request; - -import jakarta.validation.constraints.NotBlank; - -public class CategoryRequestDTO { - - public record Create( - @NotBlank String name - ) {} - - public record Update( - @NotBlank String name - ) {} -} diff --git a/src/main/java/com/example/Spot/store/presentation/dto/response/CategoryResponseDTO.java b/src/main/java/com/example/Spot/store/presentation/dto/response/CategoryResponseDTO.java deleted file mode 100644 index 402379f7..00000000 --- a/src/main/java/com/example/Spot/store/presentation/dto/response/CategoryResponseDTO.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.example.Spot.store.presentation.dto.response; - -import java.time.LocalTime; -import java.util.UUID; - -public class CategoryResponseDTO { - - public record CategoryItem( - UUID id, - String name - ) {} - - public record CategoryDetail( - UUID id, - String name - ) {} - - public record StoreSummary( - UUID id, - String name, - String address, - String phoneNumber, - LocalTime openTime, - LocalTime closeTime - ) {} -} diff --git a/src/test/java/com/example/Spot/config/TestConfig.java b/src/test/java/com/example/Spot/config/TestConfig.java deleted file mode 100644 index 00a5d359..00000000 --- a/src/test/java/com/example/Spot/config/TestConfig.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.example.Spot.config; - -import org.springframework.boot.test.context.TestConfiguration; -import org.springframework.context.annotation.Bean; -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; -import org.springframework.test.context.ActiveProfiles; - -@TestConfiguration -@ActiveProfiles("test") -public class TestConfig { - - @Bean - public BCryptPasswordEncoder bCryptPasswordEncoder() { - return new BCryptPasswordEncoder(); - } -} diff --git a/src/test/java/com/example/Spot/payments/presentation/controller/PaymentControllerTest.java b/src/test/java/com/example/Spot/payments/presentation/controller/PaymentControllerTest.java deleted file mode 100644 index 22157a98..00000000 --- a/src/test/java/com/example/Spot/payments/presentation/controller/PaymentControllerTest.java +++ /dev/null @@ -1,705 +0,0 @@ -package com.example.Spot.payments.presentation.controller; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.BDDMockito.given; -import static org.mockito.BDDMockito.willDoNothing; -import static org.mockito.BDDMockito.willThrow; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; -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.MockMvcResultHandlers.print; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -import java.time.LocalDateTime; -import java.util.List; -import java.util.UUID; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.context.annotation.Import; -import org.springframework.http.MediaType; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.test.web.servlet.MockMvc; - -import com.example.Spot.infra.auth.security.CustomUserDetails; -import com.example.Spot.payments.application.service.PaymentService; -import com.example.Spot.payments.domain.entity.PaymentEntity.PaymentMethod; -import com.example.Spot.payments.presentation.dto.request.PaymentRequestDto; -import com.example.Spot.payments.presentation.dto.response.PaymentResponseDto; -import com.example.Spot.user.domain.Role; -import com.example.Spot.user.domain.entity.UserEntity; -import com.fasterxml.jackson.databind.ObjectMapper; - -@WebMvcTest(PaymentController.class) -@Import(TestSecurityConfig.class) -class PaymentControllerTest { - - @Autowired - private MockMvc mockMvc; - - @Autowired - private ObjectMapper objectMapper; - - @MockBean - private PaymentService paymentService; - - private UUID orderId; - private UUID paymentId; - private Integer userId; - - @BeforeEach - void setUp() { - orderId = UUID.randomUUID(); - paymentId = UUID.randomUUID(); - userId = 1; - } - - private CustomUserDetails createUserDetails(Role role) { - UserEntity user = UserEntity.forAuthentication(userId, role); - return new CustomUserDetails(user); - } - - private void setSecurityContext(Role role) { - CustomUserDetails userDetails = createUserDetails(role); - UsernamePasswordAuthenticationToken authentication = - new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); - SecurityContextHolder.getContext().setAuthentication(authentication); - } - - @Nested - @DisplayName("POST /api/payments/{order_id}/confirm - 결제 승인") - class ConfirmPaymentTest { - - @Test - @DisplayName("정상: CUSTOMER가 결제를 승인한다") - void CUSTOMER_결제_승인_성공() throws Exception { - setSecurityContext(Role.CUSTOMER); - - PaymentRequestDto.Confirm request = PaymentRequestDto.Confirm.builder() - .title("테스트 결제") - .content("테스트 내용") - .userId(userId) - .orderId(orderId) - .paymentMethod(PaymentMethod.CREDIT_CARD) - .paymentAmount(10000L) - .build(); - - PaymentResponseDto.Confirm response = PaymentResponseDto.Confirm.builder() - .paymentId(paymentId) - .status("DONE") - .amount(10000L) - .approvedAt(LocalDateTime.now()) - .build(); - - willDoNothing().given(paymentService).validateOrderOwnership(orderId, userId); - given(paymentService.preparePayment(any(PaymentRequestDto.Confirm.class))).willReturn(paymentId); - given(paymentService.executePaymentBilling(paymentId)).willReturn(response); - - mockMvc.perform(post("/api/payments/{order_id}/confirm", orderId) - .with(csrf()) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.isSuccess").value(true)) - .andExpect(jsonPath("$.result.paymentId").value(paymentId.toString())) - .andExpect(jsonPath("$.result.status").value("DONE")) - .andExpect(jsonPath("$.result.amount").value(10000)); - - verify(paymentService, times(1)).validateOrderOwnership(orderId, userId); - verify(paymentService, times(1)).preparePayment(any(PaymentRequestDto.Confirm.class)); - verify(paymentService, times(1)).executePaymentBilling(paymentId); - } - - @Test - @DisplayName("정상: OWNER가 결제를 승인한다") - void OWNER_결제_승인_성공() throws Exception { - setSecurityContext(Role.OWNER); - - PaymentRequestDto.Confirm request = PaymentRequestDto.Confirm.builder() - .title("테스트 결제") - .content("테스트 내용") - .userId(userId) - .orderId(orderId) - .paymentMethod(PaymentMethod.CREDIT_CARD) - .paymentAmount(10000L) - .build(); - - PaymentResponseDto.Confirm response = PaymentResponseDto.Confirm.builder() - .paymentId(paymentId) - .status("DONE") - .amount(10000L) - .build(); - - willDoNothing().given(paymentService).validateOrderStoreOwnership(orderId, userId); - given(paymentService.preparePayment(any(PaymentRequestDto.Confirm.class))).willReturn(paymentId); - given(paymentService.executePaymentBilling(paymentId)).willReturn(response); - - mockMvc.perform(post("/api/payments/{order_id}/confirm", orderId) - .with(csrf()) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.isSuccess").value(true)); - - verify(paymentService, times(1)).validateOrderStoreOwnership(orderId, userId); - } - - @Test - @DisplayName("정상: MANAGER가 결제를 승인한다 (소유권 검증 없음)") - void MANAGER_결제_승인_성공() throws Exception { - setSecurityContext(Role.MANAGER); - - PaymentRequestDto.Confirm request = PaymentRequestDto.Confirm.builder() - .title("테스트 결제") - .content("테스트 내용") - .userId(userId) - .orderId(orderId) - .paymentMethod(PaymentMethod.CREDIT_CARD) - .paymentAmount(10000L) - .build(); - - PaymentResponseDto.Confirm response = PaymentResponseDto.Confirm.builder() - .paymentId(paymentId) - .status("DONE") - .amount(10000L) - .build(); - - given(paymentService.preparePayment(any(PaymentRequestDto.Confirm.class))).willReturn(paymentId); - given(paymentService.executePaymentBilling(paymentId)).willReturn(response); - - mockMvc.perform(post("/api/payments/{order_id}/confirm", orderId) - .with(csrf()) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.isSuccess").value(true)); - - verify(paymentService, never()).validateOrderOwnership(any(), any()); - verify(paymentService, never()).validateOrderStoreOwnership(any(), any()); - } - - @Test - @DisplayName("예외: 다른 사용자의 주문에 결제를 시도하면 실패한다") - void 소유권_없음_결제_승인_실패() throws Exception { - setSecurityContext(Role.CUSTOMER); - - PaymentRequestDto.Confirm request = PaymentRequestDto.Confirm.builder() - .title("테스트 결제") - .content("테스트 내용") - .userId(userId) - .orderId(orderId) - .paymentMethod(PaymentMethod.CREDIT_CARD) - .paymentAmount(10000L) - .build(); - - willThrow(new IllegalStateException("해당 주문에 대한 접근 권한이 없습니다.")) - .given(paymentService).validateOrderOwnership(orderId, userId); - - mockMvc.perform(post("/api/payments/{order_id}/confirm", orderId) - .with(csrf()) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().is5xxServerError()); - - verify(paymentService, never()).preparePayment(any()); - } - - @Test - @DisplayName("정상: 결제 금액이 포함된 결제 승인 요청") - void 결제_금액_포함_승인_요청() throws Exception { - setSecurityContext(Role.CUSTOMER); - - PaymentRequestDto.Confirm request = PaymentRequestDto.Confirm.builder() - .title("고가 결제") - .content("프리미엄 상품") - .userId(userId) - .orderId(orderId) - .paymentMethod(PaymentMethod.BANK_TRANSFER) - .paymentAmount(1000000L) - .build(); - - PaymentResponseDto.Confirm response = PaymentResponseDto.Confirm.builder() - .paymentId(paymentId) - .status("DONE") - .amount(1000000L) - .build(); - - willDoNothing().given(paymentService).validateOrderOwnership(orderId, userId); - given(paymentService.preparePayment(any(PaymentRequestDto.Confirm.class))).willReturn(paymentId); - given(paymentService.executePaymentBilling(paymentId)).willReturn(response); - - mockMvc.perform(post("/api/payments/{order_id}/confirm", orderId) - .with(csrf()) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.result.amount").value(1000000)); - } - } - - @Nested - @DisplayName("POST /api/payments/{order_id}/cancel - 결제 취소") - class CancelPaymentTest { - - @Test - @DisplayName("정상: CUSTOMER가 결제를 취소한다") - void CUSTOMER_결제_취소_성공() throws Exception { - setSecurityContext(Role.CUSTOMER); - - PaymentRequestDto.Cancel request = PaymentRequestDto.Cancel.builder() - .paymentId(paymentId) - .cancelReason("고객 요청") - .build(); - - PaymentResponseDto.Cancel response = PaymentResponseDto.Cancel.builder() - .paymentId(paymentId) - .cancelAmount(10000L) - .cancelReason("고객 요청") - .canceledAt(LocalDateTime.now()) - .build(); - - willDoNothing().given(paymentService).validateOrderOwnership(orderId, userId); - given(paymentService.executeCancel(any(PaymentRequestDto.Cancel.class))).willReturn(response); - - mockMvc.perform(post("/api/payments/{order_id}/cancel", orderId) - .with(csrf()) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.isSuccess").value(true)) - .andExpect(jsonPath("$.result.paymentId").value(paymentId.toString())) - .andExpect(jsonPath("$.result.cancelReason").value("고객 요청")); - - verify(paymentService, times(1)).executeCancel(any(PaymentRequestDto.Cancel.class)); - } - - @Test - @DisplayName("정상: MASTER가 결제를 취소한다 (소유권 검증 없음)") - void MASTER_결제_취소_성공() throws Exception { - setSecurityContext(Role.MASTER); - - PaymentRequestDto.Cancel request = PaymentRequestDto.Cancel.builder() - .paymentId(paymentId) - .cancelReason("관리자 취소") - .build(); - - PaymentResponseDto.Cancel response = PaymentResponseDto.Cancel.builder() - .paymentId(paymentId) - .cancelAmount(10000L) - .cancelReason("관리자 취소") - .canceledAt(LocalDateTime.now()) - .build(); - - given(paymentService.executeCancel(any(PaymentRequestDto.Cancel.class))).willReturn(response); - - mockMvc.perform(post("/api/payments/{order_id}/cancel", orderId) - .with(csrf()) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.isSuccess").value(true)); - - verify(paymentService, never()).validateOrderOwnership(any(), any()); - } - - @Test - @DisplayName("정상: OWNER가 가게 주문의 결제를 취소한다") - void OWNER_결제_취소_성공() throws Exception { - setSecurityContext(Role.OWNER); - - PaymentRequestDto.Cancel request = PaymentRequestDto.Cancel.builder() - .paymentId(paymentId) - .cancelReason("가게 사정") - .build(); - - PaymentResponseDto.Cancel response = PaymentResponseDto.Cancel.builder() - .paymentId(paymentId) - .cancelAmount(15000L) - .cancelReason("가게 사정") - .canceledAt(LocalDateTime.now()) - .build(); - - willDoNothing().given(paymentService).validateOrderStoreOwnership(orderId, userId); - given(paymentService.executeCancel(any(PaymentRequestDto.Cancel.class))).willReturn(response); - - mockMvc.perform(post("/api/payments/{order_id}/cancel", orderId) - .with(csrf()) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.result.cancelAmount").value(15000)); - - verify(paymentService, times(1)).validateOrderStoreOwnership(orderId, userId); - } - } - - @Nested - @DisplayName("GET /api/payments - 전체 결제 목록 조회") - class GetAllPaymentTest { - - @Test - @DisplayName("정상: MANAGER가 전체 결제 목록을 조회한다") - void MANAGER_전체_결제_목록_조회_성공() throws Exception { - setSecurityContext(Role.MANAGER); - - List payments = java.util.Arrays.asList( - PaymentResponseDto.PaymentDetail.builder() - .paymentId(UUID.randomUUID()) - .title("결제1") - .content("내용1") - .paymentMethod(PaymentMethod.CREDIT_CARD) - .totalAmount(10000L) - .status("DONE") - .createdAt(LocalDateTime.now()) - .build(), - PaymentResponseDto.PaymentDetail.builder() - .paymentId(UUID.randomUUID()) - .title("결제2") - .content("내용2") - .paymentMethod(PaymentMethod.BANK_TRANSFER) - .totalAmount(20000L) - .status("READY") - .createdAt(LocalDateTime.now()) - .build() - ); - - PaymentResponseDto.PaymentList response = PaymentResponseDto.PaymentList.builder() - .payments(payments) - .totalCount(2) - .build(); - - given(paymentService.getAllPayment()).willReturn(response); - - mockMvc.perform(get("/api/payments")) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.isSuccess").value(true)) - .andExpect(jsonPath("$.result.totalCount").value(2)) - .andExpect(jsonPath("$.result.payments").isArray()) - .andExpect(jsonPath("$.result.payments.length()").value(2)); - - verify(paymentService, times(1)).getAllPayment(); - } - - @Test - @DisplayName("정상: MASTER가 전체 결제 목록을 조회한다") - void MASTER_전체_결제_목록_조회_성공() throws Exception { - setSecurityContext(Role.MASTER); - - PaymentResponseDto.PaymentList response = PaymentResponseDto.PaymentList.builder() - .payments(java.util.Collections.emptyList()) - .totalCount(0) - .build(); - - given(paymentService.getAllPayment()).willReturn(response); - - mockMvc.perform(get("/api/payments")) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.isSuccess").value(true)) - .andExpect(jsonPath("$.result.totalCount").value(0)); - } - - @Test - @DisplayName("정상: 결제 목록이 비어있을 때") - void 빈_결제_목록_조회() throws Exception { - setSecurityContext(Role.MANAGER); - - PaymentResponseDto.PaymentList response = PaymentResponseDto.PaymentList.builder() - .payments(java.util.Collections.emptyList()) - .totalCount(0) - .build(); - - given(paymentService.getAllPayment()).willReturn(response); - - mockMvc.perform(get("/api/payments")) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.result.payments").isArray()) - .andExpect(jsonPath("$.result.payments").isEmpty()); - } - } - - @Nested - @DisplayName("GET /api/payments/{paymentId} - 결제 상세 조회") - class GetDetailPaymentTest { - - @Test - @DisplayName("정상: CUSTOMER가 본인 결제 상세를 조회한다") - void CUSTOMER_결제_상세_조회_성공() throws Exception { - setSecurityContext(Role.CUSTOMER); - - PaymentResponseDto.PaymentDetail response = PaymentResponseDto.PaymentDetail.builder() - .paymentId(paymentId) - .title("테스트 결제") - .content("테스트 내용") - .paymentMethod(PaymentMethod.CREDIT_CARD) - .totalAmount(10000L) - .status("DONE") - .createdAt(LocalDateTime.now()) - .build(); - - willDoNothing().given(paymentService).validatePaymentOwnership(paymentId, userId); - given(paymentService.getDetailPayment(paymentId)).willReturn(response); - - mockMvc.perform(get("/api/payments/{paymentId}", paymentId)) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.isSuccess").value(true)) - .andExpect(jsonPath("$.result.paymentId").value(paymentId.toString())) - .andExpect(jsonPath("$.result.title").value("테스트 결제")) - .andExpect(jsonPath("$.result.totalAmount").value(10000)); - - verify(paymentService, times(1)).validatePaymentOwnership(paymentId, userId); - } - - @Test - @DisplayName("정상: OWNER가 가게 결제 상세를 조회한다") - void OWNER_결제_상세_조회_성공() throws Exception { - setSecurityContext(Role.OWNER); - - PaymentResponseDto.PaymentDetail response = PaymentResponseDto.PaymentDetail.builder() - .paymentId(paymentId) - .title("가게 결제") - .content("가게 주문 내용") - .paymentMethod(PaymentMethod.CREDIT_CARD) - .totalAmount(25000L) - .status("DONE") - .createdAt(LocalDateTime.now()) - .build(); - - willDoNothing().given(paymentService).validatePaymentStoreOwnership(paymentId, userId); - given(paymentService.getDetailPayment(paymentId)).willReturn(response); - - mockMvc.perform(get("/api/payments/{paymentId}", paymentId)) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.isSuccess").value(true)) - .andExpect(jsonPath("$.result.totalAmount").value(25000)); - - verify(paymentService, times(1)).validatePaymentStoreOwnership(paymentId, userId); - } - - @Test - @DisplayName("예외: 다른 사용자의 결제 상세 조회시 실패한다") - void 타인_결제_상세_조회_실패() throws Exception { - setSecurityContext(Role.CUSTOMER); - - willThrow(new IllegalStateException("해당 결제에 대한 접근 권한이 없습니다.")) - .given(paymentService).validatePaymentOwnership(paymentId, userId); - - mockMvc.perform(get("/api/payments/{paymentId}", paymentId)) - .andDo(print()) - .andExpect(status().is5xxServerError()); - - verify(paymentService, never()).getDetailPayment(any()); - } - - @Test - @DisplayName("정상: MANAGER가 모든 결제 상세를 조회한다") - void MANAGER_결제_상세_조회_성공() throws Exception { - setSecurityContext(Role.MANAGER); - - PaymentResponseDto.PaymentDetail response = PaymentResponseDto.PaymentDetail.builder() - .paymentId(paymentId) - .title("관리자 조회") - .content("관리자가 조회하는 결제") - .paymentMethod(PaymentMethod.CREDIT_CARD) - .totalAmount(50000L) - .status("DONE") - .createdAt(LocalDateTime.now()) - .build(); - - given(paymentService.getDetailPayment(paymentId)).willReturn(response); - - mockMvc.perform(get("/api/payments/{paymentId}", paymentId)) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.isSuccess").value(true)); - - verify(paymentService, never()).validatePaymentOwnership(any(), any()); - verify(paymentService, never()).validatePaymentStoreOwnership(any(), any()); - } - } - - @Nested - @DisplayName("GET /api/payments/cancel - 전체 취소 목록 조회") - class GetAllPaymentCancelTest { - - @Test - @DisplayName("정상: MANAGER가 전체 취소 목록을 조회한다") - void MANAGER_전체_취소_목록_조회_성공() throws Exception { - setSecurityContext(Role.MANAGER); - - List cancellations = java.util.Collections.singletonList( - PaymentResponseDto.CancelDetail.builder() - .cancelId(UUID.randomUUID()) - .paymentId(UUID.randomUUID()) - .cancelAmount(10000L) - .cancelReason("고객 요청") - .canceledAt(LocalDateTime.now()) - .build() - ); - - PaymentResponseDto.CancelList response = PaymentResponseDto.CancelList.builder() - .cancellations(cancellations) - .totalCount(1) - .build(); - - given(paymentService.getAllPaymentCancel()).willReturn(response); - - mockMvc.perform(get("/api/payments/cancel")) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.isSuccess").value(true)) - .andExpect(jsonPath("$.result.totalCount").value(1)); - - verify(paymentService, times(1)).getAllPaymentCancel(); - } - - @Test - @DisplayName("정상: MASTER가 전체 취소 목록을 조회한다") - void MASTER_전체_취소_목록_조회_성공() throws Exception { - setSecurityContext(Role.MASTER); - - List cancellations = java.util.Arrays.asList( - PaymentResponseDto.CancelDetail.builder() - .cancelId(UUID.randomUUID()) - .paymentId(UUID.randomUUID()) - .cancelAmount(20000L) - .cancelReason("관리자 취소") - .canceledAt(LocalDateTime.now()) - .build(), - PaymentResponseDto.CancelDetail.builder() - .cancelId(UUID.randomUUID()) - .paymentId(UUID.randomUUID()) - .cancelAmount(15000L) - .cancelReason("고객 요청") - .canceledAt(LocalDateTime.now()) - .build() - ); - - PaymentResponseDto.CancelList response = PaymentResponseDto.CancelList.builder() - .cancellations(cancellations) - .totalCount(2) - .build(); - - given(paymentService.getAllPaymentCancel()).willReturn(response); - - mockMvc.perform(get("/api/payments/cancel")) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.result.totalCount").value(2)) - .andExpect(jsonPath("$.result.cancellations.length()").value(2)); - } - } - - @Nested - @DisplayName("GET /api/payments/{paymentId}/cancel - 특정 결제 취소 내역 조회") - class GetDetailPaymentCancelTest { - - @Test - @DisplayName("정상: CUSTOMER가 본인 결제의 취소 내역을 조회한다") - void CUSTOMER_결제_취소_내역_조회_성공() throws Exception { - setSecurityContext(Role.CUSTOMER); - - List cancellations = java.util.Collections.singletonList( - PaymentResponseDto.CancelDetail.builder() - .cancelId(UUID.randomUUID()) - .paymentId(paymentId) - .cancelAmount(10000L) - .cancelReason("고객 요청") - .canceledAt(LocalDateTime.now()) - .build() - ); - - PaymentResponseDto.CancelList response = PaymentResponseDto.CancelList.builder() - .cancellations(cancellations) - .totalCount(1) - .build(); - - willDoNothing().given(paymentService).validatePaymentOwnership(paymentId, userId); - given(paymentService.getDetailPaymentCancel(paymentId)).willReturn(response); - - mockMvc.perform(get("/api/payments/{paymentId}/cancel", paymentId)) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.isSuccess").value(true)) - .andExpect(jsonPath("$.result.totalCount").value(1)) - .andExpect(jsonPath("$.result.cancellations[0].paymentId").value(paymentId.toString())); - - verify(paymentService, times(1)).validatePaymentOwnership(paymentId, userId); - verify(paymentService, times(1)).getDetailPaymentCancel(paymentId); - } - - @Test - @DisplayName("정상: MASTER가 모든 결제의 취소 내역을 조회한다") - void MASTER_결제_취소_내역_조회_성공() throws Exception { - setSecurityContext(Role.MASTER); - - PaymentResponseDto.CancelList response = PaymentResponseDto.CancelList.builder() - .cancellations(java.util.Collections.emptyList()) - .totalCount(0) - .build(); - - given(paymentService.getDetailPaymentCancel(paymentId)).willReturn(response); - - mockMvc.perform(get("/api/payments/{paymentId}/cancel", paymentId)) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.isSuccess").value(true)); - - verify(paymentService, never()).validatePaymentOwnership(any(), any()); - verify(paymentService, times(1)).getDetailPaymentCancel(paymentId); - } - - @Test - @DisplayName("정상: OWNER가 가게 결제의 취소 내역을 조회한다") - void OWNER_결제_취소_내역_조회_성공() throws Exception { - setSecurityContext(Role.OWNER); - - List cancellations = java.util.Collections.singletonList( - PaymentResponseDto.CancelDetail.builder() - .cancelId(UUID.randomUUID()) - .paymentId(paymentId) - .cancelAmount(30000L) - .cancelReason("재고 부족") - .canceledAt(LocalDateTime.now()) - .build() - ); - - PaymentResponseDto.CancelList response = PaymentResponseDto.CancelList.builder() - .cancellations(cancellations) - .totalCount(1) - .build(); - - willDoNothing().given(paymentService).validatePaymentStoreOwnership(paymentId, userId); - given(paymentService.getDetailPaymentCancel(paymentId)).willReturn(response); - - mockMvc.perform(get("/api/payments/{paymentId}/cancel", paymentId)) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.result.cancellations[0].cancelReason").value("재고 부족")); - - verify(paymentService, times(1)).validatePaymentStoreOwnership(paymentId, userId); - } - } -} diff --git a/src/test/java/com/example/Spot/payments/presentation/controller/TestSecurityConfig.java b/src/test/java/com/example/Spot/payments/presentation/controller/TestSecurityConfig.java deleted file mode 100644 index 8f537e46..00000000 --- a/src/test/java/com/example/Spot/payments/presentation/controller/TestSecurityConfig.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.example.Spot.payments.presentation.controller; - -import org.springframework.boot.test.context.TestConfiguration; -import org.springframework.context.annotation.Bean; -import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; -import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; -import org.springframework.security.web.SecurityFilterChain; - -@TestConfiguration -@EnableWebSecurity -@EnableMethodSecurity(prePostEnabled = true) -public class TestSecurityConfig { - - @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { - http - .csrf(AbstractHttpConfigurer::disable) - .authorizeHttpRequests(auth -> auth - .anyRequest().authenticated() - ); - return http.build(); - } -} diff --git a/src/test/resources/.keep b/src/test/resources/.keep deleted file mode 100644 index e69de29b..00000000 From f3fc807fc2f1271bdf747bbf9283f8f87d55d8a0 Mon Sep 17 00:00:00 2001 From: dbswjd7 Date: Thu, 22 Jan 2026 00:25:22 +0900 Subject: [PATCH 28/77] =?UTF-8?q?feat(#224):=20spot-gateway=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EB=B0=8F=20feign=20config=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 41 +++ infra/client/feign/FeignCommonConfig.java | 4 +- .../feign/FeignHeaderRelayInterceptor.java | 31 +++ infra/client/feign/RequestIdInterceptor.java | 21 -- spot-gateway/.gitattributes | 3 + spot-gateway/.gitignore | 37 +++ spot-gateway/build.gradle | 39 +++ .../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 45633 bytes spot-gateway/gradlew | 248 ++++++++++++++++++ spot-gateway/gradlew.bat | 93 +++++++ spot-gateway/settings.gradle | 1 + .../example/spot/SpotGatewayApplication.java | 13 + .../spot/filter/GatewayFilterConfig.java | 35 +++ .../spot/SpotGatewayApplicationTests.java | 13 + .../src/main/resources/application.properties | 1 + .../src/main/resources/application.properties | 6 - .../src/main/resources/application.properties | 1 - spot-user/build.gradle | 3 + .../src/main/resources/application.properties | 6 - 19 files changed, 560 insertions(+), 36 deletions(-) create mode 100644 infra/client/feign/FeignHeaderRelayInterceptor.java delete mode 100644 infra/client/feign/RequestIdInterceptor.java create mode 100644 spot-gateway/.gitattributes create mode 100644 spot-gateway/.gitignore create mode 100644 spot-gateway/build.gradle create mode 100644 spot-gateway/gradle/wrapper/gradle-wrapper.jar create mode 100755 spot-gateway/gradlew create mode 100644 spot-gateway/gradlew.bat create mode 100644 spot-gateway/settings.gradle create mode 100644 spot-gateway/src/main/java/com/example/spot/SpotGatewayApplication.java create mode 100644 spot-gateway/src/main/java/com/example/spot/filter/GatewayFilterConfig.java create mode 100644 spot-gateway/src/test/java/com/example/spot/SpotGatewayApplicationTests.java delete mode 100644 spot-payment/src/main/resources/application.properties delete mode 100644 spot-store/src/main/resources/application.properties delete mode 100644 spot-user/src/main/resources/application.properties diff --git a/.gitignore b/.gitignore index 4d2ac7fd..1d66ff7c 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,44 @@ postgres_data/* ### Gradle ### .gradle/ build/ + +### yml ### +**/*.yml +**/*.yaml +**/*.properties + +!**/application-example.yml +!**/application-example.yaml +!**/application.example.yml +!**/application.example.yaml + + +### Maven ### +target/ +pom.xml.tag +pom.xml.releaseBackup +pom.xml.utils +pom.xml.v +release.properties + + +### IntelliJ IDEA ### +*.iws +*.iml +*.ipr + + +### Eclipse ### +.settings/ +.project +.classpath +.factorypath + +### OS ### +.DS_Store +Thumbs.db + +### Log files ### +*.log +logs/ + diff --git a/infra/client/feign/FeignCommonConfig.java b/infra/client/feign/FeignCommonConfig.java index b8947437..546d2d74 100644 --- a/infra/client/feign/FeignCommonConfig.java +++ b/infra/client/feign/FeignCommonConfig.java @@ -9,8 +9,8 @@ public class FeignCommonConfig { @Bean - public RequestIdInterceptor requestIdInterceptor() { - return new RequestIdInterceptor(); + public RequestInterceptor feignHeaderRelayInterceptor() { + return new FeignHeaderRelayInterceptor(); } @Bean diff --git a/infra/client/feign/FeignHeaderRelayInterceptor.java b/infra/client/feign/FeignHeaderRelayInterceptor.java new file mode 100644 index 00000000..22c085ed --- /dev/null +++ b/infra/client/feign/FeignHeaderRelayInterceptor.java @@ -0,0 +1,31 @@ +package com.example.Spot.global.infrastructure.client.feign; + +import jakarta.servlet.http.HttpServletRequest; + +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import feign.RequestInterceptor; +import feign.RequestTemplate; + +public class FeignHeaderRelayInterceptor implements RequestInterceptor { + + public static final String HEADER_AUTHORIZATION = "Authorization"; + + @Override + public void apply(RequestTemplate template) { + ServletRequestAttributes attrs = + (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + + if (attrs == null) { + return; + } + + HttpServletRequest request = attrs.getRequest(); + String auth = request.getHeader(HEADER_AUTHORIZATION); + + if (auth != null && !auth.isBlank()) { + template.header(HEADER_AUTHORIZATION, auth); + } + } +} diff --git a/infra/client/feign/RequestIdInterceptor.java b/infra/client/feign/RequestIdInterceptor.java deleted file mode 100644 index c98d32e5..00000000 --- a/infra/client/feign/RequestIdInterceptor.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.example.Spot.global.infrastructure.client.feign; - -import feign.RequestInterceptor; -import feign.RequestTemplate; -import org.slf4j.MDC; - -import java.util.UUID; - -public class RequestIdInterceptor implements RequestInterceptor { - - public static final String HEADER_REQUEST_ID = "X-Request-Id"; - - @Override - public void apply(RequestTemplate template) { - String requestId = MDC.get(HEADER_REQUEST_ID); - if (requestId == null) { - requestId = UUID.randomUUID().toString(); - } - template.header(HEADER_REQUEST_ID, requestId); - } -} diff --git a/spot-gateway/.gitattributes b/spot-gateway/.gitattributes new file mode 100644 index 00000000..8af972cd --- /dev/null +++ b/spot-gateway/.gitattributes @@ -0,0 +1,3 @@ +/gradlew text eol=lf +*.bat text eol=crlf +*.jar binary diff --git a/spot-gateway/.gitignore b/spot-gateway/.gitignore new file mode 100644 index 00000000..c2065bc2 --- /dev/null +++ b/spot-gateway/.gitignore @@ -0,0 +1,37 @@ +HELP.md +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ diff --git a/spot-gateway/build.gradle b/spot-gateway/build.gradle new file mode 100644 index 00000000..9c3f4d04 --- /dev/null +++ b/spot-gateway/build.gradle @@ -0,0 +1,39 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '4.0.1' + id 'io.spring.dependency-management' version '1.1.7' +} + +group = 'com.example' +version = '0.0.1-SNAPSHOT' +description = 'spot-gateway' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +repositories { + mavenCentral() +} + +ext { + set('springCloudVersion', "2025.1.0") +} + +dependencies { + implementation 'org.springframework.cloud:spring-cloud-starter-gateway-server-webflux' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' +} + +dependencyManagement { + imports { + mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}" + } +} + +tasks.named('test') { + useJUnitPlatform() +} diff --git a/spot-gateway/gradle/wrapper/gradle-wrapper.jar b/spot-gateway/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..f8e1ee3125fe0768e9a76ee977ac089eb657005e GIT binary patch literal 45633 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�|d9Zj^A1Dgv5yzCONAB>8LM`TRL&7v_NKg(bEl#y&Z$py}mu<4DrT@8HHjE zqD@4|aM>vt!Yvc2;9Y#V;KJ8M>vPjiS2ycq52qkxInUK*QqA3$&OJ`jZBo zpzw&PT%w0$D94KD%}VN9c)eCueh1^)utGt2OQ+DP(BXszodfc1kFPWl~BQ5Psy*d`UIf zc}zQ8TVw35jdCSc78)MljC-g3$GX2$<0<3MEQXS&i<(ZFClz9WlL}}?%u>S2hhEk_ zyzfm&@Q%YVB-vw3KH|lU#c_)0aeG^;aDG&!bwfOz_9)6gLe;et;h(?*0d-RV0V)1l zzliq#`b9Y*c`0!*6;*mU@&EFSbW>9>L5xUX+unp%@tCW#kLfz)%3vwN{1<-R*g+B_C^W8)>?n%G z<#+`!wU$L&dn)Pz(9DGGI%RlmM2RpeDy9)31OZV$c2T>-Jl&4$6nul&e7){1u-{nP zE$uZs%gyanu+yBcAb+jTYGy(^<;&EzeLeqveN12Lvv)FQFn0o&*qAaH+gLJ)*xT9y z>`Y`W?M#K7%w26w?Oen>j7=R}EbZ;+jcowV&i}P|IfW^C5GJHt5D;Q~)|=gW3iQ;N zQGl4SQFtz=&~BGon6hO@mRnjpmM79ye^LY_L2no{f_M?j80pr`o3BrI7ice#8#Zt4 zO45G97Hpef+AUEU%jN-dLmPYHY(|t#D)9|IeB^i1X|eEq+ymld_Uj$l^zVAPRilx- z^II$sL4G~{^7?sik2BK7;ZV-VIVhrKjUxBIsf^N&K`)5;PjVg-DTm1Xtw4-tGtElU zJgVTCk4^N4#-kPuX=7p~GMf5Jj5A#>)GX)FIcOqY4lf}Vv2gjrOTuFusB@ERW-&fb zTp=E0E?gXkwzn)AMMY*QCftp%MOL-cbsG{02$0~b?-JD{-nwj58 zBHO1YL~yn~RpnZ6*;XA|MSJeBfX-D?afH*E!2uGjT%k!jtx~OG_jJ`Ln}lMQb7W41 zmTIRd%o$pu;%2}}@2J$x%fg{DZEa-Wxdu6mRP~Ea0zD2+g;Dl*to|%sO-5mUrZ`~C zjJ zUe^**YRgBvlxl<(r0LjxjSQKiTx+E<7$@9VO=RYgL9ldTyKzfqR;Y&gu^ub!fVX7u z3H@;8j#tVgga~EMuXv_#Q8<*uK@R{mGzn92eDYkF1sbxh5!P|M-D)T~Ae*SO`@u$Q z7=5s)HM)w~s2j5{I67cqSn6BLLhCMcn0=OTVE?T7bAmY!T+xZ_N3op~wZ3Oxlm6(a5qB({6KghlvBd9HJ#V6YY_zxbj-zI`%FN|C*Q`DiV z#>?Kk7VbuoE*I9tJaa+}=i7tJnMRn`P+(08 za*0VeuAz!eI7giYTsd26P|d^E2p1f#oF*t{#klPhgaShQ1*J7?#CTD@iDRQIV+Z$@ z>qE^3tR3~MVu=%U%*W(1(waaFG_1i5WE}mvAax;iwZKv^g1g}qXY7lAd;!QQa#5e= z1_8KLHje1@?^|6Wb(A{HQ_krJJP1GgE*|?H0Q$5yPBQJlGi;&Lt<3Qc+W4c}Ih~@* zj8lYvme}hwf@Js%Oj=4BxXm15E}7zS0(dW`7X0|$damJ|gJ6~&qKL>gB_eC7%1&Uh zLtOkf7N0b;B`Qj^9)Bfh-( z0or96!;EwEMnxwp!CphwxxJ+DDdP4y3F0i`zZp-sQ5wxGIHIsZCCQz5>QRetx8gq{ zA33BxQ}8Lpe!_o?^u2s3b!a-$DF$OoL=|9aNa7La{$zI#JTu_tYG{m2ly$k?>Yc); zTA9ckzd+ibu>SE6Rc=Yd&?GA9S5oaQgT~ER-|EwANJIAY74|6 z($#j^GP}EJqi%)^jURCj&i;Zl^-M9{=WE69<*p-cmBIz-400wEewWVEd^21}_@A#^ z2DQMldk_N)6bhFZeo8dDTWD@-IVunEY*nYRON_FYII-1Q@@hzzFe(lTvqm}InfjQ2 zN>>_rUG0Lhaz`s;GRPklV?0 z;~t4S8M)ZBW-ED?#UNbCrsWb=??P># zVc}MW_f80ygG_o~SW+Q6oeIUdFqV2Fzys*7+vxr^ZDeXcZZc;{kqK;(kR-DKL zByDdPnUQgnX^>x?1Tz~^wZ%Flu}ma$Xmgtc7pSmBIH%&H*Tnm=L-{GzCv^UBIrTH5 zaoPO|&G@SB{-N8Xq<+RVaM_{lHo@X-q}`zjeayVZ9)5&u*Y>1!$(wh9Qoe>yWbPgw zt#=gnjCaT_+$}w^*=pgiHD8N$hzqEuY5iVL_!Diw#>NP7mEd?1I@Io+?=$?7cU=yK zdDKk_(h_dB9A?NX+&=%k8g+?-f&`vhAR}&#zP+iG%;s}kq1~c{ac1@tfK4jP65Z&O zXj8Ew>l7c|PMp!cT|&;o+(3+)-|SK&0EVU-0-c&guW?6F$S`=hcKi zpx{Z)UJcyihmN;^E?*;fxjE3kLN4|&X?H&$md+Ege&9en#nUe=m>ep3VW#C?0V=aS zLhL6v)|%$G5AO4x?Jxy8e+?*)YR~<|-qrKO7k7`jlxpl6l5H&!C4sePiVjAT#)b#h zEwhfkpFN9eY%EAqg-h&%N>E0#%`InXY?sHyptcct{roG42Mli5l)sWt66D_nG2ed@ z#4>jF?sor7ME^`pDlPyQ(|?KL9Q88;+$C&3h*UV*B+*g$L<{yT9NG>;C^ZmPbVe(a z09K^qVO2agL`Hy{ISUJ{khPKh@5-)UG|S8Sg%xbJMF)wawbgll3bxk#^WRqmdY7qv zr_bqa3{`}CCbREypKd!>oIh^IUj4yl1I55=^}2mZAAW6z}Kpt3_o1b4__sQ;b zv)1=xHO?gE-1FL}Y$0YdD-N!US;VSH>UXnyKoAS??;T%tya@-u zfFo)@YA&Q#Q^?Mtam19`(PS*DL{PHjEZa(~LV7DNt5yoo1(;KT)?C7%^Mg;F!C)q= z6$>`--hQX4r?!aPEXn;L*bykF1r8JVDZ)x4aykACQy(5~POL;InZPU&s5aZm-w1L< z`crCS5=x>k_88n(*?zn=^w*;0+8>ui2i>t*Kr!4?aA1`yj*GXi#>$h8@#P{S)%8+N zCBeL6%!Ob1YJs5+a*yh{vZ8jH>5qpZhz_>(ph}ozKy9d#>gba1x3}`-s_zi+SqIeR z0NCd7B_Z|Fl+(r$W~l@xbeAPl5{uJ{`chq}Q;y8oUN0sUr4g@1XLZQ31z9h(fE_y( z_iQ(KB39LWd;qwPIzkvNNkL(P(6{Iu{)!#HvBlsbm`g2qy&cTsOsAbwMYOEw8!+75D!>V{9SZ?IP@pR9sFG{T#R*6ez2&BmP8*m^6+H2_ z>%9pg(+R^)*(S21iHjLmdt$fmq6y!B9L!%+;wL5WHc^MZRNjpL9EqbBMaMns2F(@h zN0BEqZ3EWGLjvY&I!8@-WV-o@>biD;nx;D}8DPapQF5ivpHVim8$G%3JrHtvN~U&) zb1;=o*lGfPq#=9Moe$H_UhQPBjzHuYw;&e!iD^U2veY8)!QX_E(X@3hAlPBIc}HoD z*NH1vvCi5xy@NS41F1Q3=Jkfu&G{Syin^RWwWX|JqUIX_`}l;_UIsj&(AFQ)ST*5$ z{G&KmdZcO;jGIoI^+9dsg{#=v5eRuPO41<*Ym!>=zHAXH#=LdeROU-nzj_@T4xr4M zJI+d{Pp_{r=IPWj&?%wfdyo`DG1~|=ef?>=DR@|vTuc)w{LHqNKVz9`Dc{iCOH;@H5T{ zc<$O&s%k_AhP^gCUT=uzrzlEHI3q`Z3em0*qOrPHpfl1v=8Xkp{!f9d2p!4 zL40+eJB4@5IT=JTTawIA=Z%3AFvv=l1A~JX>r6YUMV7GGLTSaIn-PUw| z;9L`a<)`D@Qs(@P(TlafW&-87mcZuwFxo~bpa01_M9;$>;4QYkMQlFPgmWv!eU8Ut zrV2<(`u-@1BTMc$oA*fX;OvklC1T$vQlZWS@&Wl}d!72MiXjOXxmiL8oq;sP{)oBe zS#i5knjf`OfBl}6l;BSHeY31w8c~8G>$sJ9?^^!)Z*Z*Xg zbTbkcbBpgFui(*n32hX~sC7gz{L?nlnOjJBd@ zUC4gd`o&YB4}!T9JGTe9tqo0M!JnEw4KH7WbrmTRsw^Nf z^>RxG?2A33VG3>E?iN|`G6jgr`wCzKo(#+zlOIzp-^E0W0%^a>zO)&f(Gc93WgnJ2p-%H-xhe{MqmO z8Iacz=Qvx$ML>Lhz$O;3wB(UI{yTk1LJHf+KDL2JPQ6#m%^bo>+kTj4-zQ~*YhcqS z2mOX!N!Q$d+KA^P0`EEA^%>c12X(QI-Z}-;2Rr-0CdCUOZ=7QqaxjZPvR%{pzd21HtcUSU>u1nw?)ZCy+ zAaYQGz59lqhNXR4GYONpUwBU+V&<{z+xA}`Q$fajmR86j$@`MeH}@zz*ZFeBV9Ot< ze8BLzuIIDxM&8=dS!1-hxiAB-x-cVmtpN}JcP^`LE#2r9ti-k8>Jnk{?@Gw>-WhL=v+H!*tv*mcNvtwo)-XpMnV#X>U1F z?HM?tn^zY$6#|(|S~|P!BPp6mur58i)tY=Z-9(pM&QIHq+I5?=itn>u1FkXiehCRC zW_3|MNOU)$-zrjKnU~{^@i9V^OvOJMp@(|iNnQ%|iojG2_Snnt`1Cqx2t)`vW&w2l zwb#`XLNY@FsnC-~O&9|#Lpvw7n!$wL9azSk)$O}?ygN@FEY({2%bTl)@F2wevCv`; zZb{`)uMENiwE|mti*q5U4;4puX{VWFJ#QIaa*%IHKyrU*HtjW_=@!3SlL~pqLRs?L zoqi&}JLsaP)yEH!=_)zmV-^xy!*MCtc{n|d%O zRM>N>eMG*Qi_XAxg@82*#zPe+!!f#;xBxS#6T-$ziegN-`dLm z=tTN|xpfCPng06|X^6_1JgN}dM<_;WsuL9lu#zLVt!0{%%D9*$nT2E>5@F(>Fxi%Y zpLHE%4LZSJ1=_qm0;^Wi%x56}k3h2Atro;!Ey}#g&*BpbNXXS}v>|nn=Mi0O(5?=1V7y1^1Bdt5h3}oL@VsG>NAH z1;5?|Sth=0*>dbXSQ%MQKB?eN$LRu?yBy@qQVaUl*f#p+sLy$Jd>*q;(l>brvNUbIF0OCf zk%Q;Zg!#0w0_#l)!t?3iz~`X8A>Yd3!P&A4Ov6&EdZmOixeTd4J`*Wutura(}4w@KV>i#rf(0PYL&v^89QiXBP6sj=N;q8kVxS}hA! z|3QaiYz!w+xQ%9&Zg${JgQ*Ip_bg2rmmG`JkX^}&5gbZF!Z(gDD1s5{QwarPK(li- zW9y-CiQ`5Ug1ceN1w7lCxl=2}7c*8_XH8W7y0AICn19qZ`w}z0iCJ$tJ}NjzQCH90 zc!UzpKvk%3;`XfFi2;F*q2eMQQ5fzO{!`KU1T^J?Z64|2Z}b1b6h80_H%~J)J)kbM0hsj+FV6%@_~$FjK9OG7lY}YA zRzyYxxy18z<+mCBiX?3Q{h{TrNRkHsyF|eGpLo0fKUQ|19Z0BamMNE9sW z?vq)r`Qge{9wN|ezzW=@ojpVQRwp##Q91F|B5c`a0A{HaIcW>AnqQ*0WT$wj^5sWOC1S;Xw7%)n(=%^in zw#N*+9bpt?0)PY$(vnU9SGSwRS&S!rpd`8xbF<1JmD&6fwyzyUqk){#Q9FxL*Z9%#rF$} zf8SsEkE+i91VY8d>Fap#FBacbS{#V&r0|8bQa;)D($^v2R1GdsQ8YUk(_L2;=DEyN%X*3 z;O@fS(pPLRGatI93mApLsX|H9$VL2)o(?EYqlgZMP{8oDYS8)3G#TWE<(LmZ6X{YA zRdvPLLBTatiUG$g@WK9cZzw%s6TT1Chmw#wQF&&opN6^(D`(5p0~ zNG~fjdyRsZv9Y?UCK(&#Q2XLH5G{{$9Y4vgMDutsefKVVPoS__MiT%qQ#_)3UUe=2fK)*36yXbQUp#E98ah(v`E$c3kAce_8a60#pa7rq6ZRtzSx6=I^-~A|D%>Riv{Y`F9n3CUPL>d`MZdRmBzCum2K%}z@Z(b7#K!-$Hb<+R@Rl9J6<~ z4Wo8!!y~j(!4nYsDtxPIaWKp+I*yY(ib`5Pg356Wa7cmM9sG6alwr7WB4IcAS~H3@ zWmYt|TByC?wY7yODHTyXvay9$7#S?gDlC?aS147Ed7zW!&#q$^E^_1sgB7GKfhhYu zOqe*Rojm~)8(;b!gsRgQZ$vl5mN>^LDgWicjGIcK9x4frI?ZR4Z%l1J=Q$0lSd5a9 z@(o?OxC72<>Gun*Y@Z8sq@od{7GGsf8lnBW^kl6sX|j~UA2$>@^~wtceTt^AtqMIx zO6!N}OC#Bh^qdQV+B=9hrwTj>7HvH1hfOQ{^#nf%e+l)*Kgv$|!kL5od^ka#S)BNT z{F(miX_6#U3+3k;KxPyYXE0*0CfL8;hDj!QHM@)sekF9uyBU$DRZkka4ie^-J2N8w z3PK+HEv7kMnJU1Y+>rheEpHdQ3_aTQkM3`0`tC->mpV=VtvU((Cq$^(S^p=+$P|@} zueLA}Us^NTI83TNI-15}vrC7j6s_S`f6T(BH{6Jj{Lt;`C+)d}vwPGx62x7WXOX19 z2mv1;f^p6cG|M`vfxMhHmZxkkmWHRNyu2PDTEpC(iJhH^af+tl7~h?Y(?qNDa`|Ogv{=+T@7?v344o zvge%8Jw?LRgWr7IFf%{-h>9}xlP}Y#GpP_3XM7FeGT?iN;BN-qzy=B# z=r$79U4rd6o4Zdt=$|I3nYy;WwCb^`%oikowOPGRUJ3IzChrX91DUDng5_KvhiEZwXl^y z+E!`Z6>}ijz5kq$nNM8JA|5gf_(J-);?SAn^N-(q2r6w31sQh6vLYp^ z<>+GyGLUe_6eTzX7soWpw{dDbP-*CsyKVw@I|u`kVX&6_h5m!A5&3#=UbYHYJ5GK& zLcq@0`%1;8KjwLiup&i&u&rmt*LqALkIqxh-)Exk&(V)gh9@Fn+WU=6-UG^X2~*Q-hnQ$;;+<&lRZ>g0I`~yuv!#84 zy>27(l&zrfDI!2PgzQyV*R(YFd`C`YwR_oNY+;|79t{NNMN1@fp?EaNjuM2DKuG%W z5749Br2aU6K|b=g4(IR39R8_!|B`uQ)bun^C9wR4!8isr$;w$VOtYk+1L9#CiJ#F) z)L}>^6>;X~0q&CO>>ZBo0}|Ex9$p*Hor@Ej9&75b&AGqzpGpM^dx}b~E^pPKau2i5 zr#tT^S+01mMm}z480>-WjU#q`6-gw4BJMWmW?+VXBZ#JPzPW5QQm@RM#+zbQMpr>M zX$huprL(A?yhv8Y81K}pTD|Gxs#z=K(Wfh+?#!I$js5u8+}vykZh~NcoLO?ofpg0! zlV4E9BAY_$pN~e-!VETD&@v%7J~_jdtS}<_U<4aRqEBa&LDpc?V;n72lTM?pIVG+> z*5cxz_iD@3vIL5f9HdHov{o()HQ@6<+c}hfC?LkpBEZ4xzMME^~AdB8?2F=#6ff!F740l&v7FN!n_ zoc1%OfX(q}cg4LDk-1%|iZ^=`x5Vs{oJYhXufP;BgVd*&@a04pSek6OS@*UH`*dAp z7wY#70IO^kSqLhoh9!qIj)8t4W6*`Kxy!j%Bi%(HKRtASZ2%vA0#2fZ=fHe0zDg8^ zucp;9(vmuO;Zq9tlNH)GIiPufZlt?}>i|y|haP!l#dn)rvm8raz5L?wKj9wTG znpl>V@};D!M{P!IE>evm)RAn|n=z-3M9m5J+-gkZHZ{L1Syyw|vHpP%hB!tMT+rv8 zIQ=keS*PTV%R7142=?#WHFnEJsTMGeG*h)nCH)GpaTT@|DGBJ6t>3A)XO)=jKPO<# zhkrgZtDV6oMy?rW$|*NdJYo#5?e|Nj>OAvCXHg~!MC4R;Q!W5xcMwX#+vXhI+{ywS zGP-+ZNr-yZmpm-A`e|Li#ehuWB{{ul8gB&6c98(k59I%mMN9MzK}i2s>Ejv_zVmcMsnobQLkp z)jmsJo2dwCR~lcUZs@-?3D6iNa z2k@iM#mvemMo^D1bu5HYpRfz(3k*pW)~jt8UrU&;(FDI5ZLE7&|ApGRFLZa{yynWx zEOzd$N20h|=+;~w$%yg>je{MZ!E4p4x05dc#<3^#{Fa5G4ZQDWh~%MPeu*hO-6}2*)t-`@rBMoz&gn0^@c)N>z|Ikj8|7Uvdf5@ng296rq2LiM#7KrWq{Jc7;oJ@djxbC1s6^OE>R6cuCItGJ? z6AA=5i=$b;RoVo7+GqbqKzFk>QKMOf?`_`!!S!6;PSCI~IkcQ?YGxRh_v86Q%go2) zG=snIC&_n9G^|`+KOc$@QwNE$b7wxBY*;g=K1oJnw8+ZR)ye`1Sn<@P&HZm0wDJV* z=rozX4l;bJROR*PEfHHSmFVY3M#_fw=4b_={0@MP<5k4RCa-ZShp|CIGvW^9$f|BM#Z`=3&=+=p zp%*DC-rEH3N;$A(Z>k_9rDGGj2&WPH|}=Pe3(g}v3=+`$+A=C5PLB3UEGUMk92-erU%0^)5FkU z^Yx#?Gjyt*$W>Os^Fjk-r-eu`{0ZJbhlsOsR;hD=`<~eP6ScQ)%8fEGvJ15u9+M0c|LM4@D(tTx!T(sRv zWg?;1n7&)-y0oXR+eBs9O;54ZKg=9eJ4gryudL84MAMsKwGo$85q6&cz+vi)9Y zvg#u>v&pQQ1NfOhD#L@}NNZe+l_~BQ+(xC1j-+({Cg3_jrZ(YpI{3=0F1GZsf+3&f z#+sRf=v7DVwTcYw;SiNxi5As}hE-Tpt)-2+lBmcAO)8cP55d0MXS*A3yI5A!Hq&IN zzb+)*y8d8WTE~Vm3(pgOzy%VI_e4lBx&hJEVBu!!P|g}j(^!S=rNaJ>H=Ef;;{iS$$0k-N(`n#J_K40VJP^8*3YR2S`* zED;iCzkrz@mP_(>i6ol5pMh!mnhrxM-NYm0gxPF<%(&Az*pqoRTpgaeC!~-qYKZHJ z2!g(qL_+hom-fp$7r=1#mU~Dz?(UFkV|g;&XovHh~^6 z1eq4BcKE%*aMm-a?zrj+p;2t>oJxxMgsmJ^Cm%SwDO?odL%v6fXU869KBEMoC0&x>qebmE%y+W z51;V2xca9B=wtmln74g7LcEgJe1z7o>kwc1W=K1X7WAcW%73eGwExo&{SSTnXR+pA zRL)j$LV7?Djn8{-8CVk94n|P>RAw}F9uvp$bpNz<>Yw3PgWVJo?zFYH9jzq zU|S+$C6I?B?Jm>V{P67c9aRvK283bnM(uikbL=``ew5E)AfV$SR4b8&4mPDkKT&M3 zok(sTB}>Gz%RzD{hz|7(AFjB$@#3&PZFF5_Ay&V3?c&mT8O;9(vSgWdwcy?@L-|`( z@@P4$nXBmVE&Xy(PFGHEl*K;31`*ilik77?w@N11G7IW!eL@1cz~XpM^02Z?CRv1R z5&x6kevgJ5Bh74Q8p(-u#_-3`246@>kY~V4!XlYgz|zMe18m7Vs`0+D!LQwTPzh?a zp?X169uBrRvG3p%4U@q_(*^M`uaNY!T6uoKk@>x(29EcJW_eY@I|Un z*d;^-XTsE{Vjde=Pp3`In(n!ohHxqB%V`0vSVMsYsbjN6}N6NC+Ea`Hhv~yo@ z|Ab%QndSEzidwOqoXCaF-%oZ?SFWn`*`1pjc1OIk2G8qSJ$QdrMzd~dev;uoh z>SneEICV>k}mz6&xMqp=Bs_0AW81D{_hqJXl6ZWPRNm@cC#+pF&w z{{TT0=$yGcqkPQL>NN%!#+tn}4H>ct#L#Jsg_I35#t}p)nNQh>j6(dfd6ng#+}x3^ zEH`G#vyM=;7q#SBQzTc%%Dz~faHJK+H;4xaAXn)7;)d(n*@Bv5cUDNTnM#byv)DTG zaD+~o&c-Z<$c;HIOc!sERIR>*&bsB8V_ldq?_>fT!y4X-UMddUmfumowO!^#*pW$- z_&)moxY0q!ypaJva)>Bc&tDs?D=Rta*Wc^n@uBO%dd+mnsCi0aBZ3W%?tz844FkZD zzhl+RuCVk=9Q#k;8EpXtSmR;sZUa5(o>dt+PBe96@6G}h`2)tAx(WKR4TqXy(YHIT z@feU+no42!!>y5*3Iv$!rn-B_%sKf6f4Y{2UpRgGg*dxU)B@IRQ`b{ncLrg9@Q)n$ zOZ7q3%zL99j1{56$!W(Wu{#m|@(6BBb-*zV23M!PmH7nzOD@~);0aK^iixd%>#BwR zyIlVF*t4-Ww*IPTGko3RuyJ*^bo-h}wJ{YkHa2y3mIK%U%>PFunkx0#EeIm{u93PX z4L24jUh+37=~WR47l=ug2cn_}7CLR(kWaIpH8ojFsD}GN3G}v6fI-IMK2sXnpgS5O zHt<|^d9q}_znrbP0~zxoJ-hh6o81y+N;i@6M8%S@#UT)#aKPYdm-xlbL@v*`|^%VS(M$ zMQqxcVVEKe5s~61T77N=9x7ndQ=dzWp^+#cX}v`1bbnH@&{k?%I%zUPTDB(DCWY6( zR`%eblFFkL&C{Q}T6PTF0@lW0JViFzz4s5Qt?P?wep8G8+z3QFAJ{Q8 z9J41|iAs{Um!2i{R7&sV=ESh*k(9`2MM2U#EXF4!WGl(6lI!mg_V%pRenG>dEhJug z^oLZ?bErlIPc@Jo&#@jy@~D<3Xo%x$)(5Si@~}ORyawQ{z^mzNSa$nwLYTh6E%!w_ zUe?c`JJ&RqFh1h18}LE47$L1AwR#xAny*v9NWjK$&6(=e0)H_v^+ZIJ{iVg^e_K-I z|L;t=x>(vU{1+G+P5=i7QzubN=dWIe(bqeBJ2fX85qrBYh5pj*f05=8WxcP7do(_h zkfEQ1Fhf^}%V~vr>ed9*Z2aL&OaYSRhJQFWHtirwJFFkfJdT$gZo;aq70{}E#rx((U`7NMIb~uf>{Y@Fy@-kmo{)ei*VjvpSH7AU zQG&3Eol$C{Upe`034cH43cD*~Fgt?^0R|)r(uoq3ZjaJqfj@tiI~`dQnxfcQIY8o| zx?Ye>NWZK8L1(kkb1S9^8Z8O_(anGZY+b+@QY;|DoLc>{O|aq(@x2=s^G<9MAhc~H z+C1ib(J*&#`+Lg;GpaQ^sWw~f&#%lNQ~GO}O<5{cJ@iXSW4#};tQz2#pIfu71!rQ( z4kCuX$!&s;)cMU9hv?R)rQE?_vV6Kg?&KyIEObikO?6Nay}u#c#`ywL(|Y-0_4B_| zZFZ?lHfgURDmYjMmoR8@i&Z@2Gxs;4uH)`pIv#lZ&^!198Fa^Jm;?}TWtz8sulPrL zKbu$b{{4m1$lv0`@ZWKA|0h5U!uIwqUkm{p7gFZ|dl@!5af*zlF% zpT-i|4JMt%M|0c1qZ$s8LIRgm6_V5}6l6_$cFS# z83cqh6K^W(X|r?V{bTQp14v|DQg;&;fZMu?5QbEN|DizzdZSB~$ZB%UAww;P??AT_-JFKAde%=4c z*WK^Iy5_Y`*IZ+cF`jvkCv~Urz3`nP{hF!UT7Z&e;MlB~LBDvL^hy{%; z7t5+&Ik;KwQ5H^i!;(ly8mfp@O>kH67-aW0cAAT~U)M1u`B>fG=Q2uC8k}6}DEV=% z<0n@WaN%dDBTe*&LIe^r-!r&t`a?#mEwYQuwZ69QU3&}7##(|SIP*4@y+}%v^Gb3# zrJ~68hi~77ya4=W-%{<(XErMm>&kvG`{7*$QxRf(jrz|KGXJN3Hs*8BfBx&9|5sZ1 zpFJ1(B%-bD42(%cOiT@2teyYoUBS`L%<(g;$b6nECbs|ADH5$LYxj?i3+2^#L@d{%E(US^chG<>aL7o>Fg~ zW@9wW@Mb&X;BoMz+kUPUcrDQOImm;-%|nxkXJ8xRz|MlPz5zcJHP<+yvqjB4hJAPE zRv>l{lLznW~SOGRU~u77UcOZyR#kuJrIH_){hzx!6NMX z>(OKAFh@s2V;jk|$k5-Q_ufVe;(KCrD}*^oBx{IZq^AB|7z*bH+g_-tkT~8S$bzdU zhbMY*g?Qb;-m|0`&Jm}A8SEI0twaTfXhIc=no}$>)n5^cc)v!C^YmpxLt=|kf%!%f zp5L$?mnzMt!o(fg7V`O^BLyjG=rNa}=$hiZzYo~0IVX$bp^H-hQn!;9JiFAF<3~nt zVhpABVoLWDQ}2vEEF3-?zzUA(yoYw&$YeHB#WGCXkK+YrG=+t0N~!OmTN;fK*k>^! zJW_v+4Q4n2GP7vgBmK;xHg^7zFqyTTfq|0+1^H2lXhn6PpG#TB*``?1STTC#wcaj3 zG~Q9!XHZ#1oPZo zB6h(BVIW5K+S@JG_HctDLHWb;wobZ0h(3xr6(uUspOSK0WoSHeF$ZLw@)cpoIP|kL zu`GnW>gD$rMt}J0qa9kJzn0s`@JNy1Crkb&;ve|()+_%!x%us>1_Xz|BS>9oQeD3O zy#CHX#(q^~`=@_p$XV6N&RG*~oEH$z96b8S16(6wqH)$vPs=ia!(xPVX5o&5OIYQ%E(-QAR1}CnLTIy zgu1MCqL{_wE)gkj0BAezF|AzPJs=8}H2bHAT-Q@Vuff?0GL=)t3hn{$Le?|+{-2N~`HWe24?!1a^UpC~3nK$(yZ_Gp(EzP~a{qe>xK@fN zEETlwEV_%9d1aWU0&?U>p3%4%>t5Pa@kMrL4&S@ zmSn!Dllj>DIO{6w+0^gt{RO_4fDC)f+Iq4?_cU@t8(B^je`$)eOOJh1Xs)5%u3hf; zjw$47aUJ9%1n1pGWTuBfjeBumDI)#nkldRmBPRW|;l|oDBL@cq1A~Zq`dXwO)hZkI zZ=P7a{Azp06yl(!tREU`!JsmXRps!?Z~zar>ix0-1C+}&t)%ist94(Ty$M}ZKn1sDaiZpcoW{q&ns8aWPf$bRkbMdSgG+=2BSRQ6GG_f%Lu#_F z&DxHu+nKZ!GuDhb>_o^vZn&^Sl8KWHRDV;z#6r*1Vp@QUndqwscd3kK;>7H!_nvYH zUl|agIWw_LPRj95F=+Ex$J05p??T9_#uqc|q>SXS&=+;eTYdcOOCJDhz7peuvzKoZhTAj&^RulU`#c?SktERgU|C$~O)>Q^$T8ippom{6Ze0_44rQB@UpR~wB? zPsL@8C)uCKxH7xrDor zeNvVfLLATsB!DD{STl{Fn3}6{tRWwG8*@a2OTysNQz2!b6Q2)r*|tZwIovIK9Ik#- z0k=RUmu97T$+6Lz%WQYdmL*MNII&MI^0WWWGKTTi&~H&*Ay7&^6Bpm!0yoVNlSvkB z;!l3U21sJyqc`dt)82)oXA5p>P_irU*EyG72iH%fEpUkm1K$?1^#-^$$Sb=c8_? zOWxxguW7$&-qzSI=Z{}sRGAqzy3J-%QYz2Cffj6SOU|{CshhHx z6?5L$V_QIUbI)HZ9pwP9S15 zXc%$`dxETq+S3_jrfmi$k=)YO5iUeuQ&uX}rCFvz&ubO?u)tv|^-G_`h$pb+8vn@f z7@eQe#Kx|8^37a4d0GulYIUAW|@I5|NIh%=OqHU{(>(UhKvJ}i_X*>!Geb+Rs0MWf66Lf z-cQ(4QOENSbTX$6w_9w4{5eR?14#?)Jqf2UCk5US4bnz8!e>vFduH6(cZZ=5*_!M# zUTZ_b<4v@}dSQOcH@wt-s;3JhkVDct$6k9!ETdi-tplkaxl^qF=p}Q8KMVm+ zeIa2q?RYr}nM0d_W2YWv%JKyCrGSePj8GrRN)<$Nsq8l$X=>`W;?>0eME3|8t&d$~ zH`XG45lBh>-te_f0Mh0??)=Ee0~zESx=sZPv<#!sAVv$0qTn@CmCUNJU<#=`GC)&P z9zuV~9*3_n2*ZQBUh)2xIi;0yo)9XXJxM-VB*6xpyz{Rx2ZCvFnF$2aPcYFG( zyXkO(B30?mt;5GW&{m^w3?!P`#_o;Y%P2z^A`|4%Bt2@3G?C2dcSPNy1#HMXZ>{+L z3BE#xvqR@Ub}uKfzGC=RO|W%dJpUK#m8p&Dk|6Ub8S+dN3qxf9dJ_|WFdM9CSNQv~ zjaFxIX`xx-($#Fq+EI76uB@kK=B4FS0k=9(c8UQnr(nLQxa2qWbuJyD7%`zuqH|eF zNrpM@SIBy@lKb%*$uLeRJQ->ko3yaG~8&}9|f z*KE`oMHQ(HdHlb&)jIzj5~&z8r}w?IM1KSdR=|GFYzDwbn8-uUfu+^h?80e*-9h%Nr;@)Q-TI#dN1V zQPT2;!Wk)DP`kiY<{o7*{on%It(j0&qSv=fNfg3qeNjT@CW{WT<)1Eig!g9lAGx6& zk9_Zrp2I+w_f!LRFsgxKA}gO=xSPSY``kn=c~orU4+0|^K762LWuk_~oK{!-4N8p8 zUDVu0ZhvoD0fN8!3RD~9Bz5GNEn%0~#+E-Js}NTBX;JXE@29MdGln$Aoa3Nzd@%Z= z^zuGY4xk?r(ax7i4RfxA?IPe27s87(e-2Z_KJ(~YI!7bhMQvfN4QX{!68nj@lz^-& z1Zwf=V5ir;j*30AT$nKSfB;K9(inDFwbI^%ohwEDOglz}2l}0!#LsdS3IW43= zBR#E@135bu#VExrtj?)RH^PM(K4B`d=Z6^kix`8$C1&q)w1<&?bAS?70}9fZwZU7R z5RYFo?2Q>e3RW2dl&3E^!&twE<~Lk+apY?#4PM5GWJb2xuWyZs6aAH-9gqg${<1?M zoK&n+$ZyGIi=hakHqRu{^8T4h@$xl?9OM46t;~1_mPs9}jV58E-sp!_CPH4<^A|Q5 zedUHmiyxTc2zgdxU?4PyQ{ON@r+Ucn1kjWSOsh6WzLV~Bv&vWLaj#Xz4VSDs*F#@M>#e^ixNCQ-J|iC=LcB*M4WUb>?v6C z14^8h9Ktd1>XhO$kb-rRL}SFTH)kSu+Dwds$oed7qL)Jbd zhQys4$Uw~yj03)6Kq+K-BsEDftLgjDZk@qLjAyrb5UMeuO^>D43g%0GoKJ~TO0o!D z9E$WfxEDFTT?~sT?|!7aYY*mpt`}i;WTgY|Cb4{Cscrmzb(?UE+nz1wC3#QSjbg>N zleu?7MGaQ&FtejK#?07Uq$vIZX5FqR*a=(zUm`Fq$VUl){GQ{2MA)_j4H$U8FZ`=A z&GU_an)?g%ULunbBq4EUT7uT=vI6~uapKC|H6uz1#Rqt$G(!hE7|c8_#JH%wp9+F? zX`ZigNe9GzC(|Nr8GlmwPre3*Nfu+ zF=SHtv_g@vvoVpev$Jxs|F7CH`X5#HAI=ke(>G6DQQ=h^U8>*J=t5Z3Fi>eH9}1|6 znwv3k>D=kufcp= zAyK#v05qERJxS_ts79QVns}M?sIf(hCO0Q9hKe49a@PzvqzZXTAde6a)iZLw|8V-) ziK`-s)d(oQSejO?eJki$UtP0ped)5T1b)uVFQJq*`7w8liL4TX*#K`hdS!pY9aLD+ zLt=c$c_wt^$Wp~N^!_nT(HiDVibxyq2oM^dw-jC~+3m-#=n!`h^8JYkDTP2fqcVC& zA`VWy*eJC$Eo7qIe@KK;HyTYo0c{Po-_yp=>J(1h#)aH5nV8WGT(oSP)LPgusH%N$?o%U%2I@Ftso10xd z)Tx(jT_vrmTQJDx0QI%9BRI1i!wMNy(LzFXM_wucgJGRBUefc413a9+)}~*UzvNI{KL# z_t4U&srNV|0+ZqwL(<}<%8QtjUD8kSB&p$v^y}vuEC2wyW{aXp2{LTi$EBEHjVnS# z+4=G$GUllsjw&hTbh6z%D2j=cG>gkNVlh|24QUfD*-x9OMzTO93n*pE(U7Vz7BaL% z@(c!GbEjK~fH}sqbB1JNI!~b+AYb5le<-qxDA9&r2o)|epl9@5Ya7}yVkcM)yW6KY7QOX_0-N=)+M!A$NpG? z6BvZ8Tb}Pw(i9f7S00=KbWmNvJGL(-MsAz3@aR~PM$Z>t)%AiCZu?A|?P*~UdhhFT`;Nb)MxIg*0QlkYVX+46( zSd%WoWR@kYToK7)(J=#qUD-ss;4M&27w#03y6$gk6X<-VL8AJM@NFTx#Z!n)F5T357%njjKyjro(yW8ceP{!%;*Y>DN`&_18p(z2Hg$%K zohbgJcp%+ux%q6F?(sc_mYJ<$;DxgkTEi?yjT6Du@+n(KsKtFHcO%7O z=AsfLSTdE2>7a@0^`;)?Fg|s2XOPV&fo<%Q)Izaw4s&RvrX0^+aPNq|yE?oSa7 zsnNs!+vGcTM4yM|$9so*2Nv;ngDD}b0MjH6i4e|l^O`lzCRj)-qa6f%|afJpmf(S1J2k7Nt^!;Q}0 z4ejPF?^M~Sv+@LYn&IFUk2;1h?kb8lfrT`oMm=JBm{fo5N|HY~yQQ`T*e2?!tF%*t zf+ncx15$NdF82GXrpP5rJ7!PVE3>u`ME$9Hw5RlP zUh+s#pg{9kEOsAhvu2pry#@dvbB3Lti+9VkLxPZSl;fNr9}wv1cTahUw_Py7%Xp;C zaz__|kz*ydKiYbsqK{?cXhqR(!1KMoV-+!mz>3S8S`Va4kD#(aKyqecGXB^nF*>mS z1gG>fKZc?R~Tye>%x+43D8=e zf0eKr-)>VEu7^I{%T}BT-WaGXO3+x<2w2jwnXePdc2#BdofU6wbE)ZWHsyj=_NT3o z)kySji#CTEnx8*-n=88Ld+TuNy;x$+vDpZ)=XwCr_Gx-+N=;=LCE7CqKX9 zQ-0{jIr zktqqWCgBa3PYK*qQqd=BO70DfM#|JvuW*0%zmTE{mBI$55J=Y2b2UoZ)Yk z3M%rrX7!nwk#@CXTr5=J__(3cI-8~*MC+>R);Z)0Zkj2kpsifdJeH)2uhA|9^B;S$ z4lT3;_fF@g%#qFotZ#|r-IB*zSo;fokxbsmMrfNfJEU&&TF%|!+YuN=#8jFS4^f*m zazCA-2krJ-;Tkufh!-urx#z*imYo|n6+NDGT#*EH355(vRfrGnr*x z5PWMD7>3IwEh=lO^V>O>iLP~S!GjrvI5lx<7oOg(d;6uEFqo5>IwptBQz;`>zx`n$ zjZQ#Hb)qJdQy#ML&qcfmb$KT+f_1#uYNo7HHDY}7xAw8qbl;9LWO-cndfI=5$%jBw zb}K3U%88Fg^|&0Vc~99bKl|$3JzdawRZ|`7%1S<8B7>9*rWAT0U<@mHDfnL1`~1U| zDw7m@<@}C|zqeHM(OK@di6~sKHiJvk^I0^S<LBe^_xZsUOzVkYSE)Bxn*NekQYbyTn5SRt!n{EseOo-$u)vjM(PV%6cIG3Kv$>dd}HUyXi;_Lv>}OyUj38dPe8+1Pr?{LXnIBCoTnocD60@vhsz+GG5lJB9ncgP8T6@LwuzZ)J zKETBS~AvzGE!{u^+Rd-|Gn!rc@UUnioP0{@_j_>tg8YI#?y zL-H$=&xXkCJ2Qe7&exbI!z`OyPxBp|4_ zZrrc;OAb%T4Ze%7E}FBB`8t$QN0sA3vpwU>?7QAmE%-ethXdCtby$Qm3v$lNxB2a7 ze6F5eEWV`={#W(G)Va}7?$D65WF|f0nmfZT;?=LE6Yz{{W3CV2h^Ma+LXdZ(HMVKZ z!YXJ*34lo!FA>)jSo@*!Hs_)IwmTo6pBr3c^j2u_amZ~g;&Z2jZIw!}v@w8DtZz7|A%rFksD4^HYB!xFAqX;u0HxPeG!3Z(z z4}+^N5-nckKf2YSR5R_}PD+2?Wq#BOiON74#{`u=4f59WKdy_77EYq~_|X6cNtno{ zZ?WLwbV57Z6uI|uY_;vzv~~`eiiOl($Au7C*X<&MY5v0b`KEu-GW}{2UNfmmrP!^Y zAOczy!}TIJsom=}kxH)9W`&Rp&rR6T7y&~5nXbut;wcs@M?aa^9j{ZDtx=1?P8TV{ zee2kKf%CE$mogyKKT=xQQ#)OCl9bjc)}{p2X$}aG`^B0w0yi-rI!d4e-u9uR$kJK3 zhqBG9Wx<-3DFw5olJ6neF@hB;8o(r(GB_;p1i>}cjN`JNEZg-dlxtLL=8~gfLrBy_ z1~bGh{I>_xqh(}?%bCf1U6~K@+N*i}bTi+pUAW)oM0`D*PeJq=S(-|Plxe9OqxBRg zM((r)xkSH@j!8@+=cA4US0fDL&O?W~x=Mlu>7zvHO2sy7D5_7ulP+YMecP~}F0b*K z3oO2j{o&WHd<&UWcyA(&6hvBJv}qUZ!@R<(mwKB^;y3zeE1>LzbDWSkRD1|5MZPx( zxd=&MsQi1eE@@6W+4N`cF?yh!3R5JlAV--&RONWQ#?SbrQ95<@ag>C{jQmGXpQX{) z1dbFg1_`qLxuDZnX#PKfCW*Jl3F&^7@gO&{>Nb8um$VBcF1!AL=N6`A%BFj=`QaPI z+m^`n+{o)KLif;Gt|7aQ(XXRP@x)jJt}s{&S`I3}jPTY>$@W0BD3Oif^ehs~!H7T1FUSWxLS&W;0q6+azjbWn?3!q$ z9qbmdr4H4Y)p^NOACJ^L>u}NS8T0_5hW)G z%Hv}dAqM}d@t;|hf8>+NHHPi*xePsRlqr46njzhiXXZti7i5+GTKcrlxA->OJ9*Pna`02EIA5~(SMV`T@H6F2VtwwP1$tYujbC1^VE$Yd&I`WSwB^1( zT7NP3|85z#R%&wktjwY_i*n_$RRZPM^ota{LPV%*>=>sAv%fn*cnkCIX{^SJRmwZv z!?f@T&D%Lz@*!mNYTGp{J|7)~PR*ib`;l^E)rQw@)Qn0ECnB8W1S_SbLZWdqcmo?V zX5g0_3qhn4TrN27^x#Qdq*4*G1L|)I^b8GuP_8O{p|M`uvZO6McXa>OSQRW|kQTNPZ#Zyj~SZ<`6B)Y+}jxpn+YT>MhZ!Rxyd@rU>N zP>MkDBLX|<)SJaO?Ge=!D>i+Wq&PgneO?ZXUq4IQuTq z+V{ZGkuw77o~o$!b>4ov`6CKJ)$cf=S6%1ZQyYU!kz_qiuNxY2*Bh;K9J6o_YV6xQ znW|>x+#Mymu&wF9P|3wP*(ZjwE+ou|{eFqMv}d_iEyH zQ?NSf3VX+EpbrIKmp|oD-t_rh(D#e)fp)dYbG{=yPj-3-#l+iu7r+~#w|(#wv@G0` z38`Yhf5CznhyDEhD;jzaz7fc8L?(n-m zR#|5hqq#yRoeTm+h^9J42mnB>BY>HSu&&O-Hxo6j!dqck)dGS&odS@Hsk2-*Z~x z0!%{@gT645S5DeF@JZeE$DFl*nJB8Z|JKvs%7d`KjbJ*AsA_=fEZ&V9=*+K{(TF^( ztjjYr(7@fV^tDs9c*#=8)ZRKO17A5Z`8v*)U+?hS>3sEfgh3`#vFO^7n}&&adV?}n zdy&BY1h|I@eBm=l*kqiJn>vNkOH4l$Op5Hw3K_w8lF!6T@-H)S2W|Km#6!-X#NqLJ zsiVDrc%*@I3^Gen$)6O0C_qw;8{aucF;}U^1%YE`?AYTtb`Z$B$vfhcHQF`VCB(Pf z_G#fV*Colv-k!O+=^nDNe(03?m+RTu&28d%>JrrwFNb{ND&?Ad(=DP@voz$usk1|w z&#gTB7F)#*LtY6@pIb(g72*LcnXRlTPQAD?)ZFnB*EsZqxM&Uk_KGXnR{4}K`I6i- zU9}R>tiO0De1Hx=kAy>7O+nKO@kGQEYOai&S9&WTY+flvR?uhI695W-xZnq4aRMh8 zwfp)+KYWVB#r=5AwwlSdM4@x7-R_{2;1iqz2lXL$7iu1>5W*+I)jlkMs>60=LN)Y= zbPw;;%U+%p_&{2Obemh$BLmbpDd31YxJ8#TpH3~3B8QLUMvx1X5Vl48hWSNN*UTlO zQgQyZbmyjGC-s$3tnB z0mfKUu2+_c`ZVvDVwUy#j3W*l^BSXXQ%=r6Z}C73jx8DAk!t7k{dK^udpHIcUejp# zyx}og$Hr+f>9kaZvno*Om`d|VTUce9tHM=R8thoG!a=NT$s;g@n_rAN%cp7nnLuav z6}j56TSSfPL$p#y#!5TVyqa3zTzi7@#IoeR=E6CdS`JrR+@i2DwZ?T*bh+(k5!a)0 zgRdF93z8XJ|5?>hDN!YAW5cK=+BwDLNT_+otd zqC@*{S0hCKZ+TnN*2&qx+WP;ZjHA`yytPcwKl~)uy)sQ}Q*0-&3X|YFYAjmolaciq zxS$r5^fxICetD*Dw78M9leVvhAOZ$=;SP7L!Vs?+0f1h*YCuTXIt03iAf)0=0KEvZ zB69o-zg`0C#hQ>`4`}1g=a~EID(j9HbjJG^tV-zumR-+fahTPveA{%0u2uQwMZ%}5 zwY!|}i0oTd&>^QSRhIKU+cMC#|C3f>|647?v1B(wH)EWb{vuJEJh~!#|J7%=h!x3| zCH6m}wg;>Q&?@5Ct1%n`lj%*>9a52d@wmvE`=aQjtz$sWj3V;fDns5<7d2*``)u1( zh!Ub>!#N0m=Vz1n1=El zwb2IVRw$6NIFRpGyUoM0iqc$IPehcmm7<0s7F*Yv+zq?_%pf*SS~~}s0M`m(rMbx% zi?|Wjr6fJN`_J8&B2$4+V+iO~m>s~Zr2T3Y3HGREFQ%%pEoU0N));AeSVM#gYQ>l} z0`RhgS`R^pJH31YQ~eTeJiI}g$&^|nv{!h?8mJK{{XDt+sG8D`7)$jvM#hjPI(5sS zfFW4s7wao%Lo| z#pJRC?iZOai;57ANs|vm6%}rPlGo}}Aso1t#xJn}%VW@~1WSjh(@JTgM$0x6ZQ)gB zdiox3f>kqGZY}+R<;wlNoWJ8#X-v)1;wRD*ec*wnvsN06Q@cZuD`deT-Bu&G;2fBC z0FE1%pG@{Yo2O87&dE;w???%`9s1gs=3GpM8xx_}=AB$K9y=cD);^iE*p4;T1RU%B zBPr)yqOBX<2}xt%g9qr>;z&|?4vhhw7@$a}Uy2b%_^VdB^VfzrebKUPnq;hliCNU% zVt3R5EHkhN^Pv`REF+npA@#HdCQN9IbQbqSDs^+zt(A6;rLwN+@Em}WrV5vPEo!w^ zSCd3RZ8{7a@d9@|IF&&G%irS7FHle?@49LctrtTt=rP$W)se*#RkFmyf)D1^U6EYI zfh+N?uH?-))O$9zM19VsuGn8?o~5`scXU?!P@_cWP&1U4PQqGus=sQzrX+YvKG%XBL3nt6!&M<#}wqA;Mo(}qrq<1lNkpQD-T#-y>grt|E+JNU) z2j+g+QPcA9VEFc0k;H(hSNOpp$I+!$ z&d&W6kBM9+c{X%vr_X0}tdB5dvEDyk5H2*T(QW8Yz-#tjvF?up=^Kfym``^!&O-X! z@HdfpHn;}_)y$Xjb-5cR$Q#-XdhKpmJG5pl>h*Q2(u*gt_4(>6?kG)%T3*&TT0qI( zL!aR~4HiJiaHlgdNcOQP6xx1f3AWx&8}(NEps|G!cO>J^rE2@&-t#_Jb7GYgnLnML~1ze1D$?~BwbgA^=pr55tC|d7w42vN11_8bS75u z_MRKqE7Xik8fk>6(VE5{qT}6rSzd|o}Zb>*aI*Bwg%ccE$_ytH;g2H z^i3qY!+aE*&s^BMH9TI6GLm&9c`D6)3{-+?2Pon+040Yuv$2(LqV*krKhTg5CHOj* zquacxc1&~=S(O@gR8aI#?R%)meONmw1rub9E2QzeM$pBBm2wbPNR3tab{op53<oFwaUbARdD5jSA_6zmKX7!VicEP1m)rYnk{P- zruRj;4c8S29Rd#Baf|fq_pA^r3K#qRHS;($XNoLI*`puZjM?bA0tH>FDiVc9qR*|3 zGn#nhqxkvqFwRfCB~2yA0pxWapfjCdAem$utuon-`*6}mUP?l%$CE(FjAwL%Oe7GQbu7*+&q>*(cAofJr^gg>xw>hx-SO7Lx2)I} zJ)tV1XKbkE4sS&La#-smSq>S9gBzGLH%v?KVezdGv%Xs}kDJZJi{lDl(FpLZupBta z3iDlkd6LlkRro}+El?GIObw06D%NTXpL{W}Ve*%u#{wTC=+VHS%o`sAez&cYz|Tn` zcK_~pvN%cd^8FlFypCjTjw9@ulLoJ^!QAK*++^wC2~}CFeoY;q6y~r&f^+0>LR6)n z$hSev@GzzGgDc>)#u5_;{T9^5y5I?m=z7=J!eVId8p6R5>NV8)h|bA}#3KUufq4CPGiWYvGj%0=H@Q66);F)#cDMND4 zX|?rg>Bb28q*a!_sgVF(A=OeC&je$C4>$0%yy;Fla-hl(|9Ww4!@Q#E2hpJMMxpQ2L+R;+ZMpS+|j*F`Fh}p)`a_*<`AaeFzNEq^- zlF$7BFKD%p@K+3$Vx%N{QOayKKWU#JOAwXiLO62cA6=|DiDG_Z=ef;f&gQ5-?+Pb+ z)4NsyEZXCdjq5tgDN39V9!6#w25+R1;PD7ss;hFvQn}Hnl3^3h<`ylzJdVEL>|Jj0 zg>=Pscwx&;pWEzMn`ld**$1F-nhqlMuX;G{lWrT<<4$7MZ^*4a2hAMf)3eYiT$lRz&9({j<=%DWIRpgu zoOns@gF}AQ_6Y5RhySg7yMtJcYQap6^hgy{`zX1Zv26q4<)g@t%aIi|-lmcySuRN8*5f*$aEFi8o#kMKRCMnrAY~l`= zez#50^@Qo+6r508>iKfAbbc3JwCnjnmw;~=mlMG`(H8EJz7W6mh@mdinO&)#zHX=| z&|fo@s`;njVkkCMczSnp+TnW8YPU4w2&QmzEh1}orF~KlT=V+`!!rH|PtULCcL!P*m0EaN0Ad2qBw%Gs40jfu=%`N*k@z2-p?&B?Yum-p+h?7(!D^ z&f2Bn_#t!4HM2y^*1GN;U+_x8T$Z2>U9Yx;p_9Qf=ww z2hxO^*{%p9-CwMKz}C4mTi8xvqhivltE|}Kgq5MK@f6tBT&`@RYzsFFi>*eMZ0Z6Y zKBl`GOh!U%C+PXJ|7PF)V*~#8eS80D@v-NL2U&;i62W}k+vJAC+7xF`eq%c0b?{PVTcqiDr%6jLBdkVcTwLJSd313SP)1r=;2`cORbMzrhqZxMWcTWru5-l_H8;f|?{^M%%7>sU zGx2{fX*t;7SewS|NvPR-6F5p(ji7d}CK#%7y}jsPkgj%F5cUbQ?b7uWpYks^|DL*n zau%X$^(%wXMS3c;C4=p*#q>ahmLH5woLsn-YcZP~mH-rGnRyl#KU4MsLu+G3z90+q zM$HCWgZYR`8_I%8)SYuBltP$sN`-6hcjnzhDsVl+Y}yqMN*4MWsJX_6R>Cyw8cHGQ z1>r%vkDxxc#ACA4+-ZO|QBMUz`YHrS{l-*$> zi(n_;4{Gn+d2gn)TA<9) zibWdKJv#s_f5K}vM=d0NaYrd;5A+Fy^=+WgKC`@bS>!P5@K4fzE#VYfMcNdbbvLPY zeR~!f3xU>|pfq-LOsoF=t94x%K!8>#8tR4KQ2G3Yr?Cb98^KL*+G8``rHMpNUN}-T z5HGAkiLh{WR;N$Nk3X_2^3pW=vOFTOb(LS0Wu)0)I{8sZj>}5ZGtD=va-72l&5`L= zhyzBWie2UrC|?(sTcuk$OwvV4oVlxc3ncXPj|cD%%*6(hoKMd5wzPQs^6g)B0xK#d zemOodB7D(!@v!|eYqMfx@M#b+D)PwAuvimOW#13i-xAR5)Ai; zXNX(A@M*y&+TVZI zGHo$F*Ipg~Rnp`KlMNAl2o86}r%Yv9#!O-oo`pe`880;-Y28tR)b4H%nqXXHxN9m0 zI&#!(XhT=T3$WS$)K4#Y=ceN`MsP0v1X{nIoQ14S2^--MnUp21=V3&Uv8|y}^}7Vl zI5tRbOp#?@ay6uncZFE0hg}kt(k%piw^M8;0yynsK_!l~uP??IqzmKJMUqAW^GG{~ z7Fg)Q&zBlp z%Tj8jOUpuR>YHP6zYsX?)aJ`)_pRwu+Tn8I;brOW_`v$u$`$9T)cO*O$j=?mg>dW$ zw=&3=v||fqCr`-$okN*$S9(Nyrs}+Lu#IwDg2xSBz_VfU*?A&26vwv>&>*U_TT7-7 zS~X}fT%9+q(Xvc0qzOG^8gmMcZE9izi5feqvY(aY=%reP+wVZ&cRd`^y6}-gJ&_6n zR%Wdl3vQ4DOt!X9ry7j%=+7pLPdus*@7dZMBo0_WKZPD1(o{=;D> zyc9_WFI3{URv=d6EXcnOG0$(J(R#8Oz$kmuSFQ{-Y20}1027!FkodTU!fouSybwqn zRO-$2BH(w4)$wiPo<1w-4*p=Q0@YKRm^cgiA>~ho)U8^e>SBk*!@xvr0CdvnLHS#CACVuQfgzF>8qV znqf{oO1}RWhiZ3g!Tx9sk!JfLqcP`>Ksx#vZuLg-DC6h4mT!vlU zqw0`0CzZgY!EN0*{sQnDNFn;T<+e_x$zY|n;p0@d^hK*n!S!=#^;P{*D^6~h!T7r6 zoiMxtovMo-dj*{qZPy*c3gaMBEDQDkINU%d8HeBZVlRuzkCId9rx{?L= z-dLlk$w&JX5wn+8`mtqCpKnx+w+$@6DEUI}8P%xN$MEsw%S1-$9PM6r^jP-@?cS<# zhg$wl0X=s3{8EZ2U9(};p{X_b1@jJuGgx`gDK{6MpF|XON_=Rv%-<Ee1cuuy?nl9xVDa~x=+8ppnOQ9 zN$53qi4QQ!co(;f!#YJ8(=Z>_9UF#(QOVjS7T!g2)*Oecrf-R^)tFugBkQsMVNua# zS;1V^#fJS{h+!O+FgS%0=Pd9;lMa0QHn?-n(<0b2$<|@r>fjiyw6u*UoGmU$ayJM@ zfp;c4@{$b*Z_v9?8ZEp{m6Q(mDHW<``n?jg-ZN)Hhvxn*l=O1f*K%{5s77WCt!ugS?*2oG5-Q)JEJd0+W5=doeD$Wh?U$ZRg)K$v8cmQ{hba9jw_mF&X zi-dV?WITgIz!!0uB~jE?(t`&qo{WGyUspX| zc6+F2K4l5$LqxERF#`I&k^^opVIMZjGhsJ^vI0c%kV+|&_k>~}ueTtj;^Dfb@xHs` z)-39elzVA~D~n_aoyBQ1>Qd2!;E!G*pZM&RX`r*y)b`yxvP2;#vM*;CQGPg|gni)} z47`Log3PUyVfdmJ2zvHBhg7T#D-H=myzkeUa$@);WC(yB4k^*$wda3=S-UH5Q1Hx6 zPcGxMP&kXBa+4$s#Sw3-V?mlHj^8&bLpIN~GkYj;!;M!$ZxvtQY4j&Ngz_mxuQRqx zYTbN6epx@-!0jRV5yiSIJ<^mCZ<|;&x2~a)t+(eAVB!1XpCZok*Z2C5P7&>z-Oy?t zf@F(_FLsSrfCus61+Vt~svP%(u<4pzT5{w*0XqfPV%~|=%aq^$=*U+_trGQaoUxbt zBV#Yqx+ULku8yPJs4gGcC?+3iRt_6)Oi0DNLxdb(!n!cup_XUZ3eDe(!DChZ!IG&L?_;T-1GB!R;;Sk;l3Y*JQ!I|l20_f}ZyC;4D7R@6F z>%z~wV;Bj1b(*kp26Ed!Y-OKxNbt3%t))xxOrazWsmwvW;uaSaJ0ou+{01vXvU>_V z6Ha@+;giVaiyg`J8ENQf)Pq>!Nf22>XFHnXTNk84&jp-^YwmlUqnOll8)5mzlO$o! z#fSMwH8Pn+Fy7O5M5#ZGr$cKfaGf8g;XN)<*TrQjMk<}_oRf&b6qZoR38Q{Zxo{V; zby+J_hCZT1>`4~jnQxo|ji%BQ0=BLzC6c!1=B(jS5+fcp%q)JI)=c3{D|=k5;0&c2 zrbRE|qxkNqah2nvextOvjYA{T43n1c6eO7B9DH)tLqB46E7;0xKM=%#wx-*-+*OY{ zQ#7gMStz%I&2&rbo>#T20OD_#g`WYbt9+!MC08%zSMhqMoRk)7VOk%~`sD%(U6zzO zdmSC9@x0GCv2_)umYc5@#%efP0_cu+=f^}k$H9$N_>piA_(5UM_o{++8+Yf8SJ)?C zDd3l=GGm3EEy;&Z6N=+XP@IM0L=uW^ooyYQYyx1vwFR?@U~BAtAqTu%Mi2 zTCQh$K=UZA{P`Cw0I$xAh_f?fq-Goe`7I38{3L8?K3`lRhSAyB)tHT@4c!Y;bJAAS z3u>Q7qx>9SJs4$EB=hxh)u`W5jp?>^g1s_MV7<1zN zXt{FSt?Mt&8aCy67<)b@eg@h0iCW@%+pF-V>p${fyEk6_Gvp|ms{Whi-9eNId?xzZ zm|MI>F;JSuaUnQp#|}k3o&ddCZEeTI608txuU4~7K(wg9 zg%+}(7h2@(%>LI1F*puF(h$ZD`Q+ar!VoVajPY0-XS$>6F_F?sc6Mr7>SL-&{pC;2 zKx@2{@ULz7RCpaKg$iu2rcY+y*~qaPo0}^7T1K$_(NPS<1;V zTj8-xC%WvgDI_YYEG{bySvyO3M>XKY)oXgGG*eB{yDgNQ3s3)A~@n>!O#lNh0! z(-dqW#_z&mMfq#2+u61N`L^({4UoU8wE5`4c}{SGFzKb(BK8hM%cf_zj_HmC48)M& z398ICVJTGzBaz7K{L+Ew=;z^0xA``wbtPs`r+Wrb^_vzzhukq{;A`t&-ktzb zbqy`Z0#D6fdVAiodjF3J+qI*vu#=OCjiL4bIIXEf4?zmN7(H|+<+WfR7@7jrMx7FY z5*0X1enhay-q^M?j}3Pd^|U9(C3#CQU3=hlc~@y9@NQD{UZNfC^5?Cuuuu{ebn_<7 zEzudv*b@QP%)N^5jP;86nQGb<*SOytCM5wmf-=rH#K{Wd$2(X#S$jF}XIxZC1)zir zU2Wq>hIB44nCTqx2x<{_wiVzLSJR}L%P!Y|lFHtA_=bDj=OqvmmSZ}ffuqPge#V-f zZDk|XX0RK}=73LxL`H%OXxK*^I2!fp&kxatErK~&tM3@j1a(Yrq$z)R()i?}p|0^Y zhW&8!IpRA1jJ3e!p66ZY=eBmEA+$A`!%s+{Cz!s$IA`{_Dh0^jt!vn;+Nw}hx019Q z_Wg=#-G-~&@>l=&H~48$L8`LX)!Bcq%(DFa2Loc91u@WcwlHzJwo{cdur>bQ;{fr_ z`rC5QRQ_)`8EadJzz-{K&sUI~>NX>P|c4l)fKS0gkuGe_P ziaQy!%CK(CtAwj-J8&#kyU=G(k%3y`!gS9dU&1xIrGRL|!&aVMEaezUIpopoET~xE zp`%~`LZfn!Lu^+00?>v4UOfM!HeeQoLZP<#o`^9oi69|$0BM?n17R~tGpY)eJiv@$ zTV-~ZZ*}C1J{a}p`>l$Bx8qRBq91;dLdmp84auzmcd|XzJG%I|r z^E-8Tm~jRn_>as(R=@~z3I2E3<=#hXn>A=0`wfOGIxiP)N2%!cG?&^w=E#TR z`lSY@Mm36zu4p3}+S#67MpL$d{gf@dnP%*ZMW=gCXK-%0E(xAC!^+b7hCSMF$m;Rn zCTErbBK#;a)>kHX5}w6PRmnw(!Gy>m_g*2opfklHyx>eb1bu|_lwJdf!ogxhk}X^v zc+^L;F7ta!8+i%6?M}XvQn4b%aOSCpDW+4#JDDG(wvXC*9%9(XBhbv4LX3R5G&(+@ z)nbdivYRQ5pW;9~@YGf{h~Rm(@MfV8Tj&T@EejO6(C#(+z7FVNBR`@j!#wScHM5ki%j+^GykUJ2m zYgpwm;#Q)~LoozUSV($?r3vQ~#ZU_}ggl~J%z*1dYt_^4K6e7o&qs_ORz{km+D+^a zqDdUO)d}|)v9h(Zz3}#DLWyRVCY!=PMCO{=PA)Upb@)1j?c)||l{6&pI=;U#bS#Jk zOOiwVH3FM!SuJDIPnN$|ZKz5fQwHmzn8f^?B+T2ew%~PSE#X_jk`Wu;a{4}9%AHg7 zZm8^bAee$bdpwklIE`$fV15=pI+tgJpll4uQjIM;Q!gvISFc_{@=lUSc-lABE%U?+ zHW$;!NcH1&F;AS~7RH=n<=!NTKnm3t`B@YeL?8d2{WGrmSjG;yBbY*9$N&DT^e?l2 z|1A2482Or7n7KF_TpRn|nmqD}`-=?QJ0z5q$C9Td^sML&aN7OGi+W$uYjDXKJg+0W@S=FoQP2dBI=48|FH>p2mh zFrdu!AwoG$NkvnZp_KT8HEo=RNNJ4IxucGXLr2N*I5Ao>Efb+pNOm9Zw0_7_s|9ac zS6}W##>$W*cBmksip;43p#a4&iTpM)8(gRGekW+AKm5zb)xpUFT>~b+FOH`Zs!$RDgpSCE z>;CL8Uu|EWeR~TvgDX@K=mtReFed;FZ!M2SjzW35i;UqfyemM?rq5yZS#hK5Y~|wt z2#^`Q6$b~uGT_++C3+B~#(oFHdSL&hh`Z8{t5#=ZkoaWVJoLm)3vT_@5HOnZGa;s~ z;4=E`3Eo@=$BxFjS`Iu|8SALB`<#TPTeE%h(dol+#CzJ=Zb&EHpw*=0H*~8x6 z`G`b<@>L2(AS*J!NVp`DN{g!8R#h(~URslf zC8PwGM$5V}+$WcoT*C~*$WmCpS6Gis&sZo|9OfRiwjX$f*&25Gjv6$YPde1smwGw( zb@y=gbl1!8>hm-il3&~zFca0~aJN!?b97+$E>2$Gn$31OR&UnE=Tm= zH44$Dx2HNN1lrCGjfuwo@+(m2j85w-oxre9FopupEV+6HACFyTbt}s-`lCCJ8om5RIE~T#Yg_DWu1u zyAp%jp;3&%D4;CRaR6g=f*ZvPqw2BadP=*ZYy_~CV3@wFx5YA(E8)jfqx z8tjEkMf>msMqi)zaY2fWrMq`lZzZdiMcluc(@(yxK(4hPEFk0~HO3^CUZk3;?Tv3` ze-rjZ8@hBrVPzA$^4hW?<33{d2)h7Jw?$t%V6(C_m+bNhXl9vXCJcBWmMeQoLDm5b zt9|A5pDHY#Y@(rlEo_WzXila!uaZE*WVc`=IM)SSc`#liZ2Wt*~fHgm9uH^ISX2d@)XGZ)_$qnbx6?J<14_=SS(ITs#LPDk03a&%x;bAuGz=P ze^<4p@tD@J|M;88;~IsEOPpB+&3C4!3q;}Kk2tb*WuuE z2u(BE$1(2AwbbBrmU-YLI4>#K((6&QZ~m2Yp;I14x0N8hos}{uoQuMG)Wy?ogaNayqmc&`I=8y6&dPf{Fky#B7 z#F=Xy213s`NFxjKuMqH3+ibWsFRi=QtH*j$9^)Zy8F|^vSmgj~l5<04MiU;BNyAn) zlM+c20Y#%@>WgdY>5kx}H)7*!D~BZJdg8d5iHx|>(jj=!MEmr)-$kH8?A#;DyBone(uz;e^|=9nIwfuWY?yw; zC|H`;8#O$vTPm5AW1Gg-Up&#Ca$<@!JZkAUDbmd*?X}QSA5$(*c+FZ|l+}F%*L1OH z{ck}P=j@=7>6ga#cqzj|ODXHD>ckIBmOd9Fh=~>?C7$uII_3rEX%UKdywsInR~{t- zg|t`~l=L1P_QPkZN53Q>!^A*QDZ zK(f;%VVQo)n1bsy)LWL#?&|wN`hL~Rnxhd3d-bOvlRQAiybH&=i;SlnwP$3P-!%x3^o)t6aoT-zXU}ARq-l^bOW-zg$@b|19Aua zF+k$V!uO;fNwCUEi;6!|5?4_MKtTq}|C`2gXh8EhWP1bTgZ)DqHZ&-x|E2*6Ka!RZ zS5jsHN&IW7%g1yUln@bn$cO!hR2b+`P~1-3dFIx!6EltRa{a z6Z@Y$_ug)~d%u)K$+?LYfc<87}bupdiK(3|m%hiA$Pc>zKNP0hqBj{X*L0rm@j(0s(f>>t{1L0?w#rS+#E)IdBKcF5|Dq-S zZ*-X3x;NeSuOSxS<3Q%uy1zwQ+?Kj&)Ou~-|2+&J{Zi^T=lx9+&+B^K_lQ;hY2H6D zeZ9T!H&;?$+kt+MLCs%i{8QEVi8<(Pft!mFt`}r~k5Y%93jAjQ!fgoD?Zh|Vi~q5A z27G^+_!lc1Zfo3}625-J{(B@p`IW|R4(!c|yX*Pn?*SA0)3iUGUB11uH>ab1{F$$g z|7q4=O#$9cezU54J)`wKI1_%J{14{0Zj0P3wEcKU`%-=?@(1PW+Zs0qGuI`%??IID dD~*3C;60WFKt@K_BOwYX49GZ$DDV2e{|AYb(KrAA literal 0 HcmV?d00001 diff --git a/spot-gateway/gradlew b/spot-gateway/gradlew new file mode 100755 index 00000000..adff685a --- /dev/null +++ b/spot-gateway/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/spot-gateway/gradlew.bat b/spot-gateway/gradlew.bat new file mode 100644 index 00000000..c4bdd3ab --- /dev/null +++ b/spot-gateway/gradlew.bat @@ -0,0 +1,93 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/spot-gateway/settings.gradle b/spot-gateway/settings.gradle new file mode 100644 index 00000000..078fe200 --- /dev/null +++ b/spot-gateway/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'Spot' diff --git a/spot-gateway/src/main/java/com/example/spot/SpotGatewayApplication.java b/spot-gateway/src/main/java/com/example/spot/SpotGatewayApplication.java new file mode 100644 index 00000000..3c3d94d9 --- /dev/null +++ b/spot-gateway/src/main/java/com/example/spot/SpotGatewayApplication.java @@ -0,0 +1,13 @@ +package com.example.spot; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class SpotGatewayApplication { + + public static void main(String[] args) { + SpringApplication.run(SpotGatewayApplication.class, args); + } + +} diff --git a/spot-gateway/src/main/java/com/example/spot/filter/GatewayFilterConfig.java b/spot-gateway/src/main/java/com/example/spot/filter/GatewayFilterConfig.java new file mode 100644 index 00000000..e0f716fd --- /dev/null +++ b/spot-gateway/src/main/java/com/example/spot/filter/GatewayFilterConfig.java @@ -0,0 +1,35 @@ +package com.example.spot.filter; + +import java.util.UUID; + +import org.springframework.cloud.gateway.filter.GlobalFilter; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import reactor.core.publisher.Mono; + +@Configuration +public class GatewayFilterConfig { + + @Bean + public GlobalFilter requestIdFilter() { + return (exchange, chain) -> { + String rid = exchange.getRequest().getHeaders().getFirst("X-Request-Id"); + if (rid == null || rid.isBlank()) { + rid = UUID.randomUUID().toString(); + } + final String finalRequestId = rid; + + var mutated = exchange.mutate() + .request(exchange.getRequest().mutate() + .header("X-Request-Id", finalRequestId) + .build()) + .build(); + + return chain.filter(mutated) + .then(Mono.fromRunnable(() -> + mutated.getResponse().getHeaders().set("X-Request-Id", finalRequestId) + )); + }; + } +} \ No newline at end of file diff --git a/spot-gateway/src/test/java/com/example/spot/SpotGatewayApplicationTests.java b/spot-gateway/src/test/java/com/example/spot/SpotGatewayApplicationTests.java new file mode 100644 index 00000000..e409c789 --- /dev/null +++ b/spot-gateway/src/test/java/com/example/spot/SpotGatewayApplicationTests.java @@ -0,0 +1,13 @@ +package com.example.spot; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class SpotGatewayApplicationTests { + + @Test + void contextLoads() { + } + +} diff --git a/spot-order/src/main/resources/application.properties b/spot-order/src/main/resources/application.properties index 4ae2cd3b..dfcd5237 100644 --- a/spot-order/src/main/resources/application.properties +++ b/spot-order/src/main/resources/application.properties @@ -1,3 +1,4 @@ +server.port=8082 spring.application.name=spot-order # Feign Client URLs diff --git a/spot-payment/src/main/resources/application.properties b/spot-payment/src/main/resources/application.properties deleted file mode 100644 index 4395eb00..00000000 --- a/spot-payment/src/main/resources/application.properties +++ /dev/null @@ -1,6 +0,0 @@ -spring.application.name=spot-payment - -# Feign Client URLs -feign.order.url=http://localhost:8082 -feign.user.url=http://localhost:8081 -feign.store.url=http://localhost:8083 diff --git a/spot-store/src/main/resources/application.properties b/spot-store/src/main/resources/application.properties deleted file mode 100644 index 3418c7fc..00000000 --- a/spot-store/src/main/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ -spring.application.name=spot-store diff --git a/spot-user/build.gradle b/spot-user/build.gradle index 2e963302..ccb46f94 100644 --- a/spot-user/build.gradle +++ b/spot-user/build.gradle @@ -59,6 +59,9 @@ dependencies { // postgreSQL implementation 'org.postgresql:postgresql' + + // spring cloud gateway + implementation 'org.springframework.cloud:spring-cloud-starter-gateway' } tasks.named('test') { diff --git a/spot-user/src/main/resources/application.properties b/spot-user/src/main/resources/application.properties deleted file mode 100644 index f8402720..00000000 --- a/spot-user/src/main/resources/application.properties +++ /dev/null @@ -1,6 +0,0 @@ -spring.application.name=spot-user - -# Feign Client URLs -feign.store.url=http://localhost:8082 -feign.order.url=http://localhost:8083 -feign.payment.url=http://localhost:8084 From a0b3966b6c560a1ac1fa5fc6ec6714bcf1f2950c Mon Sep 17 00:00:00 2001 From: dbswjd7 Date: Thu, 22 Jan 2026 00:40:02 +0900 Subject: [PATCH 29/77] =?UTF-8?q?feat(#224):=20import=20=EA=B2=BD=EB=A1=9C?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/presentation/controller/AdminStoreController.java | 2 +- .../src/main/java/com/example/Spot/auth/jwt/JWTFilter.java | 2 +- .../main/java/com/example/Spot/auth/jwt/LoginFilter.java | 2 +- .../infrastructure/config/security/SecurityConfig.java | 6 +++--- .../example/Spot/user/application/service/TokenService.java | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/spot-user/src/main/java/com/example/Spot/admin/presentation/controller/AdminStoreController.java b/spot-user/src/main/java/com/example/Spot/admin/presentation/controller/AdminStoreController.java index 4a2ea04b..031cb314 100644 --- a/spot-user/src/main/java/com/example/Spot/admin/presentation/controller/AdminStoreController.java +++ b/spot-user/src/main/java/com/example/Spot/admin/presentation/controller/AdminStoreController.java @@ -14,7 +14,7 @@ import com.example.Spot.admin.application.service.AdminStoreService; import com.example.Spot.global.presentation.ApiResponse; import com.example.Spot.global.presentation.code.GeneralSuccessCode; -import com.example.Spot.infra.auth.security.CustomUserDetails; +import com.example.Spot.auth.security.CustomUserDetails; import com.example.Spot.store.presentation.dto.response.StoreListResponse; import lombok.RequiredArgsConstructor; diff --git a/spot-user/src/main/java/com/example/Spot/auth/jwt/JWTFilter.java b/spot-user/src/main/java/com/example/Spot/auth/jwt/JWTFilter.java index 2c133dae..7aa37acb 100644 --- a/spot-user/src/main/java/com/example/Spot/auth/jwt/JWTFilter.java +++ b/spot-user/src/main/java/com/example/Spot/auth/jwt/JWTFilter.java @@ -7,7 +7,7 @@ import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.filter.OncePerRequestFilter; -import com.example.Spot.infra.auth.security.CustomUserDetails; +import com.example.Spot.auth.security.CustomUserDetails; import com.example.Spot.user.domain.Role; import com.example.Spot.user.domain.entity.UserEntity; diff --git a/spot-user/src/main/java/com/example/Spot/auth/jwt/LoginFilter.java b/spot-user/src/main/java/com/example/Spot/auth/jwt/LoginFilter.java index fbf18483..5e316cc7 100644 --- a/spot-user/src/main/java/com/example/Spot/auth/jwt/LoginFilter.java +++ b/spot-user/src/main/java/com/example/Spot/auth/jwt/LoginFilter.java @@ -6,7 +6,7 @@ import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; -import com.example.Spot.infra.auth.security.CustomUserDetails; +import com.example.Spot.auth.security.CustomUserDetails; import com.example.Spot.user.domain.Role; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; diff --git a/spot-user/src/main/java/com/example/Spot/global/infrastructure/config/security/SecurityConfig.java b/spot-user/src/main/java/com/example/Spot/global/infrastructure/config/security/SecurityConfig.java index 64dea560..70c4a8be 100644 --- a/spot-user/src/main/java/com/example/Spot/global/infrastructure/config/security/SecurityConfig.java +++ b/spot-user/src/main/java/com/example/Spot/global/infrastructure/config/security/SecurityConfig.java @@ -12,9 +12,9 @@ import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; -import com.example.Spot.infra.auth.jwt.JWTFilter; -import com.example.Spot.infra.auth.jwt.JWTUtil; -import com.example.Spot.infra.auth.jwt.LoginFilter; +import com.example.Spot.auth.jwt.JWTFilter; +import com.example.Spot.auth.jwt.JWTUtil; +import com.example.Spot.auth.jwt.LoginFilter; @Configuration @EnableWebSecurity diff --git a/spot-user/src/main/java/com/example/Spot/user/application/service/TokenService.java b/spot-user/src/main/java/com/example/Spot/user/application/service/TokenService.java index d0f124f0..daef602f 100644 --- a/spot-user/src/main/java/com/example/Spot/user/application/service/TokenService.java +++ b/spot-user/src/main/java/com/example/Spot/user/application/service/TokenService.java @@ -3,7 +3,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; -import com.example.Spot.infra.auth.jwt.JWTUtil; +import com.example.Spot.auth.jwt.JWTUtil; import com.example.Spot.user.domain.Role; import com.example.Spot.user.domain.repository.UserRepository; From 0d92375da549a32b2a8429c9309394685736618b Mon Sep 17 00:00:00 2001 From: dbswjd7 Date: Thu, 22 Jan 2026 00:49:11 +0900 Subject: [PATCH 30/77] =?UTF-8?q?feat(#224):=20userauthentity=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=20=EC=97=90=EB=9F=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/example/Spot/user/domain/entity/UserAuthEntity.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/spot-user/src/main/java/com/example/Spot/user/domain/entity/UserAuthEntity.java b/spot-user/src/main/java/com/example/Spot/user/domain/entity/UserAuthEntity.java index f9ec4df8..3c8628c1 100644 --- a/spot-user/src/main/java/com/example/Spot/user/domain/entity/UserAuthEntity.java +++ b/spot-user/src/main/java/com/example/Spot/user/domain/entity/UserAuthEntity.java @@ -18,7 +18,9 @@ import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.OneToMany; +import jakarta.persistence.OneToOne; import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; From d32263a4caab62ab6c986754ff114ef5c6dcf301 Mon Sep 17 00:00:00 2001 From: dbswjd7 Date: Thu, 22 Jan 2026 01:01:30 +0900 Subject: [PATCH 31/77] =?UTF-8?q?feat(#224):=20properties=EC=97=90=20feign?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 - spot-gateway/gradle/wrapper/gradle-wrapper.properties | 7 +++++++ spot-gateway/src/main/resources/application.properties | 1 + spot-payment/src/main/resources/application.properties | 7 +++++++ spot-store/src/main/resources/application.properties | 6 ++++++ spot-user/src/main/resources/application.properties | 7 +++++++ 6 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 spot-gateway/gradle/wrapper/gradle-wrapper.properties create mode 100644 spot-gateway/src/main/resources/application.properties create mode 100644 spot-payment/src/main/resources/application.properties create mode 100644 spot-store/src/main/resources/application.properties create mode 100644 spot-user/src/main/resources/application.properties diff --git a/.gitignore b/.gitignore index 1d66ff7c..fd5a5165 100644 --- a/.gitignore +++ b/.gitignore @@ -16,7 +16,6 @@ build/ ### yml ### **/*.yml **/*.yaml -**/*.properties !**/application-example.yml !**/application-example.yaml diff --git a/spot-gateway/gradle/wrapper/gradle-wrapper.properties b/spot-gateway/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..23449a2b --- /dev/null +++ b/spot-gateway/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/spot-gateway/src/main/resources/application.properties b/spot-gateway/src/main/resources/application.properties new file mode 100644 index 00000000..70b12dee --- /dev/null +++ b/spot-gateway/src/main/resources/application.properties @@ -0,0 +1 @@ +spring.application.name=spot-gateway diff --git a/spot-payment/src/main/resources/application.properties b/spot-payment/src/main/resources/application.properties new file mode 100644 index 00000000..0b48ba31 --- /dev/null +++ b/spot-payment/src/main/resources/application.properties @@ -0,0 +1,7 @@ +server.port=8084 +spring.application.name=spot-payment + +# Feign Client URLs +feign.user.url=http://localhost:8081 +feign.store.url=http://localhost:8083 +feign.order.url=http://localhost:8082 diff --git a/spot-store/src/main/resources/application.properties b/spot-store/src/main/resources/application.properties new file mode 100644 index 00000000..41ea4b6e --- /dev/null +++ b/spot-store/src/main/resources/application.properties @@ -0,0 +1,6 @@ +server.port=8083 +spring.application.name=spot-store + +# Feign Client URLs +feign.user.url=http://localhost:8081 +feign.order.url=http://localhost:8082 diff --git a/spot-user/src/main/resources/application.properties b/spot-user/src/main/resources/application.properties new file mode 100644 index 00000000..74c4910e --- /dev/null +++ b/spot-user/src/main/resources/application.properties @@ -0,0 +1,7 @@ +server.port=8081 +spring.application.name=spot-user + +# Feign Client URLs +feign.order.url=http://localhost:8082 +feign.store.url=http://localhost:8083 +feign.payment.url=http://localhost:8084 From 733cddf7a493660455fb53dc803e619cf439779d Mon Sep 17 00:00:00 2001 From: dbswjd7 Date: Thu, 22 Jan 2026 09:21:39 +0900 Subject: [PATCH 32/77] =?UTF-8?q?feat(#224):=20reviewrepository=EC=9D=98?= =?UTF-8?q?=20user=EC=A1=B0=EC=9D=B8=ED=95=B4=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../review/domain/repository/ReviewRepository.java | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/spot-store/src/main/java/com/example/Spot/review/domain/repository/ReviewRepository.java b/spot-store/src/main/java/com/example/Spot/review/domain/repository/ReviewRepository.java index 8f21f90f..f31eef21 100644 --- a/spot-store/src/main/java/com/example/Spot/review/domain/repository/ReviewRepository.java +++ b/spot-store/src/main/java/com/example/Spot/review/domain/repository/ReviewRepository.java @@ -18,22 +18,22 @@ public interface ReviewRepository extends JpaRepository { // 특정 가게의 리뷰 조회 (삭제되지 않은 리뷰만) @Query("SELECT r FROM ReviewEntity r " + - "LEFT JOIN FETCH r.user u " + - "WHERE r.store.id = :storeId " + + "JOIN FETCH r.store s" + + "WHERE s.id = :storeId " + "AND r.isDeleted = false " + "ORDER BY r.createdAt DESC") Page findByStoreIdAndIsDeletedFalse(@Param("storeId") UUID storeId, Pageable pageable); // 특정 가게의 전체 리뷰 (삭제된 것 포함, 관리자용) @Query("SELECT r FROM ReviewEntity r " + - "LEFT JOIN FETCH r.user u " + - "WHERE r.store.id = :storeId " + + "JOIN FETCH r.store s " + + "WHERE s.id = :storeId " + "ORDER BY r.createdAt DESC") Page findByStoreId(@Param("storeId") UUID storeId, Pageable pageable); // 특정 사용자의 리뷰 조회 @Query("SELECT r FROM ReviewEntity r " + - "LEFT JOIN FETCH r.store s " + + "JOIN FETCH r.store s " + "WHERE r.user.id = :userId " + "AND r.isDeleted = false " + "ORDER BY r.createdAt DESC") @@ -41,8 +41,7 @@ public interface ReviewRepository extends JpaRepository { // 리뷰 상세 조회 (작성자/관리자 확인용) @Query("SELECT r FROM ReviewEntity r " + - "LEFT JOIN FETCH r.user u " + - "LEFT JOIN FETCH r.store s " + + "JOIN FETCH r.store s " + "WHERE r.id = :reviewId " + "AND r.isDeleted = false") Optional findByIdWithDetails(@Param("reviewId") UUID reviewId); From eaa7669b8dd3230400b1d109a9b359c34ab47f71 Mon Sep 17 00:00:00 2001 From: dbswjd7 Date: Thu, 22 Jan 2026 10:12:35 +0900 Subject: [PATCH 33/77] =?UTF-8?q?feat(#224):=20user=20-=20adminstore=20?= =?UTF-8?q?=EC=A1=B0=EC=9D=B8=20=ED=95=B4=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../InternalAdminStoreController.java | 0 .../service/AdminStatsService.java | 2 +- .../service/AdminStoreService.java | 16 +++++++--- .../controller/AdminStoreController.java | 19 ++++++++---- .../controller/AdminUserController.java | 9 +++++- .../response/AdminStoreListResponseDto.java | 9 ++++++ .../com/example/Spot/auth/jwt/JWTUtil.java | 5 +-- .../Spot/global/aop/QueryLoggingAspect.java | 10 +++--- .../Spot/global/feign/StoreAdminClient.java | 31 +++++++++++++++++++ .../global/feign/dto/StorePageResponse.java | 28 ++++++----------- .../user/domain/entity/ResetTokenEntity.java | 5 --- .../user/domain/entity/UserAuthEntity.java | 7 ----- .../Spot/user/domain/entity/UserEntity.java | 3 -- 13 files changed, 91 insertions(+), 53 deletions(-) create mode 100644 spot-store/src/main/java/com/example/Spot/store/presentation/controller/InternalAdminStoreController.java create mode 100644 spot-user/src/main/java/com/example/Spot/admin/presentation/dto/response/AdminStoreListResponseDto.java create mode 100644 spot-user/src/main/java/com/example/Spot/global/feign/StoreAdminClient.java diff --git a/spot-store/src/main/java/com/example/Spot/store/presentation/controller/InternalAdminStoreController.java b/spot-store/src/main/java/com/example/Spot/store/presentation/controller/InternalAdminStoreController.java new file mode 100644 index 00000000..e69de29b diff --git a/spot-user/src/main/java/com/example/Spot/admin/application/service/AdminStatsService.java b/spot-user/src/main/java/com/example/Spot/admin/application/service/AdminStatsService.java index eac3291e..81ac8e0b 100644 --- a/spot-user/src/main/java/com/example/Spot/admin/application/service/AdminStatsService.java +++ b/spot-user/src/main/java/com/example/Spot/admin/application/service/AdminStatsService.java @@ -53,7 +53,7 @@ public AdminStatsResponseDto getStats() { return AdminStatsResponseDto.builder() .totalUsers(totalUsers) .totalOrders(orderStats.getTotalOrders()) - .totalStores(storeResponse.getTotalElements()) + .totalStores(storeResponse.totalElements()) .totalRevenue(orderStats.getTotalRevenue()) .recentOrders(recentOrders.getContent()) .userGrowth(userGrowth) diff --git a/spot-user/src/main/java/com/example/Spot/admin/application/service/AdminStoreService.java b/spot-user/src/main/java/com/example/Spot/admin/application/service/AdminStoreService.java index 5d688004..f8d59c2c 100644 --- a/spot-user/src/main/java/com/example/Spot/admin/application/service/AdminStoreService.java +++ b/spot-user/src/main/java/com/example/Spot/admin/application/service/AdminStoreService.java @@ -2,28 +2,36 @@ import java.util.UUID; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; +import com.example.Spot.admin.presentation.dto.AdminStoreListResponseDto; import com.example.Spot.global.feign.StoreClient; import com.example.Spot.global.feign.dto.StorePageResponse; import lombok.RequiredArgsConstructor; - @Service @RequiredArgsConstructor public class AdminStoreService { private final StoreClient storeClient; - public StorePageResponse getAllStores(int page, int size) { - return storeClient.getAllStores(page, size); + + public StorePageResponse getAllStores(Pageable pageable) { + return storeClient.getAllStores( + pageable.getPageNumber(), + pageable.getPageSize() + ); } + public void approveStore(UUID storeId) { storeClient.updateStoreStatus(storeId, "APPROVED"); } - public void deleteStore(UUID storeId) { + public void deleteStore(UUID storeId, Integer userId) { storeClient.deleteStore(storeId); } + + } diff --git a/spot-user/src/main/java/com/example/Spot/admin/presentation/controller/AdminStoreController.java b/spot-user/src/main/java/com/example/Spot/admin/presentation/controller/AdminStoreController.java index 031cb314..eced6eb0 100644 --- a/spot-user/src/main/java/com/example/Spot/admin/presentation/controller/AdminStoreController.java +++ b/spot-user/src/main/java/com/example/Spot/admin/presentation/controller/AdminStoreController.java @@ -2,23 +2,30 @@ import java.util.UUID; -import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.*; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; import com.example.Spot.admin.application.service.AdminStoreService; +import com.example.Spot.admin.presentation.dto.AdminStoreListResponseDto; +import com.example.Spot.auth.security.CustomUserDetails; +import com.example.Spot.global.feign.dto.StorePageResponse; import com.example.Spot.global.presentation.ApiResponse; import com.example.Spot.global.presentation.code.GeneralSuccessCode; -import com.example.Spot.auth.security.CustomUserDetails; -import com.example.Spot.store.presentation.dto.response.StoreListResponse; import lombok.RequiredArgsConstructor; + @RestController @RequestMapping("/api/admin/stores") @RequiredArgsConstructor @@ -28,14 +35,14 @@ public class AdminStoreController { private final AdminStoreService adminStoreService; @GetMapping - public ResponseEntity>> getAllStores( + public ResponseEntity>> getAllStores( @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, @RequestParam(defaultValue = "name") String sortBy, @RequestParam(defaultValue = "ASC") Sort.Direction direction) { Pageable pageable = PageRequest.of(page, size, Sort.by(direction, sortBy)); - Page stores = adminStoreService.getAllStores(pageable); + StorePageResponse stores = adminStoreService.getAllStores(pageable); return ResponseEntity .ok(ApiResponse.onSuccess(GeneralSuccessCode.GOOD_REQUEST, stores)); diff --git a/spot-user/src/main/java/com/example/Spot/admin/presentation/controller/AdminUserController.java b/spot-user/src/main/java/com/example/Spot/admin/presentation/controller/AdminUserController.java index df07a78d..caa32ed0 100644 --- a/spot-user/src/main/java/com/example/Spot/admin/presentation/controller/AdminUserController.java +++ b/spot-user/src/main/java/com/example/Spot/admin/presentation/controller/AdminUserController.java @@ -6,7 +6,14 @@ import org.springframework.data.domain.Sort; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.web.bind.annotation.*; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; import com.example.Spot.admin.application.service.AdminUserService; import com.example.Spot.admin.presentation.dto.request.UserRoleUpdateRequestDto; diff --git a/spot-user/src/main/java/com/example/Spot/admin/presentation/dto/response/AdminStoreListResponseDto.java b/spot-user/src/main/java/com/example/Spot/admin/presentation/dto/response/AdminStoreListResponseDto.java new file mode 100644 index 00000000..d5bb01f2 --- /dev/null +++ b/spot-user/src/main/java/com/example/Spot/admin/presentation/dto/response/AdminStoreListResponseDto.java @@ -0,0 +1,9 @@ +package com.example.Spot.admin.presentation.dto; + +import java.util.UUID; + +public record AdminStoreListResponseDto( + UUID storeId, + String name, + String status +) {} diff --git a/spot-user/src/main/java/com/example/Spot/auth/jwt/JWTUtil.java b/spot-user/src/main/java/com/example/Spot/auth/jwt/JWTUtil.java index bb430683..94b1d727 100644 --- a/spot-user/src/main/java/com/example/Spot/auth/jwt/JWTUtil.java +++ b/spot-user/src/main/java/com/example/Spot/auth/jwt/JWTUtil.java @@ -1,10 +1,11 @@ package com.example.Spot.auth.jwt; -import javax.crypto.SecretKey; -import javax.crypto.spec.SecretKeySpec; import java.nio.charset.StandardCharsets; import java.util.Date; +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; + import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; diff --git a/spot-user/src/main/java/com/example/Spot/global/aop/QueryLoggingAspect.java b/spot-user/src/main/java/com/example/Spot/global/aop/QueryLoggingAspect.java index 6bc511f6..4a8aa1ee 100644 --- a/spot-user/src/main/java/com/example/Spot/global/aop/QueryLoggingAspect.java +++ b/spot-user/src/main/java/com/example/Spot/global/aop/QueryLoggingAspect.java @@ -12,7 +12,7 @@ @Component public class QueryLoggingAspect { - private static final Logger log = LoggerFactory.getLogger(QueryLoggingAspect.class); + private static final Logger LOG = LoggerFactory.getLogger(QueryLoggingAspect.class); @Pointcut("execution(* com.example.Spot.global.feign.*Client.*(..))") public void feignClientMethods() { @@ -23,18 +23,18 @@ public Object logFeignCall(ProceedingJoinPoint joinPoint) throws Throwable { String methodName = joinPoint.getSignature().getName(); String className = joinPoint.getTarget().getClass().getSimpleName(); - log.info("[Feign Call Start] {}.{}", className, methodName); + LOG.info("[Feign Call Start] {}.{}", className, methodName); long startTime = System.currentTimeMillis(); try { Object result = joinPoint.proceed(); long endTime = System.currentTimeMillis(); - log.info("[Feign Call End] {}.{} - {}ms", className, methodName, (endTime - startTime)); + LOG.info("[Feign Call End] {}.{} - {}ms", className, methodName, endTime - startTime); return result; } catch (Exception e) { long endTime = System.currentTimeMillis(); - log.error("[Feign Call Error] {}.{} - {}ms - Error: {}", - className, methodName, (endTime - startTime), e.getMessage()); + LOG.error("[Feign Call Error] {}.{} - {}ms - Error: {}", + className, methodName, endTime - startTime, e.getMessage()); throw e; } } diff --git a/spot-user/src/main/java/com/example/Spot/global/feign/StoreAdminClient.java b/spot-user/src/main/java/com/example/Spot/global/feign/StoreAdminClient.java new file mode 100644 index 00000000..4e750a36 --- /dev/null +++ b/spot-user/src/main/java/com/example/Spot/global/feign/StoreAdminClient.java @@ -0,0 +1,31 @@ +package com.example.Spot.global.feign; + +import java.util.UUID; + +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; + +import com.example.Spot.admin.presentation.dto.AdminStoreListResponseDto; +import com.example.Spot.global.feign.dto.StorePageResponse; + +@FeignClient(name = "store-service", url = "${feign.store.url}") +public interface StoreAdminClient { + + @GetMapping("/api/internal/admin/stores") + StorePageResponse getAllStores( + @RequestParam("page") int page, + @RequestParam("size") int size, + @RequestParam("sortBy") String sortBy, + @RequestParam("direction") String direction + ); + + @PatchMapping("/api/internal/admin/stores/{storeId}/approve") + void approveStore(@PathVariable("storeId") UUID storeId); + + @DeleteMapping("/api/internal/admin/stores/{storeId}") + void deleteStore(@PathVariable("storeId") UUID storeId); +} diff --git a/spot-user/src/main/java/com/example/Spot/global/feign/dto/StorePageResponse.java b/spot-user/src/main/java/com/example/Spot/global/feign/dto/StorePageResponse.java index 98828d02..de4cc272 100644 --- a/spot-user/src/main/java/com/example/Spot/global/feign/dto/StorePageResponse.java +++ b/spot-user/src/main/java/com/example/Spot/global/feign/dto/StorePageResponse.java @@ -2,22 +2,12 @@ import java.util.List; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Getter -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class StorePageResponse { - - private List content; - private int totalPages; - private long totalElements; - private int size; - private int number; - private boolean first; - private boolean last; -} +public record StorePageResponse( + List content, + int page, + int size, + long totalElements, + int totalPages, + boolean first, + boolean last +) {} diff --git a/spot-user/src/main/java/com/example/Spot/user/domain/entity/ResetTokenEntity.java b/spot-user/src/main/java/com/example/Spot/user/domain/entity/ResetTokenEntity.java index b9d4062f..ba6a0338 100644 --- a/spot-user/src/main/java/com/example/Spot/user/domain/entity/ResetTokenEntity.java +++ b/spot-user/src/main/java/com/example/Spot/user/domain/entity/ResetTokenEntity.java @@ -5,21 +5,16 @@ import com.example.Spot.global.common.UpdateBaseEntity; -import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; -import jakarta.persistence.OneToMany; import jakarta.persistence.Table; import lombok.AccessLevel; -import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; diff --git a/spot-user/src/main/java/com/example/Spot/user/domain/entity/UserAuthEntity.java b/spot-user/src/main/java/com/example/Spot/user/domain/entity/UserAuthEntity.java index 3c8628c1..49c1e34b 100644 --- a/spot-user/src/main/java/com/example/Spot/user/domain/entity/UserAuthEntity.java +++ b/spot-user/src/main/java/com/example/Spot/user/domain/entity/UserAuthEntity.java @@ -6,18 +6,11 @@ import com.example.Spot.global.common.UpdateBaseEntity; -import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; import jakarta.persistence.FetchType; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; -import jakarta.persistence.OneToMany; import jakarta.persistence.OneToOne; import jakarta.persistence.Table; import jakarta.persistence.UniqueConstraint; diff --git a/spot-user/src/main/java/com/example/Spot/user/domain/entity/UserEntity.java b/spot-user/src/main/java/com/example/Spot/user/domain/entity/UserEntity.java index c7cba192..6d049df6 100644 --- a/spot-user/src/main/java/com/example/Spot/user/domain/entity/UserEntity.java +++ b/spot-user/src/main/java/com/example/Spot/user/domain/entity/UserEntity.java @@ -3,16 +3,13 @@ import com.example.Spot.global.common.UpdateBaseEntity; import com.example.Spot.user.domain.Role; -import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; -import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; -import jakarta.persistence.OneToMany; import jakarta.persistence.Table; import lombok.AccessLevel; import lombok.Builder; From cbe8ea762b30cfb1ce5ecce75c8108524eca4649 Mon Sep 17 00:00:00 2001 From: dbswjd7 Date: Thu, 22 Jan 2026 10:18:52 +0900 Subject: [PATCH 34/77] feat(#224): user checkstyle --- .../service/AdminStoreInternelService.java | 8 +++ .../InternalAdminStoreController.java | 51 +++++++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 spot-store/src/main/java/com/example/Spot/store/application/service/AdminStoreInternelService.java diff --git a/spot-store/src/main/java/com/example/Spot/store/application/service/AdminStoreInternelService.java b/spot-store/src/main/java/com/example/Spot/store/application/service/AdminStoreInternelService.java new file mode 100644 index 00000000..2b5d0a5a --- /dev/null +++ b/spot-store/src/main/java/com/example/Spot/store/application/service/AdminStoreInternelService.java @@ -0,0 +1,8 @@ +@Service +@RequiredArgsConstructor +public class AdminStoreInternalService { + + + public Page getAllStores(Pageable pageable) { ... } + +} diff --git a/spot-store/src/main/java/com/example/Spot/store/presentation/controller/InternalAdminStoreController.java b/spot-store/src/main/java/com/example/Spot/store/presentation/controller/InternalAdminStoreController.java index e69de29b..46b91e92 100644 --- a/spot-store/src/main/java/com/example/Spot/store/presentation/controller/InternalAdminStoreController.java +++ b/spot-store/src/main/java/com/example/Spot/store/presentation/controller/InternalAdminStoreController.java @@ -0,0 +1,51 @@ +package com.example.Spot.store.presentation.controller.internal; + +import java.util.UUID; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import com.example.Spot.store.application.service.AdminStoreInternalService; +import com.example.Spot.store.presentation.dto.request.StoreStatusUpdateRequest; +import com.example.Spot.store.presentation.dto.response.AdminStoreListResponse; +import com.example.Spot.store.presentation.dto.response.StorePageResponse; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/internal/admin/stores") +@PreAuthorize("hasAnyRole('MASTER','MANAGER')") +public class InternalAdminStoreController { + + private final AdminStoreInternalService adminStoreInternalService; + + @GetMapping + public ResponseEntity getAllStores( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + Pageable pageable = PageRequest.of(page, size); + Page stores = adminStoreInternalService.getAllStores(pageable); + return ResponseEntity.ok(StorePageResponse.from(stores)); + } + + @PatchMapping("/{storeId}/status") + public ResponseEntity updateStoreStatus( + @PathVariable UUID storeId, + @RequestBody StoreStatusUpdateRequest request + ) { + adminStoreInternalService.updateStoreStatus(storeId, request.status()); + return ResponseEntity.noContent().build(); + } + + @DeleteMapping("/{storeId}") + public ResponseEntity deleteStore(@PathVariable UUID storeId) { + adminStoreInternalService.deleteStore(storeId); + return ResponseEntity.noContent().build(); + } +} From f8058c6cdb7dead72b5ab09df4c4d131738d2eb7 Mon Sep 17 00:00:00 2001 From: dbswjd7 Date: Thu, 22 Jan 2026 10:22:51 +0900 Subject: [PATCH 35/77] feat(#224): user checkstyle --- .../service/AdminStoreInternelService.java | 20 ++++++++++++++++++- .../dto/response/AdminStoreListResonse.java | 16 +++++++++++++++ .../dto/response/AdminStatsResponseDto.java | 8 +++++--- .../controller/UserController.java | 9 ++++++++- 4 files changed, 48 insertions(+), 5 deletions(-) create mode 100644 spot-store/src/main/java/com/example/Spot/store/presentation/dto/response/AdminStoreListResonse.java diff --git a/spot-store/src/main/java/com/example/Spot/store/application/service/AdminStoreInternelService.java b/spot-store/src/main/java/com/example/Spot/store/application/service/AdminStoreInternelService.java index 2b5d0a5a..89362f5f 100644 --- a/spot-store/src/main/java/com/example/Spot/store/application/service/AdminStoreInternelService.java +++ b/spot-store/src/main/java/com/example/Spot/store/application/service/AdminStoreInternelService.java @@ -1,8 +1,26 @@ +package com.example.Spot.store.application.service; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.example.Spot.store.domain.entity.StoreEntity; +import com.example.Spot.store.domain.repository.StoreRepository; +import com.example.Spot.store.presentation.dto.response.AdminStoreListResponse; + +import lombok.RequiredArgsConstructor; + @Service @RequiredArgsConstructor +@Transactional(readOnly = true) public class AdminStoreInternalService { + private final StoreRepository storeRepository; - public Page getAllStores(Pageable pageable) { ... } + public Page getAllStores(Pageable pageable) { + return storeRepository.findAll(pageable) + .map(AdminStoreListResponse::from); + } } diff --git a/spot-store/src/main/java/com/example/Spot/store/presentation/dto/response/AdminStoreListResonse.java b/spot-store/src/main/java/com/example/Spot/store/presentation/dto/response/AdminStoreListResonse.java new file mode 100644 index 00000000..6a8f325d --- /dev/null +++ b/spot-store/src/main/java/com/example/Spot/store/presentation/dto/response/AdminStoreListResonse.java @@ -0,0 +1,16 @@ +package com.example.Spot.store.presentation.dto.response; + +import java.util.UUID; + +import com.example.Spot.store.domain.entity.StoreEntity; + +public record AdminStoreListResponse( + UUID storeId, + String name, + String status +) { + + public static AdminStoreListResponse from(StoreEntity store) { + return new AdminStoreListResponse(store.getId(), store.getName(), store.getStatus()); + } +} diff --git a/spot-user/src/main/java/com/example/Spot/admin/presentation/dto/response/AdminStatsResponseDto.java b/spot-user/src/main/java/com/example/Spot/admin/presentation/dto/response/AdminStatsResponseDto.java index a028180d..995819d3 100644 --- a/spot-user/src/main/java/com/example/Spot/admin/presentation/dto/response/AdminStatsResponseDto.java +++ b/spot-user/src/main/java/com/example/Spot/admin/presentation/dto/response/AdminStatsResponseDto.java @@ -1,11 +1,13 @@ -package com.example.Spot.admin.presentation.dto.response; - import java.math.BigDecimal; import java.util.List; import com.example.Spot.global.feign.dto.OrderResponse; -import lombok.*; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; @Getter @Builder diff --git a/spot-user/src/main/java/com/example/Spot/user/presentation/controller/UserController.java b/spot-user/src/main/java/com/example/Spot/user/presentation/controller/UserController.java index 08cdb437..4e8aa8c8 100644 --- a/spot-user/src/main/java/com/example/Spot/user/presentation/controller/UserController.java +++ b/spot-user/src/main/java/com/example/Spot/user/presentation/controller/UserController.java @@ -5,7 +5,14 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.Authentication; -import org.springframework.web.bind.annotation.*; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; import com.example.Spot.user.application.service.UserService; import com.example.Spot.user.presentation.dto.request.UserUpdateRequestDTO; From 32a9f6bf9679c9f3b7963492032943cb48ed4ca1 Mon Sep 17 00:00:00 2001 From: dbswjd7 Date: Thu, 22 Jan 2026 10:32:24 +0900 Subject: [PATCH 36/77] feat(#224): checkstyle --- .../Spot/global/feign/dto/StorePageResponse.java | 13 +++++++++++++ .../presentation/controller/StoreController.java | 4 ++-- .../dto/response/AdminStatsResponseDto.java | 2 ++ 3 files changed, 17 insertions(+), 2 deletions(-) create mode 100644 spot-store/src/main/java/com/example/Spot/global/feign/dto/StorePageResponse.java diff --git a/spot-store/src/main/java/com/example/Spot/global/feign/dto/StorePageResponse.java b/spot-store/src/main/java/com/example/Spot/global/feign/dto/StorePageResponse.java new file mode 100644 index 00000000..de4cc272 --- /dev/null +++ b/spot-store/src/main/java/com/example/Spot/global/feign/dto/StorePageResponse.java @@ -0,0 +1,13 @@ +package com.example.Spot.global.feign.dto; + +import java.util.List; + +public record StorePageResponse( + List content, + int page, + int size, + long totalElements, + int totalPages, + boolean first, + boolean last +) {} diff --git a/spot-store/src/main/java/com/example/Spot/store/presentation/controller/StoreController.java b/spot-store/src/main/java/com/example/Spot/store/presentation/controller/StoreController.java index 11904528..02975aaa 100644 --- a/spot-store/src/main/java/com/example/Spot/store/presentation/controller/StoreController.java +++ b/spot-store/src/main/java/com/example/Spot/store/presentation/controller/StoreController.java @@ -89,7 +89,7 @@ public ResponseEntity getStoreDetails( @Override @GetMapping - public ResponseEntity> getAllStores( + public ResponseEntity> getAllStores( @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "50") int size, @AuthenticationPrincipal CustomUserDetails principal @@ -152,7 +152,7 @@ public ResponseEntity updateStoreStatus( @Override @GetMapping("/search") - public ResponseEntity> searchStores( + public ResponseEntity> searchStores( @RequestParam String keyword, @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "50") int size, diff --git a/spot-user/src/main/java/com/example/Spot/admin/presentation/dto/response/AdminStatsResponseDto.java b/spot-user/src/main/java/com/example/Spot/admin/presentation/dto/response/AdminStatsResponseDto.java index 995819d3..edcda7e8 100644 --- a/spot-user/src/main/java/com/example/Spot/admin/presentation/dto/response/AdminStatsResponseDto.java +++ b/spot-user/src/main/java/com/example/Spot/admin/presentation/dto/response/AdminStatsResponseDto.java @@ -1,3 +1,5 @@ +package com.example.Spot.admin.presentation.dto.response; + import java.math.BigDecimal; import java.util.List; From c228d3ddd6ca5b05337095b62d5d795e531f13d4 Mon Sep 17 00:00:00 2001 From: dbswjd7 Date: Thu, 22 Jan 2026 10:48:34 +0900 Subject: [PATCH 37/77] =?UTF-8?q?feat(#224):user=EC=9D=98=20storeclient=20?= =?UTF-8?q?contextid=EB=B6=80=EC=97=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...StoreInternelService.java => AdminStoreInternalService.java} | 0 .../{AdminStoreListResonse.java => AdminStoreListResponse.java} | 0 .../java/com/example/Spot/global/feign/StoreAdminClient.java | 2 +- .../main/java/com/example/Spot/global/feign/StoreClient.java | 2 +- 4 files changed, 2 insertions(+), 2 deletions(-) rename spot-store/src/main/java/com/example/Spot/store/application/service/{AdminStoreInternelService.java => AdminStoreInternalService.java} (100%) rename spot-store/src/main/java/com/example/Spot/store/presentation/dto/response/{AdminStoreListResonse.java => AdminStoreListResponse.java} (100%) diff --git a/spot-store/src/main/java/com/example/Spot/store/application/service/AdminStoreInternelService.java b/spot-store/src/main/java/com/example/Spot/store/application/service/AdminStoreInternalService.java similarity index 100% rename from spot-store/src/main/java/com/example/Spot/store/application/service/AdminStoreInternelService.java rename to spot-store/src/main/java/com/example/Spot/store/application/service/AdminStoreInternalService.java diff --git a/spot-store/src/main/java/com/example/Spot/store/presentation/dto/response/AdminStoreListResonse.java b/spot-store/src/main/java/com/example/Spot/store/presentation/dto/response/AdminStoreListResponse.java similarity index 100% rename from spot-store/src/main/java/com/example/Spot/store/presentation/dto/response/AdminStoreListResonse.java rename to spot-store/src/main/java/com/example/Spot/store/presentation/dto/response/AdminStoreListResponse.java diff --git a/spot-user/src/main/java/com/example/Spot/global/feign/StoreAdminClient.java b/spot-user/src/main/java/com/example/Spot/global/feign/StoreAdminClient.java index 4e750a36..c0b580d0 100644 --- a/spot-user/src/main/java/com/example/Spot/global/feign/StoreAdminClient.java +++ b/spot-user/src/main/java/com/example/Spot/global/feign/StoreAdminClient.java @@ -12,7 +12,7 @@ import com.example.Spot.admin.presentation.dto.AdminStoreListResponseDto; import com.example.Spot.global.feign.dto.StorePageResponse; -@FeignClient(name = "store-service", url = "${feign.store.url}") +@FeignClient(name = "store-service",contextId = "storeAdminClient", url = "${feign.store.url}") public interface StoreAdminClient { @GetMapping("/api/internal/admin/stores") diff --git a/spot-user/src/main/java/com/example/Spot/global/feign/StoreClient.java b/spot-user/src/main/java/com/example/Spot/global/feign/StoreClient.java index 08baad38..b89b534b 100644 --- a/spot-user/src/main/java/com/example/Spot/global/feign/StoreClient.java +++ b/spot-user/src/main/java/com/example/Spot/global/feign/StoreClient.java @@ -12,7 +12,7 @@ import com.example.Spot.global.feign.dto.StorePageResponse; import com.example.Spot.global.feign.dto.StoreResponse; -@FeignClient(name = "store-service", url = "${feign.store.url}") +@FeignClient(name = "store-service",contextId = "storeClient", url = "${feign.store.url}") public interface StoreClient { @GetMapping("/api/stores") From 44f1798e510f0b08939c9b34bab40e1cdedaa463 Mon Sep 17 00:00:00 2001 From: dbswjd7 Date: Thu, 22 Jan 2026 10:50:42 +0900 Subject: [PATCH 38/77] feat(#224): checkstyle --- .../java/com/example/Spot/global/feign/StoreAdminClient.java | 2 +- .../main/java/com/example/Spot/global/feign/StoreClient.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/spot-user/src/main/java/com/example/Spot/global/feign/StoreAdminClient.java b/spot-user/src/main/java/com/example/Spot/global/feign/StoreAdminClient.java index c0b580d0..9468d198 100644 --- a/spot-user/src/main/java/com/example/Spot/global/feign/StoreAdminClient.java +++ b/spot-user/src/main/java/com/example/Spot/global/feign/StoreAdminClient.java @@ -12,7 +12,7 @@ import com.example.Spot.admin.presentation.dto.AdminStoreListResponseDto; import com.example.Spot.global.feign.dto.StorePageResponse; -@FeignClient(name = "store-service",contextId = "storeAdminClient", url = "${feign.store.url}") +@FeignClient(name = "store-service", contextId = "storeAdminClient", url = "${feign.store.url}") public interface StoreAdminClient { @GetMapping("/api/internal/admin/stores") diff --git a/spot-user/src/main/java/com/example/Spot/global/feign/StoreClient.java b/spot-user/src/main/java/com/example/Spot/global/feign/StoreClient.java index b89b534b..6b183b03 100644 --- a/spot-user/src/main/java/com/example/Spot/global/feign/StoreClient.java +++ b/spot-user/src/main/java/com/example/Spot/global/feign/StoreClient.java @@ -12,7 +12,7 @@ import com.example.Spot.global.feign.dto.StorePageResponse; import com.example.Spot.global.feign.dto.StoreResponse; -@FeignClient(name = "store-service",contextId = "storeClient", url = "${feign.store.url}") +@FeignClient(name = "store-service", contextId = "storeClient", url = "${feign.store.url}") public interface StoreClient { @GetMapping("/api/stores") From d5a5ef547b7e29dba5d9743595ef8f50ed98b52d Mon Sep 17 00:00:00 2001 From: dbswjd7 Date: Thu, 22 Jan 2026 11:25:37 +0900 Subject: [PATCH 39/77] =?UTF-8?q?feat(#224):=20store=20=EC=98=A4=EB=A5=98?= =?UTF-8?q?=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../example/Spot/SpotStoreApplication.java | 2 ++ .../global/feign/dto/StorePageResponse.java | 17 ++++++++- .../domain/repository/ReviewRepository.java | 4 +-- .../service/AdminStoreInternalService.java | 8 ++++- .../InternalAdminStoreController.java | 36 ++++++++++--------- .../controller/StoreController.java | 10 ++++-- .../dto/response/AdminStoreListResponse.java | 3 +- .../store/presentation/swagger/StoreApi.java | 6 ++-- 8 files changed, 60 insertions(+), 26 deletions(-) diff --git a/spot-store/src/main/java/com/example/Spot/SpotStoreApplication.java b/spot-store/src/main/java/com/example/Spot/SpotStoreApplication.java index acd72395..22ff0974 100644 --- a/spot-store/src/main/java/com/example/Spot/SpotStoreApplication.java +++ b/spot-store/src/main/java/com/example/Spot/SpotStoreApplication.java @@ -2,8 +2,10 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cloud.openfeign.EnableFeignClients; @SpringBootApplication +@EnableFeignClients public class SpotStoreApplication { public static void main(String[] args) { diff --git a/spot-store/src/main/java/com/example/Spot/global/feign/dto/StorePageResponse.java b/spot-store/src/main/java/com/example/Spot/global/feign/dto/StorePageResponse.java index de4cc272..22fc800b 100644 --- a/spot-store/src/main/java/com/example/Spot/global/feign/dto/StorePageResponse.java +++ b/spot-store/src/main/java/com/example/Spot/global/feign/dto/StorePageResponse.java @@ -2,6 +2,8 @@ import java.util.List; +import org.springframework.data.domain.Page; + public record StorePageResponse( List content, int page, @@ -10,4 +12,17 @@ public record StorePageResponse( int totalPages, boolean first, boolean last -) {} +) { + + public static StorePageResponse from(Page page) { + return new StorePageResponse<>( + page.getContent(), + page.getNumber(), + page.getSize(), + page.getTotalElements(), + page.getTotalPages(), + page.isFirst(), + page.isLast() + ); + } +} diff --git a/spot-store/src/main/java/com/example/Spot/review/domain/repository/ReviewRepository.java b/spot-store/src/main/java/com/example/Spot/review/domain/repository/ReviewRepository.java index f31eef21..470ddbc5 100644 --- a/spot-store/src/main/java/com/example/Spot/review/domain/repository/ReviewRepository.java +++ b/spot-store/src/main/java/com/example/Spot/review/domain/repository/ReviewRepository.java @@ -18,7 +18,7 @@ public interface ReviewRepository extends JpaRepository { // 특정 가게의 리뷰 조회 (삭제되지 않은 리뷰만) @Query("SELECT r FROM ReviewEntity r " + - "JOIN FETCH r.store s" + + "JOIN FETCH r.store s " + "WHERE s.id = :storeId " + "AND r.isDeleted = false " + "ORDER BY r.createdAt DESC") @@ -34,7 +34,7 @@ public interface ReviewRepository extends JpaRepository { // 특정 사용자의 리뷰 조회 @Query("SELECT r FROM ReviewEntity r " + "JOIN FETCH r.store s " + - "WHERE r.user.id = :userId " + + "WHERE r.userId = :userId " + "AND r.isDeleted = false " + "ORDER BY r.createdAt DESC") List findByUserIdAndIsDeletedFalse(@Param("userId") Integer userId); diff --git a/spot-store/src/main/java/com/example/Spot/store/application/service/AdminStoreInternalService.java b/spot-store/src/main/java/com/example/Spot/store/application/service/AdminStoreInternalService.java index 89362f5f..fe01bcee 100644 --- a/spot-store/src/main/java/com/example/Spot/store/application/service/AdminStoreInternalService.java +++ b/spot-store/src/main/java/com/example/Spot/store/application/service/AdminStoreInternalService.java @@ -1,11 +1,12 @@ package com.example.Spot.store.application.service; +import java.util.UUID; + import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import com.example.Spot.store.domain.entity.StoreEntity; import com.example.Spot.store.domain.repository.StoreRepository; import com.example.Spot.store.presentation.dto.response.AdminStoreListResponse; @@ -16,11 +17,16 @@ @Transactional(readOnly = true) public class AdminStoreInternalService { + private final StoreService storeService; private final StoreRepository storeRepository; public Page getAllStores(Pageable pageable) { return storeRepository.findAll(pageable) .map(AdminStoreListResponse::from); } + @Transactional + public void deleteStore(UUID storeId, Integer userId) { + storeService.deleteStore(storeId, userId, true); + } } diff --git a/spot-store/src/main/java/com/example/Spot/store/presentation/controller/InternalAdminStoreController.java b/spot-store/src/main/java/com/example/Spot/store/presentation/controller/InternalAdminStoreController.java index 46b91e92..7360dd08 100644 --- a/spot-store/src/main/java/com/example/Spot/store/presentation/controller/InternalAdminStoreController.java +++ b/spot-store/src/main/java/com/example/Spot/store/presentation/controller/InternalAdminStoreController.java @@ -7,12 +7,18 @@ import org.springframework.data.domain.Pageable; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.web.bind.annotation.*; - +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.example.Spot.global.feign.dto.StorePageResponse; +import com.example.Spot.infra.auth.security.CustomUserDetails; import com.example.Spot.store.application.service.AdminStoreInternalService; -import com.example.Spot.store.presentation.dto.request.StoreStatusUpdateRequest; import com.example.Spot.store.presentation.dto.response.AdminStoreListResponse; -import com.example.Spot.store.presentation.dto.response.StorePageResponse; import lombok.RequiredArgsConstructor; @@ -25,27 +31,25 @@ public class InternalAdminStoreController { private final AdminStoreInternalService adminStoreInternalService; @GetMapping - public ResponseEntity getAllStores( + public ResponseEntity> getAllStores( @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size ) { Pageable pageable = PageRequest.of(page, size); - Page stores = adminStoreInternalService.getAllStores(pageable); + + // 너가 이미 갖고 있는 admin 목록 조회 서비스가 Page로 주면 이대로 변환 + Page stores = Page.empty(pageable); + return ResponseEntity.ok(StorePageResponse.from(stores)); } - @PatchMapping("/{storeId}/status") - public ResponseEntity updateStoreStatus( + @DeleteMapping("/{storeId}") + public ResponseEntity deleteStore( @PathVariable UUID storeId, - @RequestBody StoreStatusUpdateRequest request + @AuthenticationPrincipal CustomUserDetails principal ) { - adminStoreInternalService.updateStoreStatus(storeId, request.status()); - return ResponseEntity.noContent().build(); - } - - @DeleteMapping("/{storeId}") - public ResponseEntity deleteStore(@PathVariable UUID storeId) { - adminStoreInternalService.deleteStore(storeId); + Integer userId = principal.getUserId(); + adminStoreInternalService.deleteStore(storeId, userId); return ResponseEntity.noContent().build(); } } diff --git a/spot-store/src/main/java/com/example/Spot/store/presentation/controller/StoreController.java b/spot-store/src/main/java/com/example/Spot/store/presentation/controller/StoreController.java index 02975aaa..ecdcd121 100644 --- a/spot-store/src/main/java/com/example/Spot/store/presentation/controller/StoreController.java +++ b/spot-store/src/main/java/com/example/Spot/store/presentation/controller/StoreController.java @@ -19,6 +19,7 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import com.example.Spot.global.feign.dto.StorePageResponse; import com.example.Spot.infra.auth.security.CustomUserDetails; import com.example.Spot.store.application.service.StoreService; import com.example.Spot.store.domain.StoreStatus; @@ -96,7 +97,9 @@ public ResponseEntity> getAllStores( ) { boolean isAdmin = principal.getRole() == "MANAGER" || principal.getRole() == "MASTER"; Pageable pageable = PageRequest.of(page, size); - return ResponseEntity.ok(storeService.getAllStores(isAdmin, pageable)); + Page stores = storeService.getAllStores(isAdmin, pageable); + + return ResponseEntity.ok(StorePageResponse.from(stores)); } @Override @@ -160,6 +163,9 @@ public ResponseEntity> searchStores( ) { boolean isAdmin = principal.getRole() == "MANAGER" || principal.getRole() == "MASTER"; Pageable pageable = PageRequest.of(page, size); - return ResponseEntity.ok(storeService.searchStoresByName(keyword, isAdmin, pageable)); + Page stores = + storeService.searchStoresByName(keyword, isAdmin, pageable); + + return ResponseEntity.ok(StorePageResponse.from(stores)); } } diff --git a/spot-store/src/main/java/com/example/Spot/store/presentation/dto/response/AdminStoreListResponse.java b/spot-store/src/main/java/com/example/Spot/store/presentation/dto/response/AdminStoreListResponse.java index 6a8f325d..7b56fb84 100644 --- a/spot-store/src/main/java/com/example/Spot/store/presentation/dto/response/AdminStoreListResponse.java +++ b/spot-store/src/main/java/com/example/Spot/store/presentation/dto/response/AdminStoreListResponse.java @@ -2,12 +2,13 @@ import java.util.UUID; +import com.example.Spot.store.domain.StoreStatus; import com.example.Spot.store.domain.entity.StoreEntity; public record AdminStoreListResponse( UUID storeId, String name, - String status + StoreStatus status ) { public static AdminStoreListResponse from(StoreEntity store) { diff --git a/spot-store/src/main/java/com/example/Spot/store/presentation/swagger/StoreApi.java b/spot-store/src/main/java/com/example/Spot/store/presentation/swagger/StoreApi.java index 2a9cf51b..ceb0aaab 100644 --- a/spot-store/src/main/java/com/example/Spot/store/presentation/swagger/StoreApi.java +++ b/spot-store/src/main/java/com/example/Spot/store/presentation/swagger/StoreApi.java @@ -2,13 +2,13 @@ import java.util.UUID; -import org.springframework.data.domain.Page; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; +import com.example.Spot.global.feign.dto.StorePageResponse; import com.example.Spot.infra.auth.security.CustomUserDetails; import com.example.Spot.store.presentation.dto.request.StoreCreateRequest; import com.example.Spot.store.presentation.dto.request.StoreUpdateRequest; @@ -52,7 +52,7 @@ ResponseEntity getStoreDetails( @ApiResponses({ @ApiResponse(responseCode = "200", description = "조회 성공") }) - ResponseEntity> getAllStores( + ResponseEntity> getAllStores( @Parameter(description = "페이지 번호") @RequestParam(defaultValue = "0") int page, @Parameter(description = "페이지 크기") @RequestParam(defaultValue = "50") int size, @Parameter(hidden = true) @AuthenticationPrincipal CustomUserDetails principal @@ -97,7 +97,7 @@ ResponseEntity deleteStore( @ApiResponses({ @ApiResponse(responseCode = "200", description = "검색 성공") }) - ResponseEntity> searchStores( + ResponseEntity> searchStores( @Parameter(description = "검색 키워드") @RequestParam String keyword, @Parameter(description = "페이지 번호") @RequestParam(defaultValue = "0") int page, @Parameter(description = "페이지 크기") @RequestParam(defaultValue = "50") int size, From 9566259d7a2871b52c7c810e998d4d70362e837c Mon Sep 17 00:00:00 2001 From: dbswjd7 Date: Thu, 22 Jan 2026 11:34:17 +0900 Subject: [PATCH 40/77] =?UTF-8?q?feat(#224):=20user=20=EC=97=90=EB=9F=AC?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- spot-user/build.gradle | 2 -- 1 file changed, 2 deletions(-) diff --git a/spot-user/build.gradle b/spot-user/build.gradle index ccb46f94..b2e1d5b0 100644 --- a/spot-user/build.gradle +++ b/spot-user/build.gradle @@ -60,8 +60,6 @@ dependencies { // postgreSQL implementation 'org.postgresql:postgresql' - // spring cloud gateway - implementation 'org.springframework.cloud:spring-cloud-starter-gateway' } tasks.named('test') { From 29d6f37b5b3c62c5222d1f9edfcde5b040719360 Mon Sep 17 00:00:00 2001 From: dbswjd7 Date: Thu, 22 Jan 2026 11:55:07 +0900 Subject: [PATCH 41/77] =?UTF-8?q?feat(#224):=20=EC=A0=84=EC=B2=B4=20build?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- settings.gradle | 0 spot-gateway/settings.gradle | 1 - spot-mono/settings.gradle | 3 --- spot-order/settings.gradle | 1 - spot-payment/settings.gradle | 1 - spot-store/settings.gradle | 1 - spot-user/settings.gradle | 1 - 7 files changed, 8 deletions(-) create mode 100644 settings.gradle delete mode 100644 spot-gateway/settings.gradle delete mode 100644 spot-mono/settings.gradle delete mode 100644 spot-order/settings.gradle delete mode 100644 spot-payment/settings.gradle delete mode 100644 spot-store/settings.gradle delete mode 100644 spot-user/settings.gradle diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 00000000..e69de29b diff --git a/spot-gateway/settings.gradle b/spot-gateway/settings.gradle deleted file mode 100644 index 078fe200..00000000 --- a/spot-gateway/settings.gradle +++ /dev/null @@ -1 +0,0 @@ -rootProject.name = 'Spot' diff --git a/spot-mono/settings.gradle b/spot-mono/settings.gradle deleted file mode 100644 index dfbd53fb..00000000 --- a/spot-mono/settings.gradle +++ /dev/null @@ -1,3 +0,0 @@ -rootProject.name = 'Spot' - -include 'spt-user' \ No newline at end of file diff --git a/spot-order/settings.gradle b/spot-order/settings.gradle deleted file mode 100644 index 6c2c044b..00000000 --- a/spot-order/settings.gradle +++ /dev/null @@ -1 +0,0 @@ -rootProject.name = 'spot-order' diff --git a/spot-payment/settings.gradle b/spot-payment/settings.gradle deleted file mode 100644 index 08378698..00000000 --- a/spot-payment/settings.gradle +++ /dev/null @@ -1 +0,0 @@ -rootProject.name = 'spot-payment' diff --git a/spot-store/settings.gradle b/spot-store/settings.gradle deleted file mode 100644 index 9c2c37c9..00000000 --- a/spot-store/settings.gradle +++ /dev/null @@ -1 +0,0 @@ -rootProject.name = 'spot-store' diff --git a/spot-user/settings.gradle b/spot-user/settings.gradle deleted file mode 100644 index d85eefc0..00000000 --- a/spot-user/settings.gradle +++ /dev/null @@ -1 +0,0 @@ -rootProject.name = 'spot-user' From f5740eac0108c8d0bb1a624385803a47fdb258a0 Mon Sep 17 00:00:00 2001 From: dbswjd7 Date: Thu, 22 Jan 2026 11:58:15 +0900 Subject: [PATCH 42/77] =?UTF-8?q?feat(#224):=20=EC=A0=84=EC=B2=B4=20settin?= =?UTF-8?q?gs.gradle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 45457 bytes gradle/wrapper/gradle-wrapper.properties | 7 + gradlew | 251 +++++++++++++++++++++++ gradlew.bat | 94 +++++++++ settings.gradle | 7 + 5 files changed, 359 insertions(+) create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..8bdaf60c75ab801e22807dde59e12a8735a34077 GIT binary patch literal 45457 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 c82=K%QXLKFx%@O{wJjEi6Y56o#$)Bpeg literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..2a84e188 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 00000000..ef07e016 --- /dev/null +++ b/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 00000000..5eed7ee8 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle b/settings.gradle index e69de29b..54bc50f9 100644 --- a/settings.gradle +++ b/settings.gradle @@ -0,0 +1,7 @@ +rootProject.name = "Spot" + +include("spot-user") +include("spot-store") +include("spot-order") +include("spot-payment") +include("spot-gateway") From 173671b26f9609387badf682f6b43294b2c4f2de Mon Sep 17 00:00:00 2001 From: dbswjd7 Date: Thu, 22 Jan 2026 12:08:33 +0900 Subject: [PATCH 43/77] =?UTF-8?q?feat(#224):=20settings.gradle=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- settings.gradle | 1 + 1 file changed, 1 insertion(+) diff --git a/settings.gradle b/settings.gradle index 54bc50f9..69b9c2c7 100644 --- a/settings.gradle +++ b/settings.gradle @@ -4,4 +4,5 @@ include("spot-user") include("spot-store") include("spot-order") include("spot-payment") +include("spot-mono") include("spot-gateway") From 003369d97497ef4987b5092dd5ae1a3edc96ec32 Mon Sep 17 00:00:00 2001 From: dbswjd7 Date: Thu, 22 Jan 2026 14:55:03 +0900 Subject: [PATCH 44/77] =?UTF-8?q?feat(#224):=20build.gradle=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- spot-gateway/build.gradle | 36 +++---------------- .../gradle/wrapper/gradle-wrapper.properties | 2 +- 2 files changed, 6 insertions(+), 32 deletions(-) diff --git a/spot-gateway/build.gradle b/spot-gateway/build.gradle index 9c3f4d04..f680f213 100644 --- a/spot-gateway/build.gradle +++ b/spot-gateway/build.gradle @@ -1,39 +1,13 @@ plugins { id 'java' - id 'org.springframework.boot' version '4.0.1' + id 'org.springframework.boot' version '3.5.9' id 'io.spring.dependency-management' version '1.1.7' } -group = 'com.example' -version = '0.0.1-SNAPSHOT' -description = 'spot-gateway' - -java { - toolchain { - languageVersion = JavaLanguageVersion.of(21) - } -} - -repositories { - mavenCentral() -} - -ext { - set('springCloudVersion', "2025.1.0") -} +repositories { mavenCentral() } dependencies { - implementation 'org.springframework.cloud:spring-cloud-starter-gateway-server-webflux' - testImplementation 'org.springframework.boot:spring-boot-starter-test' - testRuntimeOnly 'org.junit.platform:junit-platform-launcher' -} - -dependencyManagement { - imports { - mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}" - } -} - -tasks.named('test') { - useJUnitPlatform() + implementation "org.springframework.boot:spring-boot-starter-actuator" + implementation "org.springframework.cloud:spring-cloud-starter-gateway-server-webflux:4.3.3" + testImplementation "org.springframework.boot:spring-boot-starter-test" } diff --git a/spot-gateway/gradle/wrapper/gradle-wrapper.properties b/spot-gateway/gradle/wrapper/gradle-wrapper.properties index 23449a2b..df97d72b 100644 --- a/spot-gateway/gradle/wrapper/gradle-wrapper.properties +++ b/spot-gateway/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME From 32398230624da34b266b2e2b0c14d2478be9570f Mon Sep 17 00:00:00 2001 From: Yoonchulchung Date: Thu, 22 Jan 2026 15:02:19 +0900 Subject: [PATCH 45/77] =?UTF-8?q?feat(#224):=20Docker=20file=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test.sh만 실행시키면 동작 가능합니다. --- .gitignore | 1 + docker-compose.yaml | 109 ++++++++++++++++++ spot-gateway/Dockerfile | 7 ++ spot-gateway/config/checkstyle/checkstyle.xml | 99 ++++++++++++++++ .../src/main/resources/application.properties | 1 - spot-mono/Dockerfile | 7 ++ spot-order/Dockerfile | 7 ++ spot-payment/Dockerfile | 7 ++ spot-store/Dockerfile | 7 ++ spot-user/Dockerfile | 7 ++ test.sh | 30 +++++ 11 files changed, 281 insertions(+), 1 deletion(-) create mode 100644 spot-gateway/Dockerfile create mode 100644 spot-gateway/config/checkstyle/checkstyle.xml delete mode 100644 spot-gateway/src/main/resources/application.properties create mode 100644 spot-mono/Dockerfile create mode 100644 spot-order/Dockerfile create mode 100644 spot-payment/Dockerfile create mode 100644 spot-store/Dockerfile create mode 100644 spot-user/Dockerfile create mode 100755 test.sh diff --git a/.gitignore b/.gitignore index fd5a5165..668187ce 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,4 @@ Thumbs.db *.log logs/ +./config/common.yml \ No newline at end of file diff --git a/docker-compose.yaml b/docker-compose.yaml index 24e2ef0e..74331aa7 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -12,9 +12,118 @@ services: - "5432:5432" volumes: - ./postgres_data:/var/lib/postgresql/data + networks: + - spot-network redis: image: redis:alpine container_name: redis_cache ports: - "6379:6379" + networks: + - spot-network + + spot-gateway: + build: + context: ./spot-gateway + dockerfile: Dockerfile + container_name: spot-gateway + ports: + - "8080:8080" + environment: + - SPRING_CONFIG_ADDITIONAL_LOCATION=file:/config/application.yml + volumes: + - ./config/common.yml:/config/common.yml:ro + - ./config/spot-gateway.yml:/config/application.yml:ro + depends_on: + - redis + - spot-user + - spot-store + - spot-order + - spot-payment + networks: + - spot-network + + spot-mono: + build: + context: ./spot-mono + dockerfile: Dockerfile + container_name: spot-mono + networks: + - spot-network + + spot-order: + build: + context: ./spot-order + dockerfile: Dockerfile + container_name: spot-order + ports: + - "8083:8083" + environment: + - SPRING_CONFIG_ADDITIONAL_LOCATION=file:/config/application.yml + volumes: + - ./config/common.yml:/config/common.yml:ro + - ./config/spot-order.yml:/config/application.yml:ro + depends_on: + - db + - redis + networks: + - spot-network + + spot-payment: + build: + context: ./spot-payment + dockerfile: Dockerfile + container_name: spot-payment + ports: + - "8084:8084" + environment: + - SPRING_CONFIG_ADDITIONAL_LOCATION=file:/config/application.yml + volumes: + - ./config/common.yml:/config/common.yml:ro + - ./config/spot-payment.yml:/config/application.yml:ro + depends_on: + - db + - redis + networks: + - spot-network + + spot-store: + build: + context: ./spot-store + dockerfile: Dockerfile + container_name: spot-store + ports: + - "8082:8082" + environment: + - SPRING_CONFIG_ADDITIONAL_LOCATION=file:/config/application.yml + volumes: + - ./config/common.yml:/config/common.yml:ro + - ./config/spot-store.yml:/config/application.yml:ro + depends_on: + - db + - redis + networks: + - spot-network + + spot-user: + build: + context: ./spot-user + dockerfile: Dockerfile + container_name: spot-user + ports: + - "8081:8081" + environment: + - SPRING_CONFIG_ADDITIONAL_LOCATION=file:/config/application.yml + volumes: + - ./config/common.yml:/config/common.yml:ro + - ./config/spot-user.yml:/config/application.yml:ro + depends_on: + - db + - redis + networks: + - spot-network + +networks: + spot-network: + driver: bridge diff --git a/spot-gateway/Dockerfile b/spot-gateway/Dockerfile new file mode 100644 index 00000000..eaf9117a --- /dev/null +++ b/spot-gateway/Dockerfile @@ -0,0 +1,7 @@ +FROM eclipse-temurin:21-jre +WORKDIR /app + +COPY build/libs/spot-gateway-0.0.1-SNAPSHOT.jar app.jar + +EXPOSE 8080 +ENTRYPOINT ["java", "-jar", "app.jar", "--spring.config.location=file:/config/application.yml"] diff --git a/spot-gateway/config/checkstyle/checkstyle.xml b/spot-gateway/config/checkstyle/checkstyle.xml new file mode 100644 index 00000000..c5c571cc --- /dev/null +++ b/spot-gateway/config/checkstyle/checkstyle.xml @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/spot-gateway/src/main/resources/application.properties b/spot-gateway/src/main/resources/application.properties deleted file mode 100644 index 70b12dee..00000000 --- a/spot-gateway/src/main/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ -spring.application.name=spot-gateway diff --git a/spot-mono/Dockerfile b/spot-mono/Dockerfile new file mode 100644 index 00000000..e8e774b1 --- /dev/null +++ b/spot-mono/Dockerfile @@ -0,0 +1,7 @@ +FROM eclipse-temurin:21-jre +WORKDIR /app + +COPY build/libs/spot-mono-0.0.1-SNAPSHOT.jar app.jar + +EXPOSE 8080 +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/spot-order/Dockerfile b/spot-order/Dockerfile new file mode 100644 index 00000000..d95694c3 --- /dev/null +++ b/spot-order/Dockerfile @@ -0,0 +1,7 @@ +FROM eclipse-temurin:21-jre +WORKDIR /app + +COPY build/libs/spot-order-0.0.1-SNAPSHOT.jar app.jar + +EXPOSE 8083 +ENTRYPOINT ["java", "-jar", "app.jar", "--spring.config.location=optional:classpath:/application.yml,optional:classpath:/application.properties,file:/config/application.yml"] diff --git a/spot-payment/Dockerfile b/spot-payment/Dockerfile new file mode 100644 index 00000000..86b9dc9a --- /dev/null +++ b/spot-payment/Dockerfile @@ -0,0 +1,7 @@ +FROM eclipse-temurin:21-jre +WORKDIR /app + +COPY build/libs/spot-payment-0.0.1-SNAPSHOT.jar app.jar + +EXPOSE 8084 +ENTRYPOINT ["java", "-jar", "app.jar", "--spring.config.location=optional:classpath:/application.yml,optional:classpath:/application.properties,file:/config/application.yml"] diff --git a/spot-store/Dockerfile b/spot-store/Dockerfile new file mode 100644 index 00000000..a6765ffa --- /dev/null +++ b/spot-store/Dockerfile @@ -0,0 +1,7 @@ +FROM eclipse-temurin:21-jre +WORKDIR /app + +COPY build/libs/spot-store-0.0.1-SNAPSHOT.jar app.jar + +EXPOSE 8082 +ENTRYPOINT ["java", "-jar", "app.jar", "--spring.config.location=optional:classpath:/application.yml,optional:classpath:/application.properties,file:/config/application.yml"] diff --git a/spot-user/Dockerfile b/spot-user/Dockerfile new file mode 100644 index 00000000..4ecff699 --- /dev/null +++ b/spot-user/Dockerfile @@ -0,0 +1,7 @@ +FROM eclipse-temurin:21-jre +WORKDIR /app + +COPY build/libs/spot-user-0.0.1-SNAPSHOT.jar app.jar + +EXPOSE 8081 +ENTRYPOINT ["java", "-jar", "app.jar", "--spring.config.location=optional:classpath:/application.yml,optional:classpath:/application.properties,file:/config/application.yml"] diff --git a/test.sh b/test.sh new file mode 100755 index 00000000..46efa9b9 --- /dev/null +++ b/test.sh @@ -0,0 +1,30 @@ +#!/bin/bash + +echo "=== 기존 컨테이너 종료 및 삭제 ===" +docker-compose down --remove-orphans +docker rm -f redis_cache local-postgres_db spot-gateway spot-user spot-store spot-order spot-payment spot-mono 2>/dev/null || true + +echo "=== 각 MSA 서비스 빌드 ===" +echo ">> spot-gateway 빌드" +(cd spot-gateway && ./gradlew bootJar -x test) + +echo ">> spot-user 빌드" +(cd spot-user && ./gradlew bootJar -x test) + +echo ">> spot-store 빌드" +(cd spot-store && ./gradlew bootJar -x test) + +echo ">> spot-order 빌드" +(cd spot-order && ./gradlew bootJar -x test) + +echo ">> spot-payment 빌드" +(cd spot-payment && ./gradlew bootJar -x test) + +echo ">> spot-mono 빌드" +(cd spot-mono && ./gradlew bootJar -x test) + +echo "=== Docker 이미지 빌드 및 컨테이너 시작 ===" +docker-compose up --build -d + +echo "=== 실행 중인 컨테이너 확인 ===" +docker-compose ps From f61522c1b72e5d7fe35f7db98646ed9fe62d0ea1 Mon Sep 17 00:00:00 2001 From: Yoonchulchung Date: Thu, 22 Jan 2026 15:04:47 +0900 Subject: [PATCH 46/77] =?UTF-8?q?feat(#224):=20yml=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/spot-gateway.yml | 56 +++++++++++++++++++++++++++++++++++++++++ config/spot-order.yml | 8 ++++++ config/spot-payment.yml | 8 ++++++ config/spot-store.yml | 8 ++++++ config/spot-user.yml | 16 ++++++++++++ 5 files changed, 96 insertions(+) create mode 100644 config/spot-gateway.yml create mode 100644 config/spot-order.yml create mode 100644 config/spot-payment.yml create mode 100644 config/spot-store.yml create mode 100644 config/spot-user.yml diff --git a/config/spot-gateway.yml b/config/spot-gateway.yml new file mode 100644 index 00000000..d44c5292 --- /dev/null +++ b/config/spot-gateway.yml @@ -0,0 +1,56 @@ +spring: + application: + name: spot-gateway + cloud: + gateway: + server: + webflux: + routes: + - id: user-auth + uri: http://spot-user:8081 + predicates: + - Path=/api/login,/api/join,/api/auth/refresh + + - id: user-service + uri: http://spot-user:8081 + predicates: + - Path=/api/users/** + + - id: order-service + uri: http://spot-order:8082 + predicates: + - Path=/api/orders/** + + - id: store-service + uri: http://spot-store:8083 + predicates: + - Path=/api/stores/** + + - id: payment-service + uri: http://spot-payment:8084 + predicates: + - Path=/api/payments/** + + - id: block-internal + uri: http://localhost:9999 + predicates: + - Path=/internal/** + filters: + - SetStatus=403 + + +logging: + level: + org.springframework.boot.context.config: DEBUG + org.springframework.cloud.gateway: DEBUG + org.springframework.cloud.gateway.route.RouteDefinitionRouteLocator: DEBUG + org.springframework.cloud.gateway.handler.RoutePredicateHandlerMapping: TRACE + +management: + endpoints: + web: + exposure: + include: "*" + endpoint: + gateway: + enabled: true \ No newline at end of file diff --git a/config/spot-order.yml b/config/spot-order.yml new file mode 100644 index 00000000..a0db6bc6 --- /dev/null +++ b/config/spot-order.yml @@ -0,0 +1,8 @@ +spring: + config: + import: file:/config/common.yml + application: + name: spot-order + +server: + port: 8082 diff --git a/config/spot-payment.yml b/config/spot-payment.yml new file mode 100644 index 00000000..7ebccbcd --- /dev/null +++ b/config/spot-payment.yml @@ -0,0 +1,8 @@ +spring: + config: + import: file:/config/common.yml + application: + name: spot-payment + +server: + port: 8084 diff --git a/config/spot-store.yml b/config/spot-store.yml new file mode 100644 index 00000000..3f4b0a30 --- /dev/null +++ b/config/spot-store.yml @@ -0,0 +1,8 @@ +spring: + config: + import: file:/config/common.yml + application: + name: spot-store + +server: + port: 8083 diff --git a/config/spot-user.yml b/config/spot-user.yml new file mode 100644 index 00000000..c14f1810 --- /dev/null +++ b/config/spot-user.yml @@ -0,0 +1,16 @@ +spring: + config: + import: file:/config/common.yml + application: + name: spot-user + +server: + port: 8081 + +feign: + order: + url: http://spot-order:8083 + store: + url: http://spot-store:8082 + payment: + url: http://spot-payment:8084 From 71a360ccb77ca99108b2945c23e2b899d0771490 Mon Sep 17 00:00:00 2001 From: Yoonchulchung Date: Thu, 22 Jan 2026 15:26:05 +0900 Subject: [PATCH 47/77] =?UTF-8?q?fix(#224):=20docker=20file=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.yaml | 4 ++-- spot-gateway/Dockerfile | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docker-compose.yaml b/docker-compose.yaml index 74331aa7..d8cffb76 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -58,7 +58,7 @@ services: dockerfile: Dockerfile container_name: spot-order ports: - - "8083:8083" + - "8082:8082" environment: - SPRING_CONFIG_ADDITIONAL_LOCATION=file:/config/application.yml volumes: @@ -94,7 +94,7 @@ services: dockerfile: Dockerfile container_name: spot-store ports: - - "8082:8082" + - "8083:8083" environment: - SPRING_CONFIG_ADDITIONAL_LOCATION=file:/config/application.yml volumes: diff --git a/spot-gateway/Dockerfile b/spot-gateway/Dockerfile index eaf9117a..007dffa2 100644 --- a/spot-gateway/Dockerfile +++ b/spot-gateway/Dockerfile @@ -1,7 +1,7 @@ FROM eclipse-temurin:21-jre WORKDIR /app -COPY build/libs/spot-gateway-0.0.1-SNAPSHOT.jar app.jar +COPY build/libs/spot-gateway.jar app.jar EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar", "--spring.config.location=file:/config/application.yml"] From dcfd3cc0bc5e67482ebe01986de327d51c24a703 Mon Sep 17 00:00:00 2001 From: Yoonchulchung Date: Thu, 22 Jan 2026 15:45:16 +0900 Subject: [PATCH 48/77] =?UTF-8?q?feat(#224):=20log=20=EC=B6=9C=EB=A0=A5=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test.sh b/test.sh index 46efa9b9..1af72659 100755 --- a/test.sh +++ b/test.sh @@ -28,3 +28,6 @@ docker-compose up --build -d echo "=== 실행 중인 컨테이너 확인 ===" docker-compose ps + +echo "=== 로그 확인 ===" +docker compose logs -f | grep --line-buffered -v -E "redis_cache|local-posgre_db" | tee -a current_logs.txt \ No newline at end of file From 103d215a004809f2e293b896b9c55198b9f07411 Mon Sep 17 00:00:00 2001 From: Yoonchulchung Date: Thu, 22 Jan 2026 15:46:52 +0900 Subject: [PATCH 49/77] =?UTF-8?q?fix(#224):=20cors=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/spot-gateway.yml | 37 ++++++++++++++++++++++++++++--------- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/config/spot-gateway.yml b/config/spot-gateway.yml index d44c5292..20eb5db6 100644 --- a/config/spot-gateway.yml +++ b/config/spot-gateway.yml @@ -3,31 +3,50 @@ spring: name: spot-gateway cloud: gateway: + globalcors: + cors-configurations: + '[/**]': + allowedOrigins: + - '*' + allowedMethods: + - GET + - POST + - PATCH + - DELETE + - OPTIONS + allowedHeaders: + - '*' + exposedHeaders: + - Authorization + allowCredentials: true + server: webflux: routes: - id: user-auth - uri: http://spot-user:8081 + uri: http://localhost:8081 predicates: - Path=/api/login,/api/join,/api/auth/refresh - id: user-service - uri: http://spot-user:8081 + uri: http://localhost:8081 predicates: - Path=/api/users/** - - - id: order-service - uri: http://spot-order:8082 - predicates: - - Path=/api/orders/** - id: store-service - uri: http://spot-store:8083 + uri: http://localhost:8083 predicates: - Path=/api/stores/** + filters: + - RewritePath=/api/stores/(?.*), /stores/${segment} + + - id: order-service + uri: http://localhost:8082 + predicates: + - Path=/api/orders/** - id: payment-service - uri: http://spot-payment:8084 + uri: http://localhost:8084 predicates: - Path=/api/payments/** From ea1e3e1a4740a50df63bb4f02419f2577e0912dc Mon Sep 17 00:00:00 2001 From: Yoonchulchung Date: Thu, 22 Jan 2026 15:47:59 +0900 Subject: [PATCH 50/77] =?UTF-8?q?feat(#224):=20compose=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test.sh b/test.sh index 1af72659..ccc036d8 100755 --- a/test.sh +++ b/test.sh @@ -1,7 +1,7 @@ #!/bin/bash echo "=== 기존 컨테이너 종료 및 삭제 ===" -docker-compose down --remove-orphans +docker compose down --remove-orphans docker rm -f redis_cache local-postgres_db spot-gateway spot-user spot-store spot-order spot-payment spot-mono 2>/dev/null || true echo "=== 각 MSA 서비스 빌드 ===" From 1a9babef4ec7f236309ebed6da833900999a5dbc Mon Sep 17 00:00:00 2001 From: Yoonchulchung Date: Thu, 22 Jan 2026 15:48:44 +0900 Subject: [PATCH 51/77] =?UTF-8?q?feat(#224):=20compose=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test.sh b/test.sh index ccc036d8..62ba21cf 100755 --- a/test.sh +++ b/test.sh @@ -24,10 +24,10 @@ echo ">> spot-mono 빌드" (cd spot-mono && ./gradlew bootJar -x test) echo "=== Docker 이미지 빌드 및 컨테이너 시작 ===" -docker-compose up --build -d +docker compose up --build -d echo "=== 실행 중인 컨테이너 확인 ===" -docker-compose ps +docker compose ps echo "=== 로그 확인 ===" docker compose logs -f | grep --line-buffered -v -E "redis_cache|local-posgre_db" | tee -a current_logs.txt \ No newline at end of file From edcb018a9ad332b633ab0e153e581698b06bb53a Mon Sep 17 00:00:00 2001 From: dbswjd7 Date: Thu, 22 Jan 2026 16:09:58 +0900 Subject: [PATCH 52/77] =?UTF-8?q?feat(#224):=20order=20store=20payment=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=ED=86=B5=EA=B3=BC=ED=95=98=EA=B2=8C=EB=81=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../config/security/SecurityConfig.java | 21 ++++---- .../auth/security/CustomUserDetails.java | 3 ++ .../infra/auth/security/DevAuthFilter.java | 51 ++++++++++--------- .../infra/auth/security/DevPrincipal.java | 3 ++ .../config/security/SecurityConfig.java | 21 ++++---- .../auth/security/CustomUserDetails.java | 4 +- .../infra/auth/security/DevAuthFilter.java | 51 ++++++++++--------- .../infra/auth/security/DevPrincipal.java | 3 ++ .../config/security/SecurityConfig.java | 21 ++++---- .../auth/security/CustomUserDetails.java | 3 ++ .../infra/auth/security/DevAuthFilter.java | 51 ++++++++++--------- .../infra/auth/security/DevPrincipal.java | 3 ++ .../com/example/Spot/store/domain/Role.java | 14 +++++ .../controller/StoreController.java | 4 +- 14 files changed, 146 insertions(+), 107 deletions(-) create mode 100644 spot-order/src/main/java/com/example/Spot/infra/auth/security/DevPrincipal.java create mode 100644 spot-payment/src/main/java/com/example/Spot/infra/auth/security/DevPrincipal.java create mode 100644 spot-store/src/main/java/com/example/Spot/infra/auth/security/DevPrincipal.java create mode 100644 spot-store/src/main/java/com/example/Spot/store/domain/Role.java diff --git a/spot-order/src/main/java/com/example/Spot/global/infrastructure/config/security/SecurityConfig.java b/spot-order/src/main/java/com/example/Spot/global/infrastructure/config/security/SecurityConfig.java index 6e33832b..95873571 100644 --- a/spot-order/src/main/java/com/example/Spot/global/infrastructure/config/security/SecurityConfig.java +++ b/spot-order/src/main/java/com/example/Spot/global/infrastructure/config/security/SecurityConfig.java @@ -15,7 +15,6 @@ @Configuration @EnableWebSecurity @EnableMethodSecurity -@Profile({"local", "ci"}) // CI도 local로 돌리면 됨 public class SecurityConfig { @Bean @@ -25,22 +24,20 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.formLogin(form -> form.disable()); http.httpBasic(basic -> basic.disable()); + http.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); http.authorizeHttpRequests(auth -> auth - .requestMatchers("OPTIONS", "/**").permitAll() - .requestMatchers( - "/", "/swagger-ui/**", "/v3/api-docs/**", - "/api/orders/**" - ).permitAll() - .anyRequest()//.authentaicated() +// .requestMatchers("OPTIONS", "/**").permitAll() +// .requestMatchers( +// "/", "/swagger-ui/**", "/v3/api-docs/**", +// "/api/stores/**", "/api/categories/**" +// ).permitAll() +// .anyRequest().authenticated() + .anyRequest().permitAll() ); // 개발/CI용: 임시 principal 주입 (예: OWNER) - http.addFilterBefore( - new DevAuthFilter(1, "OWNER"), - UsernamePasswordAuthenticationFilter.class - ); + http.addFilterBefore(new DevAuthFilter(), UsernamePasswordAuthenticationFilter.class); - http.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); return http.build(); } } diff --git a/spot-order/src/main/java/com/example/Spot/infra/auth/security/CustomUserDetails.java b/spot-order/src/main/java/com/example/Spot/infra/auth/security/CustomUserDetails.java index 9f73ddad..22368484 100644 --- a/spot-order/src/main/java/com/example/Spot/infra/auth/security/CustomUserDetails.java +++ b/spot-order/src/main/java/com/example/Spot/infra/auth/security/CustomUserDetails.java @@ -9,6 +9,9 @@ public CustomUserDetails(Integer userId, String role) { this.userId = userId; this.role = role; } + public static CustomUserDetails forDev(Integer userId, String role) { + return new CustomUserDetails(userId, role); + } public Integer getUserId() { return userId; diff --git a/spot-order/src/main/java/com/example/Spot/infra/auth/security/DevAuthFilter.java b/spot-order/src/main/java/com/example/Spot/infra/auth/security/DevAuthFilter.java index 49a6a10f..54a43fb7 100644 --- a/spot-order/src/main/java/com/example/Spot/infra/auth/security/DevAuthFilter.java +++ b/spot-order/src/main/java/com/example/Spot/infra/auth/security/DevAuthFilter.java @@ -3,11 +3,15 @@ import java.io.IOException; import java.util.List; +import com.example.Spot.infra.auth.security.DevPrincipal; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.filter.OncePerRequestFilter; +import com.example.Spot.infra.auth.security.CustomUserDetails; +import com.example.Spot.store.domain.Role; + import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; @@ -15,33 +19,34 @@ public class DevAuthFilter extends OncePerRequestFilter { - private final Integer fixedUserId; - private final String fixedRole; + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { - public DevAuthFilter(Integer fixedUserId, String fixedRole) { - this.fixedUserId = fixedUserId; - this.fixedRole = fixedRole; - } + if (SecurityContextHolder.getContext().getAuthentication() != null) { + filterChain.doFilter(request, response); + return; + } - @Override - protected void doFilterInternal( - HttpServletRequest request, - HttpServletResponse response, - FilterChain filterChain - ) throws ServletException, IOException { - - if (SecurityContextHolder.getContext().getAuthentication() == null) { - CustomUserDetails principal = new CustomUserDetails(fixedUserId, fixedRole); - - var auth = new UsernamePasswordAuthenticationToken( - principal, - null, - List.of(new SimpleGrantedAuthority("ROLE_" + fixedRole)) - ); - - SecurityContextHolder.getContext().setAuthentication(auth); + String userIdHeader = request.getHeader("X-User-Id"); + String roleHeader = request.getHeader("X-Role"); // OWNER / MASTER / MANAGER + + if (userIdHeader == null || roleHeader == null) { + filterChain.doFilter(request, response); + return; } + Integer userId = Integer.valueOf(userIdHeader); + String role = roleHeader.trim(); + + CustomUserDetails principal = CustomUserDetails.forDev(userId, role); + + var authorities = List.of(new SimpleGrantedAuthority("ROLE_" + role)); + + var auth = new UsernamePasswordAuthenticationToken(principal, null, authorities); + SecurityContextHolder.getContext().setAuthentication(auth); + filterChain.doFilter(request, response); } } diff --git a/spot-order/src/main/java/com/example/Spot/infra/auth/security/DevPrincipal.java b/spot-order/src/main/java/com/example/Spot/infra/auth/security/DevPrincipal.java new file mode 100644 index 00000000..30c8d104 --- /dev/null +++ b/spot-order/src/main/java/com/example/Spot/infra/auth/security/DevPrincipal.java @@ -0,0 +1,3 @@ +package com.example.Spot.infra.auth.security; + +public record DevPrincipal(Integer userId, String role) { } diff --git a/spot-payment/src/main/java/com/example/Spot/global/infrastructure/config/security/SecurityConfig.java b/spot-payment/src/main/java/com/example/Spot/global/infrastructure/config/security/SecurityConfig.java index 62c16601..95873571 100644 --- a/spot-payment/src/main/java/com/example/Spot/global/infrastructure/config/security/SecurityConfig.java +++ b/spot-payment/src/main/java/com/example/Spot/global/infrastructure/config/security/SecurityConfig.java @@ -15,7 +15,6 @@ @Configuration @EnableWebSecurity @EnableMethodSecurity -@Profile({"local", "ci"}) // CI도 local로 돌리면 됨 public class SecurityConfig { @Bean @@ -25,22 +24,20 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.formLogin(form -> form.disable()); http.httpBasic(basic -> basic.disable()); + http.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); http.authorizeHttpRequests(auth -> auth - .requestMatchers("OPTIONS", "/**").permitAll() - .requestMatchers( - "/", "/swagger-ui/**", "/v3/api-docs/**", - "/api/payments/**" - ).permitAll() - .anyRequest()//.authentaicated() +// .requestMatchers("OPTIONS", "/**").permitAll() +// .requestMatchers( +// "/", "/swagger-ui/**", "/v3/api-docs/**", +// "/api/stores/**", "/api/categories/**" +// ).permitAll() +// .anyRequest().authenticated() + .anyRequest().permitAll() ); // 개발/CI용: 임시 principal 주입 (예: OWNER) - http.addFilterBefore( - new DevAuthFilter(1, "OWNER"), - UsernamePasswordAuthenticationFilter.class - ); + http.addFilterBefore(new DevAuthFilter(), UsernamePasswordAuthenticationFilter.class); - http.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); return http.build(); } } diff --git a/spot-payment/src/main/java/com/example/Spot/infra/auth/security/CustomUserDetails.java b/spot-payment/src/main/java/com/example/Spot/infra/auth/security/CustomUserDetails.java index 9f73ddad..972fc678 100644 --- a/spot-payment/src/main/java/com/example/Spot/infra/auth/security/CustomUserDetails.java +++ b/spot-payment/src/main/java/com/example/Spot/infra/auth/security/CustomUserDetails.java @@ -9,7 +9,9 @@ public CustomUserDetails(Integer userId, String role) { this.userId = userId; this.role = role; } - + public static CustomUserDetails forDev(Integer userId, String role) { + return new CustomUserDetails(userId, role); + } public Integer getUserId() { return userId; } diff --git a/spot-payment/src/main/java/com/example/Spot/infra/auth/security/DevAuthFilter.java b/spot-payment/src/main/java/com/example/Spot/infra/auth/security/DevAuthFilter.java index 49a6a10f..54a43fb7 100644 --- a/spot-payment/src/main/java/com/example/Spot/infra/auth/security/DevAuthFilter.java +++ b/spot-payment/src/main/java/com/example/Spot/infra/auth/security/DevAuthFilter.java @@ -3,11 +3,15 @@ import java.io.IOException; import java.util.List; +import com.example.Spot.infra.auth.security.DevPrincipal; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.filter.OncePerRequestFilter; +import com.example.Spot.infra.auth.security.CustomUserDetails; +import com.example.Spot.store.domain.Role; + import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; @@ -15,33 +19,34 @@ public class DevAuthFilter extends OncePerRequestFilter { - private final Integer fixedUserId; - private final String fixedRole; + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { - public DevAuthFilter(Integer fixedUserId, String fixedRole) { - this.fixedUserId = fixedUserId; - this.fixedRole = fixedRole; - } + if (SecurityContextHolder.getContext().getAuthentication() != null) { + filterChain.doFilter(request, response); + return; + } - @Override - protected void doFilterInternal( - HttpServletRequest request, - HttpServletResponse response, - FilterChain filterChain - ) throws ServletException, IOException { - - if (SecurityContextHolder.getContext().getAuthentication() == null) { - CustomUserDetails principal = new CustomUserDetails(fixedUserId, fixedRole); - - var auth = new UsernamePasswordAuthenticationToken( - principal, - null, - List.of(new SimpleGrantedAuthority("ROLE_" + fixedRole)) - ); - - SecurityContextHolder.getContext().setAuthentication(auth); + String userIdHeader = request.getHeader("X-User-Id"); + String roleHeader = request.getHeader("X-Role"); // OWNER / MASTER / MANAGER + + if (userIdHeader == null || roleHeader == null) { + filterChain.doFilter(request, response); + return; } + Integer userId = Integer.valueOf(userIdHeader); + String role = roleHeader.trim(); + + CustomUserDetails principal = CustomUserDetails.forDev(userId, role); + + var authorities = List.of(new SimpleGrantedAuthority("ROLE_" + role)); + + var auth = new UsernamePasswordAuthenticationToken(principal, null, authorities); + SecurityContextHolder.getContext().setAuthentication(auth); + filterChain.doFilter(request, response); } } diff --git a/spot-payment/src/main/java/com/example/Spot/infra/auth/security/DevPrincipal.java b/spot-payment/src/main/java/com/example/Spot/infra/auth/security/DevPrincipal.java new file mode 100644 index 00000000..30c8d104 --- /dev/null +++ b/spot-payment/src/main/java/com/example/Spot/infra/auth/security/DevPrincipal.java @@ -0,0 +1,3 @@ +package com.example.Spot.infra.auth.security; + +public record DevPrincipal(Integer userId, String role) { } diff --git a/spot-store/src/main/java/com/example/Spot/global/infrastructure/config/security/SecurityConfig.java b/spot-store/src/main/java/com/example/Spot/global/infrastructure/config/security/SecurityConfig.java index 73719a38..91577d66 100644 --- a/spot-store/src/main/java/com/example/Spot/global/infrastructure/config/security/SecurityConfig.java +++ b/spot-store/src/main/java/com/example/Spot/global/infrastructure/config/security/SecurityConfig.java @@ -15,7 +15,6 @@ @Configuration @EnableWebSecurity @EnableMethodSecurity -@Profile({"local", "ci"})// CI도 local로 돌리면 됨 public class SecurityConfig { @Bean @@ -25,22 +24,20 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.formLogin(form -> form.disable()); http.httpBasic(basic -> basic.disable()); + http.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); http.authorizeHttpRequests(auth -> auth - .requestMatchers("OPTIONS", "/**").permitAll() - .requestMatchers( - "/", "/swagger-ui/**", "/v3/api-docs/**", - "/api/stores/**", "/api/categories/**" - ).permitAll() - .anyRequest().authenticated() +// .requestMatchers("OPTIONS", "/**").permitAll() +// .requestMatchers( +// "/", "/swagger-ui/**", "/v3/api-docs/**", +// "/api/stores/**", "/api/categories/**" +// ).permitAll() +// .anyRequest().authenticated() + .anyRequest().permitAll() ); // 개발/CI용: 임시 principal 주입 (예: OWNER) - http.addFilterBefore( - new DevAuthFilter(1, "OWNER"), - UsernamePasswordAuthenticationFilter.class - ); + http.addFilterBefore(new DevAuthFilter(), UsernamePasswordAuthenticationFilter.class); - http.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); return http.build(); } } diff --git a/spot-store/src/main/java/com/example/Spot/infra/auth/security/CustomUserDetails.java b/spot-store/src/main/java/com/example/Spot/infra/auth/security/CustomUserDetails.java index f9e0a3cd..e2d548f4 100644 --- a/spot-store/src/main/java/com/example/Spot/infra/auth/security/CustomUserDetails.java +++ b/spot-store/src/main/java/com/example/Spot/infra/auth/security/CustomUserDetails.java @@ -11,6 +11,9 @@ public CustomUserDetails(Integer userId, String role) { this.userId = userId; this.role = role; } + public static CustomUserDetails forDev(Integer userId, String role) { + return new CustomUserDetails(userId, role); + } public Integer getUserId() { return userId; diff --git a/spot-store/src/main/java/com/example/Spot/infra/auth/security/DevAuthFilter.java b/spot-store/src/main/java/com/example/Spot/infra/auth/security/DevAuthFilter.java index 49a6a10f..54a43fb7 100644 --- a/spot-store/src/main/java/com/example/Spot/infra/auth/security/DevAuthFilter.java +++ b/spot-store/src/main/java/com/example/Spot/infra/auth/security/DevAuthFilter.java @@ -3,11 +3,15 @@ import java.io.IOException; import java.util.List; +import com.example.Spot.infra.auth.security.DevPrincipal; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.filter.OncePerRequestFilter; +import com.example.Spot.infra.auth.security.CustomUserDetails; +import com.example.Spot.store.domain.Role; + import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; @@ -15,33 +19,34 @@ public class DevAuthFilter extends OncePerRequestFilter { - private final Integer fixedUserId; - private final String fixedRole; + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { - public DevAuthFilter(Integer fixedUserId, String fixedRole) { - this.fixedUserId = fixedUserId; - this.fixedRole = fixedRole; - } + if (SecurityContextHolder.getContext().getAuthentication() != null) { + filterChain.doFilter(request, response); + return; + } - @Override - protected void doFilterInternal( - HttpServletRequest request, - HttpServletResponse response, - FilterChain filterChain - ) throws ServletException, IOException { - - if (SecurityContextHolder.getContext().getAuthentication() == null) { - CustomUserDetails principal = new CustomUserDetails(fixedUserId, fixedRole); - - var auth = new UsernamePasswordAuthenticationToken( - principal, - null, - List.of(new SimpleGrantedAuthority("ROLE_" + fixedRole)) - ); - - SecurityContextHolder.getContext().setAuthentication(auth); + String userIdHeader = request.getHeader("X-User-Id"); + String roleHeader = request.getHeader("X-Role"); // OWNER / MASTER / MANAGER + + if (userIdHeader == null || roleHeader == null) { + filterChain.doFilter(request, response); + return; } + Integer userId = Integer.valueOf(userIdHeader); + String role = roleHeader.trim(); + + CustomUserDetails principal = CustomUserDetails.forDev(userId, role); + + var authorities = List.of(new SimpleGrantedAuthority("ROLE_" + role)); + + var auth = new UsernamePasswordAuthenticationToken(principal, null, authorities); + SecurityContextHolder.getContext().setAuthentication(auth); + filterChain.doFilter(request, response); } } diff --git a/spot-store/src/main/java/com/example/Spot/infra/auth/security/DevPrincipal.java b/spot-store/src/main/java/com/example/Spot/infra/auth/security/DevPrincipal.java new file mode 100644 index 00000000..30c8d104 --- /dev/null +++ b/spot-store/src/main/java/com/example/Spot/infra/auth/security/DevPrincipal.java @@ -0,0 +1,3 @@ +package com.example.Spot.infra.auth.security; + +public record DevPrincipal(Integer userId, String role) { } diff --git a/spot-store/src/main/java/com/example/Spot/store/domain/Role.java b/spot-store/src/main/java/com/example/Spot/store/domain/Role.java new file mode 100644 index 00000000..56ae503e --- /dev/null +++ b/spot-store/src/main/java/com/example/Spot/store/domain/Role.java @@ -0,0 +1,14 @@ +package com.example.Spot.store.domain; + +public enum Role { + ADMIN, + CUSTOMER, + OWNER, + CHEF, + MANAGER, + MASTER; + + public String getAuthority() { + return "ROLE_" + this.name(); + } +} diff --git a/spot-store/src/main/java/com/example/Spot/store/presentation/controller/StoreController.java b/spot-store/src/main/java/com/example/Spot/store/presentation/controller/StoreController.java index ecdcd121..67edc6a8 100644 --- a/spot-store/src/main/java/com/example/Spot/store/presentation/controller/StoreController.java +++ b/spot-store/src/main/java/com/example/Spot/store/presentation/controller/StoreController.java @@ -95,7 +95,9 @@ public ResponseEntity> getAllStores( @RequestParam(defaultValue = "50") int size, @AuthenticationPrincipal CustomUserDetails principal ) { - boolean isAdmin = principal.getRole() == "MANAGER" || principal.getRole() == "MASTER"; + boolean isAdmin = + "MANAGER".equals(principal.getRole()) || "MASTER".equals(principal.getRole()); + Pageable pageable = PageRequest.of(page, size); Page stores = storeService.getAllStores(isAdmin, pageable); From 8b000b8cd9e76c1e426c9a57468aebfc2f6d568f Mon Sep 17 00:00:00 2001 From: dbswjd7 Date: Thu, 22 Jan 2026 16:16:25 +0900 Subject: [PATCH 53/77] feat(#224): checkstyle --- .../global/infrastructure/config/security/SecurityConfig.java | 1 - .../com/example/Spot/infra/auth/security/DevAuthFilter.java | 4 ---- 2 files changed, 5 deletions(-) diff --git a/spot-payment/src/main/java/com/example/Spot/global/infrastructure/config/security/SecurityConfig.java b/spot-payment/src/main/java/com/example/Spot/global/infrastructure/config/security/SecurityConfig.java index 95873571..3ee33fdc 100644 --- a/spot-payment/src/main/java/com/example/Spot/global/infrastructure/config/security/SecurityConfig.java +++ b/spot-payment/src/main/java/com/example/Spot/global/infrastructure/config/security/SecurityConfig.java @@ -2,7 +2,6 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Profile; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; diff --git a/spot-payment/src/main/java/com/example/Spot/infra/auth/security/DevAuthFilter.java b/spot-payment/src/main/java/com/example/Spot/infra/auth/security/DevAuthFilter.java index 54a43fb7..c9e1edb5 100644 --- a/spot-payment/src/main/java/com/example/Spot/infra/auth/security/DevAuthFilter.java +++ b/spot-payment/src/main/java/com/example/Spot/infra/auth/security/DevAuthFilter.java @@ -3,15 +3,11 @@ import java.io.IOException; import java.util.List; -import com.example.Spot.infra.auth.security.DevPrincipal; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.filter.OncePerRequestFilter; -import com.example.Spot.infra.auth.security.CustomUserDetails; -import com.example.Spot.store.domain.Role; - import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; From 1d2c8a4607b1df8cb58cfa5d3cc8b00d0de950a7 Mon Sep 17 00:00:00 2001 From: Yoonchulchung Date: Thu, 22 Jan 2026 16:18:03 +0900 Subject: [PATCH 54/77] =?UTF-8?q?fix(#224):=20docker=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/spot-gateway.yml | 45 ++++++++++++++++++++--------------------- spot-order/Dockerfile | 2 +- spot-order/HELP.md | 16 --------------- spot-payment/HELP.md | 16 --------------- spot-store/Dockerfile | 2 +- spot-store/HELP.md | 16 --------------- test.sh | 3 ++- 7 files changed, 26 insertions(+), 74 deletions(-) delete mode 100644 spot-order/HELP.md delete mode 100644 spot-payment/HELP.md delete mode 100644 spot-store/HELP.md diff --git a/config/spot-gateway.yml b/config/spot-gateway.yml index 20eb5db6..ac4b6bf0 100644 --- a/config/spot-gateway.yml +++ b/config/spot-gateway.yml @@ -3,50 +3,49 @@ spring: name: spot-gateway cloud: gateway: - globalcors: - cors-configurations: - '[/**]': - allowedOrigins: - - '*' - allowedMethods: - - GET - - POST - - PATCH - - DELETE - - OPTIONS - allowedHeaders: - - '*' - exposedHeaders: - - Authorization - allowCredentials: true - server: webflux: + globalcors: + cors-configurations: + '[/**]': + allowedOriginPatterns: + - '*' + allowedMethods: + - GET + - POST + - PATCH + - DELETE + - OPTIONS + allowedHeaders: + - '*' + exposedHeaders: + - Authorization + allowCredentials: true routes: - id: user-auth - uri: http://localhost:8081 + uri: http://spot-user:8081 predicates: - Path=/api/login,/api/join,/api/auth/refresh - id: user-service - uri: http://localhost:8081 + uri: http://spot-user:8081 predicates: - Path=/api/users/** - id: store-service - uri: http://localhost:8083 + uri: http://spot-store:8083 predicates: - - Path=/api/stores/** + - Path=/api/stores/**, /api/categories/**, /api/reviews/** filters: - RewritePath=/api/stores/(?.*), /stores/${segment} - id: order-service - uri: http://localhost:8082 + uri: http://spot-order:8082 predicates: - Path=/api/orders/** - id: payment-service - uri: http://localhost:8084 + uri: http://spot-payment:8084 predicates: - Path=/api/payments/** diff --git a/spot-order/Dockerfile b/spot-order/Dockerfile index d95694c3..c3f76250 100644 --- a/spot-order/Dockerfile +++ b/spot-order/Dockerfile @@ -3,5 +3,5 @@ WORKDIR /app COPY build/libs/spot-order-0.0.1-SNAPSHOT.jar app.jar -EXPOSE 8083 +EXPOSE 8082 ENTRYPOINT ["java", "-jar", "app.jar", "--spring.config.location=optional:classpath:/application.yml,optional:classpath:/application.properties,file:/config/application.yml"] diff --git a/spot-order/HELP.md b/spot-order/HELP.md deleted file mode 100644 index c865e765..00000000 --- a/spot-order/HELP.md +++ /dev/null @@ -1,16 +0,0 @@ -# Getting Started - -### Reference Documentation - -For further reference, please consider the following sections: - -* [Official Gradle documentation](https://docs.gradle.org) -* [Spring Boot Gradle Plugin Reference Guide](https://docs.spring.io/spring-boot/4.0.1/gradle-plugin) -* [Create an OCI image](https://docs.spring.io/spring-boot/4.0.1/gradle-plugin/packaging-oci-image.html) - -### Additional Links - -These additional references should also help you: - -* [Gradle Build Scans – insights for your project's build](https://scans.gradle.com#gradle) - diff --git a/spot-payment/HELP.md b/spot-payment/HELP.md deleted file mode 100644 index c865e765..00000000 --- a/spot-payment/HELP.md +++ /dev/null @@ -1,16 +0,0 @@ -# Getting Started - -### Reference Documentation - -For further reference, please consider the following sections: - -* [Official Gradle documentation](https://docs.gradle.org) -* [Spring Boot Gradle Plugin Reference Guide](https://docs.spring.io/spring-boot/4.0.1/gradle-plugin) -* [Create an OCI image](https://docs.spring.io/spring-boot/4.0.1/gradle-plugin/packaging-oci-image.html) - -### Additional Links - -These additional references should also help you: - -* [Gradle Build Scans – insights for your project's build](https://scans.gradle.com#gradle) - diff --git a/spot-store/Dockerfile b/spot-store/Dockerfile index a6765ffa..f2aa9e0e 100644 --- a/spot-store/Dockerfile +++ b/spot-store/Dockerfile @@ -3,5 +3,5 @@ WORKDIR /app COPY build/libs/spot-store-0.0.1-SNAPSHOT.jar app.jar -EXPOSE 8082 +EXPOSE 8083 ENTRYPOINT ["java", "-jar", "app.jar", "--spring.config.location=optional:classpath:/application.yml,optional:classpath:/application.properties,file:/config/application.yml"] diff --git a/spot-store/HELP.md b/spot-store/HELP.md deleted file mode 100644 index c865e765..00000000 --- a/spot-store/HELP.md +++ /dev/null @@ -1,16 +0,0 @@ -# Getting Started - -### Reference Documentation - -For further reference, please consider the following sections: - -* [Official Gradle documentation](https://docs.gradle.org) -* [Spring Boot Gradle Plugin Reference Guide](https://docs.spring.io/spring-boot/4.0.1/gradle-plugin) -* [Create an OCI image](https://docs.spring.io/spring-boot/4.0.1/gradle-plugin/packaging-oci-image.html) - -### Additional Links - -These additional references should also help you: - -* [Gradle Build Scans – insights for your project's build](https://scans.gradle.com#gradle) - diff --git a/test.sh b/test.sh index 62ba21cf..aa833793 100755 --- a/test.sh +++ b/test.sh @@ -30,4 +30,5 @@ echo "=== 실행 중인 컨테이너 확인 ===" docker compose ps echo "=== 로그 확인 ===" -docker compose logs -f | grep --line-buffered -v -E "redis_cache|local-posgre_db" | tee -a current_logs.txt \ No newline at end of file +mkdir -p ./logs +docker compose logs -f | grep --line-buffered -v -E "redis_cache|local-posgre_db" | tee -a "./logs/current_logs_$(date +%Y%m%d_%H%M%S).txt" \ No newline at end of file From ab83eaa81b5289fe68bb3072abd5bcd01ea7236e Mon Sep 17 00:00:00 2001 From: dbswjd7 Date: Thu, 22 Jan 2026 16:21:12 +0900 Subject: [PATCH 55/77] feat(#224): pr check --- .../global/infrastructure/config/security/SecurityConfig.java | 1 - .../com/example/Spot/infra/auth/security/DevAuthFilter.java | 4 ---- .../global/infrastructure/config/security/SecurityConfig.java | 1 - .../com/example/Spot/infra/auth/security/DevAuthFilter.java | 4 ---- 4 files changed, 10 deletions(-) diff --git a/spot-order/src/main/java/com/example/Spot/global/infrastructure/config/security/SecurityConfig.java b/spot-order/src/main/java/com/example/Spot/global/infrastructure/config/security/SecurityConfig.java index 95873571..3ee33fdc 100644 --- a/spot-order/src/main/java/com/example/Spot/global/infrastructure/config/security/SecurityConfig.java +++ b/spot-order/src/main/java/com/example/Spot/global/infrastructure/config/security/SecurityConfig.java @@ -2,7 +2,6 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Profile; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; diff --git a/spot-order/src/main/java/com/example/Spot/infra/auth/security/DevAuthFilter.java b/spot-order/src/main/java/com/example/Spot/infra/auth/security/DevAuthFilter.java index 54a43fb7..c9e1edb5 100644 --- a/spot-order/src/main/java/com/example/Spot/infra/auth/security/DevAuthFilter.java +++ b/spot-order/src/main/java/com/example/Spot/infra/auth/security/DevAuthFilter.java @@ -3,15 +3,11 @@ import java.io.IOException; import java.util.List; -import com.example.Spot.infra.auth.security.DevPrincipal; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.filter.OncePerRequestFilter; -import com.example.Spot.infra.auth.security.CustomUserDetails; -import com.example.Spot.store.domain.Role; - import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; diff --git a/spot-store/src/main/java/com/example/Spot/global/infrastructure/config/security/SecurityConfig.java b/spot-store/src/main/java/com/example/Spot/global/infrastructure/config/security/SecurityConfig.java index 91577d66..c32137d0 100644 --- a/spot-store/src/main/java/com/example/Spot/global/infrastructure/config/security/SecurityConfig.java +++ b/spot-store/src/main/java/com/example/Spot/global/infrastructure/config/security/SecurityConfig.java @@ -2,7 +2,6 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Profile; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; diff --git a/spot-store/src/main/java/com/example/Spot/infra/auth/security/DevAuthFilter.java b/spot-store/src/main/java/com/example/Spot/infra/auth/security/DevAuthFilter.java index 54a43fb7..c9e1edb5 100644 --- a/spot-store/src/main/java/com/example/Spot/infra/auth/security/DevAuthFilter.java +++ b/spot-store/src/main/java/com/example/Spot/infra/auth/security/DevAuthFilter.java @@ -3,15 +3,11 @@ import java.io.IOException; import java.util.List; -import com.example.Spot.infra.auth.security.DevPrincipal; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.filter.OncePerRequestFilter; -import com.example.Spot.infra.auth.security.CustomUserDetails; -import com.example.Spot.store.domain.Role; - import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; From 81989bc775997d41acb5e59e7b709c64a3339c55 Mon Sep 17 00:00:00 2001 From: Yoonchulchung Date: Thu, 22 Jan 2026 17:17:40 +0900 Subject: [PATCH 56/77] =?UTF-8?q?fix(#224):=20openfeign=20url=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.yaml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docker-compose.yaml b/docker-compose.yaml index d8cffb76..711551a5 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -61,6 +61,8 @@ services: - "8082:8082" environment: - SPRING_CONFIG_ADDITIONAL_LOCATION=file:/config/application.yml + - FEIGN_STORE_URL=http://spot-store:8083 + - FEIGN_PAYMENT_URL=http://spot-payment:8084 volumes: - ./config/common.yml:/config/common.yml:ro - ./config/spot-order.yml:/config/application.yml:ro @@ -79,6 +81,9 @@ services: - "8084:8084" environment: - SPRING_CONFIG_ADDITIONAL_LOCATION=file:/config/application.yml + - FEIGN_ORDER_URL=http://spot-order:8082 + - FEIGN_USER_URL=http://spot-user:8081 + - FEIGN_STORE_URL=http://spot-store:8083 volumes: - ./config/common.yml:/config/common.yml:ro - ./config/spot-payment.yml:/config/application.yml:ro @@ -97,6 +102,7 @@ services: - "8083:8083" environment: - SPRING_CONFIG_ADDITIONAL_LOCATION=file:/config/application.yml + - FEIGN_USER_URL=http://spot-user:8081 volumes: - ./config/common.yml:/config/common.yml:ro - ./config/spot-store.yml:/config/application.yml:ro @@ -115,6 +121,8 @@ services: - "8081:8081" environment: - SPRING_CONFIG_ADDITIONAL_LOCATION=file:/config/application.yml + - FEIGN_STORE_URL=http://spot-store:8083 + - FEIGN_ORDER_URL=http://spot-order:8082 volumes: - ./config/common.yml:/config/common.yml:ro - ./config/spot-user.yml:/config/application.yml:ro From bd40fb542f69398750a11b8db130219e5a1deb04 Mon Sep 17 00:00:00 2001 From: Yoonchulchung Date: Thu, 22 Jan 2026 17:48:14 +0900 Subject: [PATCH 57/77] =?UTF-8?q?fix(#224):=20gateway=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/spot-gateway.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/config/spot-gateway.yml b/config/spot-gateway.yml index ac4b6bf0..bb3d8226 100644 --- a/config/spot-gateway.yml +++ b/config/spot-gateway.yml @@ -36,9 +36,7 @@ spring: uri: http://spot-store:8083 predicates: - Path=/api/stores/**, /api/categories/**, /api/reviews/** - filters: - - RewritePath=/api/stores/(?.*), /stores/${segment} - + - id: order-service uri: http://spot-order:8082 predicates: From 8327d8077fce6d381268591776fe2dc986064d26 Mon Sep 17 00:00:00 2001 From: Yoonchulchung Date: Thu, 22 Jan 2026 17:51:18 +0900 Subject: [PATCH 58/77] =?UTF-8?q?feat(#224):=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Spot/global/feign/config/FeignConfig.java | 15 +++++++++ .../config/FeignHeaderRelayInterceptor.java | 31 +++++++++++++++++++ .../Spot/global/feign/config/FeignConfig.java | 15 +++++++++ .../config/FeignHeaderRelayInterceptor.java | 31 +++++++++++++++++++ .../Spot/global/feign/config/FeignConfig.java | 15 +++++++++ .../config/FeignHeaderRelayInterceptor.java | 31 +++++++++++++++++++ .../Spot/global/feign/config/FeignConfig.java | 15 +++++++++ .../config/FeignHeaderRelayInterceptor.java | 31 +++++++++++++++++++ 8 files changed, 184 insertions(+) create mode 100644 spot-order/src/main/java/com/example/Spot/global/feign/config/FeignConfig.java create mode 100644 spot-order/src/main/java/com/example/Spot/global/feign/config/FeignHeaderRelayInterceptor.java create mode 100644 spot-payment/src/main/java/com/example/Spot/global/feign/config/FeignConfig.java create mode 100644 spot-payment/src/main/java/com/example/Spot/global/feign/config/FeignHeaderRelayInterceptor.java create mode 100644 spot-store/src/main/java/com/example/Spot/global/feign/config/FeignConfig.java create mode 100644 spot-store/src/main/java/com/example/Spot/global/feign/config/FeignHeaderRelayInterceptor.java create mode 100644 spot-user/src/main/java/com/example/Spot/global/feign/config/FeignConfig.java create mode 100644 spot-user/src/main/java/com/example/Spot/global/feign/config/FeignHeaderRelayInterceptor.java diff --git a/spot-order/src/main/java/com/example/Spot/global/feign/config/FeignConfig.java b/spot-order/src/main/java/com/example/Spot/global/feign/config/FeignConfig.java new file mode 100644 index 00000000..dc3af9ab --- /dev/null +++ b/spot-order/src/main/java/com/example/Spot/global/feign/config/FeignConfig.java @@ -0,0 +1,15 @@ +package com.example.Spot.global.feign.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import feign.RequestInterceptor; + +@Configuration +public class FeignConfig { + + @Bean + public RequestInterceptor feignHeaderRelayInterceptor() { + return new FeignHeaderRelayInterceptor(); + } +} diff --git a/spot-order/src/main/java/com/example/Spot/global/feign/config/FeignHeaderRelayInterceptor.java b/spot-order/src/main/java/com/example/Spot/global/feign/config/FeignHeaderRelayInterceptor.java new file mode 100644 index 00000000..e68777a6 --- /dev/null +++ b/spot-order/src/main/java/com/example/Spot/global/feign/config/FeignHeaderRelayInterceptor.java @@ -0,0 +1,31 @@ +package com.example.Spot.global.feign.config; + +import jakarta.servlet.http.HttpServletRequest; + +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import feign.RequestInterceptor; +import feign.RequestTemplate; + +public class FeignHeaderRelayInterceptor implements RequestInterceptor { + + public static final String HEADER_AUTHORIZATION = "Authorization"; + + @Override + public void apply(RequestTemplate template) { + ServletRequestAttributes attrs = + (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + + if (attrs == null) { + return; + } + + HttpServletRequest request = attrs.getRequest(); + String auth = request.getHeader(HEADER_AUTHORIZATION); + + if (auth != null && !auth.isBlank()) { + template.header(HEADER_AUTHORIZATION, auth); + } + } +} diff --git a/spot-payment/src/main/java/com/example/Spot/global/feign/config/FeignConfig.java b/spot-payment/src/main/java/com/example/Spot/global/feign/config/FeignConfig.java new file mode 100644 index 00000000..dc3af9ab --- /dev/null +++ b/spot-payment/src/main/java/com/example/Spot/global/feign/config/FeignConfig.java @@ -0,0 +1,15 @@ +package com.example.Spot.global.feign.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import feign.RequestInterceptor; + +@Configuration +public class FeignConfig { + + @Bean + public RequestInterceptor feignHeaderRelayInterceptor() { + return new FeignHeaderRelayInterceptor(); + } +} diff --git a/spot-payment/src/main/java/com/example/Spot/global/feign/config/FeignHeaderRelayInterceptor.java b/spot-payment/src/main/java/com/example/Spot/global/feign/config/FeignHeaderRelayInterceptor.java new file mode 100644 index 00000000..e68777a6 --- /dev/null +++ b/spot-payment/src/main/java/com/example/Spot/global/feign/config/FeignHeaderRelayInterceptor.java @@ -0,0 +1,31 @@ +package com.example.Spot.global.feign.config; + +import jakarta.servlet.http.HttpServletRequest; + +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import feign.RequestInterceptor; +import feign.RequestTemplate; + +public class FeignHeaderRelayInterceptor implements RequestInterceptor { + + public static final String HEADER_AUTHORIZATION = "Authorization"; + + @Override + public void apply(RequestTemplate template) { + ServletRequestAttributes attrs = + (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + + if (attrs == null) { + return; + } + + HttpServletRequest request = attrs.getRequest(); + String auth = request.getHeader(HEADER_AUTHORIZATION); + + if (auth != null && !auth.isBlank()) { + template.header(HEADER_AUTHORIZATION, auth); + } + } +} diff --git a/spot-store/src/main/java/com/example/Spot/global/feign/config/FeignConfig.java b/spot-store/src/main/java/com/example/Spot/global/feign/config/FeignConfig.java new file mode 100644 index 00000000..dc3af9ab --- /dev/null +++ b/spot-store/src/main/java/com/example/Spot/global/feign/config/FeignConfig.java @@ -0,0 +1,15 @@ +package com.example.Spot.global.feign.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import feign.RequestInterceptor; + +@Configuration +public class FeignConfig { + + @Bean + public RequestInterceptor feignHeaderRelayInterceptor() { + return new FeignHeaderRelayInterceptor(); + } +} diff --git a/spot-store/src/main/java/com/example/Spot/global/feign/config/FeignHeaderRelayInterceptor.java b/spot-store/src/main/java/com/example/Spot/global/feign/config/FeignHeaderRelayInterceptor.java new file mode 100644 index 00000000..e68777a6 --- /dev/null +++ b/spot-store/src/main/java/com/example/Spot/global/feign/config/FeignHeaderRelayInterceptor.java @@ -0,0 +1,31 @@ +package com.example.Spot.global.feign.config; + +import jakarta.servlet.http.HttpServletRequest; + +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import feign.RequestInterceptor; +import feign.RequestTemplate; + +public class FeignHeaderRelayInterceptor implements RequestInterceptor { + + public static final String HEADER_AUTHORIZATION = "Authorization"; + + @Override + public void apply(RequestTemplate template) { + ServletRequestAttributes attrs = + (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + + if (attrs == null) { + return; + } + + HttpServletRequest request = attrs.getRequest(); + String auth = request.getHeader(HEADER_AUTHORIZATION); + + if (auth != null && !auth.isBlank()) { + template.header(HEADER_AUTHORIZATION, auth); + } + } +} diff --git a/spot-user/src/main/java/com/example/Spot/global/feign/config/FeignConfig.java b/spot-user/src/main/java/com/example/Spot/global/feign/config/FeignConfig.java new file mode 100644 index 00000000..dc3af9ab --- /dev/null +++ b/spot-user/src/main/java/com/example/Spot/global/feign/config/FeignConfig.java @@ -0,0 +1,15 @@ +package com.example.Spot.global.feign.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import feign.RequestInterceptor; + +@Configuration +public class FeignConfig { + + @Bean + public RequestInterceptor feignHeaderRelayInterceptor() { + return new FeignHeaderRelayInterceptor(); + } +} diff --git a/spot-user/src/main/java/com/example/Spot/global/feign/config/FeignHeaderRelayInterceptor.java b/spot-user/src/main/java/com/example/Spot/global/feign/config/FeignHeaderRelayInterceptor.java new file mode 100644 index 00000000..e68777a6 --- /dev/null +++ b/spot-user/src/main/java/com/example/Spot/global/feign/config/FeignHeaderRelayInterceptor.java @@ -0,0 +1,31 @@ +package com.example.Spot.global.feign.config; + +import jakarta.servlet.http.HttpServletRequest; + +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import feign.RequestInterceptor; +import feign.RequestTemplate; + +public class FeignHeaderRelayInterceptor implements RequestInterceptor { + + public static final String HEADER_AUTHORIZATION = "Authorization"; + + @Override + public void apply(RequestTemplate template) { + ServletRequestAttributes attrs = + (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + + if (attrs == null) { + return; + } + + HttpServletRequest request = attrs.getRequest(); + String auth = request.getHeader(HEADER_AUTHORIZATION); + + if (auth != null && !auth.isBlank()) { + template.header(HEADER_AUTHORIZATION, auth); + } + } +} From 550983294e050da394331dc323564630febd473a Mon Sep 17 00:00:00 2001 From: Yoonchulchung Date: Thu, 22 Jan 2026 17:57:18 +0900 Subject: [PATCH 59/77] =?UTF-8?q?fix(#224):=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../config/FeignHeaderRelayInterceptor.java | 16 ++++++++++++---- .../config/FeignHeaderRelayInterceptor.java | 16 ++++++++++++---- .../config/FeignHeaderRelayInterceptor.java | 16 ++++++++++++---- .../config/FeignHeaderRelayInterceptor.java | 16 ++++++++++++---- 4 files changed, 48 insertions(+), 16 deletions(-) diff --git a/spot-order/src/main/java/com/example/Spot/global/feign/config/FeignHeaderRelayInterceptor.java b/spot-order/src/main/java/com/example/Spot/global/feign/config/FeignHeaderRelayInterceptor.java index e68777a6..5755a01f 100644 --- a/spot-order/src/main/java/com/example/Spot/global/feign/config/FeignHeaderRelayInterceptor.java +++ b/spot-order/src/main/java/com/example/Spot/global/feign/config/FeignHeaderRelayInterceptor.java @@ -10,7 +10,9 @@ public class FeignHeaderRelayInterceptor implements RequestInterceptor { - public static final String HEADER_AUTHORIZATION = "Authorization"; + private static final String HEADER_AUTHORIZATION = "Authorization"; + private static final String HEADER_USER_ID = "X-User-Id"; + private static final String HEADER_ROLE = "X-Role"; @Override public void apply(RequestTemplate template) { @@ -22,10 +24,16 @@ public void apply(RequestTemplate template) { } HttpServletRequest request = attrs.getRequest(); - String auth = request.getHeader(HEADER_AUTHORIZATION); - if (auth != null && !auth.isBlank()) { - template.header(HEADER_AUTHORIZATION, auth); + relayHeader(request, template, HEADER_AUTHORIZATION); + relayHeader(request, template, HEADER_USER_ID); + relayHeader(request, template, HEADER_ROLE); + } + + private void relayHeader(HttpServletRequest request, RequestTemplate template, String headerName) { + String value = request.getHeader(headerName); + if (value != null && !value.isBlank()) { + template.header(headerName, value); } } } diff --git a/spot-payment/src/main/java/com/example/Spot/global/feign/config/FeignHeaderRelayInterceptor.java b/spot-payment/src/main/java/com/example/Spot/global/feign/config/FeignHeaderRelayInterceptor.java index e68777a6..5755a01f 100644 --- a/spot-payment/src/main/java/com/example/Spot/global/feign/config/FeignHeaderRelayInterceptor.java +++ b/spot-payment/src/main/java/com/example/Spot/global/feign/config/FeignHeaderRelayInterceptor.java @@ -10,7 +10,9 @@ public class FeignHeaderRelayInterceptor implements RequestInterceptor { - public static final String HEADER_AUTHORIZATION = "Authorization"; + private static final String HEADER_AUTHORIZATION = "Authorization"; + private static final String HEADER_USER_ID = "X-User-Id"; + private static final String HEADER_ROLE = "X-Role"; @Override public void apply(RequestTemplate template) { @@ -22,10 +24,16 @@ public void apply(RequestTemplate template) { } HttpServletRequest request = attrs.getRequest(); - String auth = request.getHeader(HEADER_AUTHORIZATION); - if (auth != null && !auth.isBlank()) { - template.header(HEADER_AUTHORIZATION, auth); + relayHeader(request, template, HEADER_AUTHORIZATION); + relayHeader(request, template, HEADER_USER_ID); + relayHeader(request, template, HEADER_ROLE); + } + + private void relayHeader(HttpServletRequest request, RequestTemplate template, String headerName) { + String value = request.getHeader(headerName); + if (value != null && !value.isBlank()) { + template.header(headerName, value); } } } diff --git a/spot-store/src/main/java/com/example/Spot/global/feign/config/FeignHeaderRelayInterceptor.java b/spot-store/src/main/java/com/example/Spot/global/feign/config/FeignHeaderRelayInterceptor.java index e68777a6..5755a01f 100644 --- a/spot-store/src/main/java/com/example/Spot/global/feign/config/FeignHeaderRelayInterceptor.java +++ b/spot-store/src/main/java/com/example/Spot/global/feign/config/FeignHeaderRelayInterceptor.java @@ -10,7 +10,9 @@ public class FeignHeaderRelayInterceptor implements RequestInterceptor { - public static final String HEADER_AUTHORIZATION = "Authorization"; + private static final String HEADER_AUTHORIZATION = "Authorization"; + private static final String HEADER_USER_ID = "X-User-Id"; + private static final String HEADER_ROLE = "X-Role"; @Override public void apply(RequestTemplate template) { @@ -22,10 +24,16 @@ public void apply(RequestTemplate template) { } HttpServletRequest request = attrs.getRequest(); - String auth = request.getHeader(HEADER_AUTHORIZATION); - if (auth != null && !auth.isBlank()) { - template.header(HEADER_AUTHORIZATION, auth); + relayHeader(request, template, HEADER_AUTHORIZATION); + relayHeader(request, template, HEADER_USER_ID); + relayHeader(request, template, HEADER_ROLE); + } + + private void relayHeader(HttpServletRequest request, RequestTemplate template, String headerName) { + String value = request.getHeader(headerName); + if (value != null && !value.isBlank()) { + template.header(headerName, value); } } } diff --git a/spot-user/src/main/java/com/example/Spot/global/feign/config/FeignHeaderRelayInterceptor.java b/spot-user/src/main/java/com/example/Spot/global/feign/config/FeignHeaderRelayInterceptor.java index e68777a6..5755a01f 100644 --- a/spot-user/src/main/java/com/example/Spot/global/feign/config/FeignHeaderRelayInterceptor.java +++ b/spot-user/src/main/java/com/example/Spot/global/feign/config/FeignHeaderRelayInterceptor.java @@ -10,7 +10,9 @@ public class FeignHeaderRelayInterceptor implements RequestInterceptor { - public static final String HEADER_AUTHORIZATION = "Authorization"; + private static final String HEADER_AUTHORIZATION = "Authorization"; + private static final String HEADER_USER_ID = "X-User-Id"; + private static final String HEADER_ROLE = "X-Role"; @Override public void apply(RequestTemplate template) { @@ -22,10 +24,16 @@ public void apply(RequestTemplate template) { } HttpServletRequest request = attrs.getRequest(); - String auth = request.getHeader(HEADER_AUTHORIZATION); - if (auth != null && !auth.isBlank()) { - template.header(HEADER_AUTHORIZATION, auth); + relayHeader(request, template, HEADER_AUTHORIZATION); + relayHeader(request, template, HEADER_USER_ID); + relayHeader(request, template, HEADER_ROLE); + } + + private void relayHeader(HttpServletRequest request, RequestTemplate template, String headerName) { + String value = request.getHeader(headerName); + if (value != null && !value.isBlank()) { + template.header(headerName, value); } } } From f4bc9e01ec8afa84e036bea55449806e6fd0a30e Mon Sep 17 00:00:00 2001 From: eqqmayo Date: Fri, 23 Jan 2026 08:36:02 +0900 Subject: [PATCH 60/77] =?UTF-8?q?fix:=20import=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../test/java/com/example/Spot/SpotPaymentApplicationTests.java | 2 +- .../presentation/controller/InternalAdminStoreController.java | 2 +- .../Spot/admin/application/service/AdminStoreService.java | 2 +- .../admin/presentation/controller/AdminStoreController.java | 2 +- .../presentation/dto/response/AdminStoreListResponseDto.java | 2 +- .../java/com/example/Spot/global/feign/StoreAdminClient.java | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/spot-payment/src/test/java/com/example/Spot/SpotPaymentApplicationTests.java b/spot-payment/src/test/java/com/example/Spot/SpotPaymentApplicationTests.java index 8ccc62e7..3a525bdb 100644 --- a/spot-payment/src/test/java/com/example/Spot/SpotPaymentApplicationTests.java +++ b/spot-payment/src/test/java/com/example/Spot/SpotPaymentApplicationTests.java @@ -1,4 +1,4 @@ -package com.example.spotpayment; +package com.example.Spot; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; diff --git a/spot-store/src/main/java/com/example/Spot/store/presentation/controller/InternalAdminStoreController.java b/spot-store/src/main/java/com/example/Spot/store/presentation/controller/InternalAdminStoreController.java index 7360dd08..107bfe23 100644 --- a/spot-store/src/main/java/com/example/Spot/store/presentation/controller/InternalAdminStoreController.java +++ b/spot-store/src/main/java/com/example/Spot/store/presentation/controller/InternalAdminStoreController.java @@ -1,4 +1,4 @@ -package com.example.Spot.store.presentation.controller.internal; +package com.example.Spot.store.presentation.controller; import java.util.UUID; diff --git a/spot-user/src/main/java/com/example/Spot/admin/application/service/AdminStoreService.java b/spot-user/src/main/java/com/example/Spot/admin/application/service/AdminStoreService.java index f8d59c2c..de7ef444 100644 --- a/spot-user/src/main/java/com/example/Spot/admin/application/service/AdminStoreService.java +++ b/spot-user/src/main/java/com/example/Spot/admin/application/service/AdminStoreService.java @@ -5,7 +5,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; -import com.example.Spot.admin.presentation.dto.AdminStoreListResponseDto; +import com.example.Spot.admin.presentation.dto.response.AdminStoreListResponseDto; import com.example.Spot.global.feign.StoreClient; import com.example.Spot.global.feign.dto.StorePageResponse; diff --git a/spot-user/src/main/java/com/example/Spot/admin/presentation/controller/AdminStoreController.java b/spot-user/src/main/java/com/example/Spot/admin/presentation/controller/AdminStoreController.java index eced6eb0..0fd1c6a6 100644 --- a/spot-user/src/main/java/com/example/Spot/admin/presentation/controller/AdminStoreController.java +++ b/spot-user/src/main/java/com/example/Spot/admin/presentation/controller/AdminStoreController.java @@ -17,7 +17,7 @@ import org.springframework.web.bind.annotation.RestController; import com.example.Spot.admin.application.service.AdminStoreService; -import com.example.Spot.admin.presentation.dto.AdminStoreListResponseDto; +import com.example.Spot.admin.presentation.dto.response.AdminStoreListResponseDto; import com.example.Spot.auth.security.CustomUserDetails; import com.example.Spot.global.feign.dto.StorePageResponse; import com.example.Spot.global.presentation.ApiResponse; diff --git a/spot-user/src/main/java/com/example/Spot/admin/presentation/dto/response/AdminStoreListResponseDto.java b/spot-user/src/main/java/com/example/Spot/admin/presentation/dto/response/AdminStoreListResponseDto.java index d5bb01f2..99805925 100644 --- a/spot-user/src/main/java/com/example/Spot/admin/presentation/dto/response/AdminStoreListResponseDto.java +++ b/spot-user/src/main/java/com/example/Spot/admin/presentation/dto/response/AdminStoreListResponseDto.java @@ -1,4 +1,4 @@ -package com.example.Spot.admin.presentation.dto; +package com.example.Spot.admin.presentation.dto.response; import java.util.UUID; diff --git a/spot-user/src/main/java/com/example/Spot/global/feign/StoreAdminClient.java b/spot-user/src/main/java/com/example/Spot/global/feign/StoreAdminClient.java index 9468d198..d4f019f5 100644 --- a/spot-user/src/main/java/com/example/Spot/global/feign/StoreAdminClient.java +++ b/spot-user/src/main/java/com/example/Spot/global/feign/StoreAdminClient.java @@ -9,7 +9,7 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestParam; -import com.example.Spot.admin.presentation.dto.AdminStoreListResponseDto; +import com.example.Spot.admin.presentation.dto.response.AdminStoreListResponseDto; import com.example.Spot.global.feign.dto.StorePageResponse; @FeignClient(name = "store-service", contextId = "storeAdminClient", url = "${feign.store.url}") From 29e087a11d6ffecd6f85267ccdeb4fc7a177613f Mon Sep 17 00:00:00 2001 From: eqqmayo Date: Fri, 23 Jan 2026 08:45:31 +0900 Subject: [PATCH 61/77] =?UTF-8?q?feat:=20route=2053=20to=20ecs,=20db,=20re?= =?UTF-8?q?dis,=20monitoring=20=EA=B8=B0=EB=B3=B8=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- infra/terraform/.gitignore | 19 ++ infra/terraform/ec2.tf | 12 - .../environments/dev/.terraform.lock.hcl | 25 ++ infra/terraform/environments/dev/locals.tf | 9 + infra/terraform/environments/dev/main.tf | 185 +++++++++++++++ infra/terraform/environments/dev/outputs.tf | 117 ++++++++++ infra/terraform/environments/dev/provider.tf | 27 +++ .../environments/dev/terraform.tfvars.example | 13 ++ infra/terraform/environments/dev/variables.tf | 205 +++++++++++++++++ infra/terraform/environments/prod/locals.tf | 9 + infra/terraform/environments/prod/main.tf | 95 ++++++++ infra/terraform/environments/prod/outputs.tf | 57 +++++ infra/terraform/environments/prod/provider.tf | 27 +++ .../prod/terraform.tfvars.example | 13 ++ .../terraform/environments/prod/variables.tf | 136 +++++++++++ infra/terraform/keypair.tf | 15 -- infra/terraform/modules/alb/main.tf | 73 ++++++ infra/terraform/modules/alb/outputs.tf | 29 +++ infra/terraform/modules/alb/variables.tf | 37 +++ infra/terraform/modules/api-gateway/main.tf | 54 +++++ .../terraform/modules/api-gateway/outputs.tf | 19 ++ .../modules/api-gateway/variables.tf | 25 ++ infra/terraform/modules/database/main.tf | 56 +++++ infra/terraform/modules/database/outputs.tf | 29 +++ infra/terraform/modules/database/variables.tf | 60 +++++ infra/terraform/modules/dns/main.tf | 90 ++++++++ infra/terraform/modules/dns/outputs.tf | 29 +++ infra/terraform/modules/dns/variables.tf | 27 +++ infra/terraform/modules/ecr/main.tf | 35 +++ infra/terraform/modules/ecr/outputs.tf | 14 ++ infra/terraform/modules/ecr/variables.tf | 21 ++ infra/terraform/modules/ecs/main.tf | 169 ++++++++++++++ infra/terraform/modules/ecs/outputs.tf | 19 ++ infra/terraform/modules/ecs/variables.tf | 111 +++++++++ infra/terraform/modules/elasticache/main.tf | 91 ++++++++ .../terraform/modules/elasticache/outputs.tf | 32 +++ .../modules/elasticache/variables.tf | 76 ++++++ infra/terraform/modules/monitoring/main.tf | 216 ++++++++++++++++++ infra/terraform/modules/monitoring/outputs.tf | 25 ++ .../terraform/modules/monitoring/variables.tf | 113 +++++++++ infra/terraform/modules/network/main.tf | 153 +++++++++++++ infra/terraform/modules/network/outputs.tf | 29 +++ infra/terraform/modules/network/variables.tf | 36 +++ infra/terraform/modules/s3/main.tf | 212 +++++++++++++++++ infra/terraform/modules/s3/outputs.tf | 32 +++ infra/terraform/modules/s3/variables.tf | 43 ++++ infra/terraform/modules/waf/main.tf | 140 ++++++++++++ infra/terraform/modules/waf/outputs.tf | 19 ++ infra/terraform/modules/waf/variables.tf | 28 +++ infra/terraform/outputs.tf | 19 -- infra/terraform/security.tf | 25 -- infra/terraform/vpc.tf | 42 ---- 52 files changed, 3079 insertions(+), 113 deletions(-) create mode 100644 infra/terraform/.gitignore delete mode 100644 infra/terraform/ec2.tf create mode 100644 infra/terraform/environments/dev/.terraform.lock.hcl create mode 100644 infra/terraform/environments/dev/locals.tf create mode 100644 infra/terraform/environments/dev/main.tf create mode 100644 infra/terraform/environments/dev/outputs.tf create mode 100644 infra/terraform/environments/dev/provider.tf create mode 100644 infra/terraform/environments/dev/terraform.tfvars.example create mode 100644 infra/terraform/environments/dev/variables.tf create mode 100644 infra/terraform/environments/prod/locals.tf create mode 100644 infra/terraform/environments/prod/main.tf create mode 100644 infra/terraform/environments/prod/outputs.tf create mode 100644 infra/terraform/environments/prod/provider.tf create mode 100644 infra/terraform/environments/prod/terraform.tfvars.example create mode 100644 infra/terraform/environments/prod/variables.tf delete mode 100644 infra/terraform/keypair.tf create mode 100644 infra/terraform/modules/alb/main.tf create mode 100644 infra/terraform/modules/alb/outputs.tf create mode 100644 infra/terraform/modules/alb/variables.tf create mode 100644 infra/terraform/modules/api-gateway/main.tf create mode 100644 infra/terraform/modules/api-gateway/outputs.tf create mode 100644 infra/terraform/modules/api-gateway/variables.tf create mode 100644 infra/terraform/modules/database/main.tf create mode 100644 infra/terraform/modules/database/outputs.tf create mode 100644 infra/terraform/modules/database/variables.tf create mode 100644 infra/terraform/modules/dns/main.tf create mode 100644 infra/terraform/modules/dns/outputs.tf create mode 100644 infra/terraform/modules/dns/variables.tf create mode 100644 infra/terraform/modules/ecr/main.tf create mode 100644 infra/terraform/modules/ecr/outputs.tf create mode 100644 infra/terraform/modules/ecr/variables.tf create mode 100644 infra/terraform/modules/ecs/main.tf create mode 100644 infra/terraform/modules/ecs/outputs.tf create mode 100644 infra/terraform/modules/ecs/variables.tf create mode 100644 infra/terraform/modules/elasticache/main.tf create mode 100644 infra/terraform/modules/elasticache/outputs.tf create mode 100644 infra/terraform/modules/elasticache/variables.tf create mode 100644 infra/terraform/modules/monitoring/main.tf create mode 100644 infra/terraform/modules/monitoring/outputs.tf create mode 100644 infra/terraform/modules/monitoring/variables.tf create mode 100644 infra/terraform/modules/network/main.tf create mode 100644 infra/terraform/modules/network/outputs.tf create mode 100644 infra/terraform/modules/network/variables.tf create mode 100644 infra/terraform/modules/s3/main.tf create mode 100644 infra/terraform/modules/s3/outputs.tf create mode 100644 infra/terraform/modules/s3/variables.tf create mode 100644 infra/terraform/modules/waf/main.tf create mode 100644 infra/terraform/modules/waf/outputs.tf create mode 100644 infra/terraform/modules/waf/variables.tf delete mode 100644 infra/terraform/outputs.tf delete mode 100644 infra/terraform/security.tf delete mode 100644 infra/terraform/vpc.tf diff --git a/infra/terraform/.gitignore b/infra/terraform/.gitignore new file mode 100644 index 00000000..7a89c8aa --- /dev/null +++ b/infra/terraform/.gitignore @@ -0,0 +1,19 @@ +# Terraform +.terraform/ +*.tfstate +*.tfstate.* +*.tfstate.lock.info +crash.log +crash.*.log +*.tfvars +!*.tfvars.example +*.tfvars.json +override.tf +override.tf.json +*_override.tf +*_override.tf.json +.terraformrc +terraform.rc + +# Generated files +*.zip diff --git a/infra/terraform/ec2.tf b/infra/terraform/ec2.tf deleted file mode 100644 index 7438e7d4..00000000 --- a/infra/terraform/ec2.tf +++ /dev/null @@ -1,12 +0,0 @@ -resource "aws_instance" "web" { - ami = "ami-0a71e3eb8b23101ed" - instance_type = "t3.micro" - subnet_id = aws_subnet.public.id - vpc_security_group_ids = [aws_security_group.web.id] - associate_public_ip_address = true - key_name = aws_key_pair.terraform_key.key_name - - tags = { - Name = "terraform-web" - } -} \ No newline at end of file diff --git a/infra/terraform/environments/dev/.terraform.lock.hcl b/infra/terraform/environments/dev/.terraform.lock.hcl new file mode 100644 index 00000000..cdc1668d --- /dev/null +++ b/infra/terraform/environments/dev/.terraform.lock.hcl @@ -0,0 +1,25 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "5.100.0" + constraints = "~> 5.0" + hashes = [ + "h1:Ijt7pOlB7Tr7maGQIqtsLFbl7pSMIj06TVdkoSBcYOw=", + "zh:054b8dd49f0549c9a7cc27d159e45327b7b65cf404da5e5a20da154b90b8a644", + "zh:0b97bf8d5e03d15d83cc40b0530a1f84b459354939ba6f135a0086c20ebbe6b2", + "zh:1589a2266af699cbd5d80737a0fe02e54ec9cf2ca54e7e00ac51c7359056f274", + "zh:6330766f1d85f01ae6ea90d1b214b8b74cc8c1badc4696b165b36ddd4cc15f7b", + "zh:7c8c2e30d8e55291b86fcb64bdf6c25489d538688545eb48fd74ad622e5d3862", + "zh:99b1003bd9bd32ee323544da897148f46a527f622dc3971af63ea3e251596342", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:9f8b909d3ec50ade83c8062290378b1ec553edef6a447c56dadc01a99f4eaa93", + "zh:aaef921ff9aabaf8b1869a86d692ebd24fbd4e12c21205034bb679b9caf883a2", + "zh:ac882313207aba00dd5a76dbd572a0ddc818bb9cbf5c9d61b28fe30efaec951e", + "zh:bb64e8aff37becab373a1a0cc1080990785304141af42ed6aa3dd4913b000421", + "zh:dfe495f6621df5540d9c92ad40b8067376350b005c637ea6efac5dc15028add4", + "zh:f0ddf0eaf052766cfe09dea8200a946519f653c384ab4336e2a4a64fdd6310e9", + "zh:f1b7e684f4c7ae1eed272b6de7d2049bb87a0275cb04dbb7cda6636f600699c9", + "zh:ff461571e3f233699bf690db319dfe46aec75e58726636a0d97dd9ac6e32fb70", + ] +} diff --git a/infra/terraform/environments/dev/locals.tf b/infra/terraform/environments/dev/locals.tf new file mode 100644 index 00000000..9512a84d --- /dev/null +++ b/infra/terraform/environments/dev/locals.tf @@ -0,0 +1,9 @@ +locals { + name_prefix = "${var.project}-${var.environment}" + + common_tags = { + Project = var.project + Environment = var.environment + ManagedBy = "terraform" + } +} diff --git a/infra/terraform/environments/dev/main.tf b/infra/terraform/environments/dev/main.tf new file mode 100644 index 00000000..ee249590 --- /dev/null +++ b/infra/terraform/environments/dev/main.tf @@ -0,0 +1,185 @@ +# ============================================================================= +# Data Sources +# ============================================================================= +data "aws_caller_identity" "current" {} + +# ============================================================================= +# Network +# ============================================================================= +module "network" { + source = "../../modules/network" + + name_prefix = local.name_prefix + common_tags = local.common_tags + vpc_cidr = var.vpc_cidr + public_subnet_cidrs = var.public_subnet_cidrs + private_subnet_cidrs = var.private_subnet_cidrs + availability_zones = var.availability_zones + nat_instance_type = var.nat_instance_type +} + +# ============================================================================= +# Database +# ============================================================================= +module "database" { + source = "../../modules/database" + + name_prefix = local.name_prefix + common_tags = local.common_tags + vpc_id = module.network.vpc_id + vpc_cidr = module.network.vpc_cidr + subnet_ids = module.network.private_subnet_ids + db_name = var.db_name + username = var.db_username + password = var.db_password + instance_class = var.db_instance_class + allocated_storage = var.db_allocated_storage + engine_version = var.db_engine_version +} + +# ============================================================================= +# ECR +# ============================================================================= +module "ecr" { + source = "../../modules/ecr" + + project = var.project + name_prefix = local.name_prefix + common_tags = local.common_tags +} + +# ============================================================================= +# ALB +# ============================================================================= +module "alb" { + source = "../../modules/alb" + + name_prefix = local.name_prefix + common_tags = local.common_tags + vpc_id = module.network.vpc_id + vpc_cidr = module.network.vpc_cidr + subnet_ids = module.network.private_subnet_ids + container_port = var.container_port + health_check_path = var.health_check_path +} + +# ============================================================================= +# ECS +# ============================================================================= +module "ecs" { + source = "../../modules/ecs" + + project = var.project + name_prefix = local.name_prefix + common_tags = local.common_tags + region = var.region + vpc_id = module.network.vpc_id + subnet_ids = [module.network.public_subnet_a_id] # NAT 문제로 public 사용 + ecr_repository_url = module.ecr.repository_url + alb_security_group_id = module.alb.security_group_id + target_group_arn = module.alb.target_group_arn + alb_listener_arn = module.alb.listener_arn + container_port = var.container_port + cpu = var.ecs_cpu + memory = var.ecs_memory + desired_count = var.ecs_desired_count + assign_public_ip = true # NAT 문제로 public IP 사용 + + # Database 연결 정보 + db_endpoint = module.database.endpoint + db_name = var.db_name + db_username = var.db_username + db_password = var.db_password +} + +# ============================================================================= +# API Gateway +# ============================================================================= +module "api_gateway" { + source = "../../modules/api-gateway" + + name_prefix = local.name_prefix + common_tags = local.common_tags + subnet_ids = module.network.private_subnet_ids + ecs_security_group_id = module.ecs.security_group_id + alb_listener_arn = module.alb.listener_arn +} + +# ============================================================================= +# DNS (Route 53 + ACM) +# ============================================================================= +module "dns" { + source = "../../modules/dns" + + name_prefix = local.name_prefix + common_tags = local.common_tags + domain_name = var.domain_name + create_api_domain = var.create_api_domain + api_gateway_id = module.api_gateway.api_id +} + +# ============================================================================= +# WAF (Web Application Firewall) +# ============================================================================= +module "waf" { + source = "../../modules/waf" + + name_prefix = local.name_prefix + common_tags = local.common_tags + api_gateway_stage_arn = module.api_gateway.stage_arn + rate_limit = var.waf_rate_limit +} + +# ============================================================================= +# S3 (정적 파일 / 로그 저장) +# ============================================================================= +module "s3" { + source = "../../modules/s3" + + name_prefix = local.name_prefix + common_tags = local.common_tags + account_id = data.aws_caller_identity.current.account_id + region = var.region + log_transition_days = var.s3_log_transition_days + log_expiration_days = var.s3_log_expiration_days +} + +# ============================================================================= +# ElastiCache (Redis 캐시/세션) +# ============================================================================= +module "elasticache" { + source = "../../modules/elasticache" + + name_prefix = local.name_prefix + common_tags = local.common_tags + vpc_id = module.network.vpc_id + subnet_ids = module.network.private_subnet_ids + allowed_security_group_ids = [module.ecs.security_group_id] + node_type = var.redis_node_type + num_cache_clusters = var.redis_num_cache_clusters + engine_version = var.redis_engine_version +} + +# ============================================================================= +# CloudWatch Monitoring (알람/대시보드) +# ============================================================================= +module "monitoring" { + source = "../../modules/monitoring" + + name_prefix = local.name_prefix + common_tags = local.common_tags + alert_email = var.alert_email + + # ECS 모니터링 + ecs_cluster_name = module.ecs.cluster_name + ecs_service_name = module.ecs.service_name + + # RDS 모니터링 + rds_instance_id = module.database.instance_id + + # ALB 모니터링 + alb_arn_suffix = module.alb.arn_suffix + + # Redis 모니터링 (선택) + redis_cluster_id = "${local.name_prefix}-redis-001" +} diff --git a/infra/terraform/environments/dev/outputs.tf b/infra/terraform/environments/dev/outputs.tf new file mode 100644 index 00000000..d79a2c8d --- /dev/null +++ b/infra/terraform/environments/dev/outputs.tf @@ -0,0 +1,117 @@ +# ============================================================================= +# Network +# ============================================================================= +output "vpc_id" { + description = "VPC ID" + value = module.network.vpc_id +} + +# ============================================================================= +# Database +# ============================================================================= +output "rds_endpoint" { + description = "RDS 엔드포인트" + value = module.database.endpoint +} + +output "spring_datasource_url" { + description = "Spring JDBC URL" + value = module.database.jdbc_url +} + +# ============================================================================= +# ECR +# ============================================================================= +output "ecr_repository_url" { + description = "ECR 저장소 URL" + value = module.ecr.repository_url +} + +# ============================================================================= +# ECS +# ============================================================================= +output "ecs_cluster_name" { + description = "ECS 클러스터 이름" + value = module.ecs.cluster_name +} + +output "ecs_service_name" { + description = "ECS 서비스 이름" + value = module.ecs.service_name +} + +# ============================================================================= +# ALB +# ============================================================================= +output "alb_dns" { + description = "ALB DNS" + value = module.alb.alb_dns_name +} + +# ============================================================================= +# API Gateway +# ============================================================================= +output "api_url" { + description = "API Gateway URL" + value = module.api_gateway.api_endpoint +} + +# ============================================================================= +# DNS +# ============================================================================= +output "name_servers" { + description = "Route 53 네임서버 (도메인 등록 기관에 설정 필요)" + value = module.dns.name_servers +} + +output "api_custom_domain" { + description = "API 커스텀 도메인" + value = module.dns.api_domain +} + +output "certificate_arn" { + description = "SSL 인증서 ARN" + value = module.dns.certificate_arn +} + +# ============================================================================= +# WAF +# ============================================================================= +output "waf_web_acl_arn" { + description = "WAF Web ACL ARN" + value = module.waf.web_acl_arn +} + +# ============================================================================= +# S3 +# ============================================================================= +output "s3_static_bucket" { + description = "정적 파일 버킷 이름" + value = module.s3.static_bucket_id +} + +output "s3_logs_bucket" { + description = "로그 버킷 이름" + value = module.s3.logs_bucket_id +} + +# ============================================================================= +# ElastiCache (Redis) +# ============================================================================= +output "redis_endpoint" { + description = "Redis 엔드포인트" + value = module.elasticache.redis_endpoint +} + +output "redis_connection_string" { + description = "Redis 연결 문자열 (Spring Boot용)" + value = module.elasticache.redis_connection_string +} + +# ============================================================================= +# Monitoring +# ============================================================================= +output "sns_alerts_topic_arn" { + description = "알람 알림 SNS Topic ARN" + value = module.monitoring.sns_topic_arn +} diff --git a/infra/terraform/environments/dev/provider.tf b/infra/terraform/environments/dev/provider.tf new file mode 100644 index 00000000..31091ab6 --- /dev/null +++ b/infra/terraform/environments/dev/provider.tf @@ -0,0 +1,27 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.0" + } + } + + # 원격 상태 저장소 (팀 협업 시 활성화) + # backend "s3" { + # bucket = "spot-terraform-state" + # key = "dev/terraform.tfstate" + # region = "ap-northeast-2" + # encrypt = true + # dynamodb_table = "spot-terraform-lock" + # } +} + +provider "aws" { + region = var.region + + default_tags { + tags = local.common_tags + } +} diff --git a/infra/terraform/environments/dev/terraform.tfvars.example b/infra/terraform/environments/dev/terraform.tfvars.example new file mode 100644 index 00000000..765b6b46 --- /dev/null +++ b/infra/terraform/environments/dev/terraform.tfvars.example @@ -0,0 +1,13 @@ +# ============================================================================= +# Dev 환경 변수 설정 +# 사용법: 이 파일을 terraform.tfvars로 복사하여 사용 +# ============================================================================= + +# 데이터베이스 (필수) +db_username = "spot" +db_password = "your_secure_password" + +# 선택 사항 (기본값 사용 시 생략 가능) +# project = "spot" +# environment = "dev" +# region = "ap-northeast-2" diff --git a/infra/terraform/environments/dev/variables.tf b/infra/terraform/environments/dev/variables.tf new file mode 100644 index 00000000..67d076b3 --- /dev/null +++ b/infra/terraform/environments/dev/variables.tf @@ -0,0 +1,205 @@ +# ============================================================================= +# 프로젝트 기본 설정 +# ============================================================================= +variable "project" { + description = "프로젝트 이름" + type = string + default = "spot" +} + +variable "environment" { + description = "환경 (dev, prod)" + type = string + default = "dev" +} + +variable "region" { + description = "AWS 리전" + type = string + default = "ap-northeast-2" +} + +# ============================================================================= +# 네트워크 설정 +# ============================================================================= +variable "vpc_cidr" { + description = "VPC CIDR 블록" + type = string + default = "10.0.0.0/16" +} + +variable "public_subnet_cidrs" { + description = "Public 서브넷 CIDR 목록" + type = map(string) + default = { + "a" = "10.0.1.0/24" + } +} + +variable "private_subnet_cidrs" { + description = "Private 서브넷 CIDR 목록" + type = map(string) + default = { + "a" = "10.0.10.0/24" + "c" = "10.0.20.0/24" + } +} + +variable "availability_zones" { + description = "사용할 가용 영역" + type = map(string) + default = { + "a" = "ap-northeast-2a" + "c" = "ap-northeast-2c" + } +} + +variable "nat_instance_type" { + description = "NAT Instance 타입" + type = string + default = "t3.nano" +} + +# ============================================================================= +# 데이터베이스 설정 +# ============================================================================= +variable "db_name" { + description = "데이터베이스 이름" + type = string + default = "spotdb" +} + +variable "db_username" { + description = "데이터베이스 사용자 이름" + type = string + sensitive = true +} + +variable "db_password" { + description = "데이터베이스 비밀번호" + type = string + sensitive = true +} + +variable "db_instance_class" { + description = "RDS 인스턴스 클래스" + type = string + default = "db.t3.micro" +} + +variable "db_allocated_storage" { + description = "RDS 스토리지 크기 (GB)" + type = number + default = 20 +} + +variable "db_engine_version" { + description = "PostgreSQL 버전" + type = string + default = "16" +} + +# ============================================================================= +# ECS 설정 +# ============================================================================= +variable "ecs_cpu" { + description = "ECS Task CPU" + type = string + default = "256" +} + +variable "ecs_memory" { + description = "ECS Task Memory" + type = string + default = "512" +} + +variable "ecs_desired_count" { + description = "ECS 희망 태스크 수" + type = number + default = 1 +} + +variable "container_port" { + description = "컨테이너 포트" + type = number + default = 8080 +} + +# ============================================================================= +# ALB 설정 +# ============================================================================= +variable "health_check_path" { + description = "헬스체크 경로" + type = string + default = "/" +} + +# ============================================================================= +# DNS / SSL 설정 +# ============================================================================= +variable "domain_name" { + description = "도메인 이름" + type = string + default = "spotorder.org" +} + +variable "create_api_domain" { + description = "API Gateway 커스텀 도메인 생성 여부" + type = bool + default = true +} + +# ============================================================================= +# WAF 설정 +# ============================================================================= +variable "waf_rate_limit" { + description = "5분당 최대 요청 수 (Rate Limiting)" + type = number + default = 2000 +} + +# ============================================================================= +# S3 설정 +# ============================================================================= +variable "s3_log_transition_days" { + description = "로그를 Glacier로 이동하는 일수" + type = number + default = 30 +} + +variable "s3_log_expiration_days" { + description = "로그 삭제 일수" + type = number + default = 90 +} + +# ============================================================================= +# ElastiCache (Redis) 설정 +# ============================================================================= +variable "redis_node_type" { + description = "Redis 노드 타입" + type = string + default = "cache.t3.micro" +} + +variable "redis_num_cache_clusters" { + description = "Redis 클러스터 수 (1=단일, 2+=복제본)" + type = number + default = 1 # dev 환경에서는 단일 노드 +} + +variable "redis_engine_version" { + description = "Redis 엔진 버전" + type = string + default = "7.1" +} + +# ============================================================================= +# Monitoring 설정 +# ============================================================================= +variable "alert_email" { + description = "알람 알림 받을 이메일 (빈 값이면 구독 안함)" + type = string + default = "" +} diff --git a/infra/terraform/environments/prod/locals.tf b/infra/terraform/environments/prod/locals.tf new file mode 100644 index 00000000..9512a84d --- /dev/null +++ b/infra/terraform/environments/prod/locals.tf @@ -0,0 +1,9 @@ +locals { + name_prefix = "${var.project}-${var.environment}" + + common_tags = { + Project = var.project + Environment = var.environment + ManagedBy = "terraform" + } +} diff --git a/infra/terraform/environments/prod/main.tf b/infra/terraform/environments/prod/main.tf new file mode 100644 index 00000000..0554ce4a --- /dev/null +++ b/infra/terraform/environments/prod/main.tf @@ -0,0 +1,95 @@ +# ============================================================================= +# Network +# ============================================================================= +module "network" { + source = "../../modules/network" + + name_prefix = local.name_prefix + common_tags = local.common_tags + vpc_cidr = var.vpc_cidr + public_subnet_cidrs = var.public_subnet_cidrs + private_subnet_cidrs = var.private_subnet_cidrs + availability_zones = var.availability_zones + nat_instance_type = var.nat_instance_type +} + +# ============================================================================= +# Database +# ============================================================================= +module "database" { + source = "../../modules/database" + + name_prefix = local.name_prefix + common_tags = local.common_tags + vpc_id = module.network.vpc_id + vpc_cidr = module.network.vpc_cidr + subnet_ids = module.network.private_subnet_ids + db_name = var.db_name + username = var.db_username + password = var.db_password + instance_class = var.db_instance_class + allocated_storage = var.db_allocated_storage + engine_version = var.db_engine_version +} + +# ============================================================================= +# ECR +# ============================================================================= +module "ecr" { + source = "../../modules/ecr" + + project = var.project + name_prefix = local.name_prefix + common_tags = local.common_tags +} + +# ============================================================================= +# ALB +# ============================================================================= +module "alb" { + source = "../../modules/alb" + + name_prefix = local.name_prefix + common_tags = local.common_tags + vpc_id = module.network.vpc_id + vpc_cidr = module.network.vpc_cidr + subnet_ids = module.network.private_subnet_ids + container_port = var.container_port + health_check_path = var.health_check_path +} + +# ============================================================================= +# ECS +# ============================================================================= +module "ecs" { + source = "../../modules/ecs" + + project = var.project + name_prefix = local.name_prefix + common_tags = local.common_tags + region = var.region + vpc_id = module.network.vpc_id + subnet_ids = module.network.private_subnet_ids # prod는 private subnet 사용 + ecr_repository_url = module.ecr.repository_url + alb_security_group_id = module.alb.security_group_id + target_group_arn = module.alb.target_group_arn + alb_listener_arn = module.alb.listener_arn + container_port = var.container_port + cpu = var.ecs_cpu + memory = var.ecs_memory + desired_count = var.ecs_desired_count + assign_public_ip = false # prod는 private +} + +# ============================================================================= +# API Gateway +# ============================================================================= +module "api_gateway" { + source = "../../modules/api-gateway" + + name_prefix = local.name_prefix + common_tags = local.common_tags + subnet_ids = module.network.private_subnet_ids + ecs_security_group_id = module.ecs.security_group_id + alb_listener_arn = module.alb.listener_arn +} diff --git a/infra/terraform/environments/prod/outputs.tf b/infra/terraform/environments/prod/outputs.tf new file mode 100644 index 00000000..ab998a81 --- /dev/null +++ b/infra/terraform/environments/prod/outputs.tf @@ -0,0 +1,57 @@ +# ============================================================================= +# Network +# ============================================================================= +output "vpc_id" { + description = "VPC ID" + value = module.network.vpc_id +} + +# ============================================================================= +# Database +# ============================================================================= +output "rds_endpoint" { + description = "RDS 엔드포인트" + value = module.database.endpoint +} + +output "spring_datasource_url" { + description = "Spring JDBC URL" + value = module.database.jdbc_url +} + +# ============================================================================= +# ECR +# ============================================================================= +output "ecr_repository_url" { + description = "ECR 저장소 URL" + value = module.ecr.repository_url +} + +# ============================================================================= +# ECS +# ============================================================================= +output "ecs_cluster_name" { + description = "ECS 클러스터 이름" + value = module.ecs.cluster_name +} + +output "ecs_service_name" { + description = "ECS 서비스 이름" + value = module.ecs.service_name +} + +# ============================================================================= +# ALB +# ============================================================================= +output "alb_dns" { + description = "ALB DNS" + value = module.alb.alb_dns_name +} + +# ============================================================================= +# API Gateway +# ============================================================================= +output "api_url" { + description = "API Gateway URL" + value = module.api_gateway.api_endpoint +} diff --git a/infra/terraform/environments/prod/provider.tf b/infra/terraform/environments/prod/provider.tf new file mode 100644 index 00000000..fcbd04bf --- /dev/null +++ b/infra/terraform/environments/prod/provider.tf @@ -0,0 +1,27 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.0" + } + } + + # 원격 상태 저장소 (팀 협업 시 활성화) + # backend "s3" { + # bucket = "spot-terraform-state" + # key = "prod/terraform.tfstate" + # region = "ap-northeast-2" + # encrypt = true + # dynamodb_table = "spot-terraform-lock" + # } +} + +provider "aws" { + region = var.region + + default_tags { + tags = local.common_tags + } +} diff --git a/infra/terraform/environments/prod/terraform.tfvars.example b/infra/terraform/environments/prod/terraform.tfvars.example new file mode 100644 index 00000000..fdd6912c --- /dev/null +++ b/infra/terraform/environments/prod/terraform.tfvars.example @@ -0,0 +1,13 @@ +# ============================================================================= +# Prod 환경 변수 설정 +# 사용법: 이 파일을 terraform.tfvars로 복사하여 사용 +# ============================================================================= + +# 데이터베이스 (필수) +db_username = "spot" +db_password = "your_very_secure_password" + +# 선택 사항 (기본값 사용 시 생략 가능) +# project = "spot" +# environment = "prod" +# region = "ap-northeast-2" diff --git a/infra/terraform/environments/prod/variables.tf b/infra/terraform/environments/prod/variables.tf new file mode 100644 index 00000000..4c8f08da --- /dev/null +++ b/infra/terraform/environments/prod/variables.tf @@ -0,0 +1,136 @@ +# ============================================================================= +# 프로젝트 기본 설정 +# ============================================================================= +variable "project" { + description = "프로젝트 이름" + type = string + default = "spot" +} + +variable "environment" { + description = "환경 (dev, prod)" + type = string + default = "prod" +} + +variable "region" { + description = "AWS 리전" + type = string + default = "ap-northeast-2" +} + +# ============================================================================= +# 네트워크 설정 +# ============================================================================= +variable "vpc_cidr" { + description = "VPC CIDR 블록" + type = string + default = "10.1.0.0/16" # prod는 다른 CIDR +} + +variable "public_subnet_cidrs" { + description = "Public 서브넷 CIDR 목록" + type = map(string) + default = { + "a" = "10.1.1.0/24" + } +} + +variable "private_subnet_cidrs" { + description = "Private 서브넷 CIDR 목록" + type = map(string) + default = { + "a" = "10.1.10.0/24" + "c" = "10.1.20.0/24" + } +} + +variable "availability_zones" { + description = "사용할 가용 영역" + type = map(string) + default = { + "a" = "ap-northeast-2a" + "c" = "ap-northeast-2c" + } +} + +variable "nat_instance_type" { + description = "NAT Instance 타입" + type = string + default = "t3.micro" # prod는 더 큰 타입 +} + +# ============================================================================= +# 데이터베이스 설정 +# ============================================================================= +variable "db_name" { + description = "데이터베이스 이름" + type = string + default = "spotdb" +} + +variable "db_username" { + description = "데이터베이스 사용자 이름" + type = string + sensitive = true +} + +variable "db_password" { + description = "데이터베이스 비밀번호" + type = string + sensitive = true +} + +variable "db_instance_class" { + description = "RDS 인스턴스 클래스" + type = string + default = "db.t3.small" # prod는 더 큰 타입 +} + +variable "db_allocated_storage" { + description = "RDS 스토리지 크기 (GB)" + type = number + default = 50 # prod는 더 큰 용량 +} + +variable "db_engine_version" { + description = "PostgreSQL 버전" + type = string + default = "16" +} + +# ============================================================================= +# ECS 설정 +# ============================================================================= +variable "ecs_cpu" { + description = "ECS Task CPU" + type = string + default = "512" # prod는 더 큰 사양 +} + +variable "ecs_memory" { + description = "ECS Task Memory" + type = string + default = "1024" # prod는 더 큰 사양 +} + +variable "ecs_desired_count" { + description = "ECS 희망 태스크 수" + type = number + default = 2 # prod는 고가용성 +} + +variable "container_port" { + description = "컨테이너 포트" + type = number + default = 8080 +} + +# ============================================================================= +# ALB 설정 +# ============================================================================= +variable "health_check_path" { + description = "헬스체크 경로" + type = string + default = "/health" +} diff --git a/infra/terraform/keypair.tf b/infra/terraform/keypair.tf deleted file mode 100644 index f3d4bee1..00000000 --- a/infra/terraform/keypair.tf +++ /dev/null @@ -1,15 +0,0 @@ -resource "tls_private_key" "ssh_key" { - algorithm = "RSA" - rsa_bits = 4096 -} - -resource "aws_key_pair" "terraform_key" { - key_name = "terraform-key" - public_key = tls_private_key.ssh_key.public_key_openssh -} - -resource "local_file" "pricate_key" { - content = tls_private_key.ssh_key.private_key_pem - filename = "${path.module}/terraform-key.pem" - file_permission = "0400" -} diff --git a/infra/terraform/modules/alb/main.tf b/infra/terraform/modules/alb/main.tf new file mode 100644 index 00000000..d79054b5 --- /dev/null +++ b/infra/terraform/modules/alb/main.tf @@ -0,0 +1,73 @@ +# ============================================================================= +# ALB Security Group +# ============================================================================= +resource "aws_security_group" "alb_sg" { + name = "${var.name_prefix}-alb-sg" + vpc_id = var.vpc_id + + ingress { + from_port = 80 + to_port = 80 + protocol = "tcp" + cidr_blocks = [var.vpc_cidr] + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = merge(var.common_tags, { Name = "${var.name_prefix}-alb-sg" }) +} + +# ============================================================================= +# Application Load Balancer (Internal) +# ============================================================================= +resource "aws_lb" "main" { + name = "${var.name_prefix}-alb" + internal = true + load_balancer_type = "application" + security_groups = [aws_security_group.alb_sg.id] + subnets = var.subnet_ids + + tags = merge(var.common_tags, { Name = "${var.name_prefix}-alb" }) +} + +# ============================================================================= +# Target Group (Blue/Green) +# ============================================================================= +resource "aws_lb_target_group" "blue" { + name = "${var.name_prefix}-tg-blue" + port = var.container_port + protocol = "HTTP" + vpc_id = var.vpc_id + target_type = "ip" + + health_check { + enabled = true + healthy_threshold = 2 + unhealthy_threshold = 2 + timeout = 5 + interval = 30 + path = var.health_check_path + matcher = "200" + } + + tags = merge(var.common_tags, { Name = "${var.name_prefix}-tg-blue" }) +} + +# ============================================================================= +# ALB Listener +# ============================================================================= +resource "aws_lb_listener" "main" { + load_balancer_arn = aws_lb.main.arn + port = 80 + protocol = "HTTP" + + default_action { + type = "forward" + target_group_arn = aws_lb_target_group.blue.arn + } +} diff --git a/infra/terraform/modules/alb/outputs.tf b/infra/terraform/modules/alb/outputs.tf new file mode 100644 index 00000000..6baf7257 --- /dev/null +++ b/infra/terraform/modules/alb/outputs.tf @@ -0,0 +1,29 @@ +output "alb_arn" { + description = "ALB ARN" + value = aws_lb.main.arn +} + +output "alb_dns_name" { + description = "ALB DNS 이름" + value = aws_lb.main.dns_name +} + +output "target_group_arn" { + description = "Target Group ARN" + value = aws_lb_target_group.blue.arn +} + +output "listener_arn" { + description = "Listener ARN" + value = aws_lb_listener.main.arn +} + +output "security_group_id" { + description = "ALB 보안그룹 ID" + value = aws_security_group.alb_sg.id +} + +output "arn_suffix" { + description = "ALB ARN suffix (CloudWatch용)" + value = aws_lb.main.arn_suffix +} diff --git a/infra/terraform/modules/alb/variables.tf b/infra/terraform/modules/alb/variables.tf new file mode 100644 index 00000000..c21979cd --- /dev/null +++ b/infra/terraform/modules/alb/variables.tf @@ -0,0 +1,37 @@ +variable "name_prefix" { + description = "리소스 네이밍 프리픽스" + type = string +} + +variable "common_tags" { + description = "공통 태그" + type = map(string) + default = {} +} + +variable "vpc_id" { + description = "VPC ID" + type = string +} + +variable "vpc_cidr" { + description = "VPC CIDR" + type = string +} + +variable "subnet_ids" { + description = "ALB 서브넷 ID 목록" + type = list(string) +} + +variable "container_port" { + description = "컨테이너 포트" + type = number + default = 8080 +} + +variable "health_check_path" { + description = "헬스체크 경로" + type = string + default = "/health" +} diff --git a/infra/terraform/modules/api-gateway/main.tf b/infra/terraform/modules/api-gateway/main.tf new file mode 100644 index 00000000..51ceee57 --- /dev/null +++ b/infra/terraform/modules/api-gateway/main.tf @@ -0,0 +1,54 @@ +# ============================================================================= +# API Gateway (HTTP API) +# ============================================================================= +resource "aws_apigatewayv2_api" "main" { + name = "${var.name_prefix}-api" + protocol_type = "HTTP" + + tags = merge(var.common_tags, { Name = "${var.name_prefix}-api" }) +} + +# ============================================================================= +# VPC Link +# ============================================================================= +resource "aws_apigatewayv2_vpc_link" "main" { + name = "${var.name_prefix}-vpc-link" + security_group_ids = [var.ecs_security_group_id] + subnet_ids = var.subnet_ids + + tags = merge(var.common_tags, { Name = "${var.name_prefix}-vpc-link" }) +} + +# ============================================================================= +# Integration (VPC Link → ALB) +# ============================================================================= +resource "aws_apigatewayv2_integration" "main" { + api_id = aws_apigatewayv2_api.main.id + integration_type = "HTTP_PROXY" + integration_method = "ANY" + integration_uri = var.alb_listener_arn + connection_type = "VPC_LINK" + connection_id = aws_apigatewayv2_vpc_link.main.id + + payload_format_version = "1.0" +} + +# ============================================================================= +# Route +# ============================================================================= +resource "aws_apigatewayv2_route" "main" { + api_id = aws_apigatewayv2_api.main.id + route_key = "ANY /{proxy+}" + target = "integrations/${aws_apigatewayv2_integration.main.id}" +} + +# ============================================================================= +# Stage +# ============================================================================= +resource "aws_apigatewayv2_stage" "main" { + api_id = aws_apigatewayv2_api.main.id + name = "$default" + auto_deploy = true + + tags = merge(var.common_tags, { Name = "${var.name_prefix}-stage" }) +} diff --git a/infra/terraform/modules/api-gateway/outputs.tf b/infra/terraform/modules/api-gateway/outputs.tf new file mode 100644 index 00000000..37cbf0c0 --- /dev/null +++ b/infra/terraform/modules/api-gateway/outputs.tf @@ -0,0 +1,19 @@ +output "api_endpoint" { + description = "API Gateway 엔드포인트 URL" + value = aws_apigatewayv2_api.main.api_endpoint +} + +output "api_id" { + description = "API Gateway ID" + value = aws_apigatewayv2_api.main.id +} + +output "stage_arn" { + description = "API Gateway Stage ARN (WAF 연결용)" + value = aws_apigatewayv2_stage.main.arn +} + +output "execution_arn" { + description = "API Gateway Execution ARN" + value = aws_apigatewayv2_api.main.execution_arn +} diff --git a/infra/terraform/modules/api-gateway/variables.tf b/infra/terraform/modules/api-gateway/variables.tf new file mode 100644 index 00000000..76e0691c --- /dev/null +++ b/infra/terraform/modules/api-gateway/variables.tf @@ -0,0 +1,25 @@ +variable "name_prefix" { + description = "리소스 네이밍 프리픽스" + type = string +} + +variable "common_tags" { + description = "공통 태그" + type = map(string) + default = {} +} + +variable "subnet_ids" { + description = "VPC Link 서브넷 ID 목록" + type = list(string) +} + +variable "ecs_security_group_id" { + description = "ECS 보안그룹 ID" + type = string +} + +variable "alb_listener_arn" { + description = "ALB Listener ARN" + type = string +} diff --git a/infra/terraform/modules/database/main.tf b/infra/terraform/modules/database/main.tf new file mode 100644 index 00000000..8b813b21 --- /dev/null +++ b/infra/terraform/modules/database/main.tf @@ -0,0 +1,56 @@ +# ============================================================================= +# Database Security Group +# ============================================================================= +resource "aws_security_group" "db_sg" { + name = "${var.name_prefix}-db-sg" + vpc_id = var.vpc_id + + ingress { + from_port = 5432 + to_port = 5432 + protocol = "tcp" + cidr_blocks = [var.vpc_cidr] + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = merge(var.common_tags, { Name = "${var.name_prefix}-db-sg" }) +} + +# ============================================================================= +# RDS Subnet Group +# ============================================================================= +resource "aws_db_subnet_group" "main" { + name = "${var.name_prefix}-db-subnet-group" + subnet_ids = var.subnet_ids + + tags = merge(var.common_tags, { Name = "${var.name_prefix}-db-subnet-group" }) +} + +# ============================================================================= +# RDS Instance (PostgreSQL) +# ============================================================================= +resource "aws_db_instance" "main" { + identifier = "${var.name_prefix}-db" + allocated_storage = var.allocated_storage + engine = "postgres" + engine_version = var.engine_version + instance_class = var.instance_class + db_name = var.db_name + username = var.username + password = var.password + + db_subnet_group_name = aws_db_subnet_group.main.name + vpc_security_group_ids = [aws_security_group.db_sg.id] + + skip_final_snapshot = true + publicly_accessible = false + storage_type = "gp3" + + tags = merge(var.common_tags, { Name = "${var.name_prefix}-db" }) +} diff --git a/infra/terraform/modules/database/outputs.tf b/infra/terraform/modules/database/outputs.tf new file mode 100644 index 00000000..0ca10b61 --- /dev/null +++ b/infra/terraform/modules/database/outputs.tf @@ -0,0 +1,29 @@ +output "endpoint" { + description = "RDS 엔드포인트 (host:port)" + value = aws_db_instance.main.endpoint +} + +output "hostname" { + description = "RDS 호스트명" + value = aws_db_instance.main.address +} + +output "port" { + description = "RDS 포트" + value = aws_db_instance.main.port +} + +output "database_name" { + description = "데이터베이스 이름" + value = aws_db_instance.main.db_name +} + +output "jdbc_url" { + description = "JDBC URL" + value = "jdbc:postgresql://${aws_db_instance.main.endpoint}/${aws_db_instance.main.db_name}" +} + +output "instance_id" { + description = "RDS 인스턴스 ID (CloudWatch용)" + value = aws_db_instance.main.identifier +} diff --git a/infra/terraform/modules/database/variables.tf b/infra/terraform/modules/database/variables.tf new file mode 100644 index 00000000..5a66a947 --- /dev/null +++ b/infra/terraform/modules/database/variables.tf @@ -0,0 +1,60 @@ +variable "name_prefix" { + description = "리소스 네이밍 프리픽스" + type = string +} + +variable "common_tags" { + description = "공통 태그" + type = map(string) + default = {} +} + +variable "vpc_id" { + description = "VPC ID" + type = string +} + +variable "vpc_cidr" { + description = "VPC CIDR (보안그룹용)" + type = string +} + +variable "subnet_ids" { + description = "DB 서브넷 ID 목록" + type = list(string) +} + +variable "db_name" { + description = "데이터베이스 이름" + type = string +} + +variable "username" { + description = "DB 사용자 이름" + type = string + sensitive = true +} + +variable "password" { + description = "DB 비밀번호" + type = string + sensitive = true +} + +variable "instance_class" { + description = "RDS 인스턴스 클래스" + type = string + default = "db.t3.micro" +} + +variable "allocated_storage" { + description = "스토리지 크기 (GB)" + type = number + default = 20 +} + +variable "engine_version" { + description = "PostgreSQL 버전" + type = string + default = "16" +} diff --git a/infra/terraform/modules/dns/main.tf b/infra/terraform/modules/dns/main.tf new file mode 100644 index 00000000..b4e09cb3 --- /dev/null +++ b/infra/terraform/modules/dns/main.tf @@ -0,0 +1,90 @@ +# ============================================================================= +# Route 53 Hosted Zone +# ============================================================================= +resource "aws_route53_zone" "main" { + name = var.domain_name + + tags = merge(var.common_tags, { Name = "${var.name_prefix}-zone" }) +} + +# ============================================================================= +# ACM Certificate +# ============================================================================= +resource "aws_acm_certificate" "main" { + domain_name = var.domain_name + subject_alternative_names = ["*.${var.domain_name}"] + validation_method = "DNS" + + lifecycle { + create_before_destroy = true + } + + tags = merge(var.common_tags, { Name = "${var.name_prefix}-cert" }) +} + +# ============================================================================= +# ACM DNS Validation Records +# ============================================================================= +resource "aws_route53_record" "acm_validation" { + for_each = { + for dvo in aws_acm_certificate.main.domain_validation_options : dvo.domain_name => { + name = dvo.resource_record_name + record = dvo.resource_record_value + type = dvo.resource_record_type + } + } + + zone_id = aws_route53_zone.main.zone_id + name = each.value.name + type = each.value.type + ttl = 60 + records = [each.value.record] + allow_overwrite = true +} + +# ============================================================================= +# ACM Certificate Validation +# ============================================================================= +resource "aws_acm_certificate_validation" "main" { + certificate_arn = aws_acm_certificate.main.arn + validation_record_fqdns = [for record in aws_route53_record.acm_validation : record.fqdn] +} + +# ============================================================================= +# API Gateway Custom Domain (Optional) +# ============================================================================= +resource "aws_apigatewayv2_domain_name" "api" { + count = var.create_api_domain ? 1 : 0 + + domain_name = "api.${var.domain_name}" + + domain_name_configuration { + certificate_arn = aws_acm_certificate_validation.main.certificate_arn + endpoint_type = "REGIONAL" + security_policy = "TLS_1_2" + } + + tags = merge(var.common_tags, { Name = "${var.name_prefix}-api-domain" }) +} + +resource "aws_apigatewayv2_api_mapping" "api" { + count = var.create_api_domain ? 1 : 0 + + api_id = var.api_gateway_id + domain_name = aws_apigatewayv2_domain_name.api[0].id + stage = "$default" +} + +resource "aws_route53_record" "api" { + count = var.create_api_domain ? 1 : 0 + + zone_id = aws_route53_zone.main.zone_id + name = "api.${var.domain_name}" + type = "A" + + alias { + name = aws_apigatewayv2_domain_name.api[0].domain_name_configuration[0].target_domain_name + zone_id = aws_apigatewayv2_domain_name.api[0].domain_name_configuration[0].hosted_zone_id + evaluate_target_health = false + } +} diff --git a/infra/terraform/modules/dns/outputs.tf b/infra/terraform/modules/dns/outputs.tf new file mode 100644 index 00000000..7e827d95 --- /dev/null +++ b/infra/terraform/modules/dns/outputs.tf @@ -0,0 +1,29 @@ +output "zone_id" { + description = "Route 53 Hosted Zone ID" + value = aws_route53_zone.main.zone_id +} + +output "zone_name" { + description = "Route 53 Hosted Zone 이름" + value = aws_route53_zone.main.name +} + +output "name_servers" { + description = "Route 53 네임서버 목록 (도메인 등록 기관에 설정 필요)" + value = aws_route53_zone.main.name_servers +} + +output "certificate_arn" { + description = "ACM 인증서 ARN" + value = aws_acm_certificate_validation.main.certificate_arn +} + +output "api_domain" { + description = "API 커스텀 도메인" + value = var.create_api_domain ? "api.${var.domain_name}" : null +} + +output "api_domain_target" { + description = "API Gateway 커스텀 도메인의 target domain name" + value = var.create_api_domain ? aws_apigatewayv2_domain_name.api[0].domain_name_configuration[0].target_domain_name : null +} diff --git a/infra/terraform/modules/dns/variables.tf b/infra/terraform/modules/dns/variables.tf new file mode 100644 index 00000000..357eb59e --- /dev/null +++ b/infra/terraform/modules/dns/variables.tf @@ -0,0 +1,27 @@ +variable "name_prefix" { + description = "리소스 네이밍 프리픽스" + type = string +} + +variable "common_tags" { + description = "공통 태그" + type = map(string) + default = {} +} + +variable "domain_name" { + description = "도메인 이름 (ex: spotorder.org)" + type = string +} + +variable "create_api_domain" { + description = "API Gateway 커스텀 도메인 생성 여부" + type = bool + default = true +} + +variable "api_gateway_id" { + description = "API Gateway ID" + type = string + default = "" +} diff --git a/infra/terraform/modules/ecr/main.tf b/infra/terraform/modules/ecr/main.tf new file mode 100644 index 00000000..b1537657 --- /dev/null +++ b/infra/terraform/modules/ecr/main.tf @@ -0,0 +1,35 @@ +# ============================================================================= +# ECR Repository +# ============================================================================= +resource "aws_ecr_repository" "main" { + name = "${var.project}-backend" + image_tag_mutability = "MUTABLE" + + image_scanning_configuration { + scan_on_push = true + } + + tags = merge(var.common_tags, { Name = "${var.name_prefix}-ecr-backend" }) +} + +# ============================================================================= +# ECR Lifecycle Policy +# ============================================================================= +resource "aws_ecr_lifecycle_policy" "main" { + repository = aws_ecr_repository.main.name + + policy = jsonencode({ + rules = [{ + rulePriority = 1 + description = "Keep last ${var.image_retention_count} images" + selection = { + tagStatus = "any" + countType = "imageCountMoreThan" + countNumber = var.image_retention_count + } + action = { + type = "expire" + } + }] + }) +} diff --git a/infra/terraform/modules/ecr/outputs.tf b/infra/terraform/modules/ecr/outputs.tf new file mode 100644 index 00000000..d1ae5837 --- /dev/null +++ b/infra/terraform/modules/ecr/outputs.tf @@ -0,0 +1,14 @@ +output "repository_url" { + description = "ECR 저장소 URL" + value = aws_ecr_repository.main.repository_url +} + +output "repository_arn" { + description = "ECR 저장소 ARN" + value = aws_ecr_repository.main.arn +} + +output "repository_name" { + description = "ECR 저장소 이름" + value = aws_ecr_repository.main.name +} diff --git a/infra/terraform/modules/ecr/variables.tf b/infra/terraform/modules/ecr/variables.tf new file mode 100644 index 00000000..907068cc --- /dev/null +++ b/infra/terraform/modules/ecr/variables.tf @@ -0,0 +1,21 @@ +variable "project" { + description = "프로젝트 이름" + type = string +} + +variable "name_prefix" { + description = "리소스 네이밍 프리픽스" + type = string +} + +variable "common_tags" { + description = "공통 태그" + type = map(string) + default = {} +} + +variable "image_retention_count" { + description = "유지할 이미지 수" + type = number + default = 5 +} diff --git a/infra/terraform/modules/ecs/main.tf b/infra/terraform/modules/ecs/main.tf new file mode 100644 index 00000000..640887c1 --- /dev/null +++ b/infra/terraform/modules/ecs/main.tf @@ -0,0 +1,169 @@ +# ============================================================================= +# Cloud Map (Service Discovery) +# ============================================================================= +resource "aws_service_discovery_private_dns_namespace" "main" { + name = "${var.project}.local" + vpc = var.vpc_id +} + +resource "aws_service_discovery_service" "backend" { + name = "backend" + + dns_config { + namespace_id = aws_service_discovery_private_dns_namespace.main.id + dns_records { + ttl = 10 + type = "A" + } + } + + health_check_custom_config { + failure_threshold = 1 + } +} + +# ============================================================================= +# ECS Cluster +# ============================================================================= +resource "aws_ecs_cluster" "main" { + name = "${var.name_prefix}-cluster" + + service_connect_defaults { + namespace = aws_service_discovery_private_dns_namespace.main.arn + } + + tags = merge(var.common_tags, { Name = "${var.name_prefix}-cluster" }) +} + +# ============================================================================= +# ECS Security Group +# ============================================================================= +resource "aws_security_group" "api_sg" { + name = "${var.name_prefix}-api-sg" + vpc_id = var.vpc_id + + ingress { + from_port = var.container_port + to_port = var.container_port + protocol = "tcp" + security_groups = [var.alb_security_group_id] + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = merge(var.common_tags, { Name = "${var.name_prefix}-api-sg" }) +} + +# ============================================================================= +# IAM Role for ECS Task Execution +# ============================================================================= +resource "aws_iam_role" "ecs_task_execution_role" { + name = "${var.name_prefix}-ecs-task-role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { Service = "ecs-tasks.amazonaws.com" } + }] + }) + + tags = var.common_tags +} + +resource "aws_iam_role_policy_attachment" "ecs_task_execution_role_policy" { + role = aws_iam_role.ecs_task_execution_role.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy" +} + +# ============================================================================= +# CloudWatch Log Group +# ============================================================================= +resource "aws_cloudwatch_log_group" "ecs_logs" { + name = "/ecs/${var.project}-backend" + retention_in_days = var.log_retention_days + + tags = var.common_tags +} + +# ============================================================================= +# ECS Task Definition +# ============================================================================= +resource "aws_ecs_task_definition" "backend" { + family = "${var.project}-backend-task" + network_mode = "awsvpc" + requires_compatibilities = ["FARGATE"] + cpu = var.cpu + memory = var.memory + execution_role_arn = aws_iam_role.ecs_task_execution_role.arn + + container_definitions = jsonencode([ + { + name = "${var.project}-backend-container" + image = "${var.ecr_repository_url}:latest" + essential = true + portMappings = [{ + name = "http" + containerPort = var.container_port + hostPort = var.container_port + }] + environment = [ + { + name = "SPRING_DATASOURCE_URL" + value = "jdbc:postgresql://${var.db_endpoint}/${var.db_name}" + }, + { + name = "SPRING_DATASOURCE_USERNAME" + value = var.db_username + }, + { + name = "SPRING_DATASOURCE_PASSWORD" + value = var.db_password + } + ] + logConfiguration = { + logDriver = "awslogs" + options = { + "awslogs-group" = aws_cloudwatch_log_group.ecs_logs.name + "awslogs-region" = var.region + "awslogs-stream-prefix" = "ecs" + } + } + } + ]) + + tags = var.common_tags +} + +# ============================================================================= +# ECS Service +# ============================================================================= +resource "aws_ecs_service" "main" { + name = "${var.project}-backend-service" + cluster = aws_ecs_cluster.main.id + task_definition = aws_ecs_task_definition.backend.arn + desired_count = var.desired_count + launch_type = "FARGATE" + + load_balancer { + target_group_arn = var.target_group_arn + container_name = "${var.project}-backend-container" + container_port = var.container_port + } + + network_configuration { + subnets = var.subnet_ids + security_groups = [aws_security_group.api_sg.id] + assign_public_ip = var.assign_public_ip + } + + depends_on = [var.alb_listener_arn] + + tags = var.common_tags +} diff --git a/infra/terraform/modules/ecs/outputs.tf b/infra/terraform/modules/ecs/outputs.tf new file mode 100644 index 00000000..086163c7 --- /dev/null +++ b/infra/terraform/modules/ecs/outputs.tf @@ -0,0 +1,19 @@ +output "cluster_name" { + description = "ECS 클러스터 이름" + value = aws_ecs_cluster.main.name +} + +output "cluster_arn" { + description = "ECS 클러스터 ARN" + value = aws_ecs_cluster.main.arn +} + +output "service_name" { + description = "ECS 서비스 이름" + value = aws_ecs_service.main.name +} + +output "security_group_id" { + description = "ECS 보안그룹 ID" + value = aws_security_group.api_sg.id +} diff --git a/infra/terraform/modules/ecs/variables.tf b/infra/terraform/modules/ecs/variables.tf new file mode 100644 index 00000000..bb2e0a98 --- /dev/null +++ b/infra/terraform/modules/ecs/variables.tf @@ -0,0 +1,111 @@ +variable "project" { + description = "프로젝트 이름" + type = string +} + +variable "name_prefix" { + description = "리소스 네이밍 프리픽스" + type = string +} + +variable "common_tags" { + description = "공통 태그" + type = map(string) + default = {} +} + +variable "region" { + description = "AWS 리전" + type = string +} + +variable "vpc_id" { + description = "VPC ID" + type = string +} + +variable "subnet_ids" { + description = "ECS 서비스 서브넷 ID 목록" + type = list(string) +} + +variable "ecr_repository_url" { + description = "ECR 저장소 URL" + type = string +} + +variable "alb_security_group_id" { + description = "ALB 보안그룹 ID" + type = string +} + +variable "target_group_arn" { + description = "ALB Target Group ARN" + type = string +} + +variable "alb_listener_arn" { + description = "ALB Listener ARN (의존성용)" + type = string +} + +variable "container_port" { + description = "컨테이너 포트" + type = number + default = 8080 +} + +variable "cpu" { + description = "Task CPU" + type = string + default = "256" +} + +variable "memory" { + description = "Task Memory" + type = string + default = "512" +} + +variable "desired_count" { + description = "희망 태스크 수" + type = number + default = 1 +} + +variable "assign_public_ip" { + description = "Public IP 할당 여부" + type = bool + default = true +} + +variable "log_retention_days" { + description = "로그 보관 일수" + type = number + default = 30 +} + +# ============================================================================= +# Database 설정 +# ============================================================================= +variable "db_endpoint" { + description = "RDS 엔드포인트" + type = string +} + +variable "db_name" { + description = "데이터베이스 이름" + type = string +} + +variable "db_username" { + description = "데이터베이스 사용자 이름" + type = string + sensitive = true +} + +variable "db_password" { + description = "데이터베이스 비밀번호" + type = string + sensitive = true +} diff --git a/infra/terraform/modules/elasticache/main.tf b/infra/terraform/modules/elasticache/main.tf new file mode 100644 index 00000000..67ab2806 --- /dev/null +++ b/infra/terraform/modules/elasticache/main.tf @@ -0,0 +1,91 @@ +# ============================================================================= +# ElastiCache Redis - 캐시 및 세션 저장소 +# ============================================================================= + +# ----------------------------------------------------------------------------- +# Subnet Group +# ----------------------------------------------------------------------------- +resource "aws_elasticache_subnet_group" "redis" { + name = "${var.name_prefix}-redis-subnet" + subnet_ids = var.subnet_ids + + tags = merge(var.common_tags, { Name = "${var.name_prefix}-redis-subnet" }) +} + +# ----------------------------------------------------------------------------- +# Security Group +# ----------------------------------------------------------------------------- +resource "aws_security_group" "redis" { + name = "${var.name_prefix}-redis-sg" + description = "Security group for ElastiCache Redis" + vpc_id = var.vpc_id + + ingress { + description = "Redis from ECS" + from_port = 6379 + to_port = 6379 + protocol = "tcp" + security_groups = var.allowed_security_group_ids + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = merge(var.common_tags, { Name = "${var.name_prefix}-redis-sg" }) +} + +# ----------------------------------------------------------------------------- +# Parameter Group (Redis 설정 커스터마이징) +# ----------------------------------------------------------------------------- +resource "aws_elasticache_parameter_group" "redis" { + name = "${var.name_prefix}-redis-params" + family = "redis7" + + # 세션 저장용 설정 + parameter { + name = "maxmemory-policy" + value = "volatile-lru" + } + + tags = merge(var.common_tags, { Name = "${var.name_prefix}-redis-params" }) +} + +# ----------------------------------------------------------------------------- +# Redis Replication Group (Cluster Mode Disabled) +# ----------------------------------------------------------------------------- +resource "aws_elasticache_replication_group" "redis" { + replication_group_id = "${var.name_prefix}-redis" + description = "${var.name_prefix} Redis cluster" + + engine = "redis" + engine_version = var.engine_version + node_type = var.node_type + num_cache_clusters = var.num_cache_clusters + port = 6379 + parameter_group_name = aws_elasticache_parameter_group.redis.name + subnet_group_name = aws_elasticache_subnet_group.redis.name + security_group_ids = [aws_security_group.redis.id] + + # 인증 설정 + auth_token = var.auth_token != "" ? var.auth_token : null + transit_encryption_enabled = var.auth_token != "" ? true : false + at_rest_encryption_enabled = true + + # 자동 장애 조치 (Multi-AZ) + automatic_failover_enabled = var.num_cache_clusters > 1 ? true : false + multi_az_enabled = var.num_cache_clusters > 1 ? true : false + + # 유지보수 윈도우 + maintenance_window = var.maintenance_window + snapshot_window = var.snapshot_window + snapshot_retention_limit = var.snapshot_retention_limit + + # 자동 버전 업그레이드 + auto_minor_version_upgrade = true + + tags = merge(var.common_tags, { Name = "${var.name_prefix}-redis" }) +} diff --git a/infra/terraform/modules/elasticache/outputs.tf b/infra/terraform/modules/elasticache/outputs.tf new file mode 100644 index 00000000..81f23dfa --- /dev/null +++ b/infra/terraform/modules/elasticache/outputs.tf @@ -0,0 +1,32 @@ +# ============================================================================= +# ElastiCache Outputs +# ============================================================================= +output "redis_endpoint" { + description = "Redis Primary Endpoint" + value = aws_elasticache_replication_group.redis.primary_endpoint_address +} + +output "redis_reader_endpoint" { + description = "Redis Reader Endpoint (복제본용)" + value = aws_elasticache_replication_group.redis.reader_endpoint_address +} + +output "redis_port" { + description = "Redis 포트" + value = aws_elasticache_replication_group.redis.port +} + +output "redis_connection_string" { + description = "Redis 연결 문자열 (Spring Boot용)" + value = "redis://${aws_elasticache_replication_group.redis.primary_endpoint_address}:${aws_elasticache_replication_group.redis.port}" +} + +output "security_group_id" { + description = "Redis Security Group ID" + value = aws_security_group.redis.id +} + +output "replication_group_id" { + description = "Redis Replication Group ID" + value = aws_elasticache_replication_group.redis.id +} diff --git a/infra/terraform/modules/elasticache/variables.tf b/infra/terraform/modules/elasticache/variables.tf new file mode 100644 index 00000000..c3586184 --- /dev/null +++ b/infra/terraform/modules/elasticache/variables.tf @@ -0,0 +1,76 @@ +# ============================================================================= +# 공통 변수 +# ============================================================================= +variable "name_prefix" { + description = "리소스 이름 접두사" + type = string +} + +variable "common_tags" { + description = "공통 태그" + type = map(string) +} + +variable "vpc_id" { + description = "VPC ID" + type = string +} + +variable "allowed_security_group_ids" { + description = "Redis 접근을 허용할 Security Group ID 목록" + type = list(string) +} + +variable "subnet_ids" { + description = "ElastiCache가 위치할 서브넷 ID 목록" + type = list(string) +} + +# ============================================================================= +# Redis 설정 +# ============================================================================= +variable "node_type" { + description = "Redis 노드 타입" + type = string + default = "cache.t3.micro" +} + +variable "num_cache_clusters" { + description = "캐시 클러스터 수 (1=단일노드, 2+=복제본)" + type = number + default = 1 +} + +variable "engine_version" { + description = "Redis 엔진 버전" + type = string + default = "7.1" +} + +variable "auth_token" { + description = "Redis AUTH 토큰 (선택, 설정 시 전송 암호화 활성화)" + type = string + default = "" + sensitive = true +} + +# ============================================================================= +# 유지보수 설정 +# ============================================================================= +variable "maintenance_window" { + description = "유지보수 윈도우 (UTC)" + type = string + default = "sun:05:00-sun:06:00" +} + +variable "snapshot_window" { + description = "스냅샷 윈도우 (UTC)" + type = string + default = "04:00-05:00" +} + +variable "snapshot_retention_limit" { + description = "스냅샷 보존 일수 (0=비활성화)" + type = number + default = 1 +} diff --git a/infra/terraform/modules/monitoring/main.tf b/infra/terraform/modules/monitoring/main.tf new file mode 100644 index 00000000..8a83e87f --- /dev/null +++ b/infra/terraform/modules/monitoring/main.tf @@ -0,0 +1,216 @@ +# ============================================================================= +# CloudWatch Monitoring - 알람 및 대시보드 +# ============================================================================= + +# ----------------------------------------------------------------------------- +# SNS Topic for Alarm Notifications +# ----------------------------------------------------------------------------- +resource "aws_sns_topic" "alerts" { + name = "${var.name_prefix}-alerts" + + tags = merge(var.common_tags, { Name = "${var.name_prefix}-alerts" }) +} + +resource "aws_sns_topic_subscription" "email" { + count = var.alert_email != "" ? 1 : 0 + topic_arn = aws_sns_topic.alerts.arn + protocol = "email" + endpoint = var.alert_email +} + +# ----------------------------------------------------------------------------- +# ECS Alarms +# ----------------------------------------------------------------------------- +resource "aws_cloudwatch_metric_alarm" "ecs_cpu_high" { + alarm_name = "${var.name_prefix}-ecs-cpu-high" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 2 + metric_name = "CPUUtilization" + namespace = "AWS/ECS" + period = 300 + statistic = "Average" + threshold = var.ecs_cpu_threshold + alarm_description = "ECS CPU utilization is too high" + alarm_actions = [aws_sns_topic.alerts.arn] + ok_actions = [aws_sns_topic.alerts.arn] + + dimensions = { + ClusterName = var.ecs_cluster_name + ServiceName = var.ecs_service_name + } + + tags = var.common_tags +} + +resource "aws_cloudwatch_metric_alarm" "ecs_memory_high" { + alarm_name = "${var.name_prefix}-ecs-memory-high" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 2 + metric_name = "MemoryUtilization" + namespace = "AWS/ECS" + period = 300 + statistic = "Average" + threshold = var.ecs_memory_threshold + alarm_description = "ECS Memory utilization is too high" + alarm_actions = [aws_sns_topic.alerts.arn] + ok_actions = [aws_sns_topic.alerts.arn] + + dimensions = { + ClusterName = var.ecs_cluster_name + ServiceName = var.ecs_service_name + } + + tags = var.common_tags +} + +# ----------------------------------------------------------------------------- +# RDS Alarms +# ----------------------------------------------------------------------------- +resource "aws_cloudwatch_metric_alarm" "rds_cpu_high" { + alarm_name = "${var.name_prefix}-rds-cpu-high" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 2 + metric_name = "CPUUtilization" + namespace = "AWS/RDS" + period = 300 + statistic = "Average" + threshold = var.rds_cpu_threshold + alarm_description = "RDS CPU utilization is too high" + alarm_actions = [aws_sns_topic.alerts.arn] + ok_actions = [aws_sns_topic.alerts.arn] + + dimensions = { + DBInstanceIdentifier = var.rds_instance_id + } + + tags = var.common_tags +} + +resource "aws_cloudwatch_metric_alarm" "rds_connections_high" { + alarm_name = "${var.name_prefix}-rds-connections-high" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 2 + metric_name = "DatabaseConnections" + namespace = "AWS/RDS" + period = 300 + statistic = "Average" + threshold = var.rds_connections_threshold + alarm_description = "RDS connection count is too high" + alarm_actions = [aws_sns_topic.alerts.arn] + ok_actions = [aws_sns_topic.alerts.arn] + + dimensions = { + DBInstanceIdentifier = var.rds_instance_id + } + + tags = var.common_tags +} + +resource "aws_cloudwatch_metric_alarm" "rds_storage_low" { + alarm_name = "${var.name_prefix}-rds-storage-low" + comparison_operator = "LessThanThreshold" + evaluation_periods = 1 + metric_name = "FreeStorageSpace" + namespace = "AWS/RDS" + period = 300 + statistic = "Average" + threshold = var.rds_storage_threshold_bytes + alarm_description = "RDS free storage is too low" + alarm_actions = [aws_sns_topic.alerts.arn] + ok_actions = [aws_sns_topic.alerts.arn] + + dimensions = { + DBInstanceIdentifier = var.rds_instance_id + } + + tags = var.common_tags +} + +# ----------------------------------------------------------------------------- +# ALB Alarms +# ----------------------------------------------------------------------------- +resource "aws_cloudwatch_metric_alarm" "alb_5xx_errors" { + alarm_name = "${var.name_prefix}-alb-5xx-errors" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 2 + metric_name = "HTTPCode_Target_5XX_Count" + namespace = "AWS/ApplicationELB" + period = 300 + statistic = "Sum" + threshold = var.alb_5xx_threshold + alarm_description = "ALB 5XX error count is too high" + alarm_actions = [aws_sns_topic.alerts.arn] + ok_actions = [aws_sns_topic.alerts.arn] + treat_missing_data = "notBreaching" + + dimensions = { + LoadBalancer = var.alb_arn_suffix + } + + tags = var.common_tags +} + +resource "aws_cloudwatch_metric_alarm" "alb_response_time" { + alarm_name = "${var.name_prefix}-alb-response-time" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 2 + metric_name = "TargetResponseTime" + namespace = "AWS/ApplicationELB" + period = 300 + statistic = "Average" + threshold = var.alb_response_time_threshold + alarm_description = "ALB response time is too high" + alarm_actions = [aws_sns_topic.alerts.arn] + ok_actions = [aws_sns_topic.alerts.arn] + + dimensions = { + LoadBalancer = var.alb_arn_suffix + } + + tags = var.common_tags +} + +# ----------------------------------------------------------------------------- +# ElastiCache (Redis) Alarms +# ----------------------------------------------------------------------------- +resource "aws_cloudwatch_metric_alarm" "redis_cpu_high" { + count = var.redis_cluster_id != "" ? 1 : 0 + alarm_name = "${var.name_prefix}-redis-cpu-high" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 2 + metric_name = "CPUUtilization" + namespace = "AWS/ElastiCache" + period = 300 + statistic = "Average" + threshold = var.redis_cpu_threshold + alarm_description = "Redis CPU utilization is too high" + alarm_actions = [aws_sns_topic.alerts.arn] + ok_actions = [aws_sns_topic.alerts.arn] + + dimensions = { + CacheClusterId = var.redis_cluster_id + } + + tags = var.common_tags +} + +resource "aws_cloudwatch_metric_alarm" "redis_memory_high" { + count = var.redis_cluster_id != "" ? 1 : 0 + alarm_name = "${var.name_prefix}-redis-memory-high" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 2 + metric_name = "DatabaseMemoryUsagePercentage" + namespace = "AWS/ElastiCache" + period = 300 + statistic = "Average" + threshold = var.redis_memory_threshold + alarm_description = "Redis memory usage is too high" + alarm_actions = [aws_sns_topic.alerts.arn] + ok_actions = [aws_sns_topic.alerts.arn] + + dimensions = { + CacheClusterId = var.redis_cluster_id + } + + tags = var.common_tags +} diff --git a/infra/terraform/modules/monitoring/outputs.tf b/infra/terraform/modules/monitoring/outputs.tf new file mode 100644 index 00000000..e315af4e --- /dev/null +++ b/infra/terraform/modules/monitoring/outputs.tf @@ -0,0 +1,25 @@ +# ============================================================================= +# Monitoring Outputs +# ============================================================================= +output "sns_topic_arn" { + description = "알람 알림 SNS Topic ARN" + value = aws_sns_topic.alerts.arn +} + +output "sns_topic_name" { + description = "알람 알림 SNS Topic 이름" + value = aws_sns_topic.alerts.name +} + +output "alarm_arns" { + description = "생성된 CloudWatch Alarm ARN 목록" + value = { + ecs_cpu = aws_cloudwatch_metric_alarm.ecs_cpu_high.arn + ecs_memory = aws_cloudwatch_metric_alarm.ecs_memory_high.arn + rds_cpu = aws_cloudwatch_metric_alarm.rds_cpu_high.arn + rds_connections = aws_cloudwatch_metric_alarm.rds_connections_high.arn + rds_storage = aws_cloudwatch_metric_alarm.rds_storage_low.arn + alb_5xx = aws_cloudwatch_metric_alarm.alb_5xx_errors.arn + alb_response = aws_cloudwatch_metric_alarm.alb_response_time.arn + } +} diff --git a/infra/terraform/modules/monitoring/variables.tf b/infra/terraform/modules/monitoring/variables.tf new file mode 100644 index 00000000..c153416e --- /dev/null +++ b/infra/terraform/modules/monitoring/variables.tf @@ -0,0 +1,113 @@ +# ============================================================================= +# 공통 변수 +# ============================================================================= +variable "name_prefix" { + description = "리소스 이름 접두사" + type = string +} + +variable "common_tags" { + description = "공통 태그" + type = map(string) +} + +# ============================================================================= +# 알림 설정 +# ============================================================================= +variable "alert_email" { + description = "알람 알림 받을 이메일 (빈 값이면 구독 안함)" + type = string + default = "" +} + +# ============================================================================= +# ECS 모니터링 대상 +# ============================================================================= +variable "ecs_cluster_name" { + description = "ECS 클러스터 이름" + type = string +} + +variable "ecs_service_name" { + description = "ECS 서비스 이름" + type = string +} + +variable "ecs_cpu_threshold" { + description = "ECS CPU 알람 임계값 (%)" + type = number + default = 80 +} + +variable "ecs_memory_threshold" { + description = "ECS Memory 알람 임계값 (%)" + type = number + default = 80 +} + +# ============================================================================= +# RDS 모니터링 대상 +# ============================================================================= +variable "rds_instance_id" { + description = "RDS 인스턴스 ID" + type = string +} + +variable "rds_cpu_threshold" { + description = "RDS CPU 알람 임계값 (%)" + type = number + default = 80 +} + +variable "rds_connections_threshold" { + description = "RDS 연결 수 알람 임계값" + type = number + default = 50 +} + +variable "rds_storage_threshold_bytes" { + description = "RDS 남은 스토리지 알람 임계값 (bytes)" + type = number + default = 5368709120 # 5GB +} + +# ============================================================================= +# ALB 모니터링 대상 +# ============================================================================= +variable "alb_arn_suffix" { + description = "ALB ARN suffix (app/xxx/xxx 형식)" + type = string +} + +variable "alb_5xx_threshold" { + description = "ALB 5XX 에러 수 알람 임계값" + type = number + default = 10 +} + +variable "alb_response_time_threshold" { + description = "ALB 응답 시간 알람 임계값 (초)" + type = number + default = 3 +} + +# ============================================================================= +# Redis 모니터링 대상 (선택) +# ============================================================================= +variable "redis_cluster_id" { + description = "ElastiCache 클러스터 ID (빈 값이면 Redis 알람 생략)" + type = string + default = "" +} + +variable "redis_cpu_threshold" { + description = "Redis CPU 알람 임계값 (%)" + type = number + default = 75 +} + +variable "redis_memory_threshold" { + description = "Redis Memory 알람 임계값 (%)" + type = number + default = 80 +} diff --git a/infra/terraform/modules/network/main.tf b/infra/terraform/modules/network/main.tf new file mode 100644 index 00000000..3537fe90 --- /dev/null +++ b/infra/terraform/modules/network/main.tf @@ -0,0 +1,153 @@ +# ============================================================================= +# VPC +# ============================================================================= +resource "aws_vpc" "main" { + cidr_block = var.vpc_cidr + enable_dns_hostnames = true + enable_dns_support = true + + tags = merge(var.common_tags, { Name = "${var.name_prefix}-vpc" }) +} + +# ============================================================================= +# Public Subnets +# ============================================================================= +resource "aws_subnet" "public_a" { + vpc_id = aws_vpc.main.id + cidr_block = var.public_subnet_cidrs["a"] + availability_zone = var.availability_zones["a"] + + tags = merge(var.common_tags, { + Name = "${var.name_prefix}-public-a" + Tier = "public" + }) +} + +# ============================================================================= +# Private Subnets +# ============================================================================= +resource "aws_subnet" "private_a" { + vpc_id = aws_vpc.main.id + cidr_block = var.private_subnet_cidrs["a"] + availability_zone = var.availability_zones["a"] + + tags = merge(var.common_tags, { + Name = "${var.name_prefix}-private-a" + Tier = "private" + }) +} + +resource "aws_subnet" "private_c" { + vpc_id = aws_vpc.main.id + cidr_block = var.private_subnet_cidrs["c"] + availability_zone = var.availability_zones["c"] + + tags = merge(var.common_tags, { Name = "${var.name_prefix}-private-c" }) +} + +# ============================================================================= +# Internet Gateway +# ============================================================================= +resource "aws_internet_gateway" "igw" { + vpc_id = aws_vpc.main.id + + tags = merge(var.common_tags, { Name = "${var.name_prefix}-igw" }) +} + +# ============================================================================= +# NAT Instance +# ============================================================================= +resource "aws_security_group" "nat_sg" { + name = "${var.name_prefix}-nat-sg" + vpc_id = aws_vpc.main.id + + ingress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = [var.vpc_cidr] + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = merge(var.common_tags, { Name = "${var.name_prefix}-nat-sg" }) +} + +data "aws_ami" "al2023" { + most_recent = true + owners = ["amazon"] + + filter { + name = "name" + values = ["al2023-ami-2023*-x86_64"] + } + + filter { + name = "state" + values = ["available"] + } +} + +resource "aws_instance" "nat_instance" { + ami = data.aws_ami.al2023.id + instance_type = var.nat_instance_type + subnet_id = aws_subnet.public_a.id + vpc_security_group_ids = [aws_security_group.nat_sg.id] + associate_public_ip_address = true + source_dest_check = false + + user_data = <<-EOF + #!/bin/bash + sudo sysctl -w net.ipv4.ip_forward=1 + sudo nft add table ip nat + sudo nft add chain ip nat postrouting { type nat hook postrouting priority 100 \; } + sudo nft add rule ip nat postrouting oifname eth0 masquerade + EOF + + tags = merge(var.common_tags, { Name = "${var.name_prefix}-nat-instance" }) +} + +# ============================================================================= +# Route Tables +# ============================================================================= +resource "aws_route_table" "public" { + vpc_id = aws_vpc.main.id + + route { + cidr_block = "0.0.0.0/0" + gateway_id = aws_internet_gateway.igw.id + } + + tags = merge(var.common_tags, { Name = "${var.name_prefix}-public-rt" }) +} + +resource "aws_route_table_association" "public_a" { + subnet_id = aws_subnet.public_a.id + route_table_id = aws_route_table.public.id +} + +resource "aws_route_table" "private" { + vpc_id = aws_vpc.main.id + + route { + cidr_block = "0.0.0.0/0" + network_interface_id = aws_instance.nat_instance.primary_network_interface_id + } + + tags = merge(var.common_tags, { Name = "${var.name_prefix}-private-rt" }) +} + +resource "aws_route_table_association" "private_a" { + subnet_id = aws_subnet.private_a.id + route_table_id = aws_route_table.private.id +} + +resource "aws_route_table_association" "private_c" { + subnet_id = aws_subnet.private_c.id + route_table_id = aws_route_table.private.id +} diff --git a/infra/terraform/modules/network/outputs.tf b/infra/terraform/modules/network/outputs.tf new file mode 100644 index 00000000..48ceef86 --- /dev/null +++ b/infra/terraform/modules/network/outputs.tf @@ -0,0 +1,29 @@ +output "vpc_id" { + description = "VPC ID" + value = aws_vpc.main.id +} + +output "vpc_cidr" { + description = "VPC CIDR 블록" + value = aws_vpc.main.cidr_block +} + +output "public_subnet_a_id" { + description = "Public Subnet A ID" + value = aws_subnet.public_a.id +} + +output "private_subnet_a_id" { + description = "Private Subnet A ID" + value = aws_subnet.private_a.id +} + +output "private_subnet_c_id" { + description = "Private Subnet C ID" + value = aws_subnet.private_c.id +} + +output "private_subnet_ids" { + description = "Private Subnet IDs" + value = [aws_subnet.private_a.id, aws_subnet.private_c.id] +} diff --git a/infra/terraform/modules/network/variables.tf b/infra/terraform/modules/network/variables.tf new file mode 100644 index 00000000..9a1257b1 --- /dev/null +++ b/infra/terraform/modules/network/variables.tf @@ -0,0 +1,36 @@ +variable "name_prefix" { + description = "리소스 네이밍 프리픽스" + type = string +} + +variable "common_tags" { + description = "공통 태그" + type = map(string) + default = {} +} + +variable "vpc_cidr" { + description = "VPC CIDR 블록" + type = string +} + +variable "public_subnet_cidrs" { + description = "Public 서브넷 CIDR 목록" + type = map(string) +} + +variable "private_subnet_cidrs" { + description = "Private 서브넷 CIDR 목록" + type = map(string) +} + +variable "availability_zones" { + description = "가용 영역" + type = map(string) +} + +variable "nat_instance_type" { + description = "NAT Instance 타입" + type = string + default = "t3.nano" +} diff --git a/infra/terraform/modules/s3/main.tf b/infra/terraform/modules/s3/main.tf new file mode 100644 index 00000000..402c5cf7 --- /dev/null +++ b/infra/terraform/modules/s3/main.tf @@ -0,0 +1,212 @@ +# ============================================================================= +# S3 Buckets - 정적 파일 및 로그 저장 +# ============================================================================= + +# ----------------------------------------------------------------------------- +# 정적 파일 버킷 (Static Assets) +# ----------------------------------------------------------------------------- +resource "aws_s3_bucket" "static" { + bucket = "${var.name_prefix}-static-${var.account_id}" + + tags = merge(var.common_tags, { Name = "${var.name_prefix}-static" }) +} + +resource "aws_s3_bucket_versioning" "static" { + bucket = aws_s3_bucket.static.id + versioning_configuration { + status = "Enabled" + } +} + +resource "aws_s3_bucket_server_side_encryption_configuration" "static" { + bucket = aws_s3_bucket.static.id + + rule { + apply_server_side_encryption_by_default { + sse_algorithm = "AES256" + } + } +} + +resource "aws_s3_bucket_public_access_block" "static" { + bucket = aws_s3_bucket.static.id + + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + +# CloudFront OAC 용 버킷 정책 (나중에 CloudFront 추가 시 사용) +resource "aws_s3_bucket_policy" "static" { + count = var.cloudfront_oac_arn != "" ? 1 : 0 + bucket = aws_s3_bucket.static.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "AllowCloudFrontOAC" + Effect = "Allow" + Principal = { + Service = "cloudfront.amazonaws.com" + } + Action = "s3:GetObject" + Resource = "${aws_s3_bucket.static.arn}/*" + Condition = { + StringEquals = { + "AWS:SourceArn" = var.cloudfront_oac_arn + } + } + } + ] + }) +} + +# ----------------------------------------------------------------------------- +# 로그 버킷 (Application & Access Logs) +# ----------------------------------------------------------------------------- +resource "aws_s3_bucket" "logs" { + bucket = "${var.name_prefix}-logs-${var.account_id}" + + tags = merge(var.common_tags, { Name = "${var.name_prefix}-logs" }) +} + +resource "aws_s3_bucket_versioning" "logs" { + bucket = aws_s3_bucket.logs.id + versioning_configuration { + status = "Enabled" + } +} + +resource "aws_s3_bucket_server_side_encryption_configuration" "logs" { + bucket = aws_s3_bucket.logs.id + + rule { + apply_server_side_encryption_by_default { + sse_algorithm = "AES256" + } + } +} + +resource "aws_s3_bucket_public_access_block" "logs" { + bucket = aws_s3_bucket.logs.id + + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + +# 로그 수명 주기 정책 - 오래된 로그 자동 삭제/이동 +resource "aws_s3_bucket_lifecycle_configuration" "logs" { + bucket = aws_s3_bucket.logs.id + + rule { + id = "log-retention" + status = "Enabled" + + filter {} # 전체 버킷에 적용 + + # 30일 후 Glacier로 이동 + transition { + days = var.log_transition_days + storage_class = "GLACIER" + } + + # 90일 후 삭제 + expiration { + days = var.log_expiration_days + } + + noncurrent_version_expiration { + noncurrent_days = 30 + } + } +} + +# 로그 버킷 정책 - CloudWatch, CloudTrail, RDS만 허용 +resource "aws_s3_bucket_policy" "logs" { + bucket = aws_s3_bucket.logs.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + # CloudWatch Logs 내보내기 + { + Sid = "AllowCloudWatchLogs" + Effect = "Allow" + Principal = { + Service = "logs.${var.region}.amazonaws.com" + } + Action = "s3:PutObject" + Resource = "${aws_s3_bucket.logs.arn}/cloudwatch-logs/*" + Condition = { + StringEquals = { + "s3:x-amz-acl" = "bucket-owner-full-control" + "aws:SourceAccount" = var.account_id + } + } + }, + { + Sid = "AllowCloudWatchLogsAclCheck" + Effect = "Allow" + Principal = { + Service = "logs.${var.region}.amazonaws.com" + } + Action = "s3:GetBucketAcl" + Resource = aws_s3_bucket.logs.arn + Condition = { + StringEquals = { + "aws:SourceAccount" = var.account_id + } + } + }, + # CloudTrail + { + Sid = "AllowCloudTrailAclCheck" + Effect = "Allow" + Principal = { + Service = "cloudtrail.amazonaws.com" + } + Action = "s3:GetBucketAcl" + Resource = aws_s3_bucket.logs.arn + Condition = { + StringEquals = { + "aws:SourceArn" = "arn:aws:cloudtrail:${var.region}:${var.account_id}:trail/*" + } + } + }, + { + Sid = "AllowCloudTrailWrite" + Effect = "Allow" + Principal = { + Service = "cloudtrail.amazonaws.com" + } + Action = "s3:PutObject" + Resource = "${aws_s3_bucket.logs.arn}/cloudtrail/*" + Condition = { + StringEquals = { + "s3:x-amz-acl" = "bucket-owner-full-control" + "aws:SourceArn" = "arn:aws:cloudtrail:${var.region}:${var.account_id}:trail/*" + } + } + }, + # RDS 로그 내보내기 (Enhanced Monitoring, Audit logs) + { + Sid = "AllowRDSLogs" + Effect = "Allow" + Principal = { + Service = "rds.amazonaws.com" + } + Action = "s3:PutObject" + Resource = "${aws_s3_bucket.logs.arn}/rds-logs/*" + Condition = { + StringEquals = { + "aws:SourceAccount" = var.account_id + } + } + } + ] + }) +} diff --git a/infra/terraform/modules/s3/outputs.tf b/infra/terraform/modules/s3/outputs.tf new file mode 100644 index 00000000..f10fc83a --- /dev/null +++ b/infra/terraform/modules/s3/outputs.tf @@ -0,0 +1,32 @@ +# ============================================================================= +# S3 Outputs +# ============================================================================= +output "static_bucket_id" { + description = "정적 파일 버킷 ID" + value = aws_s3_bucket.static.id +} + +output "static_bucket_arn" { + description = "정적 파일 버킷 ARN" + value = aws_s3_bucket.static.arn +} + +output "static_bucket_domain_name" { + description = "정적 파일 버킷 도메인" + value = aws_s3_bucket.static.bucket_regional_domain_name +} + +output "logs_bucket_id" { + description = "로그 버킷 ID" + value = aws_s3_bucket.logs.id +} + +output "logs_bucket_arn" { + description = "로그 버킷 ARN" + value = aws_s3_bucket.logs.arn +} + +output "logs_bucket_domain_name" { + description = "로그 버킷 도메인" + value = aws_s3_bucket.logs.bucket_regional_domain_name +} diff --git a/infra/terraform/modules/s3/variables.tf b/infra/terraform/modules/s3/variables.tf new file mode 100644 index 00000000..67518fd9 --- /dev/null +++ b/infra/terraform/modules/s3/variables.tf @@ -0,0 +1,43 @@ +# ============================================================================= +# 공통 변수 +# ============================================================================= +variable "name_prefix" { + description = "리소스 이름 접두사" + type = string +} + +variable "common_tags" { + description = "공통 태그" + type = map(string) +} + +variable "account_id" { + description = "AWS 계정 ID (버킷 이름 고유성)" + type = string +} + +variable "region" { + description = "AWS 리전" + type = string +} + +# ============================================================================= +# S3 설정 +# ============================================================================= +variable "cloudfront_oac_arn" { + description = "CloudFront OAC ARN (정적 파일 버킷 접근용)" + type = string + default = "" +} + +variable "log_transition_days" { + description = "로그를 Glacier로 이동하는 일수" + type = number + default = 30 +} + +variable "log_expiration_days" { + description = "로그 삭제 일수" + type = number + default = 90 +} diff --git a/infra/terraform/modules/waf/main.tf b/infra/terraform/modules/waf/main.tf new file mode 100644 index 00000000..5c7f91a0 --- /dev/null +++ b/infra/terraform/modules/waf/main.tf @@ -0,0 +1,140 @@ +# ============================================================================= +# WAF Web ACL for API Gateway +# ============================================================================= +resource "aws_wafv2_web_acl" "main" { + name = "${var.name_prefix}-waf" + description = "WAF for API Gateway" + scope = "REGIONAL" + + default_action { + allow {} + } + + # AWS Managed Rules - Common Rule Set + rule { + name = "AWSManagedRulesCommonRuleSet" + priority = 1 + + override_action { + none {} + } + + statement { + managed_rule_group_statement { + name = "AWSManagedRulesCommonRuleSet" + vendor_name = "AWS" + } + } + + visibility_config { + cloudwatch_metrics_enabled = true + metric_name = "${var.name_prefix}-common-rules" + sampled_requests_enabled = true + } + } + + # AWS Managed Rules - Known Bad Inputs + rule { + name = "AWSManagedRulesKnownBadInputsRuleSet" + priority = 2 + + override_action { + none {} + } + + statement { + managed_rule_group_statement { + name = "AWSManagedRulesKnownBadInputsRuleSet" + vendor_name = "AWS" + } + } + + visibility_config { + cloudwatch_metrics_enabled = true + metric_name = "${var.name_prefix}-bad-inputs" + sampled_requests_enabled = true + } + } + + # AWS Managed Rules - SQL Injection + rule { + name = "AWSManagedRulesSQLiRuleSet" + priority = 3 + + override_action { + none {} + } + + statement { + managed_rule_group_statement { + name = "AWSManagedRulesSQLiRuleSet" + vendor_name = "AWS" + } + } + + visibility_config { + cloudwatch_metrics_enabled = true + metric_name = "${var.name_prefix}-sqli" + sampled_requests_enabled = true + } + } + + # Rate Limiting Rule + rule { + name = "RateLimitRule" + priority = 4 + + action { + block {} + } + + statement { + rate_based_statement { + limit = var.rate_limit + aggregate_key_type = "IP" + } + } + + visibility_config { + cloudwatch_metrics_enabled = true + metric_name = "${var.name_prefix}-rate-limit" + sampled_requests_enabled = true + } + } + + visibility_config { + cloudwatch_metrics_enabled = true + metric_name = "${var.name_prefix}-waf" + sampled_requests_enabled = true + } + + tags = merge(var.common_tags, { Name = "${var.name_prefix}-waf" }) +} + +# ============================================================================= +# WAF Association with API Gateway +# ============================================================================= +#resource "aws_wafv2_web_acl_association" "api_gateway" { +# count = var.api_gateway_stage_arn != "" ? 1 : 0 +# +# resource_arn = var.api_gateway_stage_arn +# web_acl_arn = aws_wafv2_web_acl.main.arn +#} + +# ============================================================================= +# CloudWatch Log Group for WAF +# ============================================================================= +resource "aws_cloudwatch_log_group" "waf" { + name = "aws-waf-logs-${var.name_prefix}" + retention_in_days = var.log_retention_days + + tags = var.common_tags +} + +# ============================================================================= +# WAF Logging Configuration +# ============================================================================= +resource "aws_wafv2_web_acl_logging_configuration" "main" { + log_destination_configs = [aws_cloudwatch_log_group.waf.arn] + resource_arn = aws_wafv2_web_acl.main.arn +} diff --git a/infra/terraform/modules/waf/outputs.tf b/infra/terraform/modules/waf/outputs.tf new file mode 100644 index 00000000..b70adc4b --- /dev/null +++ b/infra/terraform/modules/waf/outputs.tf @@ -0,0 +1,19 @@ +output "web_acl_arn" { + description = "WAF Web ACL ARN" + value = aws_wafv2_web_acl.main.arn +} + +output "web_acl_id" { + description = "WAF Web ACL ID" + value = aws_wafv2_web_acl.main.id +} + +output "web_acl_name" { + description = "WAF Web ACL 이름" + value = aws_wafv2_web_acl.main.name +} + +output "log_group_name" { + description = "WAF 로그 그룹 이름" + value = aws_cloudwatch_log_group.waf.name +} diff --git a/infra/terraform/modules/waf/variables.tf b/infra/terraform/modules/waf/variables.tf new file mode 100644 index 00000000..6b92f8a7 --- /dev/null +++ b/infra/terraform/modules/waf/variables.tf @@ -0,0 +1,28 @@ +variable "name_prefix" { + description = "리소스 네이밍 프리픽스" + type = string +} + +variable "common_tags" { + description = "공통 태그" + type = map(string) + default = {} +} + +variable "api_gateway_stage_arn" { + description = "API Gateway Stage ARN" + type = string + default = "" +} + +variable "rate_limit" { + description = "5분당 최대 요청 수 (Rate Limiting)" + type = number + default = 2000 +} + +variable "log_retention_days" { + description = "WAF 로그 보관 일수" + type = number + default = 30 +} diff --git a/infra/terraform/outputs.tf b/infra/terraform/outputs.tf deleted file mode 100644 index 87fedadd..00000000 --- a/infra/terraform/outputs.tf +++ /dev/null @@ -1,19 +0,0 @@ -output "instance_public_ip" { - description = "EC2 인스턴스 Public IP" - value = aws_instance.web.public_ip -} - -output "ssh_connection_command" { - description = "SSH 접속 명령어" - value = "ssh -i terraform-key.pem ubuntu@${aws_instance.web.public_ip}" -} - -output "vpc_id" { - description = "VPC ID" - value = aws_vpc.main.id -} - -output "subnet_id" { - description = "Subnet ID" - value = aws_subnet.public.id -} \ No newline at end of file diff --git a/infra/terraform/security.tf b/infra/terraform/security.tf deleted file mode 100644 index ce69d185..00000000 --- a/infra/terraform/security.tf +++ /dev/null @@ -1,25 +0,0 @@ -resource "aws_security_group" "web" { - name = "terraform-web-sg" - description = "Allow HTTP and SSH" - vpc_id = aws_vpc.main.id - - ingress { - from_port = 22 - to_port = 22 - protocol = "tcp" - cidr_blocks = ["0.0.0.0/0"] - description = "Allow SSH" - } - - egress { - from_port = 0 - to_port = 0 - protocol = "-1" - cidr_blocks = ["0.0.0.0/0"] - description = "Allow all outbound" - } - - tags = { - Name = "terraform-web-sg" - } -} \ No newline at end of file diff --git a/infra/terraform/vpc.tf b/infra/terraform/vpc.tf deleted file mode 100644 index 1aa8e6b7..00000000 --- a/infra/terraform/vpc.tf +++ /dev/null @@ -1,42 +0,0 @@ -resource "aws_vpc" "main" { - cidr_block = "20.0.0.0/16" - - tags = { - Name = "terraform-vpc" - } -} - -resource "aws_subnet" "public" { - vpc_id = aws_vpc.main.id - cidr_block = "20.0.1.0/24" - - tags = { - Name = "terraform-public-subnet" - } -} - -resource "aws_internet_gateway" "main" { - vpc_id = aws_vpc.main.id - - tags = { - Name = "terraform-igw" - } -} - -resource "aws_route_table" "public" { - vpc_id = aws_vpc.main.id - - route { - cidr_block = "0.0.0.0/0" - gateway_id = aws_internet_gateway.main.id - } - - tags = { - Name = "terraform-public-rt" - } -} - -resource "aws_route_table_association" "public" { - subnet_id = aws_subnet.public.id - route_table_id = aws_route_table.public.id -} From a0fdb7acf0fdc0878be7ffb49de4c34eb1658619 Mon Sep 17 00:00:00 2001 From: eqqmayo Date: Fri, 23 Jan 2026 11:16:05 +0900 Subject: [PATCH 62/77] =?UTF-8?q?feat:=20alb=20=EB=9D=BC=EC=9A=B0=ED=8C=85?= =?UTF-8?q?,=20=EC=84=9C=EB=B9=84=EC=8A=A4=EB=B3=84=20ecr=20=EB=AA=A8?= =?UTF-8?q?=EB=93=88,=20=EC=84=9C=EB=B9=84=EC=8A=A4=EB=94=94=EC=8A=A4?= =?UTF-8?q?=EC=BB=A4=EB=B2=84=EB=A6=AC=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- infra/terraform/environments/dev/main.tf | 61 ++-- infra/terraform/environments/dev/outputs.tf | 12 +- infra/terraform/environments/dev/variables.tf | 103 ++++-- infra/terraform/modules/alb/main.tf | 52 +++- infra/terraform/modules/alb/outputs.tf | 11 +- infra/terraform/modules/alb/variables.tf | 18 +- infra/terraform/modules/ecr/main.tf | 20 +- infra/terraform/modules/ecr/outputs.tf | 18 +- infra/terraform/modules/ecr/variables.tf | 6 + infra/terraform/modules/ecs/main.tf | 293 ++++++++++++++---- infra/terraform/modules/ecs/outputs.tf | 35 ++- infra/terraform/modules/ecs/variables.tf | 88 ++++-- scripts/build-and-push.sh | 69 +++++ spot-order/Dockerfile | 2 +- .../config/FeignHeaderRelayInterceptor.java | 3 +- spot-payment/Dockerfile | 2 +- spot-store/Dockerfile | 2 +- spot-user/Dockerfile | 2 +- terraform.tfstate | 9 + 19 files changed, 610 insertions(+), 196 deletions(-) create mode 100755 scripts/build-and-push.sh create mode 100644 terraform.tfstate diff --git a/infra/terraform/environments/dev/main.tf b/infra/terraform/environments/dev/main.tf index ee249590..f83264a1 100644 --- a/infra/terraform/environments/dev/main.tf +++ b/infra/terraform/environments/dev/main.tf @@ -38,58 +38,69 @@ module "database" { } # ============================================================================= -# ECR +# ECR (Multiple Repositories) # ============================================================================= module "ecr" { source = "../../modules/ecr" - project = var.project - name_prefix = local.name_prefix - common_tags = local.common_tags + project = var.project + name_prefix = local.name_prefix + common_tags = local.common_tags + service_names = toset(keys(var.services)) } # ============================================================================= -# ALB +# ALB (Path-based Routing) # ============================================================================= module "alb" { source = "../../modules/alb" - name_prefix = local.name_prefix - common_tags = local.common_tags - vpc_id = module.network.vpc_id - vpc_cidr = module.network.vpc_cidr - subnet_ids = module.network.private_subnet_ids - container_port = var.container_port - health_check_path = var.health_check_path + name_prefix = local.name_prefix + common_tags = local.common_tags + vpc_id = module.network.vpc_id + vpc_cidr = module.network.vpc_cidr + subnet_ids = module.network.private_subnet_ids + + services = { + for k, v in var.services : k => { + container_port = v.container_port + health_check_path = v.health_check_path + path_patterns = v.path_patterns + priority = v.priority + } + } } # ============================================================================= -# ECS +# ECS (Multiple Services with Service Connect) # ============================================================================= module "ecs" { source = "../../modules/ecs" project = var.project + environment = var.environment name_prefix = local.name_prefix common_tags = local.common_tags region = var.region vpc_id = module.network.vpc_id subnet_ids = [module.network.public_subnet_a_id] # NAT 문제로 public 사용 - ecr_repository_url = module.ecr.repository_url + ecr_repository_urls = module.ecr.repository_urls alb_security_group_id = module.alb.security_group_id - target_group_arn = module.alb.target_group_arn + target_group_arns = module.alb.target_group_arns alb_listener_arn = module.alb.listener_arn - container_port = var.container_port - cpu = var.ecs_cpu - memory = var.ecs_memory - desired_count = var.ecs_desired_count assign_public_ip = true # NAT 문제로 public IP 사용 + services = var.services + enable_service_connect = var.enable_service_connect + # Database 연결 정보 db_endpoint = module.database.endpoint db_name = var.db_name db_username = var.db_username db_password = var.db_password + + # Redis 연결 정보 + redis_endpoint = module.elasticache.redis_endpoint } # ============================================================================= @@ -161,18 +172,18 @@ module "elasticache" { } # ============================================================================= -# CloudWatch Monitoring (알람/대시보드) +# CloudWatch Monitoring (Updated for MSA) # ============================================================================= module "monitoring" { source = "../../modules/monitoring" - name_prefix = local.name_prefix - common_tags = local.common_tags - alert_email = var.alert_email + name_prefix = local.name_prefix + common_tags = local.common_tags + alert_email = var.alert_email - # ECS 모니터링 + # ECS 모니터링 (대표 서비스) ecs_cluster_name = module.ecs.cluster_name - ecs_service_name = module.ecs.service_name + ecs_service_name = module.ecs.service_names["user"] # RDS 모니터링 rds_instance_id = module.database.instance_id diff --git a/infra/terraform/environments/dev/outputs.tf b/infra/terraform/environments/dev/outputs.tf index d79a2c8d..66efc4be 100644 --- a/infra/terraform/environments/dev/outputs.tf +++ b/infra/terraform/environments/dev/outputs.tf @@ -22,9 +22,9 @@ output "spring_datasource_url" { # ============================================================================= # ECR # ============================================================================= -output "ecr_repository_url" { - description = "ECR 저장소 URL" - value = module.ecr.repository_url +output "ecr_repository_urls" { + description = "ECR 저장소 URL 맵" + value = module.ecr.repository_urls } # ============================================================================= @@ -35,9 +35,9 @@ output "ecs_cluster_name" { value = module.ecs.cluster_name } -output "ecs_service_name" { - description = "ECS 서비스 이름" - value = module.ecs.service_name +output "ecs_service_names" { + description = "ECS 서비스 이름 맵" + value = module.ecs.service_names } # ============================================================================= diff --git a/infra/terraform/environments/dev/variables.tf b/infra/terraform/environments/dev/variables.tf index 67d076b3..3031761c 100644 --- a/infra/terraform/environments/dev/variables.tf +++ b/infra/terraform/environments/dev/variables.tf @@ -100,39 +100,80 @@ variable "db_engine_version" { } # ============================================================================= -# ECS 설정 -# ============================================================================= -variable "ecs_cpu" { - description = "ECS Task CPU" - type = string - default = "256" -} - -variable "ecs_memory" { - description = "ECS Task Memory" - type = string - default = "512" -} - -variable "ecs_desired_count" { - description = "ECS 희망 태스크 수" - type = number - default = 1 -} - -variable "container_port" { - description = "컨테이너 포트" - type = number - default = 8080 +# MSA Services Configuration +# ============================================================================= +variable "services" { + description = "MSA 서비스 구성 맵" + type = map(object({ + container_port = number + cpu = string + memory = string + desired_count = number + health_check_path = string + path_patterns = list(string) + priority = number + environment_vars = map(string) + })) + default = { + "order" = { + container_port = 8080 + cpu = "256" + memory = "512" + desired_count = 1 + health_check_path = "/actuator/health" + path_patterns = ["/api/orders/*", "/api/orders"] + priority = 100 + environment_vars = { + SERVICE_NAME = "spot-order" + DB_SCHEMA = "orders" + } + } + "payment" = { + container_port = 8080 + cpu = "256" + memory = "512" + desired_count = 1 + health_check_path = "/actuator/health" + path_patterns = ["/api/payments/*", "/api/payments"] + priority = 200 + environment_vars = { + SERVICE_NAME = "spot-payment" + DB_SCHEMA = "payments" + } + } + "store" = { + container_port = 8080 + cpu = "256" + memory = "512" + desired_count = 1 + health_check_path = "/actuator/health" + path_patterns = ["/api/stores/*", "/api/stores"] + priority = 300 + environment_vars = { + SERVICE_NAME = "spot-store" + DB_SCHEMA = "stores" + } + } + "user" = { + container_port = 8080 + cpu = "256" + memory = "512" + desired_count = 1 + health_check_path = "/actuator/health" + path_patterns = ["/api/users/*", "/api/users", "/api/auth/*", "/api/admin/*"] + priority = 400 + environment_vars = { + SERVICE_NAME = "spot-user" + DB_SCHEMA = "users" + } + } + } } -# ============================================================================= -# ALB 설정 -# ============================================================================= -variable "health_check_path" { - description = "헬스체크 경로" - type = string - default = "/" +variable "enable_service_connect" { + description = "ECS Service Connect 활성화 여부" + type = bool + default = true } # ============================================================================= diff --git a/infra/terraform/modules/alb/main.tf b/infra/terraform/modules/alb/main.tf index d79054b5..7494352b 100644 --- a/infra/terraform/modules/alb/main.tf +++ b/infra/terraform/modules/alb/main.tf @@ -36,11 +36,13 @@ resource "aws_lb" "main" { } # ============================================================================= -# Target Group (Blue/Green) +# Target Groups (Multiple Services) # ============================================================================= -resource "aws_lb_target_group" "blue" { - name = "${var.name_prefix}-tg-blue" - port = var.container_port +resource "aws_lb_target_group" "services" { + for_each = var.services + + name = "${var.name_prefix}-${each.key}-tg" + port = each.value.container_port protocol = "HTTP" vpc_id = var.vpc_id target_type = "ip" @@ -48,18 +50,21 @@ resource "aws_lb_target_group" "blue" { health_check { enabled = true healthy_threshold = 2 - unhealthy_threshold = 2 - timeout = 5 + unhealthy_threshold = 3 + timeout = 10 interval = 30 - path = var.health_check_path + path = each.value.health_check_path matcher = "200" } - tags = merge(var.common_tags, { Name = "${var.name_prefix}-tg-blue" }) + tags = merge(var.common_tags, { + Name = "${var.name_prefix}-${each.key}-tg" + Service = each.key + }) } # ============================================================================= -# ALB Listener +# ALB Listener (Default action returns 404) # ============================================================================= resource "aws_lb_listener" "main" { load_balancer_arn = aws_lb.main.arn @@ -67,7 +72,34 @@ resource "aws_lb_listener" "main" { protocol = "HTTP" default_action { + type = "fixed-response" + fixed_response { + content_type = "application/json" + message_body = jsonencode({ error = "Not Found", message = "No matching route" }) + status_code = "404" + } + } +} + +# ============================================================================= +# ALB Listener Rules (Path-based Routing) +# ============================================================================= +resource "aws_lb_listener_rule" "services" { + for_each = var.services + + listener_arn = aws_lb_listener.main.arn + priority = each.value.priority + + action { type = "forward" - target_group_arn = aws_lb_target_group.blue.arn + target_group_arn = aws_lb_target_group.services[each.key].arn } + + condition { + path_pattern { + values = each.value.path_patterns + } + } + + tags = merge(var.common_tags, { Service = each.key }) } diff --git a/infra/terraform/modules/alb/outputs.tf b/infra/terraform/modules/alb/outputs.tf index 6baf7257..7433a5c0 100644 --- a/infra/terraform/modules/alb/outputs.tf +++ b/infra/terraform/modules/alb/outputs.tf @@ -8,9 +8,9 @@ output "alb_dns_name" { value = aws_lb.main.dns_name } -output "target_group_arn" { - description = "Target Group ARN" - value = aws_lb_target_group.blue.arn +output "target_group_arns" { + description = "Target Group ARN 맵" + value = { for k, v in aws_lb_target_group.services : k => v.arn } } output "listener_arn" { @@ -27,3 +27,8 @@ output "arn_suffix" { description = "ALB ARN suffix (CloudWatch용)" value = aws_lb.main.arn_suffix } + +output "target_group_arn_suffixes" { + description = "Target Group ARN suffix 맵 (CloudWatch용)" + value = { for k, v in aws_lb_target_group.services : k => v.arn_suffix } +} diff --git a/infra/terraform/modules/alb/variables.tf b/infra/terraform/modules/alb/variables.tf index c21979cd..867602ab 100644 --- a/infra/terraform/modules/alb/variables.tf +++ b/infra/terraform/modules/alb/variables.tf @@ -24,14 +24,12 @@ variable "subnet_ids" { type = list(string) } -variable "container_port" { - description = "컨테이너 포트" - type = number - default = 8080 -} - -variable "health_check_path" { - description = "헬스체크 경로" - type = string - default = "/health" +variable "services" { + description = "서비스 구성 맵" + type = map(object({ + container_port = number + health_check_path = string + path_patterns = list(string) + priority = number + })) } diff --git a/infra/terraform/modules/ecr/main.tf b/infra/terraform/modules/ecr/main.tf index b1537657..013d58e2 100644 --- a/infra/terraform/modules/ecr/main.tf +++ b/infra/terraform/modules/ecr/main.tf @@ -1,22 +1,28 @@ # ============================================================================= -# ECR Repository +# ECR Repositories (Multiple Services) # ============================================================================= -resource "aws_ecr_repository" "main" { - name = "${var.project}-backend" +resource "aws_ecr_repository" "services" { + for_each = var.service_names + + name = "${var.project}-${each.key}" image_tag_mutability = "MUTABLE" image_scanning_configuration { scan_on_push = true } - tags = merge(var.common_tags, { Name = "${var.name_prefix}-ecr-backend" }) + tags = merge(var.common_tags, { + Name = "${var.name_prefix}-ecr-${each.key}" + Service = each.key + }) } # ============================================================================= -# ECR Lifecycle Policy +# ECR Lifecycle Policy (per service) # ============================================================================= -resource "aws_ecr_lifecycle_policy" "main" { - repository = aws_ecr_repository.main.name +resource "aws_ecr_lifecycle_policy" "services" { + for_each = var.service_names + repository = aws_ecr_repository.services[each.key].name policy = jsonencode({ rules = [{ diff --git a/infra/terraform/modules/ecr/outputs.tf b/infra/terraform/modules/ecr/outputs.tf index d1ae5837..d45de976 100644 --- a/infra/terraform/modules/ecr/outputs.tf +++ b/infra/terraform/modules/ecr/outputs.tf @@ -1,14 +1,14 @@ -output "repository_url" { - description = "ECR 저장소 URL" - value = aws_ecr_repository.main.repository_url +output "repository_urls" { + description = "ECR 저장소 URL 맵" + value = { for k, v in aws_ecr_repository.services : k => v.repository_url } } -output "repository_arn" { - description = "ECR 저장소 ARN" - value = aws_ecr_repository.main.arn +output "repository_arns" { + description = "ECR 저장소 ARN 맵" + value = { for k, v in aws_ecr_repository.services : k => v.arn } } -output "repository_name" { - description = "ECR 저장소 이름" - value = aws_ecr_repository.main.name +output "repository_names" { + description = "ECR 저장소 이름 맵" + value = { for k, v in aws_ecr_repository.services : k => v.name } } diff --git a/infra/terraform/modules/ecr/variables.tf b/infra/terraform/modules/ecr/variables.tf index 907068cc..c729953e 100644 --- a/infra/terraform/modules/ecr/variables.tf +++ b/infra/terraform/modules/ecr/variables.tf @@ -14,6 +14,12 @@ variable "common_tags" { default = {} } +variable "service_names" { + description = "서비스 이름 목록" + type = set(string) + default = ["order", "payment", "store", "user"] +} + variable "image_retention_count" { description = "유지할 이미지 수" type = number diff --git a/infra/terraform/modules/ecs/main.tf b/infra/terraform/modules/ecs/main.tf index 640887c1..ba0079e7 100644 --- a/infra/terraform/modules/ecs/main.tf +++ b/infra/terraform/modules/ecs/main.tf @@ -1,29 +1,44 @@ # ============================================================================= -# Cloud Map (Service Discovery) +# Cloud Map (Service Discovery Namespace) # ============================================================================= resource "aws_service_discovery_private_dns_namespace" "main" { name = "${var.project}.local" vpc = var.vpc_id + + tags = merge(var.common_tags, { Name = "${var.project}.local" }) } -resource "aws_service_discovery_service" "backend" { - name = "backend" +# ============================================================================= +# Cloud Map Services (Multiple) +# ============================================================================= +resource "aws_service_discovery_service" "services" { + for_each = var.services + + name = each.key dns_config { - namespace_id = aws_service_discovery_private_dns_namespace.main.id + namespace_id = aws_service_discovery_private_dns_namespace.main.id + routing_policy = "MULTIVALUE" + dns_records { ttl = 10 type = "A" } + dns_records { + ttl = 10 + type = "SRV" + } } health_check_custom_config { failure_threshold = 1 } + + tags = merge(var.common_tags, { Service = each.key }) } # ============================================================================= -# ECS Cluster +# ECS Cluster (Single shared cluster) # ============================================================================= resource "aws_ecs_cluster" "main" { name = "${var.name_prefix}-cluster" @@ -32,23 +47,61 @@ resource "aws_ecs_cluster" "main" { namespace = aws_service_discovery_private_dns_namespace.main.arn } + setting { + name = "containerInsights" + value = "enabled" + } + tags = merge(var.common_tags, { Name = "${var.name_prefix}-cluster" }) } +resource "aws_ecs_cluster_capacity_providers" "main" { + cluster_name = aws_ecs_cluster.main.name + + capacity_providers = ["FARGATE", "FARGATE_SPOT"] + + default_capacity_provider_strategy { + base = 1 + weight = 100 + capacity_provider = "FARGATE" + } +} + # ============================================================================= -# ECS Security Group +# MSA Security Group (Shared by all services) # ============================================================================= -resource "aws_security_group" "api_sg" { - name = "${var.name_prefix}-api-sg" - vpc_id = var.vpc_id +resource "aws_security_group" "msa_sg" { + name = "${var.name_prefix}-msa-sg" + description = "Security group for MSA services" + vpc_id = var.vpc_id + # ALB에서 들어오는 트래픽 허용 ingress { - from_port = var.container_port - to_port = var.container_port + description = "Traffic from ALB" + from_port = 8080 + to_port = 8080 protocol = "tcp" security_groups = [var.alb_security_group_id] } + # Service Connect를 위한 자기 참조 규칙 (서비스 간 통신) + ingress { + description = "Inter-service communication" + from_port = 8080 + to_port = 8080 + protocol = "tcp" + self = true + } + + # Service Connect proxy port + ingress { + description = "Service Connect proxy" + from_port = 15000 + to_port = 15001 + protocol = "tcp" + self = true + } + egress { from_port = 0 to_port = 0 @@ -56,14 +109,14 @@ resource "aws_security_group" "api_sg" { cidr_blocks = ["0.0.0.0/0"] } - tags = merge(var.common_tags, { Name = "${var.name_prefix}-api-sg" }) + tags = merge(var.common_tags, { Name = "${var.name_prefix}-msa-sg" }) } # ============================================================================= # IAM Role for ECS Task Execution # ============================================================================= resource "aws_iam_role" "ecs_task_execution_role" { - name = "${var.name_prefix}-ecs-task-role" + name = "${var.name_prefix}-ecs-task-execution-role" assume_role_policy = jsonencode({ Version = "2012-10-17" @@ -83,87 +136,221 @@ resource "aws_iam_role_policy_attachment" "ecs_task_execution_role_policy" { } # ============================================================================= -# CloudWatch Log Group +# IAM Role for ECS Task (Application level) # ============================================================================= -resource "aws_cloudwatch_log_group" "ecs_logs" { - name = "/ecs/${var.project}-backend" - retention_in_days = var.log_retention_days +resource "aws_iam_role" "ecs_task_role" { + name = "${var.name_prefix}-ecs-task-role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { Service = "ecs-tasks.amazonaws.com" } + }] + }) tags = var.common_tags } +# Service Connect requires CloudWatch permissions +resource "aws_iam_role_policy" "ecs_service_connect" { + name = "${var.name_prefix}-service-connect-policy" + role = aws_iam_role.ecs_task_role.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = [ + "logs:CreateLogStream", + "logs:PutLogEvents" + ] + Resource = "*" + } + ] + }) +} + +# ============================================================================= +# CloudWatch Log Groups (per service) +# ============================================================================= +resource "aws_cloudwatch_log_group" "services" { + for_each = var.services + + name = "/ecs/${var.project}-${each.key}" + retention_in_days = var.log_retention_days + + tags = merge(var.common_tags, { Service = each.key }) +} + # ============================================================================= -# ECS Task Definition +# ECS Task Definitions (per service) # ============================================================================= -resource "aws_ecs_task_definition" "backend" { - family = "${var.project}-backend-task" +resource "aws_ecs_task_definition" "services" { + for_each = var.services + + family = "${var.project}-${each.key}-task" network_mode = "awsvpc" requires_compatibilities = ["FARGATE"] - cpu = var.cpu - memory = var.memory + cpu = each.value.cpu + memory = each.value.memory execution_role_arn = aws_iam_role.ecs_task_execution_role.arn + task_role_arn = aws_iam_role.ecs_task_role.arn container_definitions = jsonencode([ { - name = "${var.project}-backend-container" - image = "${var.ecr_repository_url}:latest" + name = "${var.project}-${each.key}-container" + image = "${var.ecr_repository_urls[each.key]}:latest" essential = true + portMappings = [{ - name = "http" - containerPort = var.container_port - hostPort = var.container_port + name = each.key + containerPort = each.value.container_port + hostPort = each.value.container_port + protocol = "tcp" + appProtocol = "http" }] - environment = [ - { - name = "SPRING_DATASOURCE_URL" - value = "jdbc:postgresql://${var.db_endpoint}/${var.db_name}" - }, - { - name = "SPRING_DATASOURCE_USERNAME" - value = var.db_username - }, - { - name = "SPRING_DATASOURCE_PASSWORD" - value = var.db_password - } - ] + + environment = concat( + # 공통 환경 변수 + [ + { + name = "SPRING_PROFILES_ACTIVE" + value = var.environment + }, + { + name = "SPRING_DATASOURCE_URL" + value = "jdbc:postgresql://${var.db_endpoint}/${var.db_name}?currentSchema=${lookup(each.value.environment_vars, "DB_SCHEMA", each.key)}" + }, + { + name = "SPRING_DATASOURCE_USERNAME" + value = var.db_username + }, + { + name = "SPRING_DATASOURCE_PASSWORD" + value = var.db_password + }, + { + name = "SPRING_DATA_REDIS_HOST" + value = var.redis_endpoint + }, + { + name = "SPRING_DATA_REDIS_PORT" + value = "6379" + }, + # Service Discovery 환경 변수 (다른 서비스 URL) + { + name = "ORDER_SERVICE_URL" + value = "http://order.${var.project}.local:8080" + }, + { + name = "PAYMENT_SERVICE_URL" + value = "http://payment.${var.project}.local:8080" + }, + { + name = "STORE_SERVICE_URL" + value = "http://store.${var.project}.local:8080" + }, + { + name = "USER_SERVICE_URL" + value = "http://user.${var.project}.local:8080" + } + ], + # 서비스별 커스텀 환경 변수 + [for k, v in each.value.environment_vars : { + name = k + value = v + }] + ) + logConfiguration = { logDriver = "awslogs" options = { - "awslogs-group" = aws_cloudwatch_log_group.ecs_logs.name + "awslogs-group" = aws_cloudwatch_log_group.services[each.key].name "awslogs-region" = var.region "awslogs-stream-prefix" = "ecs" } } + + healthCheck = { + command = ["CMD-SHELL", "curl -f http://localhost:${each.value.container_port}${each.value.health_check_path} || exit 1"] + interval = 30 + timeout = 5 + retries = 3 + startPeriod = 60 + } } ]) - tags = var.common_tags + tags = merge(var.common_tags, { Service = each.key }) } # ============================================================================= -# ECS Service +# ECS Services (per service) # ============================================================================= -resource "aws_ecs_service" "main" { - name = "${var.project}-backend-service" +resource "aws_ecs_service" "services" { + for_each = var.services + + name = "${var.project}-${each.key}-service" cluster = aws_ecs_cluster.main.id - task_definition = aws_ecs_task_definition.backend.arn - desired_count = var.desired_count + task_definition = aws_ecs_task_definition.services[each.key].arn + desired_count = each.value.desired_count launch_type = "FARGATE" load_balancer { - target_group_arn = var.target_group_arn - container_name = "${var.project}-backend-container" - container_port = var.container_port + target_group_arn = var.target_group_arns[each.key] + container_name = "${var.project}-${each.key}-container" + container_port = each.value.container_port } network_configuration { subnets = var.subnet_ids - security_groups = [aws_security_group.api_sg.id] + security_groups = [aws_security_group.msa_sg.id] assign_public_ip = var.assign_public_ip } + # Service Connect Configuration + dynamic "service_connect_configuration" { + for_each = var.enable_service_connect ? [1] : [] + content { + enabled = true + namespace = aws_service_discovery_private_dns_namespace.main.arn + + service { + port_name = each.key + discovery_name = each.key + + client_alias { + port = each.value.container_port + dns_name = "${each.key}.${var.project}.local" + } + } + + log_configuration { + log_driver = "awslogs" + options = { + "awslogs-group" = aws_cloudwatch_log_group.services[each.key].name + "awslogs-region" = var.region + "awslogs-stream-prefix" = "service-connect" + } + } + } + } + + # Service Discovery Registration + service_registries { + registry_arn = aws_service_discovery_service.services[each.key].arn + container_name = "${var.project}-${each.key}-container" + container_port = each.value.container_port + } + depends_on = [var.alb_listener_arn] - tags = var.common_tags + tags = merge(var.common_tags, { Service = each.key }) + + lifecycle { + ignore_changes = [desired_count] + } } diff --git a/infra/terraform/modules/ecs/outputs.tf b/infra/terraform/modules/ecs/outputs.tf index 086163c7..1526a970 100644 --- a/infra/terraform/modules/ecs/outputs.tf +++ b/infra/terraform/modules/ecs/outputs.tf @@ -8,12 +8,37 @@ output "cluster_arn" { value = aws_ecs_cluster.main.arn } -output "service_name" { - description = "ECS 서비스 이름" - value = aws_ecs_service.main.name +output "service_names" { + description = "ECS 서비스 이름 맵" + value = { for k, v in aws_ecs_service.services : k => v.name } +} + +output "service_arns" { + description = "ECS 서비스 ARN 맵" + value = { for k, v in aws_ecs_service.services : k => v.id } } output "security_group_id" { - description = "ECS 보안그룹 ID" - value = aws_security_group.api_sg.id + description = "MSA 보안그룹 ID" + value = aws_security_group.msa_sg.id +} + +output "task_definition_arns" { + description = "Task Definition ARN 맵" + value = { for k, v in aws_ecs_task_definition.services : k => v.arn } +} + +output "cloudwatch_log_groups" { + description = "CloudWatch Log Group 이름 맵" + value = { for k, v in aws_cloudwatch_log_group.services : k => v.name } +} + +output "service_discovery_namespace_id" { + description = "Service Discovery Namespace ID" + value = aws_service_discovery_private_dns_namespace.main.id +} + +output "service_discovery_namespace_arn" { + description = "Service Discovery Namespace ARN" + value = aws_service_discovery_private_dns_namespace.main.arn } diff --git a/infra/terraform/modules/ecs/variables.tf b/infra/terraform/modules/ecs/variables.tf index bb2e0a98..551f59aa 100644 --- a/infra/terraform/modules/ecs/variables.tf +++ b/infra/terraform/modules/ecs/variables.tf @@ -1,8 +1,17 @@ +# ============================================================================= +# Project Settings +# ============================================================================= variable "project" { description = "프로젝트 이름" type = string } +variable "environment" { + description = "환경 (dev, prod)" + type = string + default = "dev" +} + variable "name_prefix" { description = "리소스 네이밍 프리픽스" type = string @@ -19,6 +28,9 @@ variable "region" { type = string } +# ============================================================================= +# Network Settings +# ============================================================================= variable "vpc_id" { description = "VPC ID" type = string @@ -29,19 +41,23 @@ variable "subnet_ids" { type = list(string) } -variable "ecr_repository_url" { - description = "ECR 저장소 URL" - type = string +variable "assign_public_ip" { + description = "Public IP 할당 여부" + type = bool + default = true } +# ============================================================================= +# ALB Integration +# ============================================================================= variable "alb_security_group_id" { description = "ALB 보안그룹 ID" type = string } -variable "target_group_arn" { - description = "ALB Target Group ARN" - type = string +variable "target_group_arns" { + description = "ALB Target Group ARN 맵" + type = map(string) } variable "alb_listener_arn" { @@ -49,32 +65,33 @@ variable "alb_listener_arn" { type = string } -variable "container_port" { - description = "컨테이너 포트" - type = number - default = 8080 -} - -variable "cpu" { - description = "Task CPU" - type = string - default = "256" -} - -variable "memory" { - description = "Task Memory" - type = string - default = "512" -} - -variable "desired_count" { - description = "희망 태스크 수" - type = number - default = 1 +# ============================================================================= +# ECR Integration +# ============================================================================= +variable "ecr_repository_urls" { + description = "ECR 저장소 URL 맵" + type = map(string) } -variable "assign_public_ip" { - description = "Public IP 할당 여부" +# ============================================================================= +# Services Configuration +# ============================================================================= +variable "services" { + description = "서비스 구성 맵" + type = map(object({ + container_port = number + cpu = string + memory = string + desired_count = number + health_check_path = string + path_patterns = list(string) + priority = number + environment_vars = map(string) + })) +} + +variable "enable_service_connect" { + description = "ECS Service Connect 활성화 여부" type = bool default = true } @@ -86,7 +103,7 @@ variable "log_retention_days" { } # ============================================================================= -# Database 설정 +# Database Settings # ============================================================================= variable "db_endpoint" { description = "RDS 엔드포인트" @@ -109,3 +126,12 @@ variable "db_password" { type = string sensitive = true } + +# ============================================================================= +# Redis Settings +# ============================================================================= +variable "redis_endpoint" { + description = "Redis 엔드포인트" + type = string + default = "" +} diff --git a/scripts/build-and-push.sh b/scripts/build-and-push.sh new file mode 100755 index 00000000..0e72e727 --- /dev/null +++ b/scripts/build-and-push.sh @@ -0,0 +1,69 @@ +#!/bin/bash +set -e + +# ============================================================================= +# 설정 +# ============================================================================= +AWS_REGION="ap-northeast-2" +AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) +ECR_REGISTRY="${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com" +PROJECT="spot" + +# 서비스 목록 +SERVICES=("order" "payment" "store" "user") + +# ============================================================================= +# ECR 로그인 +# ============================================================================= +echo "🔐 ECR 로그인 중..." +aws ecr get-login-password --region ${AWS_REGION} | docker login --username AWS --password-stdin ${ECR_REGISTRY} + +# ============================================================================= +# 빌드할 서비스 선택 +# ============================================================================= +if [ -n "$1" ]; then + # 특정 서비스만 빌드 + SERVICES=("$1") + echo "📦 ${1} 서비스만 빌드합니다." +else + echo "📦 모든 서비스를 빌드합니다: ${SERVICES[*]}" +fi + +# ============================================================================= +# 각 서비스 빌드 및 푸시 +# ============================================================================= +for SERVICE in "${SERVICES[@]}"; do + SERVICE_DIR="spot-${SERVICE}" + ECR_REPO="${PROJECT}-${SERVICE}" + IMAGE_TAG="${ECR_REGISTRY}/${ECR_REPO}:latest" + + echo "" + echo "==============================================" + echo "🚀 Building ${SERVICE_DIR}..." + echo "==============================================" + + # 1. Gradle 빌드 + echo "📦 Gradle 빌드 중..." + cd "${SERVICE_DIR}" + ./gradlew clean build -x test + + # 2. Docker 이미지 빌드 + echo "🐳 Docker 이미지 빌드 중..." + docker build -t ${ECR_REPO}:latest . + + # 3. 태그 지정 + docker tag ${ECR_REPO}:latest ${IMAGE_TAG} + + # 4. ECR에 푸시 + echo "⬆️ ECR에 푸시 중..." + docker push ${IMAGE_TAG} + + echo "✅ ${SERVICE} 완료: ${IMAGE_TAG}" + + cd .. +done + +echo "" +echo "==============================================" +echo "🎉 모든 서비스 빌드 및 푸시 완료!" +echo "==============================================" diff --git a/spot-order/Dockerfile b/spot-order/Dockerfile index c3f76250..f8b3c4df 100644 --- a/spot-order/Dockerfile +++ b/spot-order/Dockerfile @@ -3,5 +3,5 @@ WORKDIR /app COPY build/libs/spot-order-0.0.1-SNAPSHOT.jar app.jar -EXPOSE 8082 +EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar", "--spring.config.location=optional:classpath:/application.yml,optional:classpath:/application.properties,file:/config/application.yml"] diff --git a/spot-order/src/main/java/com/example/Spot/global/feign/config/FeignHeaderRelayInterceptor.java b/spot-order/src/main/java/com/example/Spot/global/feign/config/FeignHeaderRelayInterceptor.java index 5755a01f..38498aed 100644 --- a/spot-order/src/main/java/com/example/Spot/global/feign/config/FeignHeaderRelayInterceptor.java +++ b/spot-order/src/main/java/com/example/Spot/global/feign/config/FeignHeaderRelayInterceptor.java @@ -1,12 +1,11 @@ package com.example.Spot.global.feign.config; -import jakarta.servlet.http.HttpServletRequest; - import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; import feign.RequestInterceptor; import feign.RequestTemplate; +import jakarta.servlet.http.HttpServletRequest; public class FeignHeaderRelayInterceptor implements RequestInterceptor { diff --git a/spot-payment/Dockerfile b/spot-payment/Dockerfile index 86b9dc9a..44e85b60 100644 --- a/spot-payment/Dockerfile +++ b/spot-payment/Dockerfile @@ -3,5 +3,5 @@ WORKDIR /app COPY build/libs/spot-payment-0.0.1-SNAPSHOT.jar app.jar -EXPOSE 8084 +EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar", "--spring.config.location=optional:classpath:/application.yml,optional:classpath:/application.properties,file:/config/application.yml"] diff --git a/spot-store/Dockerfile b/spot-store/Dockerfile index f2aa9e0e..5350c6c8 100644 --- a/spot-store/Dockerfile +++ b/spot-store/Dockerfile @@ -3,5 +3,5 @@ WORKDIR /app COPY build/libs/spot-store-0.0.1-SNAPSHOT.jar app.jar -EXPOSE 8083 +EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar", "--spring.config.location=optional:classpath:/application.yml,optional:classpath:/application.properties,file:/config/application.yml"] diff --git a/spot-user/Dockerfile b/spot-user/Dockerfile index 4ecff699..d2e8e976 100644 --- a/spot-user/Dockerfile +++ b/spot-user/Dockerfile @@ -3,5 +3,5 @@ WORKDIR /app COPY build/libs/spot-user-0.0.1-SNAPSHOT.jar app.jar -EXPOSE 8081 +EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar", "--spring.config.location=optional:classpath:/application.yml,optional:classpath:/application.properties,file:/config/application.yml"] diff --git a/terraform.tfstate b/terraform.tfstate new file mode 100644 index 00000000..2ffdfe40 --- /dev/null +++ b/terraform.tfstate @@ -0,0 +1,9 @@ +{ + "version": 4, + "terraform_version": "1.14.3", + "serial": 1, + "lineage": "160e7fea-08d2-395f-ae56-957539d598ca", + "outputs": {}, + "resources": [], + "check_results": null +} From 1bc84f325887c695e61e714b7cc788301786b473 Mon Sep 17 00:00:00 2001 From: dbswjd7 Date: Fri, 23 Jan 2026 17:06:38 +0900 Subject: [PATCH 63/77] =?UTF-8?q?feat(#227):=20cognito=20=ED=86=A0?= =?UTF-8?q?=ED=81=B0=20=EB=B0=9C=EA=B8=89=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 10 ++- infra/terraform/cognito/.terraform.lock.hcl | 24 ++++++ infra/terraform/cognito/cognito.tf | 33 +++++++++ infra/terraform/cognito/cognito_client.tf | 19 +++++ infra/terraform/cognito/iam.tf | 74 +++++++++++++++++++ infra/terraform/cognito/lambda.tf | 27 +++++++ .../terraform/cognito/lambda/post_confirm.py | 60 +++++++++++++++ infra/terraform/cognito/lambda/pre_token.py | 36 +++++++++ infra/terraform/cognito/output.tf | 11 +++ infra/terraform/cognito/variable.tf | 10 +++ 10 files changed, 301 insertions(+), 3 deletions(-) create mode 100644 infra/terraform/cognito/.terraform.lock.hcl create mode 100644 infra/terraform/cognito/cognito.tf create mode 100644 infra/terraform/cognito/cognito_client.tf create mode 100644 infra/terraform/cognito/iam.tf create mode 100644 infra/terraform/cognito/lambda.tf create mode 100644 infra/terraform/cognito/lambda/post_confirm.py create mode 100644 infra/terraform/cognito/lambda/pre_token.py create mode 100644 infra/terraform/cognito/output.tf create mode 100644 infra/terraform/cognito/variable.tf diff --git a/.gitignore b/.gitignore index 668187ce..c7236de2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,11 @@ ### infra ### -infra/terraform/provider.tf -infra/terraform/terraform.* -infra/terraform/.terraform* +**/.terraform/* +**/terraform.tfstate +**/terraform.tfstate.* +*.tfvars + +# Lambda build artifacts +**/lambda/*.zip .idea/ .vscode/ diff --git a/infra/terraform/cognito/.terraform.lock.hcl b/infra/terraform/cognito/.terraform.lock.hcl new file mode 100644 index 00000000..65ccddcd --- /dev/null +++ b/infra/terraform/cognito/.terraform.lock.hcl @@ -0,0 +1,24 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "6.28.0" + hashes = [ + "h1:RwoFuX1yGMVaKJaUmXDKklEaQ/yUCEdt5k2kz+/g08c=", + "zh:0ba0d5eb6e0c6a933eb2befe3cdbf22b58fbc0337bf138f95bf0e8bb6e6df93e", + "zh:23eacdd4e6db32cf0ff2ce189461bdbb62e46513978d33c5de4decc4670870ec", + "zh:307b06a15fc00a8e6fd243abde2cbe5112e9d40371542665b91bec1018dd6e3c", + "zh:37a02d5b45a9d050b9642c9e2e268297254192280df72f6e46641daca52e40ec", + "zh:3da866639f07d92e734557d673092719c33ede80f4276c835bf7f231a669aa33", + "zh:480060b0ba310d0f6b6a14d60b276698cb103c48fd2f7e2802ae47c963995ec6", + "zh:57796453455c20db80d9168edbf125bf6180e1aae869de1546a2be58e4e405ec", + "zh:69139cba772d4df8de87598d8d8a2b1b4b254866db046c061dccc79edb14e6b9", + "zh:7312763259b859ff911c5452ca8bdf7d0be6231c5ea0de2df8f09d51770900ac", + "zh:8d2d6f4015d3c155d7eb53e36f019a729aefb46ebfe13f3a637327d3a1402ecc", + "zh:94ce589275c77308e6253f607de96919b840c2dd36c44aa798f693c9dd81af42", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:adaceec6a1bf4f5df1e12bd72cf52b72087c72efed078aef636f8988325b1a8b", + "zh:d37be1ce187d94fd9df7b13a717c219964cd835c946243f096c6b230cdfd7e92", + "zh:fe6205b5ca2ff36e68395cb8d3ae10a3728f405cdbcd46b206a515e1ebcf17a1", + ] +} diff --git a/infra/terraform/cognito/cognito.tf b/infra/terraform/cognito/cognito.tf new file mode 100644 index 00000000..1ac5c105 --- /dev/null +++ b/infra/terraform/cognito/cognito.tf @@ -0,0 +1,33 @@ +resource "aws_cognito_user_pool" "pool" { + name = "spot_cognito_user_pool" + + schema { + name = "user_id" + attribute_data_type = "String" + mutable = true + required = false + } + + schema { + name = "role" + attribute_data_type = "String" + mutable = true + required = false + } + + + lambda_config { + post_confirmation = aws_lambda_function.post_confirm.arn + + # access token customization을 위해 "pre_token_generation_config" 사용(Trigger event version V2_0) + pre_token_generation_config { + lambda_arn = aws_lambda_function.pre_token.arn + lambda_version = "V2_0" + } + } + + +} + + + diff --git a/infra/terraform/cognito/cognito_client.tf b/infra/terraform/cognito/cognito_client.tf new file mode 100644 index 00000000..e7f3e49b --- /dev/null +++ b/infra/terraform/cognito/cognito_client.tf @@ -0,0 +1,19 @@ + +resource "aws_cognito_user_pool_client" "app_client" { + name = "spot-app-client" + user_pool_id = aws_cognito_user_pool.pool.id + + generate_secret = false + + explicit_auth_flows = [ + "ALLOW_USER_PASSWORD_AUTH" + ] + + refresh_token_rotation { + feature = "ENABLED" + # 네트워크 장애 고려한 기존 refresh token 10초 유예시간 + retry_grace_period_seconds = 10 + } +} + + diff --git a/infra/terraform/cognito/iam.tf b/infra/terraform/cognito/iam.tf new file mode 100644 index 00000000..9172a5fa --- /dev/null +++ b/infra/terraform/cognito/iam.tf @@ -0,0 +1,74 @@ +data "aws_iam_policy_document" "lambda_assume" { + statement { + effect = "Allow" + actions = ["sts:AssumeRole"] + principals { + type = "Service" + identifiers = ["lambda.amazonaws.com"] + } + } +} + +# Post Confirmation Role +resource "aws_iam_role" "lambda_post_confirm" { + name = "spot-lambda-post-confirm" + assume_role_policy = data.aws_iam_policy_document.lambda_assume.json +} + +resource "aws_iam_role_policy" "lambda_post_confirm_policy" { + role = aws_iam_role.lambda_post_confirm.id + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + # logs + { + Effect = "Allow" + Action = ["logs:CreateLogGroup","logs:CreateLogStream","logs:PutLogEvents"] + Resource = "*" + }, + # Cognito custom attribute 업데이트용 + { + Effect = "Allow" + Action = ["cognito-idp:AdminUpdateUserAttributes"] + Resource = aws_cognito_user_pool.pool.arn + } + ] + }) +} + +# Pre Token Role +resource "aws_iam_role" "lambda_pre_token" { + name = "spot-lambda-pre-token" + assume_role_policy = data.aws_iam_policy_document.lambda_assume.json +} + +resource "aws_iam_role_policy" "lambda_pre_token_policy" { + role = aws_iam_role.lambda_pre_token.id + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = ["logs:CreateLogGroup","logs:CreateLogStream","logs:PutLogEvents"] + Resource = "*" + } + ] + }) +} + +# Cognito가 Lambda 호출하는 permission 연결(AccessDenied 방지) +resource "aws_lambda_permission" "allow_cognito_post_confirm" { + statement_id = "AllowCognitoInvokePostConfirm" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.post_confirm.function_name + principal = "cognito-idp.amazonaws.com" + source_arn = aws_cognito_user_pool.pool.arn +} + +resource "aws_lambda_permission" "allow_cognito_pre_token" { + statement_id = "AllowCognitoInvokePreToken" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.pre_token.function_name + principal = "cognito-idp.amazonaws.com" + source_arn = aws_cognito_user_pool.pool.arn +} diff --git a/infra/terraform/cognito/lambda.tf b/infra/terraform/cognito/lambda.tf new file mode 100644 index 00000000..ec4b0e0f --- /dev/null +++ b/infra/terraform/cognito/lambda.tf @@ -0,0 +1,27 @@ +resource "aws_lambda_function" "post_confirm" { + function_name = "spot-post-confirm" + role = aws_iam_role.lambda_post_confirm.arn + handler = "post_confirm.lambda_handler" + runtime = "python3.12" + timeout = 10 + + filename = "${path.module}/lambda/post_confirm.zip" + source_code_hash = filebase64sha256("${path.module}/lambda/post_confirm.zip") + + environment { + variables = { + USER_SERVICE_URL = var.user_service_url + } + } +} + +resource "aws_lambda_function" "pre_token" { + function_name = "spot-pre-token" + role = aws_iam_role.lambda_pre_token.arn + handler = "pre_token.lambda_handler" + runtime = "python3.12" + timeout = 5 + + filename = "${path.module}/lambda/pre_token.zip" + source_code_hash = filebase64sha256("${path.module}/lambda/pre_token.zip") +} diff --git a/infra/terraform/cognito/lambda/post_confirm.py b/infra/terraform/cognito/lambda/post_confirm.py new file mode 100644 index 00000000..fed84205 --- /dev/null +++ b/infra/terraform/cognito/lambda/post_confirm.py @@ -0,0 +1,60 @@ +import os +import json +import urllib.request +import urllib.error +import boto3 + +cognito = boto3.client("cognito-idp") + +USER_SERVICE_URL = os.environ.get("USER_SERVICE_URL", "").rstrip("/") + +def _post_json(url: str, payload: dict) -> dict: + data = json.dumps(payload).encode("utf-8") + req = urllib.request.Request(url, data=data, method="POST") + req.add_header("Content-Type", "application/json") + with urllib.request.urlopen(req, timeout=5) as resp: + body = resp.read().decode("utf-8") + return json.loads(body) if body else {} + +def lambda_handler(event, context): + # user-service 연동 전이면 즉시 실패 + if not USER_SERVICE_URL: + raise Exception("USER_SERVICE_URL is not set. Block sign-up until user-service is ready.") + + user_pool_id = event["userPoolId"] + username = event["userName"] + attrs = event.get("request", {}).get("userAttributes", {}) + + sub = attrs.get("sub") + email = attrs.get("email") + role = attrs.get("custom:role") or "CUSTOMER" + + if not sub: + raise Exception("Missing 'sub' in userAttributes") + + # 1) User DB 생성(실패하면 예외로 회원가입 흐름 막힘) + try: + created = _post_json( + f"{USER_SERVICE_URL}/internal/users", + {"cognitoSub": sub, "email": email, "role": role} + ) + except urllib.error.HTTPError as e: + raise Exception(f"user-service returned HTTPError: {e.code}") from e + except Exception as e: + raise Exception("Failed to call user-service") from e + + user_id = created.get("userId") + if user_id is None: + raise Exception("user-service response missing userId") + + # 2) Cognito custom attribute 업데이트 + cognito.admin_update_user_attributes( + UserPoolId=user_pool_id, + Username=username, + UserAttributes=[ + {"Name": "custom:user_id", "Value": str(user_id)}, + {"Name": "custom:role", "Value": str(role)}, + ], + ) + + return event diff --git a/infra/terraform/cognito/lambda/pre_token.py b/infra/terraform/cognito/lambda/pre_token.py new file mode 100644 index 00000000..3df7efe5 --- /dev/null +++ b/infra/terraform/cognito/lambda/pre_token.py @@ -0,0 +1,36 @@ +def lambda_handler(event, context): + # request/userAttributes 안전 처리 + req = event.get("request") or {} + attrs = req.get("userAttributes") or {} + + user_id = attrs.get("custom:user_id") + role = attrs.get("custom:role") or "CUSTOMER" + + # response가 None일 수 있으니 강제로 dict로 만든다 + if not isinstance(event.get("response"), dict): + event["response"] = {} + + resp = event["response"] + + # claimsAndScopeOverrideDetails도 None일 수 있으니 강제로 dict + if not isinstance(resp.get("claimsAndScopeOverrideDetails"), dict): + resp["claimsAndScopeOverrideDetails"] = {} + + cas = resp["claimsAndScopeOverrideDetails"] + + if not isinstance(cas.get("accessTokenGeneration"), dict): + cas["accessTokenGeneration"] = {} + + atg = cas["accessTokenGeneration"] + + if not isinstance(atg.get("claimsToAddOrOverride"), dict): + atg["claimsToAddOrOverride"] = {} + + claims = atg["claimsToAddOrOverride"] + + # Access Token에 커스텀 클레임 주입 + if user_id is not None: + claims["user_id"] = str(user_id) + claims["role"] = str(role) + + return event diff --git a/infra/terraform/cognito/output.tf b/infra/terraform/cognito/output.tf new file mode 100644 index 00000000..02e7a99d --- /dev/null +++ b/infra/terraform/cognito/output.tf @@ -0,0 +1,11 @@ +output "cognito_user_pool_id" { + value = aws_cognito_user_pool.pool.id +} + +output "cognito_app_client_id" { + value = aws_cognito_user_pool_client.app_client.id +} + +output "cognito_issuer_url" { + value = "https://cognito-idp.${var.aws_region}.amazonaws.com/${aws_cognito_user_pool.pool.id}" +} diff --git a/infra/terraform/cognito/variable.tf b/infra/terraform/cognito/variable.tf new file mode 100644 index 00000000..00dfb4dd --- /dev/null +++ b/infra/terraform/cognito/variable.tf @@ -0,0 +1,10 @@ +variable "aws_region" { + type = string + description = "ap-northeast-2" + default = "ap-northeast-2" +} + +variable "user_service_url" { + type = string + default = "http://user-service.internal:8080" +} From ad252b89e232c2f5b7d2d7b5a0eb4244210dcd6d Mon Sep 17 00:00:00 2001 From: eqqmayo Date: Fri, 23 Jan 2026 18:11:48 +0900 Subject: [PATCH 64/77] save --- docker-compose.yaml | 256 +++++++++--------- infra/terraform/environments/dev/main.tf | 29 +- infra/terraform/environments/dev/provider.tf | 13 + infra/terraform/environments/dev/variables.tf | 83 +++++- infra/terraform/modules/alb/main.tf | 182 ++++++------- infra/terraform/modules/ecs/main.tf | 208 ++++++++++++-- infra/terraform/modules/ecs/variables.tf | 80 ++++++ scripts/build-and-push.sh | 6 +- spot-gateway/Dockerfile | 2 +- spot-order/Dockerfile | 4 +- spot-payment/Dockerfile | 4 +- .../config/FeignHeaderRelayInterceptor.java | 3 +- spot-store/Dockerfile | 4 +- .../config/FeignHeaderRelayInterceptor.java | 3 +- spot-user/Dockerfile | 4 +- .../config/FeignHeaderRelayInterceptor.java | 3 +- 16 files changed, 609 insertions(+), 275 deletions(-) diff --git a/docker-compose.yaml b/docker-compose.yaml index 711551a5..929972c6 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,137 +1,137 @@ -version: '3.8' +# version: '3.8' -services: - db: - image: postgres:15-alpine - container_name: local-postgres_db - environment: - - POSTGRES_DB=myapp_db - - POSTGRES_USER=admin - - POSTGRES_PASSWORD=secret - ports: - - "5432:5432" - volumes: - - ./postgres_data:/var/lib/postgresql/data - networks: - - spot-network +# services: +# db: +# image: postgres:15-alpine +# container_name: local-postgres_db +# environment: +# - POSTGRES_DB=myapp_db +# - POSTGRES_USER=admin +# - POSTGRES_PASSWORD=secret +# ports: +# - "5432:5432" +# volumes: +# - ./postgres_data:/var/lib/postgresql/data +# networks: +# - spot-network - redis: - image: redis:alpine - container_name: redis_cache - ports: - - "6379:6379" - networks: - - spot-network +# redis: +# image: redis:alpine +# container_name: redis_cache +# ports: +# - "6379:6379" +# networks: +# - spot-network - spot-gateway: - build: - context: ./spot-gateway - dockerfile: Dockerfile - container_name: spot-gateway - ports: - - "8080:8080" - environment: - - SPRING_CONFIG_ADDITIONAL_LOCATION=file:/config/application.yml - volumes: - - ./config/common.yml:/config/common.yml:ro - - ./config/spot-gateway.yml:/config/application.yml:ro - depends_on: - - redis - - spot-user - - spot-store - - spot-order - - spot-payment - networks: - - spot-network +# spot-gateway: +# build: +# context: ./spot-gateway +# dockerfile: Dockerfile +# container_name: spot-gateway +# ports: +# - "8080:8080" +# environment: +# - SPRING_CONFIG_ADDITIONAL_LOCATION=file:/config/application.yml +# volumes: +# - ./config/common.yml:/config/common.yml:ro +# - ./config/spot-gateway.yml:/config/application.yml:ro +# depends_on: +# - redis +# - spot-user +# - spot-store +# - spot-order +# - spot-payment +# networks: +# - spot-network - spot-mono: - build: - context: ./spot-mono - dockerfile: Dockerfile - container_name: spot-mono - networks: - - spot-network +# spot-mono: +# build: +# context: ./spot-mono +# dockerfile: Dockerfile +# container_name: spot-mono +# networks: +# - spot-network - spot-order: - build: - context: ./spot-order - dockerfile: Dockerfile - container_name: spot-order - ports: - - "8082:8082" - environment: - - SPRING_CONFIG_ADDITIONAL_LOCATION=file:/config/application.yml - - FEIGN_STORE_URL=http://spot-store:8083 - - FEIGN_PAYMENT_URL=http://spot-payment:8084 - volumes: - - ./config/common.yml:/config/common.yml:ro - - ./config/spot-order.yml:/config/application.yml:ro - depends_on: - - db - - redis - networks: - - spot-network +# spot-order: +# build: +# context: ./spot-order +# dockerfile: Dockerfile +# container_name: spot-order +# ports: +# - "8082:8082" +# environment: +# - SPRING_CONFIG_ADDITIONAL_LOCATION=file:/config/application.yml +# - FEIGN_STORE_URL=http://spot-store:8083 +# - FEIGN_PAYMENT_URL=http://spot-payment:8084 +# volumes: +# - ./config/common.yml:/config/common.yml:ro +# - ./config/spot-order.yml:/config/application.yml:ro +# depends_on: +# - db +# - redis +# networks: +# - spot-network - spot-payment: - build: - context: ./spot-payment - dockerfile: Dockerfile - container_name: spot-payment - ports: - - "8084:8084" - environment: - - SPRING_CONFIG_ADDITIONAL_LOCATION=file:/config/application.yml - - FEIGN_ORDER_URL=http://spot-order:8082 - - FEIGN_USER_URL=http://spot-user:8081 - - FEIGN_STORE_URL=http://spot-store:8083 - volumes: - - ./config/common.yml:/config/common.yml:ro - - ./config/spot-payment.yml:/config/application.yml:ro - depends_on: - - db - - redis - networks: - - spot-network +# spot-payment: +# build: +# context: ./spot-payment +# dockerfile: Dockerfile +# container_name: spot-payment +# ports: +# - "8084:8084" +# environment: +# - SPRING_CONFIG_ADDITIONAL_LOCATION=file:/config/application.yml +# - FEIGN_ORDER_URL=http://spot-order:8082 +# - FEIGN_USER_URL=http://spot-user:8081 +# - FEIGN_STORE_URL=http://spot-store:8083 +# volumes: +# - ./config/common.yml:/config/common.yml:ro +# - ./config/spot-payment.yml:/config/application.yml:ro +# depends_on: +# - db +# - redis +# networks: +# - spot-network - spot-store: - build: - context: ./spot-store - dockerfile: Dockerfile - container_name: spot-store - ports: - - "8083:8083" - environment: - - SPRING_CONFIG_ADDITIONAL_LOCATION=file:/config/application.yml - - FEIGN_USER_URL=http://spot-user:8081 - volumes: - - ./config/common.yml:/config/common.yml:ro - - ./config/spot-store.yml:/config/application.yml:ro - depends_on: - - db - - redis - networks: - - spot-network +# spot-store: +# build: +# context: ./spot-store +# dockerfile: Dockerfile +# container_name: spot-store +# ports: +# - "8083:8083" +# environment: +# - SPRING_CONFIG_ADDITIONAL_LOCATION=file:/config/application.yml +# - FEIGN_USER_URL=http://spot-user:8081 +# volumes: +# - ./config/common.yml:/config/common.yml:ro +# - ./config/spot-store.yml:/config/application.yml:ro +# depends_on: +# - db +# - redis +# networks: +# - spot-network - spot-user: - build: - context: ./spot-user - dockerfile: Dockerfile - container_name: spot-user - ports: - - "8081:8081" - environment: - - SPRING_CONFIG_ADDITIONAL_LOCATION=file:/config/application.yml - - FEIGN_STORE_URL=http://spot-store:8083 - - FEIGN_ORDER_URL=http://spot-order:8082 - volumes: - - ./config/common.yml:/config/common.yml:ro - - ./config/spot-user.yml:/config/application.yml:ro - depends_on: - - db - - redis - networks: - - spot-network +# spot-user: +# build: +# context: ./spot-user +# dockerfile: Dockerfile +# container_name: spot-user +# ports: +# - "8081:8081" +# environment: +# - SPRING_CONFIG_ADDITIONAL_LOCATION=file:/config/application.yml +# - FEIGN_STORE_URL=http://spot-store:8083 +# - FEIGN_ORDER_URL=http://spot-order:8082 +# volumes: +# - ./config/common.yml:/config/common.yml:ro +# - ./config/spot-user.yml:/config/application.yml:ro +# depends_on: +# - db +# - redis +# networks: +# - spot-network -networks: - spot-network: - driver: bridge +# networks: +# spot-network: +# driver: bridge diff --git a/infra/terraform/environments/dev/main.tf b/infra/terraform/environments/dev/main.tf index f83264a1..ce945b67 100644 --- a/infra/terraform/environments/dev/main.tf +++ b/infra/terraform/environments/dev/main.tf @@ -50,7 +50,7 @@ module "ecr" { } # ============================================================================= -# ALB (Path-based Routing) +# ALB (Gateway Pass-through) # ============================================================================= module "alb" { source = "../../modules/alb" @@ -61,12 +61,13 @@ module "alb" { vpc_cidr = module.network.vpc_cidr subnet_ids = module.network.private_subnet_ids + # Gateway만 ALB에 연결 - 모든 트래픽이 Spring Gateway로 전달됨 services = { - for k, v in var.services : k => { - container_port = v.container_port - health_check_path = v.health_check_path - path_patterns = v.path_patterns - priority = v.priority + "gateway" = { + container_port = var.services["gateway"].container_port + health_check_path = var.services["gateway"].health_check_path + path_patterns = ["/*"] + priority = 1 } } } @@ -101,6 +102,22 @@ module "ecs" { # Redis 연결 정보 redis_endpoint = module.elasticache.redis_endpoint + + # JWT 설정 + jwt_secret = var.jwt_secret + jwt_expire_ms = var.jwt_expire_ms + refresh_token_expire_days = var.refresh_token_expire_days + + # Mail 설정 + mail_username = var.mail_username + mail_password = var.mail_password + + # Toss 결제 설정 + toss_secret_key = var.toss_secret_key + toss_customer_key = var.toss_customer_key + + # 서비스 설정 + service_active_regions = var.service_active_regions } # ============================================================================= diff --git a/infra/terraform/environments/dev/provider.tf b/infra/terraform/environments/dev/provider.tf index 31091ab6..5e14f7d0 100644 --- a/infra/terraform/environments/dev/provider.tf +++ b/infra/terraform/environments/dev/provider.tf @@ -25,3 +25,16 @@ provider "aws" { tags = local.common_tags } } + +provider "postgresql" { + host = var.db_endpoint + username = var.db_username + password = var.db_password + database = var.db_name + sslmode = "require" +} + +resource "postgresql_schema" "users" { + name = "users" # var.services["user"].environment_vars["DB_SCHEMA"] 값과 일치해야 함 + owner = var.db_username +} \ No newline at end of file diff --git a/infra/terraform/environments/dev/variables.tf b/infra/terraform/environments/dev/variables.tf index 3031761c..26c9692d 100644 --- a/infra/terraform/environments/dev/variables.tf +++ b/infra/terraform/environments/dev/variables.tf @@ -115,12 +115,25 @@ variable "services" { environment_vars = map(string) })) default = { - "order" = { + "gateway" = { container_port = 8080 cpu = "256" memory = "512" desired_count = 1 health_check_path = "/actuator/health" + # 모든 트래픽을 Gateway로 몰아주기 위해 /* 패턴 사용 + path_patterns = ["/*"] + priority = 1 # 가장 높은 우선순위 + environment_vars = { + SERVICE_NAME = "spot-gateway" + } + } + "order" = { + container_port = 8082 + cpu = "256" + memory = "512" + desired_count = 1 + health_check_path = "/actuator/health" path_patterns = ["/api/orders/*", "/api/orders"] priority = 100 environment_vars = { @@ -129,7 +142,7 @@ variable "services" { } } "payment" = { - container_port = 8080 + container_port = 8084 cpu = "256" memory = "512" desired_count = 1 @@ -142,7 +155,7 @@ variable "services" { } } "store" = { - container_port = 8080 + container_port = 8083 cpu = "256" memory = "512" desired_count = 1 @@ -155,7 +168,7 @@ variable "services" { } } "user" = { - container_port = 8080 + container_port = 8081 cpu = "256" memory = "512" desired_count = 1 @@ -244,3 +257,65 @@ variable "alert_email" { type = string default = "" } + +# ============================================================================= +# JWT 설정 +# ============================================================================= +variable "jwt_secret" { + description = "JWT 시크릿 키" + type = string + sensitive = true +} + +variable "jwt_expire_ms" { + description = "JWT 만료 시간 (밀리초)" + type = number + default = 3600000 +} + +variable "refresh_token_expire_days" { + description = "리프레시 토큰 만료 일수" + type = number + default = 14 +} + +# ============================================================================= +# Mail 설정 +# ============================================================================= +variable "mail_username" { + description = "SMTP 사용자 이름 (Gmail)" + type = string + default = "" +} + +variable "mail_password" { + description = "SMTP 비밀번호 (Gmail 앱 비밀번호)" + type = string + sensitive = true + default = "" +} + +# ============================================================================= +# Toss Payments 설정 +# ============================================================================= +variable "toss_secret_key" { + description = "Toss Payments 시크릿 키" + type = string + sensitive = true + default = "" +} + +variable "toss_customer_key" { + description = "Toss Payments 고객 키" + type = string + default = "customer_1" +} + +# ============================================================================= +# Service 설정 +# ============================================================================= +variable "service_active_regions" { + description = "서비스 활성 지역" + type = string + default = "종로구" +} diff --git a/infra/terraform/modules/alb/main.tf b/infra/terraform/modules/alb/main.tf index 7494352b..95571321 100644 --- a/infra/terraform/modules/alb/main.tf +++ b/infra/terraform/modules/alb/main.tf @@ -1,105 +1,105 @@ -# ============================================================================= -# ALB Security Group -# ============================================================================= -resource "aws_security_group" "alb_sg" { - name = "${var.name_prefix}-alb-sg" - vpc_id = var.vpc_id - - ingress { - from_port = 80 - to_port = 80 - protocol = "tcp" - cidr_blocks = [var.vpc_cidr] + # ============================================================================= + # ALB Security Group + # ============================================================================= + resource "aws_security_group" "alb_sg" { + name = "${var.name_prefix}-alb-sg" + vpc_id = var.vpc_id + + ingress { + from_port = 80 + to_port = 80 + protocol = "tcp" + cidr_blocks = [var.vpc_cidr] + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = merge(var.common_tags, { Name = "${var.name_prefix}-alb-sg" }) } - egress { - from_port = 0 - to_port = 0 - protocol = "-1" - cidr_blocks = ["0.0.0.0/0"] + # ============================================================================= + # Application Load Balancer (Internal) + # ============================================================================= + resource "aws_lb" "main" { + name = "${var.name_prefix}-alb" + internal = true + load_balancer_type = "application" + security_groups = [aws_security_group.alb_sg.id] + subnets = var.subnet_ids + + tags = merge(var.common_tags, { Name = "${var.name_prefix}-alb" }) } - tags = merge(var.common_tags, { Name = "${var.name_prefix}-alb-sg" }) -} - -# ============================================================================= -# Application Load Balancer (Internal) -# ============================================================================= -resource "aws_lb" "main" { - name = "${var.name_prefix}-alb" - internal = true - load_balancer_type = "application" - security_groups = [aws_security_group.alb_sg.id] - subnets = var.subnet_ids - - tags = merge(var.common_tags, { Name = "${var.name_prefix}-alb" }) -} - -# ============================================================================= -# Target Groups (Multiple Services) -# ============================================================================= -resource "aws_lb_target_group" "services" { - for_each = var.services - - name = "${var.name_prefix}-${each.key}-tg" - port = each.value.container_port - protocol = "HTTP" - vpc_id = var.vpc_id - target_type = "ip" - - health_check { - enabled = true - healthy_threshold = 2 - unhealthy_threshold = 3 - timeout = 10 - interval = 30 - path = each.value.health_check_path - matcher = "200" + # ============================================================================= + # Target Groups (Multiple Services) + # ============================================================================= + resource "aws_lb_target_group" "services" { + for_each = var.services + + name = "${var.name_prefix}-${each.key}-tg" + port = each.value.container_port + protocol = "HTTP" + vpc_id = var.vpc_id + target_type = "ip" + + health_check { + enabled = true + healthy_threshold = 2 + unhealthy_threshold = 3 + timeout = 10 + interval = 30 + path = each.value.health_check_path + matcher = "200" + } + + tags = merge(var.common_tags, { + Name = "${var.name_prefix}-${each.key}-tg" + Service = each.key + }) } - tags = merge(var.common_tags, { - Name = "${var.name_prefix}-${each.key}-tg" - Service = each.key - }) -} - -# ============================================================================= -# ALB Listener (Default action returns 404) -# ============================================================================= -resource "aws_lb_listener" "main" { - load_balancer_arn = aws_lb.main.arn - port = 80 - protocol = "HTTP" - - default_action { - type = "fixed-response" - fixed_response { - content_type = "application/json" - message_body = jsonencode({ error = "Not Found", message = "No matching route" }) - status_code = "404" + # ============================================================================= + # ALB Listener (Default action returns 404) + # ============================================================================= + resource "aws_lb_listener" "main" { + load_balancer_arn = aws_lb.main.arn + port = 80 + protocol = "HTTP" + + default_action { + type = "fixed-response" + fixed_response { + content_type = "application/json" + message_body = jsonencode({ error = "Not Found", message = "No matching route" }) + status_code = "404" + } } } -} -# ============================================================================= -# ALB Listener Rules (Path-based Routing) -# ============================================================================= -resource "aws_lb_listener_rule" "services" { - for_each = var.services + # ============================================================================= + # ALB Listener Rules (Path-based Routing) + # ============================================================================= + resource "aws_lb_listener_rule" "services" { + for_each = var.services - listener_arn = aws_lb_listener.main.arn - priority = each.value.priority + listener_arn = aws_lb_listener.main.arn + priority = each.value.priority - action { - type = "forward" - target_group_arn = aws_lb_target_group.services[each.key].arn - } + action { + type = "forward" + target_group_arn = aws_lb_target_group.services[each.key].arn + } - condition { - path_pattern { - values = each.value.path_patterns + condition { + path_pattern { + values = each.value.path_patterns + } } - } - tags = merge(var.common_tags, { Service = each.key }) -} + tags = merge(var.common_tags, { Service = each.key }) + } diff --git a/infra/terraform/modules/ecs/main.tf b/infra/terraform/modules/ecs/main.tf index ba0079e7..9919c18e 100644 --- a/infra/terraform/modules/ecs/main.tf +++ b/infra/terraform/modules/ecs/main.tf @@ -9,10 +9,10 @@ resource "aws_service_discovery_private_dns_namespace" "main" { } # ============================================================================= -# Cloud Map Services (Multiple) +# Cloud Map Services (Multiple) - Only when Service Connect is disabled # ============================================================================= resource "aws_service_discovery_service" "services" { - for_each = var.services + for_each = var.enable_service_connect ? {} : var.services name = each.key @@ -75,7 +75,7 @@ resource "aws_security_group" "msa_sg" { description = "Security group for MSA services" vpc_id = var.vpc_id - # ALB에서 들어오는 트래픽 허용 + # ALB에서 들어오는 트래픽 허용 (Gateway: 8080) ingress { description = "Traffic from ALB" from_port = 8080 @@ -84,11 +84,11 @@ resource "aws_security_group" "msa_sg" { security_groups = [var.alb_security_group_id] } - # Service Connect를 위한 자기 참조 규칙 (서비스 간 통신) + # Service Connect를 위한 자기 참조 규칙 (서비스 간 통신: 8080-8084) ingress { description = "Inter-service communication" from_port = 8080 - to_port = 8080 + to_port = 8084 protocol = "tcp" self = true } @@ -220,6 +220,17 @@ resource "aws_ecs_task_definition" "services" { name = "SPRING_PROFILES_ACTIVE" value = var.environment }, + { + name = "SPRING_DATA_REDIS_HOST" + value = var.redis_endpoint + }, + { + name = "SPRING_DATA_REDIS_PORT" + value = "6379" + } + ], + # 백엔드 서비스 전용 (gateway 제외) - DB, JPA, JWT 설정 + each.key != "gateway" ? [ { name = "SPRING_DATASOURCE_URL" value = "jdbc:postgresql://${var.db_endpoint}/${var.db_name}?currentSchema=${lookup(each.value.environment_vars, "DB_SCHEMA", each.key)}" @@ -233,31 +244,166 @@ resource "aws_ecs_task_definition" "services" { value = var.db_password }, { - name = "SPRING_DATA_REDIS_HOST" - value = var.redis_endpoint + name = "SPRING_JPA_HIBERNATE_DDL_AUTO" + value = "update" }, { - name = "SPRING_DATA_REDIS_PORT" - value = "6379" + name = "SPRING_JPA_SHOW_SQL" + value = "false" + }, + { + name = "SPRING_JPA_PROPERTIES_HIBERNATE_DIALECT" + value = "org.hibernate.dialect.PostgreSQLDialect" }, - # Service Discovery 환경 변수 (다른 서비스 URL) { - name = "ORDER_SERVICE_URL" - value = "http://order.${var.project}.local:8080" + name = "SPRING_JWT_SECRET" + value = var.jwt_secret }, { - name = "PAYMENT_SERVICE_URL" - value = "http://payment.${var.project}.local:8080" + name = "SPRING_JWT_EXPIRE_MS" + value = tostring(var.jwt_expire_ms) }, { - name = "STORE_SERVICE_URL" - value = "http://store.${var.project}.local:8080" + name = "SPRING_SECURITY_REFRESH_TOKEN_EXPIRE_DAYS" + value = tostring(var.refresh_token_expire_days) }, { - name = "USER_SERVICE_URL" - value = "http://user.${var.project}.local:8080" + name = "SERVICE_ACTIVE_REGIONS" + value = var.service_active_regions } - ], + ] : [], + # Service Discovery 환경 변수 (Feign Client URLs) + each.key != "gateway" ? [ + { + name = "FEIGN_ORDER_URL" + value = "http://order.${var.project}.local:${var.services["order"].container_port}" + }, + { + name = "FEIGN_PAYMENT_URL" + value = "http://payment.${var.project}.local:${var.services["payment"].container_port}" + }, + { + name = "FEIGN_STORE_URL" + value = "http://store.${var.project}.local:${var.services["store"].container_port}" + }, + { + name = "FEIGN_USER_URL" + value = "http://user.${var.project}.local:${var.services["user"].container_port}" + } + ] : [], + # Mail 설정 (user 서비스용) + each.key == "user" ? [ + { + name = "SPRING_MAIL_HOST" + value = var.mail_host + }, + { + name = "SPRING_MAIL_PORT" + value = tostring(var.mail_port) + }, + { + name = "SPRING_MAIL_USERNAME" + value = var.mail_username + }, + { + name = "SPRING_MAIL_PASSWORD" + value = var.mail_password + }, + { + name = "SPRING_MAIL_PROPERTIES_MAIL_SMTP_AUTH" + value = "true" + }, + { + name = "SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE" + value = "true" + } + ] : [], + # Toss 결제 설정 (payment 서비스용) + each.key == "payment" ? [ + { + name = "TOSS_PAYMENTS_BASE_URL" + value = var.toss_base_url + }, + { + name = "TOSS_PAYMENTS_SECRET_KEY" + value = var.toss_secret_key + }, + { + name = "TOSS_PAYMENTS_CUSTOMER_KEY" + value = var.toss_customer_key + } + ] : [], + # Gateway 전용 설정 - Spring Cloud Gateway 라우트 + each.key == "gateway" ? [ + { + name = "SPRING_CLOUD_GATEWAY_ROUTES_0_ID" + value = "user-auth" + }, + { + name = "SPRING_CLOUD_GATEWAY_ROUTES_0_URI" + value = "http://user.${var.project}.local:${var.services["user"].container_port}" + }, + { + name = "SPRING_CLOUD_GATEWAY_ROUTES_0_PREDICATES_0" + value = "Path=/api/login,/api/join,/api/auth/refresh" + }, + { + name = "SPRING_CLOUD_GATEWAY_ROUTES_1_ID" + value = "user-service" + }, + { + name = "SPRING_CLOUD_GATEWAY_ROUTES_1_URI" + value = "http://user.${var.project}.local:${var.services["user"].container_port}" + }, + { + name = "SPRING_CLOUD_GATEWAY_ROUTES_1_PREDICATES_0" + value = "Path=/api/users/**" + }, + { + name = "SPRING_CLOUD_GATEWAY_ROUTES_2_ID" + value = "store-service" + }, + { + name = "SPRING_CLOUD_GATEWAY_ROUTES_2_URI" + value = "http://store.${var.project}.local:${var.services["store"].container_port}" + }, + { + name = "SPRING_CLOUD_GATEWAY_ROUTES_2_PREDICATES_0" + value = "Path=/api/stores/**,/api/categories/**,/api/reviews/**" + }, + { + name = "SPRING_CLOUD_GATEWAY_ROUTES_3_ID" + value = "order-service" + }, + { + name = "SPRING_CLOUD_GATEWAY_ROUTES_3_URI" + value = "http://order.${var.project}.local:${var.services["order"].container_port}" + }, + { + name = "SPRING_CLOUD_GATEWAY_ROUTES_3_PREDICATES_0" + value = "Path=/api/orders/**" + }, + { + name = "SPRING_CLOUD_GATEWAY_ROUTES_4_ID" + value = "payment-service" + }, + { + name = "SPRING_CLOUD_GATEWAY_ROUTES_4_URI" + value = "http://payment.${var.project}.local:${var.services["payment"].container_port}" + }, + { + name = "SPRING_CLOUD_GATEWAY_ROUTES_4_PREDICATES_0" + value = "Path=/api/payments/**" + }, + { + name = "MANAGEMENT_ENDPOINTS_WEB_EXPOSURE_INCLUDE" + value = "*" + }, + { + name = "MANAGEMENT_ENDPOINT_GATEWAY_ENABLED" + value = "true" + } + ] : [], # 서비스별 커스텀 환경 변수 [for k, v in each.value.environment_vars : { name = k @@ -299,10 +445,13 @@ resource "aws_ecs_service" "services" { desired_count = each.value.desired_count launch_type = "FARGATE" - load_balancer { - target_group_arn = var.target_group_arns[each.key] - container_name = "${var.project}-${each.key}-container" - container_port = each.value.container_port + dynamic "load_balancer" { + for_each = each.key == "gateway" ? [1] : [] + content { + target_group_arn = var.target_group_arns[each.key] + container_name = "${var.project}-${each.key}-container" + container_port = each.value.container_port + } } network_configuration { @@ -339,11 +488,14 @@ resource "aws_ecs_service" "services" { } } - # Service Discovery Registration - service_registries { - registry_arn = aws_service_discovery_service.services[each.key].arn - container_name = "${var.project}-${each.key}-container" - container_port = each.value.container_port + # Service Discovery Registration (only when Service Connect is disabled) + dynamic "service_registries" { + for_each = var.enable_service_connect ? [] : [1] + content { + registry_arn = aws_service_discovery_service.services[each.key].arn + container_name = "${var.project}-${each.key}-container" + container_port = each.value.container_port + } } depends_on = [var.alb_listener_arn] diff --git a/infra/terraform/modules/ecs/variables.tf b/infra/terraform/modules/ecs/variables.tf index 551f59aa..744c36e9 100644 --- a/infra/terraform/modules/ecs/variables.tf +++ b/infra/terraform/modules/ecs/variables.tf @@ -135,3 +135,83 @@ variable "redis_endpoint" { type = string default = "" } + +# ============================================================================= +# JWT Settings +# ============================================================================= +variable "jwt_secret" { + description = "JWT 시크릿 키" + type = string + sensitive = true +} + +variable "jwt_expire_ms" { + description = "JWT 만료 시간 (밀리초)" + type = number + default = 3600000 +} + +variable "refresh_token_expire_days" { + description = "리프레시 토큰 만료 일수" + type = number + default = 14 +} + +# ============================================================================= +# Mail Settings +# ============================================================================= +variable "mail_host" { + description = "SMTP 호스트" + type = string + default = "smtp.gmail.com" +} + +variable "mail_port" { + description = "SMTP 포트" + type = number + default = 587 +} + +variable "mail_username" { + description = "SMTP 사용자 이름" + type = string + default = "" +} + +variable "mail_password" { + description = "SMTP 비밀번호" + type = string + sensitive = true + default = "" +} + +# ============================================================================= +# Toss Payments Settings +# ============================================================================= +variable "toss_base_url" { + description = "Toss Payments API URL" + type = string + default = "https://api.tosspayments.com" +} + +variable "toss_secret_key" { + description = "Toss Payments 시크릿 키" + type = string + sensitive = true + default = "" +} + +variable "toss_customer_key" { + description = "Toss Payments 고객 키" + type = string + default = "customer_1" +} + +# ============================================================================= +# Service Settings +# ============================================================================= +variable "service_active_regions" { + description = "서비스 활성 지역" + type = string + default = "종로구" +} diff --git a/scripts/build-and-push.sh b/scripts/build-and-push.sh index 0e72e727..fc5f3386 100755 --- a/scripts/build-and-push.sh +++ b/scripts/build-and-push.sh @@ -10,7 +10,7 @@ ECR_REGISTRY="${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com" PROJECT="spot" # 서비스 목록 -SERVICES=("order" "payment" "store" "user") +SERVICES=("gateway" "order" "payment" "store" "user") # ============================================================================= # ECR 로그인 @@ -47,9 +47,9 @@ for SERVICE in "${SERVICES[@]}"; do cd "${SERVICE_DIR}" ./gradlew clean build -x test - # 2. Docker 이미지 빌드 + # 2. Docker 이미지 빌드 (AMD64 for Fargate) echo "🐳 Docker 이미지 빌드 중..." - docker build -t ${ECR_REPO}:latest . + docker build --no-cache --platform linux/amd64 -t ${ECR_REPO}:latest . # 3. 태그 지정 docker tag ${ECR_REPO}:latest ${IMAGE_TAG} diff --git a/spot-gateway/Dockerfile b/spot-gateway/Dockerfile index 007dffa2..86604d49 100644 --- a/spot-gateway/Dockerfile +++ b/spot-gateway/Dockerfile @@ -4,4 +4,4 @@ WORKDIR /app COPY build/libs/spot-gateway.jar app.jar EXPOSE 8080 -ENTRYPOINT ["java", "-jar", "app.jar", "--spring.config.location=file:/config/application.yml"] +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/spot-order/Dockerfile b/spot-order/Dockerfile index f8b3c4df..9cce9aa6 100644 --- a/spot-order/Dockerfile +++ b/spot-order/Dockerfile @@ -3,5 +3,5 @@ WORKDIR /app COPY build/libs/spot-order-0.0.1-SNAPSHOT.jar app.jar -EXPOSE 8080 -ENTRYPOINT ["java", "-jar", "app.jar", "--spring.config.location=optional:classpath:/application.yml,optional:classpath:/application.properties,file:/config/application.yml"] +EXPOSE 8082 +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/spot-payment/Dockerfile b/spot-payment/Dockerfile index 44e85b60..a119dcc6 100644 --- a/spot-payment/Dockerfile +++ b/spot-payment/Dockerfile @@ -3,5 +3,5 @@ WORKDIR /app COPY build/libs/spot-payment-0.0.1-SNAPSHOT.jar app.jar -EXPOSE 8080 -ENTRYPOINT ["java", "-jar", "app.jar", "--spring.config.location=optional:classpath:/application.yml,optional:classpath:/application.properties,file:/config/application.yml"] +EXPOSE 8084 +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/spot-payment/src/main/java/com/example/Spot/global/feign/config/FeignHeaderRelayInterceptor.java b/spot-payment/src/main/java/com/example/Spot/global/feign/config/FeignHeaderRelayInterceptor.java index 5755a01f..38498aed 100644 --- a/spot-payment/src/main/java/com/example/Spot/global/feign/config/FeignHeaderRelayInterceptor.java +++ b/spot-payment/src/main/java/com/example/Spot/global/feign/config/FeignHeaderRelayInterceptor.java @@ -1,12 +1,11 @@ package com.example.Spot.global.feign.config; -import jakarta.servlet.http.HttpServletRequest; - import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; import feign.RequestInterceptor; import feign.RequestTemplate; +import jakarta.servlet.http.HttpServletRequest; public class FeignHeaderRelayInterceptor implements RequestInterceptor { diff --git a/spot-store/Dockerfile b/spot-store/Dockerfile index 5350c6c8..c16e6362 100644 --- a/spot-store/Dockerfile +++ b/spot-store/Dockerfile @@ -3,5 +3,5 @@ WORKDIR /app COPY build/libs/spot-store-0.0.1-SNAPSHOT.jar app.jar -EXPOSE 8080 -ENTRYPOINT ["java", "-jar", "app.jar", "--spring.config.location=optional:classpath:/application.yml,optional:classpath:/application.properties,file:/config/application.yml"] +EXPOSE 8083 +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/spot-store/src/main/java/com/example/Spot/global/feign/config/FeignHeaderRelayInterceptor.java b/spot-store/src/main/java/com/example/Spot/global/feign/config/FeignHeaderRelayInterceptor.java index 5755a01f..38498aed 100644 --- a/spot-store/src/main/java/com/example/Spot/global/feign/config/FeignHeaderRelayInterceptor.java +++ b/spot-store/src/main/java/com/example/Spot/global/feign/config/FeignHeaderRelayInterceptor.java @@ -1,12 +1,11 @@ package com.example.Spot.global.feign.config; -import jakarta.servlet.http.HttpServletRequest; - import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; import feign.RequestInterceptor; import feign.RequestTemplate; +import jakarta.servlet.http.HttpServletRequest; public class FeignHeaderRelayInterceptor implements RequestInterceptor { diff --git a/spot-user/Dockerfile b/spot-user/Dockerfile index d2e8e976..39f1703e 100644 --- a/spot-user/Dockerfile +++ b/spot-user/Dockerfile @@ -3,5 +3,5 @@ WORKDIR /app COPY build/libs/spot-user-0.0.1-SNAPSHOT.jar app.jar -EXPOSE 8080 -ENTRYPOINT ["java", "-jar", "app.jar", "--spring.config.location=optional:classpath:/application.yml,optional:classpath:/application.properties,file:/config/application.yml"] +EXPOSE 8081 +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/spot-user/src/main/java/com/example/Spot/global/feign/config/FeignHeaderRelayInterceptor.java b/spot-user/src/main/java/com/example/Spot/global/feign/config/FeignHeaderRelayInterceptor.java index 5755a01f..38498aed 100644 --- a/spot-user/src/main/java/com/example/Spot/global/feign/config/FeignHeaderRelayInterceptor.java +++ b/spot-user/src/main/java/com/example/Spot/global/feign/config/FeignHeaderRelayInterceptor.java @@ -1,12 +1,11 @@ package com.example.Spot.global.feign.config; -import jakarta.servlet.http.HttpServletRequest; - import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; import feign.RequestInterceptor; import feign.RequestTemplate; +import jakarta.servlet.http.HttpServletRequest; public class FeignHeaderRelayInterceptor implements RequestInterceptor { From 1a59f567d2fe1f454ade088921909b8ea8ceaa67 Mon Sep 17 00:00:00 2001 From: yunjeong <99129298+dbswjd7@users.noreply.github.com> Date: Fri, 23 Jan 2026 14:23:12 +0900 Subject: [PATCH 65/77] Feat(#224) spring gateway (#225) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit spring gateway를 구성하였음 - 라우팅 - User 8081 - Order 8082 - Store 8083 - Payment 8084 - 추가로 /api/login, /api/join, /api/auth/refresh도 user-service로 라우팅해 로그인 동작하도록 하였음 - jwt 인증은 gateway가 아닌 기존 로직처럼 filter에서 수행하는 코드 OpenFeign - FeignCommonConfig에서 feign관련 설정 파일 관리 - 공통error처리 로직 작성 - 내부 feign 통신에서도 Header intercept해서 jwt토큰 인가해 적용 --------- Co-authored-by: Yoonchul Chung <84674889+Yoonchulchung@users.noreply.github.com> Co-authored-by: first-lounge <137966925+first-lounge@users.noreply.github.com> Co-authored-by: Yeojun <52143231+Yun024@users.noreply.github.com> Co-authored-by: yoonchulchung Co-authored-by: yeojun Co-authored-by: eqqmayo Co-authored-by: eqqmayo <144116848+eqqmayo@users.noreply.github.com> Co-authored-by: first-lounge --- .github/workflows/dev-pr-check-gateway.yml | 106 ++++++++++++++++++ Docker/Dockerfile | 7 -- README.md | 77 ++++++++++++- docs/application/image/spot-application.png | Bin 0 -> 199476 bytes docs/infra/image/spot-infra.png | Bin 0 -> 101884 bytes .../example/Spot/SpotGatewayApplication.java | 13 +++ .../Spot/filter/GatewayFilterConfig.java | 35 ++++++ .../example/spot/SpotGatewayApplication.java | 4 +- .../spot/filter/GatewayFilterConfig.java | 2 +- .../Spot/SpotGatewayApplicationTests.java | 13 +++ .../spot/SpotGatewayApplicationTests.java | 2 +- spot-order/build.gradle | 3 + .../example/Spot/SpotOrderApplication.java | 2 +- .../com/example/Spot/global/common/Role.java | 9 ++ .../config/security/CustomUserDetails.java | 58 ++++++++++ .../config/security/JWTFilter.java | 104 +++++++++++++++++ .../config/security/JWTUtil.java | 95 ++++++++++++++++ .../controller/ChefOrderController.java | 2 +- .../controller/CustomerOrderController.java | 2 +- .../controller/OwnerOrderController.java | 2 +- .../swagger/CustomerOrderApi.java | 2 +- .../presentation/swagger/OwnerOrderApi.java | 2 +- .../src/main/resources/application.properties | 2 +- .../Docs/image/fe_be_payment_flow.png | Bin 0 -> 102289 bytes .../Docs/image/fe_be_payment_flow_detail.png | Bin 0 -> 221145 bytes spot-payment/README.md | 10 ++ spot-payment/build.gradle | 3 + .../example/Spot/SpotPaymentApplication.java | 2 +- .../com/example/Spot/global/common/Role.java | 9 ++ .../config/security/CustomUserDetails.java | 58 ++++++++++ .../config/security/JWTFilter.java | 105 +++++++++++++++++ .../config/security/JWTUtil.java | 94 ++++++++++++++++ .../application/service/PaymentService.java | 48 ++++++++ .../domain/entity/PaymentKeyEntity.java | 6 +- .../domain/entity/PaymentRetryEntity.java | 14 +-- .../controller/PaymentController.java | 12 +- .../dto/request/PaymentRequestDto.java | 11 ++ .../dto/response/PaymentResponseDto.java | 9 ++ .../presentation/swagger/PaymentApi.java | 2 +- .../config/security/CustomUserDetails.java | 58 ++++++++++ .../config/security/JWTFilter.java | 101 +++++++++++++++++ .../config/security/JWTUtil.java | 94 ++++++++++++++++ .../controller/MenuController.java | 14 +-- .../controller/MenuOptionController.java | 10 +- .../menu/presentation/swagger/MenuApi.java | 2 +- .../controller/ReviewController.java | 7 +- .../controller/CategoryController.java | 2 +- .../presentation/swagger/CategoryApi.java | 2 +- .../com/example/Spot/auth/jwt/JWTFilter.java | 2 +- .../exception/RemoteCallFailedException.java | 7 ++ .../exception/RemoteConflictException.java | 7 ++ .../exception/RemoteNotFoundException.java | 7 ++ .../RemoteServiceUnavailableException.java | 7 ++ .../controller/UserController.java | 5 +- 54 files changed, 1195 insertions(+), 55 deletions(-) create mode 100644 .github/workflows/dev-pr-check-gateway.yml delete mode 100644 Docker/Dockerfile create mode 100644 docs/application/image/spot-application.png create mode 100644 docs/infra/image/spot-infra.png create mode 100644 spot-gateway/src/main/java/com/example/Spot/SpotGatewayApplication.java create mode 100644 spot-gateway/src/main/java/com/example/Spot/filter/GatewayFilterConfig.java create mode 100644 spot-gateway/src/test/java/com/example/Spot/SpotGatewayApplicationTests.java create mode 100644 spot-order/src/main/java/com/example/Spot/global/common/Role.java create mode 100644 spot-order/src/main/java/com/example/Spot/global/infrastructure/config/security/CustomUserDetails.java create mode 100644 spot-order/src/main/java/com/example/Spot/global/infrastructure/config/security/JWTFilter.java create mode 100644 spot-order/src/main/java/com/example/Spot/global/infrastructure/config/security/JWTUtil.java create mode 100644 spot-payment/Docs/image/fe_be_payment_flow.png create mode 100644 spot-payment/Docs/image/fe_be_payment_flow_detail.png create mode 100644 spot-payment/README.md create mode 100644 spot-payment/src/main/java/com/example/Spot/global/common/Role.java create mode 100644 spot-payment/src/main/java/com/example/Spot/global/infrastructure/config/security/CustomUserDetails.java create mode 100644 spot-payment/src/main/java/com/example/Spot/global/infrastructure/config/security/JWTFilter.java create mode 100644 spot-payment/src/main/java/com/example/Spot/global/infrastructure/config/security/JWTUtil.java create mode 100644 spot-store/src/main/java/com/example/Spot/global/infrastructure/config/security/CustomUserDetails.java create mode 100644 spot-store/src/main/java/com/example/Spot/global/infrastructure/config/security/JWTFilter.java create mode 100644 spot-store/src/main/java/com/example/Spot/global/infrastructure/config/security/JWTUtil.java create mode 100644 spot-user/src/main/java/com/example/Spot/infra/feign/exception/RemoteCallFailedException.java create mode 100644 spot-user/src/main/java/com/example/Spot/infra/feign/exception/RemoteConflictException.java create mode 100644 spot-user/src/main/java/com/example/Spot/infra/feign/exception/RemoteNotFoundException.java create mode 100644 spot-user/src/main/java/com/example/Spot/infra/feign/exception/RemoteServiceUnavailableException.java diff --git a/.github/workflows/dev-pr-check-gateway.yml b/.github/workflows/dev-pr-check-gateway.yml new file mode 100644 index 00000000..adfac396 --- /dev/null +++ b/.github/workflows/dev-pr-check-gateway.yml @@ -0,0 +1,106 @@ +name: DEV PR Check - GateWay + +on: + pull_request: + branches: [ dev ] + paths: + - 'spot-gateway/**' + - '.github/workflows/pr-check-gateway.yml' + +jobs: + build-and-test: + runs-on: ubuntu-latest + defaults: + run: + working-directory: spot-gateway + + services: + postgres: + image: postgres:16 + env: + POSTGRES_DB: myapp_db + POSTGRES_USER: admin + POSTGRES_PASSWORD: secret + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + cache: gradle + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v3 + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Make application.properties + run: | + mkdir -p ./src/main/resources + echo "${{ secrets.APPLICATION_YML }}" > ./src/main/resources/application.yml + shell: bash + + - name: Check code formatting + run: ./gradlew checkstyleMain checkstyleTest + continue-on-error: true + + - name: Build with Gradle + run: ./gradlew build -x test + + - name: Run tests + run: ./gradlew test + continue-on-error: true + + - name: Test application startup + run: | + ./gradlew bootRun > bootrun.log 2>&1 & + APP_PID=$! + echo "Started bootRun with PID: $APP_PID" + + # 최대 60초 대기하면서 애플리케이션 시작 확인 + for i in {1..60}; do + # 로그에서 시작 완료 메시지 확인 + if grep -q "Started SpotGatewayApplication" bootrun.log 2>/dev/null; then + echo "✅ Application started successfully after ${i}s" + kill $APP_PID 2>/dev/null || true + exit 0 + fi + + # 프로세스가 죽었는지 확인 + if ! kill -0 $APP_PID 2>/dev/null; then + echo "❌ Application process terminated unexpectedly" + echo "=== bootRun log ===" + cat bootrun.log + exit 1 + fi + + sleep 1 + done + + echo "❌ Application failed to start within 60 seconds" + echo "=== bootRun log ===" + cat bootrun.log + kill $APP_PID 2>/dev/null || true + exit 1 + timeout-minutes: 2 + + - name: Upload build artifacts + if: failure() + uses: actions/upload-artifact@v4 + with: + name: build-reports + path: | + build/reports/ + build/test-results/ diff --git a/Docker/Dockerfile b/Docker/Dockerfile deleted file mode 100644 index b2913306..00000000 --- a/Docker/Dockerfile +++ /dev/null @@ -1,7 +0,0 @@ -FROM eclipse-temurin:21-jre -WORKDIR /app - -COPY build/libs/Spot-0.0.1-SNAPSHOT.jar app.jar - -EXPOSE 8080 -ENTRYPOINT ["java","-jar","app.jar"] diff --git a/README.md b/README.md index 2b6c3265..7abdb7db 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,8 @@ -# Spot +

+

Spot

+
- -### version +# version - Spring Boot 3.5.9 - JDK 21 @@ -15,4 +16,72 @@ git config core.hooksPath .githooks ```bash ./gradlew clean build -x test docker-compose up --build -d -``` \ No newline at end of file +``` + +## Application Flow + +![](./docs/application/image/spot-application.png) + +### 서비스 구성 +| 서비스 | 포트 | 역할 | +|--------|------|------| +| API Gateway | 8080 | 라우팅, 인증/인가 | +| User Service | 8081 | 회원 가입, 로그인, 정보 수정 | +| Order Service | 8082 | 주문 생성, 주문 조회 | +| Store Service | 8083 | 매장 및 메뉴 조회 | +| Payment Service | 8084 | 결제 처리, PG 연동 | + +### 주요 흐름 +1. **회원 관리**: API Gateway → User Service → User DB +2. **매장/메뉴 조회**: API Gateway → Store Service → Store/Menu DB (ElastiCache 캐싱) +3. **주문 생성**: API Gateway → Order Service → 사용자/가게/메뉴 유효성 검증 → Order DB +4. **결제 처리**: Order Service → Payment Service → TossPG 승인/취소 API 호출 +5. **결제 결과**: TossPG Webhook → Payment Service → Kafka → 결제 결과 확정 + +### 서비스 간 통신 +- **동기 통신**: OpenFeign을 통한 REST API 호출 +- **비동기 통신**: Kafka를 통한 이벤트 기반 메시징 +- **캐싱**: ElastiCache(Redis)를 통한 메뉴/매장 정보 캐싱 + + +# Infrastructure +AWS Prod 환경입니다. 고가용성 배포를 위해 2중화를 고려한 설계입니다. + +![](./docs/infra/image/spot-infra.png) + +### 네트워크 구성 +- **Region**: ap-northeast-2 (서울) +- **VPC**: 2개의 Availability Zone (AZ-a, AZ-b)으로 고가용성 확보 +- **Public Subnet**: NAT Gateway 배치 +- **Private Subnet**: ECS Cluster 및 애플리케이션 서비스 배치 + +### 트래픽 흐름 +``` +User → Route 53 → WAF → API Gateway → ALB → ECS Services + ↓ + Cognito (인증) +``` + +### 컴퓨팅 +- **ECS Cluster**: 각 AZ에 4개 서비스 (User, Store, Order, Payment) 이중화 배포 +- **Service Connect & Cloud Map**: 서비스 간 통신 및 서비스 디스커버리 +- **Application Load Balancer**: 트래픽 분산 + +### 데이터베이스 +- **RDS**: 서비스별 독립 데이터베이스 (User, Store, Order, Payment) +- **ElastiCache**: Redis 캐시 클러스터 +- **Kafka**: EC2 기반 3노드 클러스터 (메시지 브로커) + +### 보안 및 관리 +| 서비스 | 용도 | +|--------|------| +| IAM | 접근 권한 관리 | +| Cognito | 사용자 인증 | +| WAF | 웹 방화벽 | +| Secrets Manager | 비밀 정보 관리 | +| Parameter Store | 설정 값 관리 | + +### 모니터링 및 스토리지 +- **CloudWatch**: 로그 및 메트릭 모니터링 +- **CloudTrail**: API 호출 감사 로그 +- **S3**: 정적 파일 및 백업 스토리지 \ No newline at end of file diff --git a/docs/application/image/spot-application.png b/docs/application/image/spot-application.png new file mode 100644 index 0000000000000000000000000000000000000000..a31f7257f453422ecd060d3fcf4f93584b100ec2 GIT binary patch literal 199476 zcmeFZ^HlpmcY4mvqA*-67J_9ZE}wfONOCw9+X`NjC_HgwoyJ@m}M7yZ64I z@AK@x;Qhf5$Xs!rE049-aZb3JiYyv3F)|1QLX(%1(g1-FfnOnph!EgMC8u)*@B{3k zA^Q|mF+#cp{71?{N8VCd8N>|yjtByW+JN9;mjM5Wfqx(nTrL;{2mA!X{+0{*^C==I z7w*sR5JT97*@oa;5C{sAmy*!*0PkiYWRg9@>y^h;bApw_6W#;r=RXbp zm;d*x|CjBNfehjP+XaE7fc)Y*6Kxa!t6czw^Z37B@c$dm|82?tx8Z1#1a|M~$J$Bn zlW)+OHP*alae8HIVfe=|n3VxHDnzm~`k#v3j%mNWI+>fD9k*<|zupnm`cA;67o62F zWih2uOZS-Zb6y@5_A?ef({VOC+5zgLj90BQv*QC)rZps-T?9JqxJM`i?6lfbdQJkz z_`PO$!H2X6st2{MuJGHbgwum;;xKUxU7k zd|&!j)k#%wQnX%#K9NKiZo4p@76qY=vkXtWl&3>LC;jxv!3j5be{__`f>>_0!Gn`% zrZ^s6gF=$8Mj&dYsHliB6+45etcl5ijBmkX1w^pdee<)kv(sZ~&l^*7${8OGTz;%Il;$yV`~F-}b2`$VHC(6482sUyrLvjJ`+PU7 z!x-^MGa0!ynwZx%`MK5-TaP+Q%8h8P?wvSjy`<(S-{jzM8DJ;1`GX@T&g1)|b78xhefu(bn_2%k0Z@_>E-VyVY{%uYWqp z67+u-3to&Fi{%*=dTMm1d6-o9C5rCzdwroVd(9^=gTKz(h>YjMKGjGi7Qv3 z)^gbEaw$}oG&~9>sQ96jc63f9h};IeIU8ZhA3c15=^-_3>woA$lPQq!%?q!!&xB(2|xh#cjQLM)LP`V^)XqDu-xnNdL(PMhzy2`yy)i-SAY}G$8 zXUy9Ds6Sy?o0XDjw62gHq=|TPO)+j(`rqx`%wM#=ZoB!VZQ%4s>}@=atSQ1-&bB(T zw0(6qKL0P*iBC=|H4e95G5ik`b8gM2$4qR@iPHp}sL!jFzCGL>`1@aWQoL+hOF`XW z06#wBRO74|x61K8e`i;rYZlFW$d9S|amjH@mHox*UvB9-(B%Rfw#YQwIK2v~_O?_7 zweLm0-A0oHNIc)&Wy)gzrx#7q28Kl9qdSm$?Kf@QpB9UYZe+T|2=5DM9t*x2y1E+m zzX$&L6=%6$wtDS5Fi!fN46~(`qV|XrdQ=dM&eryM+P1~ItyMt{sgD4LoAlEDhOS|y1vtMs5%~wWYC3&8Gh(+Os5bmUB!ZeLH;?Q&G5cCOVefXUJ~GbUA2z(*YW9(Ma46 zS}y!{^cSej$pKgkga>h`%l-AXm|+FA;w@Y^mercUae74D^YeVeE%=K1}(_7Vt6^svU^e+6iU z0*So)As9#HaBwvpmE-YHmTbJ5dtryK5G;sY*(_=NA*rH@kT+z97E#jfv-cE!Ak= zJW+Z6B>O_`xdEcFyu-inQ0@hMf;#BspLqNSZK)|y3b!|1mT+P-QU+{CXWspE8KpX< z$))*8QVC$Gb~b{4OCu){#ys^E*|aWgH)7 zo=n}vt_#$Ix71D7-2umhHgPt?SBL{^z!t(j_WdXDSSXo+Z5tog%_?=YPc1L!Iw+{=5VdIPRkEah)B7{oFIg@!php2} zS@D0l;0TA=v-DLxN{wFxVd$xc`2YG9<>2udAeX^%!ZpPzdg3%W&ZaX5{rI1PP95#`UkwKqI154r{l zR&FdRGdJV4_+tY(zUR6<-89Sj4LFF&nI0o#-q=Cdbw(T2sHcGh0{gm%w zB^uTYjlb1lUvyiw2^2{^vrv*Dx#DtRElWBpb=893D&^CDWEF^ls*qKX+O_D z+yN?z4Xwo3LS|xqo~yG>Tjy86Z_Y|Pd5BqnQW@G1qDh>?r~K69(U;!i}BqHUo&kU*Gg0-;r{Ap{5*F(%ziE#Z5INeKtSPe7E;%quJ42AkW90 zUz9f~vK2`uhN~?5>*d8y7mS_7&cpB8Mr$Go$KPZr))=!vJZf5l5U6 zG{At=bW&XAEe6GX#=`C6^!qLxRiCZG)MZmjG*eOWQaEMBMn(3fV9&>Osj6rQ@;x_+ zSs3J`E<>CGR64a`+e^~zlrEGp0?UX4bSU^bkHYN2`Hqtpb!#n`_}UQyuQ5tK&Dxy7 z66$d_Wtr&1?MdmdM#T~jXW(K^^pRagMmIwXG@tq>86%Q{%=JqKr{U$+H$-3 zZkfc)osZ<1GT)%@xjc6)b!@8H@QD{~*p#GJ5*hw#BPG#~H$ItEC`%mWSQ_dp^jrz6 zbKzCVgF#{eLro_#A=7UJ^^1DU>D4?N4*eha@wZ#Yi9^D)`okt0_%!*XIPoQAwWUX& z)-beZ=NCxOOZK8xqe`PLCG(6y=`hat6zMg&r^JbbSg)FG3nYkXGL=)_s&{E*$XVKm ztWhVLP~fHQb|rF&zC$aH$AUVnTE7bjSr5k;q#DHwbA2>p!-Z6$q0^^TN@;Ox@bV41 z%NeWlP?|deROXt4y+xqN%EB$hE}C}=`aD#F)tusqpl-p&hega(oCHy3aj$CtGsvS# zF6wq7)Z@O$#=6MV;!(qPk^U;E#+?qb5UF74t8AFHHNhO@wj44(^N$LC87XX1UO$CQ zmOdD?YxDdn{K)^|W&!nK;|ARIOthjMfx;&)_J}Jwz%eQEXDt3GE5mvz&hc@MtxAi41TPZ9~chIyH>iTCy zDnFe+!jt~wI~Ba$bHP`r^pO}aMS#}D42Yahn`OERrEKaWa~&rt$Y}RuEAlJ1KhJtw ziw?5aFu8qh5WHG=Z;-y*lrny5K0zqrar zjx9@5%Ung)QaN68j1%WI7yz~UMINlziVv-DXqchjRilWAj73%> zO{N+Wc&R6$&zNNgmx>xLsE~Cf4>BPb`udH`_bI^veT0KF;l)!c`7!WQF^1;qRsHYPIG(u$lXLGxfbRVK|$gvtS`x_YXPQ zUR5Qmb=#%!W#(ukuv3$zm6^y<7mfiE?K=1Dr~EW}Y;8T;vA=NKX&TU?A4Ta;sGK6I zVgIQtfqQNe7w%TR-y(c7me)4s=TNSnt(?zh({!&s=HnThqB3nmuaHqJ1RtfPXegHD z8;FqD2r6~hh|rgm?aEU=4r{O9Q!hcD69 zeJ92J6O`rJW%0;k@zXI$#0U4L|{*Ic_~XNgF3eaoaTUbUR@ zE%==1zifJ>oFjcgBs!etm2$l#T+O%=buU9In`wb4ek@9}S*(;^bG`kM!fjH0NyV5C zWVWY14^(~|4voLgw?9@|z6X7{Ywxk}@@+`w%mFEUUw&D}?&l%rZX_SC5RRyL+P!1(KPkVViOQm|n-#^UYCs8yygH9b1*#_9F(XKwXunKF$~dDwMmy&pTP@|4pGj*=KH`J`gS;TG@ZUgH35y{U=woZ&j-1qe&ORDRcd3?F!(;A8z+s4B zKEsXhr@n2>84j6{J7(2aCC0?LSN`0Nyqg^nnqs5z7h-T3cT7 z_I1e!DfY~zeO6@WjF!}=o#q!5Yz+$+oHO`muN<)0&76Ehz^p-?j2^U?=U<26u)dZu zI5T|U-{wP-i(*v?Q9mKtQdP>Oq3a(Tp2^R{kynW7%Fd~t7lPa4lsg|AAYj)c%)g{T z>94}UwoSn>adqb_d*R$qSGGIq3huCB=b|1y88*B{4whG?L#m2L37U*Zj;z@dlu9m9 zO(-gKQ&*(kf3`%cdc$9B)&u&t*>hJ4s|JG&{y|!{ z_mR-1fxWUYjqPV!Lathk5j_)*&00Q^)Ox)ku2*ZR6Gt&GZ0xTI)W0EH8vxY^v=!E# zy|}7(`-hmI7)X?+_d7@a)nx;IH~ZQw)S65XQw*m%tHHv{X%fm8tIDI&@XKZ(o9j1j zd*BSbFSVHyPu!`Awy&@=UXhKa78NAf3m_}+bgFhthWZOIzo!pho1(7w-Lit~(5oJ< z6MQ6XJ~&X+uh;js)~04jW7mA*HUH1_yOG@_jg66GVk052&r_f4H)~Zo%pXn-ijrVq zJa7c@6TF(0dDK6P^X)9qP9vrCci~5KMxKyD#&3CIR=pz^HHJ}Ry-(9Dj271hv`TT8 zgfm)G@wmOSaEzA`Q*pFk?CxZ~9fmz=%p344uTeFWe~JJd>5168`jIOY8}}-A^f{YE zW+mfruk5nrT6>n1$w;%5!hPDwa#ovVy7#tb2(|DvYXw`i@pNy!6C1mzl~T+g2I8!Q z(idN5Z9jUltzAmZ@c=G7ir9QcR4^J*Px%kP0EK*424yAHk7(;!By+_yRqW&I3&XCs zQw>jqmW>)R*y?{RaMA=_=*0rO?3)~50C>zFUQ6GN zmke|lx{yFe8Q6^F_!v4rQnL4_N%|K$9(bBtS{=>-szp}QEwd?jiIgNT)ZAsR8ztI@ zJ>#9psqaOwq%e2z-Wp>q0BUEqgG{4s{ZgxH;A?*!5@5N{F@dK(&wR{6^>-5!W+GrD zpjQp_c9%LsW66XYYR{BDly^+Y!Ymyfk%y0mU9T=I7POJe#wP*$8!zS}-iB$&dU8ithE6p=F*T~=I z`LAVs$bstL^qA6@NyTTuFCNo>_unte147al_+2x*i=VnKfFTG0epfPbVp{%B9s1F? zBY+&iJiNSUqylOZA97URb}Rn7M{pVzpa?oc%t-&|UClUQM4&p2Gx3As*>hkd_Faxr z`0NAmj#U=yx=BQ!y~pePzU;4V-=D3HZNOU&zY8JB9gVDq!ffqK5%kM{xhuT$Ery+q zO{o!b!|w=jg}4b~q)=r0=cw(9`icE?Rga9(yJI&EhiG7Tzu?ncd96lBx2I=k^?vcz zyTeSzHYi}Urrs&LtneSeYpfT3EL$dqv=lBkwpu?ExO)RW4Gg^y_CB}&vLMukZQly~ zjUDjI0cj24fBIzwBIQ#~xBx(lAD;8cz_jRwW4x9FG-Zd)FIs#Nud&};tEUl2)%DHSjBvnZaQw>xZQlWK z_nnxpmLVV?U|m`5Zyuq%k2u*FWe7-_te07sogzk>2c{Ra#psRp8J%J`4Gj(Er4dv3 zFWH_OyLP2RDwIl;u`!KZ*~KblwAssfXW<|8PF3!wJZa9P!de z2g7QH4`_iT(*qJ853c`fA#Tb6m9jE7q3C1-Pc&K?W+s15K8lxXkHF~+#o(~$4zF66 zob2LfzS7gvA?LH#36(D3!gpH~-^F;jpukij8DtTj$!&#hK47Xq1KY%aO0K_mUk$%( z(0|y&nG7&`YRPh%WBI*zUxk3ct0aEFiq6As`cA`cUQB`tkCybD?iCBxLT>CP1+`1?vJDJPNyn zBSFyM4|XnkbPM$U>7Kma|4FzNJJt5}G0AT8$q4Em4BigskW-f3fj3X6A}W{}gu(;U|iySn}5 zv5GLKheF&Gje&WVSpp+Q|9Oj>H`=r#V9rsn79Lhn(O=T_LkcFoUrkiab{&_0R;*&7 z?pOB;J|E~+nFDe1NZP2t5rj1}KJL7c_4+t*?EpYy?(>cf^Imr7EhkwAQam|NOgbW1 zD&$h~B(bZRu8d#l+cE9}8l|Vc(b9Ja!3sVGW&+NGY>hsLUlHUx=djR0lm0Sy{5-tD z*RkfR?_T-t=;s-#f6Q{9-I5i*#=?1&4NUHKXFAxvrovUU-Fr>m2B8auNm6yyszUY# z#2w5d4U$5-Vr9W(4?o)Od0$!gnKRx-bRLM8CGWp@tVhs*{ZOjv z8)1W~nH*K}Aitxh?-W>8J0fJxz@<;u2{ZWXEYQ5xXI?K6uc|t@0xkL8P5R${mP{45 z_lo7&8?E0_hyc7$x3l9jr}tOL!UP*?#x>16?7i|AK4?AxN-!55uet-9iK6oA9^!DG zg)z*aqi+dXKoLRm@)I~3HX{6K8p$Sh93Ptrj5n2|`+EbeM$?r=AfgZ;8{zEa{Llac~k)if^RX1jxu_y=q#+r)M{CpRw@XZ(>lU zu>|@&(hCTKK$ohQ(4Tljbyw{Q7}20pR#6XO%X>f~c5>LhRlzb$D+BaLaBa$d#X)3) z)bGs$GJ{!8QV0+zYK$ktU{`_VGGh?RHg+gi^9W_zfQe^$>-Bcu0;;{NplLUGk{32* zO%Nh4#Z`8+{P61qUw&jUVo_?3F&ctwIP+^@>=A_*=)U0l8l9>#ksL*C)jn9hr^MgX zv^(z*6Eoc)I6B-t$1o1JWveKIt25yA!S8q>YHEW~=6%P^GZ~Sow4{JVX%YM8q{~sIMK@|TjLYfn@`6TY1_G6h^G+~qdc~&W-K$*<;R1flyDKn;!N~F zG99hnu&q6R8i1a@WuWA&@cCyWdyp0#ZyhnrM2=+xr0Mh3ML3U7`|{21XRRY)0yw1u zFo&=FTn(`QOdKd#SfSn+XS^B_^{=4E7xb@id!KOyPG96gM676L6UaOp?IifJyC)c& zv1>0;AH3A8XKK4XojuVP@oL%aHo;^gF-4pyIYG7Gm}}-X$_f7zJ2|4`O>N|{6w>r+ zh>ou76W??#{e3DhMF#p%Z+T(GQRak-FxoCHJ<6006+?%4xXag4D)J127k#JIZ!qKI ztUiOTao389Y35|K)|aem&{g$!fd1oMq|xm%(EFDDK2Q|?#Pl8rpl26t52B*M2VjK9 zXI7j!7=DLA1|&`<+(v=38lEMd0 zh#%0>P{|fakA-jEb9gleG%t0J8LvLtICS&J>BS6U+}yI5c;?|RjY6Rx0z=OX?wY?6%AC z$c3ws*X*AbA+72|&N3cc=pu*huY6WWdV$fxqC}9qyv#bdc|-KT1c@>R5~y|XX(fi0 zsP0F$ccBH=;&p8FPE%g_q@7JT`LD@*d~pg63dV9C^=`@Yq)Oe?jPA)uUT=ZyEcws zLVwAma`c7C4Lnwz8jw8|Uy0%-{4vVtv3ekhA&Q%v>E?zx3~(*>Fnl*9$Ek>?)|HISQ5Oq#l`X(vrv>N%g}nc^T)QQVl1SbNb{9LZj-}(Z zQ*n<^+er!Ws!HUQrll3E#g%2zkf1orG4NSWmA<`leR7X+4on7~pko-|Dj6Andr;(P zIW5VLxn5-u@9zrRnkaVJ3>CknvT@|AQW&rQVR!=!ur0_yJ``z1-BY@E!coPh_L{tp zx82j|qoc$}cf~|C^FSxe3U#FQ5nWi876sTRGF5P&zi{*B8;~lOh-sSAox-v+qZJ z_gw(~4T9BnI72ox>z%&;ay?OwTJ8;$kiU>CF%GN?pXpQmKQII5AM|Q zF%MF()~({QYZEhb3v)~uzd`(Oh7gx31=d57(jv`4#jGl#+N^T`*+ppR26(Hps3Wh2 zl>1w?DRrzI|FB)3fX|Y`G77nG5v5*Bgj^*meaw<6;{1!~mumq3K6WjS-ILO1FfPqG zIhPEUtj?r?80VYyN*^c-P7^?}d11{_Mt&|!8DYsj8p(Lf09Jo^?@JSV96T_w{duQi zP%P;Sp+v0C+o|wFg{?mGM;)Y1V>0rc>bnJ=i5tIS_{K5G_#$I_SGaDbBXC&x=}+xk z+ogOBKD(tvuHfQw@!O=}XGGoXu__w%T&!)HO?s+u+KOHCvMiMWkSF*mj>Ay^zT|Kq zx7zu!x-=1<%5~fQStUSgxQM@xH=vR<#P4m}tr=|i={#sm@(B?K>MSiUyA>l3Vy*4u zx)u<;49|^ajWnVf@AVSPME83?Ub8GNc^{t#8-s^*D3W~=C{?njHM4973_y-|AJ`FW z#!$Aw?X)ZvV+ep_pB4}k#Z8j5dsJffV49s@Iq#3B`^8kj;RS^lB8MQh7XVVaL~W-_ zHF!E)3Ob_ECY0Z5ocCfOe^Z>}_f8i=aknX}9^oW0M$NkDJ-sIS#ke4;iS--LIcHC% z!T08GwECxIA*kJ&*3+-N>tC0W48
&Y+ra4)K?Q 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 z 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 z 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 literal 0 HcmV?d00001 diff --git a/docs/infra/image/spot-infra.png b/docs/infra/image/spot-infra.png new file mode 100644 index 0000000000000000000000000000000000000000..3184a0025f2100cfbaee731206c6f8602f26fe1c GIT binary patch literal 101884 zcmcG#1y~&0)-8&=2X}XZdvFNu9vlL}-Q8V+ySux)26qb@2=4CC7qW9?pMRhI?tAyX z{ytVW)m2@q=9+8Dm}4r4fBbkA0Su%nA}FUK$DsxT1Ox>2`Uijna)$tVJwk~3`Ylp8 zWI8Z45R?Z5uLDP-cwR9@4zYr%Iw3;H7t1HcN)wxb5fL~|DtJ<^ItM-lx5OuS@Dtkk4w|?(s z#s_%vdr^L_ys>)z1^~pL-V;0lHeUb$zysfN>eKo$#e>&Zf&-mRzQ;!uuM@!clP}Mw zFW&&Ccb&il6M#Oz1wa-+^LjdwI?92 zr<>dtV8G!l=zM3f&X3M^&tA8odxCwyDqq(N{X^~@V6xNg<@`nVIqFvD%S05Q#^vOC zVv|qXtJw|XDfT$%(5ucX7%=xjv6Fd9a0KW8qyuJNgr+*j`Ah&2FQCsDFO?_jgq^Vf zR)E#><Y7+xi;m3eiEY03s2pnF#j6N>wF0&@6`3V)Y0dK%ajXiT z?e72LewgKFe{(yhDLB1g;QEqFg$0_H!0ey6z7&5y!>(-2e@)PkyleSSGn8`J;$=4Z z^xr4Vwl4dyi=+NO9`*MxDanQ!=#7Okss0>hdx%eMm{E|y3|%&&_*O6isOOTqv1x!goYmjBCovC7%5d+4() zKQ;7yaiB1A�#XGO(xm+eOA>|M6ee>hAq~W{TG$=+u(Cm68s`7aac2a@CV>%lu1X z7nzCw^9iMEeE*AF!Zo~?J-9KGhGqYoR{YjNetNFj1F0ceWRd*>Ian zA0!W9pef0>f0VI*kCYJyTlwF(42iGfgvUJWj|;a91PQ%7R$RYhOa?b*&X7uLf|jv< zsxd)2+nyJ%LD$kG%7CM@J>FYxixH?lMb*zGl37jgE%H{D{Djxp%6klIwao*>8|D@? z=p4iSNs7?POq@J|;qY&lIL$pDB*1RCMHSfaAN%TG0;d`4vS{s2U2^RRLjvW9vJs_n zbz6aAX@;}~@&BPL^BD3!x9mSP$5Wl(2PZYYZtscnug-FXmAy~h${5f||M@W)5g`Mp z=d>rA%c?1qN&mPU6vq8;)iSX)MJ=F*Oy}-DcHb}i@Uv`kLlY0Ia;DaxIFCerCNLR^ zIY>4b+nWJzch)y$itF!m0&I9Bjfl*Gix1(iOisqDz8T#!m`ugwqXTqaiUxAH0L>3H zm@^q>J@B$@zW-sq{v``dN=w|MXjbcYRDW$6h+7oQvd<28pls?mj`&nG#4XEEMVEK~ z!{GgEVcs^;JDT^_{(mRmhxsgChaiMw@OVYYQYbqD2miE$zqQ5Tx^0dc8?{F>e`%ZB zRv?yl(N%D0D)_`cb+2Pr|LxvXAnm0@rO8kqRQ+ws|6Lf?mF&O`_d`N8IO2UAFWiWy zDaKHBn6kZ}?XOr!Qg;7xv;X!X_ioMk!J5R%fbhR^DO_U+=b6(6tfc zr=mNfaF+f5Pbd40*&wfj5HGbe(1JRIN-{oGQYhHw-+k4ep6|_BNtHGCps38`p8b{6 zOWvd7JIp;8Stu5ex17>bDJb}RU%0~go!ImTxkBAPedE91PsBcN5qnlZowdf~?>#C? zcPHoxh#`&WU%5(uRS9YPf;-N{IBf4F7eepTZKILy$uh-KdeWL$`g9!Pg~kO;00!4z z`p1`1q02a+&8eNgwAopXFN$LwWF+Gp#_PQqH16DT+ZJnR-=G4fCst%U_;#!^f-76! z3*9QBikCTG9fIR~xm#km9F`LNo4fy)_7khI|M2LN$~XSYupx9`ydn>)7t6*ceo-&G z7}J*ime2e-6T`NBns9qT5T%F%YAD~8(18hh&8t0~DZ^6C-tJQg+Awj%(*{|O1|+b- zzl-*taYx$KE}11P zvt)&H6KJ|XcL6r!*Dk<&x*wsq2ytLBP;(}YV9=vg>LrFQ5BY!JE7$Cth zp57huo(99fnlZfB2y7S&focIJW-K=Fc_)2pu8Oe`I8_>E9d0xleDSLAnIMcaK>cIv z5iUuPQ_@mf8X(-zVw?*BR+W8y0*K<_aC-9zAKFOzvz2o|!H-TEH%qD3qG*JX)rR~x zD;+dna)Ja?vb9medmlP6R1blXD%M#}Gy_w8h@Z2D@#U*vIlj{cU zw58>yqOY|EDf?xv*-_`I^wO{?Y|#cQWHJ2}xfNq*z7CW#7;%uE8;g*B-<~Qa;-^&o zQ0t0@jESrmU)>Afm(GO#&rtME;IO_Kt~{(M_lmj1QX2ez%aK#HGOVtPC7Bf*C^$%f!YS*sI!HcP2~85=^c@kzE2g{hQl$g zsmY#_Wiue&HA7&0Y*<#GP&5B&MBR+;|LLQQjlh;W8|-3Km2um#GVqo&V~JQJml#Ku zGp2j09yL}hHLXSRId(2`3FF@ka61>s;|0&|bRNxgDc4f&E1?Pfxemd8DV8R&^k$${ zFN{X@AwmcG3qJ;-0;nVu;`0A(5ax73j+Tl`o;!mDhgPJP`Xu4MzeC~_Jj|$? zjNFoN)IS9NIrpJ2U)^VA1L|;3r<`Ob?MfWD^>K-JkJ-c>C28Y73`IDU1&W(jg1W0* zy?QVC7a$eQAZfoM|EB__+u6bBnaltNMp%D|I@g`$1~BWZUkT2?EDcdZEu;t(yWDG1+9>kl9cPZiAk%}}-X1}{ELzan7$S10_Z+{yeAM)lPd#@ofkG+WKWShCd$ zG9t7w#aoV>@cb!TVq3#{2R$kw`3(rJ2G`YqK7o9Uy33kRdr0kN2>i{=(mf|O{5er0 zKZGZocRwJ7y&5cCYZjS#aYQtf zaWoIB12@Pk2D|PSf|v6R9-XLYRz%@5=#B);yZ6mgi!l7|WH(~(``@Ji_YutmX`_hfr;QzTM{lKJAi_97f-0ur_!sinUhOW1(+_wBG%(G@?4hAvr}QHGZ>`(@JMJH?w)}ia?C`c-y5bj zYCn=F>9hW0c>hd6c2KiU&w6jrThuLL!>jFJ8S$u#@M z^H$AVaYH{ymf|T(|fUL%?+R;kv!3K9HH64{3Q*3#sv-*CrNAm;z$etIpP2QpIX|WuH2^_0Ebdt*%XyjBBn4NVHEDEMQ zNxi`{sIk7%WYcOImqoZpiuB0GaVZzle1|EWg{DZj+n0jTQy+)u#&!114Jp=}INRwa z|1g4KrBjw$&(&YaITAV^h_TgqaXR_O(^dHxC~e|0&AR&DBU4XJfnW5X3;(zQVF?#_ zHBFax)t@)-h{3v^8HHq7t>W~9kL2m|VYTlE8i?AJ@{@R&fTRVl!#^v64kyleZn_k| zfkbNNg*ME{>m<8BqFvcqUK2T0J8dg<@>=aN`FyeXY=6yNCGRZblXpNbJ1=_ZYC>`T zDnaIKUv^?pD;?o@Y0fojV6#P8aV!>$UF27P!BD+(0}tv5I-jJlE;J+^67eVY2WS;eH|(BqP5-m1JOFWHXo8R+FmK!8~F-N`Vema)buHH`@@Fu z>Q^SxDL%iYV4=*W;oJogPhS3J6Yy%VtT5WiNSrrpX&$Rs9bU~^bZ>ba03-8UJ_r|; zxyNrM40<{3ca<3rPF$=-6R)FkWRR!5;@%z(-vlQD5&c;Pv2Fj`UG^({G9;9A7g;86sG~7rb;1BSvx}r_Ol}Fdv-EmQS89nYdmZlVBi^@LY1N`;m zNf84TmPn(Ba^JVvGxn?JTW+4mYjXTT`Kt2Ew`Icq8=|VweZjPY7$nUIF2;(t{IP@} zTbLcV7<1|L_ccMFEqgL4&LM@jFlW6~Vx$o&gS@Zlb#NsnVi!h4G1{aKkd-epx=r4p z#O_BGJy(1_$6n{(py5E9r%+v)b|%t@2L?6qo$aqQDLLpHVg2+Mvyq_QVD*5!FcDu5 z6A9rUe1bQqkP0ybDhvdNXURx%L%RcU-&>1p6g!tD-*$&;_ys3eloGf@}mJ>lQLwutrQnp0O?nuZnC4ttA=%BmXznKqrTZ4L(sfG_$;}`~%$zTwJlZ zi4RsUqlKqvsP{!6Gy3)n{)%G?P6v;ISaW*Nb=hqY)QAf#4t0UI3^_QZ^5yCv>>ojO zz6iRSe|6-}2;JQZ#Y7t$V=_F}btFWgD!B_fafdVb4AE0Q=GnGKGwu!9Vm7m#-=S@G z{d4N$yLQTNhvU9)U%U>axn&)ZixHJug=&hV)n-CSq<~v!QR_ITUjqcab)3W4$F{@= zM(_ksK_mB%MSZ5>JcEHx6Ou&`B_mA(7n3&BqIk4eT6+S`KeqY;H(}@=cFPsVTJEhH zw+{;=c&tt~s{59}N<3gJ_J z(yY|P=m;pE=Ubb@&pK*y;sWgS#QSO{T;z(>&F?N`=|gu&3@{PRAsuHZtfRC)h@Faa<W@<)NbX}gro2hSNcZg@RG zMpp*pgY4j#O?m7)OZ&8gMBwM##_ej`qwNa(j{#P5)QxG5pCDI6ArGc_i{Z=t<06?k zJnAB^F0M;O3*$dU{STo>&@z9jWs%p&$Lf)ovN3F zPe!0C;oSPLaPKi*o^k`Xz(P9u7H;BcEOy7F(jp>A>-6CWPR2Y|!ARC~*cZ%;O+Iuf zc|t;Vd|mvh%1+Vpe+x?jWO(0T`!U?zU?gF9<#^?X8Q8^ePux(k0H(1TRRZN9qvdrfzzPCB%G5 z!yz9B7qL%mK>ob~FkEdwB4t%V{_F$s4k+*ExN=-TvwWHO*gh^aZj*djmrdJcJc5TY z>2ExIsJH3Ov02X9fcy4=ji2mNQw2nLCUZ|G91>=pQ<^%7<|Hqf!vm|gpAJ+GPNPgx zi50LLNka>l`#|P5lnxyVEX@mzj!v5rg+ET#vHK`f=NICjCuwWN9S)4tiFxz0lI5wN+t=IyK1*B3Vht?#plAY!P zW&c?Scn+Tpd|(HoEN?$W;G->-KH$$uUt5HuVoXVts3DKQ-=}w$io2~X&o8c-*AtdO zQh$TnKJmaXpfi}aVO4Gt=~QyTTxR_mDxQ%zpG8#2Cx{(}nxV!7?XtG0$iCMn7`z8~ zsCuZTXD-6qZHA6h{nBtg+3|~)AKR0|-wXawfJT_LFzU;@zH{1$s4epNqZ60$zKC}( z#s^&kel9EPvuJRO1-Lt%u_m3@zXB|*?YNf{>XZ+!MMqe&NA)upSBk`4>z_OK#0op{ zmLEO$6z|15Ipk!}_Aq`IZOj^!fHZJ3SD8tTw`?*cG_Pm^))THS-0*M93q^Xet#V>& z*uPd1q>ST^{jMg(Wp$~;EakpsQDG&uu1j!1go>`hd_Ue3tUu(L)jjd+NJ}+k>AZ)D zKHvPqN9OifTB^)sVI+7WwKkO)pA*?6AaC&p^c9tcRmT;)=2n{&^LH-_R{ubr*!ox} z2=9Ul;~zf2Eef>b_KP^QMy12`AXjP1ThrAfZ@K+W;nIR^-ea_ayj8mtoo&dTQI=R$ z?5w)*s&iG$osI)0-eI*{wgM$&e}XeXQKPY*fEr`P;!Sw@KnQQ)lfh111?jPELA>>3 z>GcZW#X7VwW~}bh4dEoElKrljt!P{FgvVia@`q-M?tg6f4XS?b$Q9!rI=(fMe144- zMp(ozf5b^{4rCHQG@*{x%a|9PNrI=af7=mYOxm<~4m;5iw4dpd#;U%i#a0V7IWOsH zLt)j+QwSmF(2@qb_LO@6CBg)97*69`H0=T(bes|%3Do>pFCr1c`-U5i&IeRomE%Et zv6JELnk9E{TIFn*;kegQob+aB9){yA!Q^u-qUcV_?oZ~?AA@Dgvq63wjY7rvHH}t^ z937{)xy8PsB-mA|{LhUFA9%j)KBM$xk2Vq2OHMY-k&;$CX}Did1RKSpQ{BzF0A z)<_F17DhZe>9tC~Y;)@5a9fpk%1qk}M`~&fluw8EtMcA-^*c8%eF^;V86g>HyPx&( zwN?eGR91Mp&%Ct2IJbRP`o3$yJe6nF=2ofRFQ*DbAxOdgZGnt-CO>n6Ux%=!i^f_B zPfEqlObQ4aKCUO5qCx2w_?Q}!KR(i}Ag7ozc@Q*-RBup^n>u+jwzw4#iLz?@&tguNsgsCH%XXpyV6wryml)ZSM40Wf8fEqsn}5j525`>wkA(VJwGa$(tH zB~<<+13U%RqND<;Dr^N;`16=^TXQH$i-rb}$n179&06oaouTh#yqxiEHA9WT6Kto* z{#_KyKps~}MXXFgD_t?#(2cD~l_bWbd1iUV=n^bWy@roLRotx4SMw7fKj9#LG(v5| z<+z9ROR}izW3&;c0Q|*Bvub_>I^1T$^UBA!ZIWL+5r=dGr}7NJoZA|SgUh1(VvESd zOs{En09(P&3n(UvaUZUxr}cS&_K+-MRE>#u(sfN6>*~!@wFU&)KiQ*E@jm0M zfVj1!k@4rm*w9~=XPiCY>pT)z6=BS(!O4l3d-tSfHjWCSkmut)DogX&B3e`!-F>1R zV?(1~d#e$tS3cwDZQ;2l|qwd0j4&;C=z~ILUE0+=LpmSlJGva^UZft?p?@jNFfLf!j#|4*1 zu)wz!`gXEf3Z$;360U1bIWJ#qUrKD#6GhpUw%Oa%(ne@9QgLqCJX+Kp3S^S+5*`e2 z_9ZByhNsv=0yr>~B(E)EZKNIR@gmmv7r?ZDIzhT!M0EImIB&QY-Y_s?@2ksBWt(lK zmsoE4>YVmji<5#?^duYg>|EMGUKG%D2fHv}k5j2`d*@VpVFrK*Mc*s$!ylfV7M04I z97Ki_=1ci71=Y!Rn-$u?c%Q@#(r&fQ7myYuniVIxE@h0dIMPdye`|>MX_Vb^!9}5K z{{AgHa~P6H*0LuVSEYO zh(VBCSr2jC#^#F#AhOkugTmoA^elw0YklFuma=#@1&X${2_t{{_&S?MMI}28r}vMT zQBt2@_T8iU%|i#6nn-3mDSNmu4f|NOi_6y&^pO%q8oDepsnU8aE(A%69g!KnT4;*8 zfOCP?Tc*dDUjfFSI8~8{fxaJe5^a zj5o&Y-Q$c2eWiVu;LDc+rj5Nx$n``%1|zBm)i8(rzLUeo{4(h~T6^v=ou)*-FSdwHe&p-eM#*w-UL@7@6Hm33+B3}frY4n*ysTmvB}(Q zCeG=ry}b-2-<8b3__5dIRqZr-mG=>2Ev_)%4Go^eTP+iV!($$yG5`TX<2tat%Fn_{0j9396xd?IN8oFYE|SIuem8ew-pep5U%mjOFrY{KpnOUWmm5VDuSUd)M#wiKM=$i$hE)z{~M)qDcLXd3hqTZ3E1V^ZRnj2*T zuot|K5X?OZk3w*iydB6PqfWfS^Z$D7|?Eb`dMZ>6^VVz zkMC8>2j-1G66M85_nax$Be~5McGVkx`26*A7ot#i^(tq-GFS}8XE!o znSr+`wX&6!l1kQ;46NzTOI^vg9&<-Nyu&k{_lf!9R^DXaGRXoY#1ZD&Wbt&WfsCwT z$@$~8{Kv}#C$YXdE@d|^bFyWTg-!6m!F{2|qnzWyi+PagxqB5_U!Q6K# zq-$5hUt|k#5plLSZCtLsPij{T4*P#_R`PxhO|Lt4k2+l`pIvSl2>1hj0XTLBc?U2E z{0~Y#jFPHq-|&9bf_%?j9vh8;jV==FwLxD?QJ%k|f8MdY#eq~T?5h@94+A_hitDEPGolERMk@+t8s*?5#Welc zh68Xv)352s1v~a9yw!^HXs58aboMMnwqI1mkFQWx-|$;UkgBwdcdzA?El!?JB#;JQ zhK}9t>jl-gf~W_uKS*cch^QIs0`F7!4zogS&iO!yn*^TRlNb#`o(lP@A@_`Z0hZY! z7N_dxC=$a#E$dbz3nFEURoXn2pUQrICU}pEi{VkA2SoD5UCT6jV>Tox*rvl*^w>_o z68N*Xf=KP3KRIsvV#fLFv!SK}AGtzNu%z?7H#ygR1yrLeC3P4r%xX^dnBz+-^%;qB zoWnj+Xd7VeQy_+{?FS=VpKXYtDz+}e*#yNWiZ5Y7d&Rdr27f}sQtOs*-_ga$NBc@b zbyw$7ht{WzOjC0~XLh`!HqsQdkg7op@|88QRbtcivVZNBbA%+CkiHA(KFFw7Oq+8i zsd>k=_OkKeXVxEtqH<5CnVWO06>otnkFaon8 z8$NaB`SV<`2^^|f&UrUFGUem54r8xLsD0M;)rq*!R{rQwyd3&hP## zFR+7>Vx~ZG6RzScemOgn< z7{zxfve*DiRf!Os!~H^604%;u6#f5 z3a$nX5W!(*N}YAzp3TcXanduiWY^#^NbGhLs4u%Pp*|tvEeOM~1#AjnOi!5?O^MLv zm4ZZ3{cc#EpY^@9AorxWf8I`46{;zykjj`QALzRj34c7n}KJuf%NoY4g6QeKX`Cxu);AAvvGv zZW~x3!0`dW`cnQtb6W*Jd|BZTSgd#tZsVO8np<46`MZgcxp>sRZ1fJKI+r;IuizNOHNap!DPg! zsuEni#TaV<4^P6(%rJS%{JWEMtXb}`YZ{{RNZ<5`ofy)U0f^oPW=S(TFiI3|ANQW1 zJI%dtsN)y}+*;JWQS%Dd&+6!?pG+q;yKpj!UQ(lwst6V2*0~151!ODoKE#f|<3*q# ztc@;HSqL0925)px8b}(Y8*98UG;qmcX#GgGe+nXwGCn>W_FE6WiEv{SglxgNtYEcH zUqfT5ahfCdFB97CJ4Q7qG6K*j0}TndIH{8IHPMdLRH2EsWMw;L z3%&MaH}dZec3q76KuNp7#ThW&!8D8K;rcTd83#>VDAv3_v6Qfung;G>%FMyc}CJ)Kj2CE%V5HDoA7bK(O0cfr`QLkX>%w6uO2FDT@W@g z*;F&EdJ644l^>o=eYL zt?_85Jr@TT+We5dv>O8j+J*Ic+|N7{q8UVr)vO>kLB|CNDw1PK2=K}mZYjO9-z5H& z^kpQ23tqTUPJ&mVICh45G2bv{JSToT8<}$z^>L>&Ox9XkDL9Ikqq+aMbjx-j`61{E zL5^JUora`GX3^Y63N~3P_7N4I0l&it93{?sGW9eddDSZ-J%CNo3uysDXz&Z-MsvgL z_we(nqar#6SK5tJ?o_!d5wddCu%>*&xcBZKXt*w^>$Ny;KC?~lcr9Kcdw@pQGEV>( zf+-e{=qV#nOSpdtq{+3g&S?JpV2xyuEB_rO(7?o)84ksWE=LTOrex~17Uc%~4Md8vq9zDW^ANM_>V2!k>NjeZ4B=85PF6MO zr_eSJr#=wplzslwpc-g!i2L}Xfx@00$>6iBJyY6(4{WW_Z5F$ikb@KBB7xH+mG*6< zX5*J6Vqj*m!F}rhm&4oQs1dC#;_-o9w;COVh{qMW&b%2(5Fl^l7xfaVl25|dQ+d{} zIN9buuRl+OK-d19zVX(G|acatnsQ zk^(`u_?)>C95N$|%o@&ouUgc`;ffEVfPI(SoWrqIO!Vc5|3~FnWr_^Gz=?x#oGY9% z-yzwGJ{^wUyQfG=<$7}UD&BX#pA)=r6i^3JFOKnCfprT9zF^fVCqsKr7&?GR)K^uN z+fbP_$}92A1Xp3t@}%^|a~%!9T-+A;IV(n)53F8~#3g;jJqnW@Y$O|>EP_O!jtwfH zC*ADO9uYcx?;ZfMNn63HDkMw8`4=-cWy~JP*qT=WPghboS35U75^h zE{ryP;3&jwYi-N4H>GV)Yn-n~WIY#Ny-^q~ocL*<D4HAs%vNKBd^dKBORrNhJh;_%qxs5a#Xc@TtI;R)3ov}kczBtB7{X`Dpg7*W z`%E%CbHcE|7Q18Ei^WzVk4)RTvAW2Auel#Tv=j#UNS}arZe~R`)AYs_XElkB?m&55 z=f-uM(FLfz7y4vx1G1z9=k7I_gAd0J2|-0;D-~}_3HD<6PqH;Kwc+}mY!N1TX_n#9 z96>ofX8=o!p!>&C`Xm%&A#=kHTv*EHC&za=DqGsQ6A^jAqhlOlAp39a3M8oT8{%>` z*(Z(57uU-XlIXZIWsqyAL7zLKV@^fK-JgBCyJ48LIKD^4bvhBtq-CA&V3h{_4B6X$ zk_lZ?;9L&<_Wk{kZ}y#ZJYUS*;{jhD!5GCWw%r0!xD=yZN_TOo%Dbr!R%Dw+kJo^) z1B83DBl4_nm6-DEGO3ylZkg5Cpy*a?K0#^L>GaxcGrn?hRGySuPd6;$gkp-#=~eEr zs?f9ELKYFkO5`O<1LPa|DTqCIZo)$IMPP6jME!?CH+V0$`CGe1<4x48E}q{Iy2dY? zyypQ*e5n1dJ%!=B8$k+co`~Oa*X6&uD+WvxK|)4-En3@U@^6uL+zcSkeOwcc23W7-AXCm#Q}53OJ;FDoL_`yeo}8mIGH%8cUF)z6%`k z{QNzYK!d{S_cK>VBjJAa;fWrtdzUHu0F_|&?~Nx}I$wMcI*6WDJIN0R{sF)Lf&JGa zJx>;1M`?P5cWxXAJ&=};kUuIuU*02qmYG}X%ZGk<&TAffm_IgF+4$0dO9c|@Jp6ggD0fBZ*^soIf(nvz zhHv%ZKUFZzY&7%Dr-J7&BM|eZB3ciV&kb8glPos{x(I=v+A^JL>)3=2g%#4A=J6{V z!eGIE5i`c)z3hH6Tiz?@zINysHtZ0){i&lxppfV(0cYZ~BSg(#TNp;z+XKH2@@EXMXnGSDm%T^oEZyg#8t36_4<2$PsMY42 zUrUf6QdadvcLN=3?B0wf)ZnWe5OZiXoM>j+>o)VIfkHQ;=KLI{zvp!RTzGkuS5u}Y z4hLpPnzTPlJ#pzUp13)(=;fRW*Aqrm9<2h|h7Mm15#hDUK4#Uy0v5W{Nx;!XwS0#Y z=3ZQC@WrhTEX>9+eP2nUhVBm4m33dduiE(Au)xZ~@!GkkaL>ip`EvS*UBUNKE!ec@bookrmgnw#yBXDw5G~7JY$3Hn3L&*%tA9K;*RPzSL;+*$v7-)xV4SgjtL20m+9$DvZj>)I=a; zUDvFYNoe3Na1*|Yn96IvoTv#fkSJ-GthjQ`u{hTmXWqaD77@5od4)J8K0%H@?G}Se zk4pnYVZhP#9QNye)LyL_rKM@#IOtK>AXQL$GxT2Iac?PIwip+Z3mDFjehuYzT6r(9 z)-@kC-9W>7e)5f1AKHwS#>RNU{xgnMBxstM$L;E6Usi6thm7|#7BK562FlZ9N%5vO zT0d4V_+!jdG2hIdeqMx?U7B2)O^5wu{=)-n)}c!_5t>#Q{}R08)1JOi%hRz1@6>Fe zh4|7mvT&7+fz9pJe7|uR7un+oQj>5QC?DYe%IhZPc{^B>0L57;CepMGJL#Y0@Zap? z|5Gjcubo-HTda0-l%BFRMPI=ni`!5$)G7|_e<||PsB=NJB&bi0V=X>anL^hOCRre>0_BUOEf9_-a-Pn*I)n4{DZH)gLZIk+tk;}-wpiZ}SL22w>Zep(h z;{P8XMUM`s&w!qWO6+K~q5QAE`M)T0&MSMr_9Fj!S!bqTY45*Jqq9L+=L19i$KhY^ zrj|KaJ;)|P&!C5AT)yY0$%<0zJm%x(tt)Uh|2z?~%VO%bpR`Rkr3ljmhSIEpibiQ1?B|&l7?fa?HRpes>>W2EDq#s*$tTvtQb`|dhdnt zm>}#allJlq*(wGFsUOii=9dY^8FD`HQiwfOqYf^R4V%Nu&M=C5=Y3NVpK9n)oYp>M zJv_LE_IO}p-TQqXU>EsQ#4uOd$*!2YstzcOzz#HBFi<(2?Gm(1_T3>ON*@leOvO6PO~re4 z^&%e&7|cHhNlDk3`mBofQ*mHN(hn^7QnIaaTY!u`A#Uu5LE`DL9T3QxYtH6_~9arI`G5wJlh7uo`l@ptumi6~kh z>dv#SF?Ez0ebdGkYYyzw3Xce%E?l~mNKa2~hhk|d&=jY{ z?%ot@ce7fm8=uKnowaB%$l9}hPX}BADo_r@^PzJdq4fQa?v%J+#{=5jAVXr}J1_jJ z9kIdWzg*&?4(`%z3Y<&8TBv^>EOm8i5o^l|s_v3Rp70Y)XsL#6YnjwZGzJMApg*Ox zb)+R(;Rxg+X%};Zy@n@G)G@^`0+5u-NCCZ9GaI2cxWrb5R5Wh@CtIqkV3m+_xe2zg z#>?{BVGCVgc1w-)SXODhB(l}(jChG_+8~VJ^VQYEJBrMtDazOQ&SZA5i8{B>$q}b% zXOr!ZQeE&~Ql8bBe?d{PimE4hr+T-&P-o4mMAyBRNq4e`v!?h)fS2WS{m=8;tt_GKfxDYV_$6iROP3RmK` zhgcCvqw5J_)l3EBtfRH&z0k`8X;i$_1s-H=5AhtoTG!ninu+3Z#hLs@*wQZp>6mN~ zYwWBB-#&SP*oH2pFz3mpT)-Q~%OXr9sgmF#x|1F=uow)cD4B@O^ZF(duva`eS_>Ls zCwD_9nSKEy*DFp#N)9ae-#b)FQ|#IqIGO|{jVDqHd-L_DWi-=C3_cVRROO{H+K#YKbtEAOryg8&r^C5 zWk;Qh*db2V6VM!@4<=gx;w)uRmj1p{TU{U}1Zg<_r5VCwUn`XpeZ!X*2i-~wWY9Jq z-0IeiQ8>)vw!uvEYFjRwPsqo^iCsMGULqJC=xQbybd>pH(_kNfPF>_0*wv3FB1s3{ zF9)B$Yw>rR=V^X-%!<9_oEq-m0gdkR8Tu(7QWpH#)V{6i2mML6;TE%@5^SaQKep=Y z`{e=mGi+HZSc!$5`aBPd5U)uL#|wI{Rwi>ESeua|Xy$Hj7N5-V;=c=6Ck`FGz9x zK+@NEz6>|I>h&nYb$9k*fy#R&iSRd**@%uonC1tn-yzc%5ex?@Mp zE8Q!A8Q!Id&&#d#fL618n={PzaSzLOCM5ZII{d<-5-D1K8tIB)OQk%qQk z&6agC(}xxHlg)%NVe!L#*7YyAn#(a zxrXaNrZsNk{AUmUMHr{l<*<-vGC=}lYiFPdk}Td<0Mg>~ak=dDwKfAy0(Pi;yHmNM zG^=+@zzWMwI*0E?C=|OKfSrXxM(g`BKYrtC^mDe`xXk$o=N)h&-^KV`265Yb7X$TBE z>a`Z|!Cw};IxXGQ4aKl-*^C7H9UchCr>?~p&zH_a5<2`e9B6AEf;S!)_O5AE*W$?Z zJ5og#RpF~>L3nrwZkxbqtt~(9%$*QVfrKv~uT@FYT=b=SqGOh-oE=|Zw+`shd{E6I zvg0iy=lR)_zVIf^**9S_tQuM`YIu#B_7BQd!eNIcX^KRRsBZ-ULq>N$ZKA~(2twRb zrcu}k)y+5+)g{tQfUXzKiE%6BJ23&&=KO z1w}`OBRyQ?zOO}r6QmYGbthLp8}dqJKjqkdSvovi{|KTZs8VjT2+4{Ck*9WVal0UMZZC#C}7s`p$qe2+~0*Dn`QU*`uY z>l14>WEc#W6IaANm7z8@DL@UnZ_f=2a-`IT$+x-1u%%c!!0v8TXvOO~B``VE^ahV0 z&8f%H10KpQAAXnxgEjTW07%#fLM=~ImZja4e<%vqZp#TBtp$85nhQKr3j`_4NQR)0 ze85_Qt*iLPnMlU3nXJ>w=OtLbeTnocKw|vY0qB4$wq6ZoM&ptj_sYJ@;09R@f7bjb z6G^po=0dXOmT(61cM@^&MvRrde(DW6TZaHrBf_`qfY*M!j<8MKU29(J!bKU++*KyG zk@oytIuVx9BZ?gbX0hTi2)+}huKq&B_p^{b({%%UyUuue))TY$1nRdrlr^W>xhetX z_Yc+BV;UZGtR2`iE##(%vmG32IWv)IbSuXqq1wCWtWpIb^JfFOy-0ggd&0rWQcfFOIy)J;v z48a1tP~2bSGRkU;8A^lk%g2J#w`Ujr@HVP@Tf*Vg z63A~FN{sPeqtSh3S;KG;=Jt04noKpSZ9Tq9R{2DB&V$)>e&H*&j$bW$XBN~Mrmc#a zd)W!gI=bfh_QSyWzx)&I2Tk0<5Dg^=!iTzpo4)bdMxV%>#@3!FXHQaoQOOnB*ov`| zRrmRj5fPZP09mjP&G$|m<6`I`c|V10arCFp9QcXclydiVjKgo)0?4WTBpbMeb8NnV z0Uc5jXL2qI5^!&S8;0_Q+MG%y6JLl}m~~&|k^km$$2e5eqbsnc%^4~4rB|T)Uv#`vkSM_xE!ehg z+qP}nw%w;~+qP}nw!2T;w!7!td*7Rg`I!93iu%Zky)tv{+?5-!sXxp?6fDL9dwyM} z)UXrEZY~&Ss!Qa2^jkZIXZJJsyt_xmiK3LaqigSeFYo2P!-%?U>BzVg*)&`v16fG^ zPq?oy23*8W5cV^-$uc4>a@R(6%JIcLOu+pRlBkNN_92^a`18&aj|3tk^^!v|DH7Tu zD;~i5ftF&W2PNuDFyIzsZ)58O|Cs{!_J1AVk4fSzCMkljXblXTrBE&lia@K+|? z!HP40HI>id->atcA(gO$1=%P%c^B~~FXXOr@ZpUvP*n%}_g3-+ADE?(vN>IZ>BN|33cB95rC7~vle8F-F zEiju1ee{C-Xnpq-azpr||41hMUq;aTG-YlMZo2t!8(RE<4Y`}?n7(47h-W-@DpZ=Q z3k7kuKtNF>7qq%nlv3Zc>97p+ed8m0&osrUzo@nLc`%rK1y~q*Fvgm>{cCuVeqPaeTocTkjBFx`!wYjVjRLUxSOjT9FYoY9 z^LN0E?a25O+c|H6+!|8Wa`wG95aYiS2;9>DPgbBXzL#WiiF-Uu%`O$C?{4{^1I%OZ z=LNptT^BEl-OurrtRosDZbdWFeeA*J9no*L#CTv94GD(-00pi3&nH+1-`FefnEF*? z4sobTx+Q#s?6_I(qInc?oTGg(6dCQos5R3s zRX*h4Fqh=s-XBs~7eYFP9|x8{Y4YG%h4i?d%G)kW-?l1`w%5t*HWD?JiR0GM@vsXK z=5`r*+ay$PBdN8e(4kXwSh`b}Au{@LN|Jz-%m1MOF8L1@?8BesIjTQeii7oczqBpF zBnT1P_98j-1Xe`_v!JsNhpcez(X-#~Et4TD5?ebXd~9AhbUQVc_N;ssrP?z$_KJkcLogZ( zrj2U-5Y|pD$jvXjb^Tx`w0Dug(NyCKyv)Z5y_zST*q?%N^sf4k2z%TnT`IWiR21nU zhEgmT7>^9AhQZPE3G#9|z_ zwC^?NIY3dYzvEn{=iAWtQi@v8nP@5{!uNYtMiKj-iGn}ozi%go_>YY4oo`&B`mTqd z4O8qICXBwsxY%&%Ot6Qq^aNE<0!5>Woa~5y?7u9v(2{rG0mS#5HGq?p-16LbosS5| z(WMvOHJt_X7}0>d6!h(guKErd@&tsWgt^e~8Pk8~+za6EmCKA-vdnh^9Rxq%e(JBQ zp)3@Cl|hWcpjS}(@K^khtnUgpd42;bE>Z}fG-}pR?0@_L0KmQebpSHq8vuJG{DH#M z=fXFQQ_)}h%-uh_N0YueJZ*McKN%tj6*lqeQpxjU6duh?>^m1S7zwA)>MvK=h0Vu+ ze6nR1IZQK@^3;IlaG+Bntqy(TP*=_6k?0sX#)6x0(AEOk?bzV*8 z2S+Qnl;rP4)dcd*q{M9J`H1MKm)0y8&(AuIU8wu^*fErAALM(=3_k%Tc$Ss3mudAo z^_(j&#V%fHVOzr{J)ita0}H_U3NcoeH0|YP?l3o7$Vjdw8peIq&T$EcG-&2VCF!^oJ{|%q zbb2814%Fs)Ga0<3EDr!?PW`FM!_`@oR>sQuOTaiiyVPhFDF;I%X(NhpE18~bVfH)- z9`B_du@w`wyn|cnr+NzL*Pu#+=d_`u>~U^(e=Pb=p;~_B_1>|Dw#OkP0}pS6EVdp1 zgS*}U06>JtzN_Xph=L<%JyYAaj-e)x;#P<;s*wFRq=XbMU7DeMH{F&)ws{<$u4>XE z*Q4Z=>&Vjpt6;l!MT|aMA=_c(p%ApjWFXS?gO)~IQzP7Ll|F3y;;YSZw5ozBXVz2i z`0{w8>1Qov2dwQRRq8fLz+o1Hj|?Lrg;i{@ijpzy?qNI3d{8_Hw7Qz17zSs;{E7x~ z^2SQcf1Y6Y)}n7c{ah_>7VdmJeeHD2Oy1EXU!2m(IWYwauG4$_`NR?i_|wjvjL~)2 zPQ*^$tMbKME#laz(-3BhjVDYa%mp;gGxe6A3j?JV_p_N{5>L;ze>l-HroQRB{*dLf zVxvv&r7^p3zd%IK{c?0j6PL^?M^p~~j8`zTTHcqklm)(x8gob<^`LrlXw0+8s0nN| zhU8WgibU0`H>jQD@!Nro(jA>~fCP`E3+?!!B%D}-wZt*wwH7rq;-Y4B|FI$p8;*?03cP>o^AxK)ZK^ zSX7qP9||0CNk*l`A{W&;hKiV~MUZB`7O|P%HFriovNGgp4kWMXQfmy2Z&53e39bGRB|2{3=XgNk!B4EEdNr!6;{_aEBLO~p$DI3ev=_OiCKB_~DGThGFUG&^!bn@T zQ9W!Gf0{Y3`yjwpHlFn&@n)t6o7mh85Z;IMdhp&r{Qp&8$`_MH=ykZ+R7`wy<87-lSRVTGP%gJBxdp1mFe0E5O5b=fd?%CVu=)MHX&k zK{|HDeFtOCwUe^46;+V`!rS%#8Zau6@A1oE=~t~}o0BMRineV`hb&hzY~YJ55P_e^ zr5_1e?p>M(yg*{E{tDy&>xWbUxkG5s&@JB7Bz4u4T>s<@vA)mtR>j&?dqdr2hw1qBg{kpbnD_QCOIjwFV70MtpL>nq_2Y29Cb$KnLvkAfs(g{ z8=n}8c>vHxJMQO{LZAwjtC{!XD2g^kE)Q8H)-;;}6N#2Q=%6S$XwR;qwKEJ@T&~6f zn}izAW)&1_*U=*}J58$Ana@t)LmVrC1AAyeOzTEW`lnbs{S_XIq$&4&>Pw`kbmXK5 zxp<#wrra>fh^a<#i@D(qowAJZX&xYNQ?6{T$$a{Jow3!&a1Kfs%Bp8|{~nw@1SyqT zgu6lEp3qLhtSTYW5Aa*iJN`>Xm@6TUG){c3vNP+aN5=m~$&3HnZ(=*R zLxI>fF3{&Id9|deVrYapO@y^Fqq1p(JVAB(TBEOFto|Z#4ZA>|KaRI;JUo0GYPqo8JSv&zX&Kr@?F*3LI zbJ3C)?%W*x^oV))@!fuscwrD!w)8jRYVawxl6f&Q7GQr4!Vd< zUEn~h_7Gq{G*NeqRT{+7t`TNs4~Ci&+IL{3Do|%SOEe6!=sfEn)HHoUq^)vpu7jG5 zKfD;cE@-o=M^q6}h`w1oF?`3UqC#`h<5$Rk_Vp&hwJObWb;j-`OK% z<5{aY!kqnVy;U7#FHu(07sa`HK!C%+C;Nz@tg7Jp&+ZdGV@J+XviEvs)-De7(Ok$v zqfsN+426N|Q@~RR-J!GiF{SS?MqyW1pDrE00^~-GwM}}+WBGOOsOvf8Arh1C4i*sh zZ#_P}DZ1Sd6Y^7Uk72tiC;;s3(wDQOLiw^C9yJNGORsPkan%_y)g-f97cT2YaOGN{ zdG)9D;V0JeKbtVkwyRSHZ~uN_BG<-a@gW@ITH78vxmw}RIAy-omBnG!L&ED0MqQZt zikxXX(KvdxJ#{!z?&KN4T<9IH;@@V>Dd6#-;kH#*2FKfFf0TB?stL6nTQ%9Km8 zOKddz5Kw79zoLXD6S$*#+JAVkZp2e{r1C6Zqm32f+-3#=bW7l4=Qdr+9`RIoW3zx> z$_&-Xwmta^lp%Vs9Tq(s#;+}mUZaAB_FI;?$dWo18f$-HbETD9C`I+bLwjI&N}>rT%~_B26L93~=-q_}BG`+%FSqAiBY5 zcKOKkZ(r6Ww>rkg`L=^mF=v@NNN057eu9XZ|7I(SHcl)JT3xGX>deH(x1)k=y7wcz zXm}oFj*|Y@Nb3iBm=h~81w1V+j{);Z1(*_Nyrr8%{=)pg;+^JYbS*;sXT#Xsni6r* zO|G5Q59`q1BgM}DPo2-nPaEpTgL$b4s+};c5S|U`7#vOm?q>rq@b|f@kHB9;y8p1n0OuNkd*9$y-C$NX2lVGpB&w2i zmd{ZKXc?k4FAl)!)(}%kTfWRt%~Q1D#hrDBC_Vr)fIq>{v)o3hopJlf)Mcd1iY$T0MoK6Dn#VMkO>P#V6Iwk4|Ic@mcr#8kTKVUBbR zT129oO6YyTJq7sX`VF5P+4C2s4^iHf?cuz z`?Ce=Np4`~qaJ;ejpZI8zL8b25zd5Bg%Ls>JnOhrVVIQi^f!s;DQ2|S>I^YQi+E-P zDHEH=6|h&dK0UsMPsb{p0G~HG=i##eJX0o)kfxk>+1TWt-~O|dY=FHZ_ugs38F!>d zqWJzpMKMe@)2&-1@f%aXo+`Nmb%Uk6-o%!J=qpJ= zU8O@~mZJrf5CL7@Di$v@5uYe(##C%n7mZ;x1o$;03I$nTQAqEimcZc_^F)Vq@+@n{ zi}80mX?5GBzMN2dTUm*6_Zrcjv1=BtC$Mhb+m_3UA1m3`TqLRJTtwFk^Y(G_Bl!Ar zew#yJ{+a`o4oQAa54JBk3U-to$=^BRaM<-j`N0u`fbA~Dx5#l1%l=+k$(ODd(VP_G z-jrn@>P-N>vnjD2M_fVr17pX;@-6Da?_s}+I%Eg5L+c3E?c|)2XRo~5m@#=^joLDU z^A}}rrWF3X%ta8u{?rwwI7EcdBuZbZ&xxt(|FC*zkHkY>Sj+TcMb{?U>VH~)Q7bQa z;vlSvkYD<86+7hrHP4D~)C){klfyhaBO0r&!kB8VDqj6 zw6R#bE)iZxoUW-)OS<0c&G;&ukHijkewR~5iR%kbWz=((gSCg1=xSE@0!r&@B~dt_ z6<^p}jFzp8M8>a3D7mxzONw_IkB<_;at^@+un~eGLToCt49~>}vnG*smn_=u@*wg$ zKt{x@^B05U$gKm!RI?&+_$Vk3u{ONdx_J*vgpT^2_te{hy{*s+SjJ4lZ`TuPX?ezJ z`u8yshRr)I|HU|ES6Ed&*Ap(Mcy+}@(t*7T1h3~*NZo9OzH}S3H6VxgC{u(+*;I{5 zO$1&^Yy8y86wWV*Us${&tdy?Vu(JJe?Q7`P5oV8ki}2$$^k(XR_~JohwPk5Bp4}C+ z?X7f_^po??X`?9T@$IGH%xZ5nGF%09NRrg_L*BBbWn`b8d|gFm|Cuk)Ox|smxmscC z4WC>^7#rbGI<<0~_?C#8ifa)y+yi*8Y^OfyTg<0wguv#rZ@IK}vT%+tEvhZ&S|zI{ zdWwihBA?!RrEo>FA3D}ffkqG<-j&D$tSOW+*ziBaDvphO zOfMC=A+aXsHXk(|kMMEd%|QCMYaW7hKxVN*;=)lsiAa29@3}u-JTX7eb9hA7<1DHc z=;*$t)7`+tAp_@VObCs2I7<5em}3ni<|lH3r$i%XPshkwEcoL~&s}2K*hm-(4aLU^ zARBkD-e=f@KrODgl@fTnbCAPC${Piv?Uo!vtA3+U#rs~uK!**=MPbY`Fi zKXH0z95=j@wSAGrEqpAYjU;LLOx7dkE>EPXcyNUPzfM)T)U$G2>Y!}iqM(%X(#4i- z*ggYcF04aY7|b6o2h6M%Z?CSWyX&Fm=f*MHh`moh5T{$iMVM&VtQln1Q01{%;bO-v zZ;~JY%<(hTADwTi;jh#jglX{xUe`EgaM4u|%^T&aXEE&(MpV#Ao;|Cbfgh8`!bOIh&ts`!%XW z{eFz1SKLTzhtoei#*sVh0k0L^WIA(JAyk8(8^_M7Ae#-j8%%oci-7*^dSQ6KBWVbO zQ;q;l8~tHgajoy?x}W%K4$^ccSB7qbdVUJbQof_td#aa?mjf{KgU)!|op%xMdg&*5I zvn&o_)|=_l{H)(*M=;eimWMh!`{C=K7olNHNJa2>{CU)uCgI5k{>8+ZM|Y~OHg zE3Eqpdx}AsRCB6p4!D2?UKSQA{p~%Z!Lkui*a@HQRJi#>*RE;uOyU17`2Ts*V)m}$ zZ}T%XlcTiy^xgyJ!7Se6SJ~xdXZUnLb2Ic>0g&10%P#CNA~w6fFRdl)g5I*j$Y;!W zr}c2|8>$0v*Zjy6WRYPf8x9;IZ<6^Y1G+^I_4<3Sj4ctYyfU^4GBt`-1@r^z5D*ja|$Ox4K7IFo^7`Oa6rfB`MSCXA+6vP zhf)BkT)w$#H5DT?@}(b4yxH&a$`O=Va(~%An&%n3X-Na5*`;k81K>T$OimjQ8vN)8 zoj|DYtjQG6UaYXDAd;9<>dX$Ip^#i`GMe03^#Ef}r3V{Jv<=#8WAJ%z|HC^9y9W&F15d7$YXQ% zyUl5qL_e~pNU6ao6<3hulyg#*);7W7g-uc4wo1VtNAFu=M{$`AnF5v8e9RK9OaNU= z%=ghSxq1T>jM~Mo1f4gOVhL`hiy#93uc*R|WEzlPpB93lV$k<SplZbQU}>&v}>CL%Re@-xf{tyC#1Q9rsrZk4L%8yt#oVc zPDq+?pflPv26}o5@zPl^tcTpUgQ%_01e01xNI;n$iekk)h99aWN0>BpgVJ= zI20TV@csWh$OvQ2$*=q)FcNQYaymFA>E2F1ILGWWLX9x5_1g)BDCRm3^Dp5{GYzI3 zEc1r@cdO7=PgU|15Xfl#PW4md_cG{8U*_8HeXQTkDx)P`(>&kborBWwKO&%#?qWDh zg|wBomfT@|<$+6;eyt_tgp@vjsnBP)*e1;7AKyWKqjcAL=NaY;$>x+;tNXl~$1n*d zgocYW!Sry)rvH8MaHpoHFo`0B9_rBa6y_`+BqR5iD3$)@&ln`%&3H&SpoV0%|L(Of zR7ApMF$u{B#9G{#|8mQN!K3fOL<1I=a{Bw_g_}Hzn2p&p5oQTb%Nbc%+T9(=cX1s+9|ToBjXPu%->mv(Cd7q&T#e9RX=`;Ugwa(7v5)PKL93Yl2Q*Br z%rZH>;#OV?wml5Vo?nx&rtClj67-}9zkX5@B@B)BS@XSBK*rKF6{Y= zsc;_W)Zz?-(wB=&xC1|iNygB_s1%TpUxI&`b5#Y(Ae~7=F2G8<_bb|lG4s>ld-Zy| zfDYR7EgiYyg$&1PYstKt_pG`6!5|f-N^cjbz=t0N_USRPpS!3m9`%HJ5|E2!WS}w+ z#->+MFY(*ZqF)yT33b+4-ynkcF=k;UCDL#XWAys8%g5hk7k>b8k@Y6i`|w?Fs!t5; z(+nBlb8J}D9S^sX764-PFjpt{B?UcPSNb|3Pwte!4hngr$e~14e{)0gaQuk?k}81N zp+355PErjpH{5qu!wIT>=K9;IDmY0sz??8&U5!+mu42SdJ1jRg?U6pUqM)q0SYe^d zrQECg0R7aA+T6*^EkH%ur zG=N?sZ#}0sP?3V2F2|Q6+Dy#yA(j4JpLQSEB?P?BPq0IMge7LNp{RY0LLSE?Ciq88 z008o!0VVBz&V>b0vTH|!l8I2yDGeihn(R1|7BW5q<)G1m+kZLDTStm%zXu~}t`rs7 zBY$)A;MV0kzASO1a%Tdr&-yD4iTFG8?C8mUyEYY)>4D8Ms@y6t=F3|oj#)GU&_pnc zj+(dhr4)IjfZqm8aS=o4+Q(H8&@Y%sEWz1)=f6H>q74bmbRUoqfDKcc{CZgcX~vw2 z)G7~nbq&OkABQ#7P?3t3t7RKGkiY4dBzl{K(Bzw?u$ygoYnZGn;anE zti~qX>;M60G~VfE`UyCz@JTn?|2GO-r{l(GB9L3X_K>P(ATe8u5G74b-t%lS&4r&q zD8Nreg}o5JQf-E?fs4`oi~+P);w#7rC;xYR2dQa)r{k106Ds+%1ymmWUDy_*;G)Ma6;O!COEe7SUMYn>C!aw zEewPO#0mUY8I2M&l*E&;xA0#2hOn(B)4Ye2?K^sWdBn%SnO-cCh4NNToa+R zKL9}JYBZ8w)_zI`-6Twz?=YZ-Bjs#}KUT?({~qso);-@o-_=BF&=}bp`psr%tg#s;qf$an-4-YrGM`ZiinMCo7BH#WZfasrNOcP2O`Nk24tvTzqI*(c z0@~^K@=zdPjmN5ND6ReF$>*IgKRNBRf<@e^M@^wl;a8Pz%!S9!5na4@l2v+sTHH}S zi%*$0;9d#a5Op?hEqEM`4jmx+wgl1eAEr06!RQ`@%#UDFrOQFc-oK<6l%A9lmw%{X zi*aHgMuq5}7@Nf|O1_D9$r;`lWdQWbdF!t^!jb)@$IL_hmLP~K?pX;Tw39hV6<-;2 zO)M65;2v@s5qnfVP96~*zz3wQs{9tH$m5u>Se)Q-|txM|Zl)T2FIX7-aL5yQCYdZbQ)KgR6| zPwd>3P|C=q;1_XO1GbGZ!K1BhA%t0 zsu+EZdn|X)9xkDONYvD?k*S@bP2Y5|6`Ct<)x1#`a;YGr5L4Q7Mf)-t)t#&ZxKYcA zP`!QRZ9gc0o&|dR zhUM^^SvS@X-g^JwgY*yT9O2Eu2}LgN8trS7dxf^-?!Y>uKmrL>LE$E!OxWAS4@33q!R@tLD6O9$`ZD{9VfK#VIr|SuPAwfkotTq`<yWzv9Mj>6(ymJp6mkqB>I#o*^s#e+qO9eyK!3Z)dJ{92hr;cy!}Z#4=p7JM(( z8@usax=2wedED+MsV>Z~kGE1_IC$@8A(G$4HFgX``C45;3y`TiTB z+YPm@q+aC}Z3a;+))f9-$RblCDw0DCm}5=dJq;{CdckCz3oj5$c-aDXbB1t*>r8Mp z!0vAG{4R`3F6*Qmjw^7J5!#7w@|c+B?ei6`MjylSrt^h|l}RJrd=Jc=<6}LdNJsxP zt7XvN@%%oNgQZ;q-S87jpx1F1*f@s}r;nrj;Umed`y9Pd+=4Qw|#& z482d(K-#lJ&>pXkCQB!Abb^c0Ud+DtL=P08U5_Ncf1%58;JY5WT7yysmopCm>l|+g z@?#90NxDoh;5Slt??7>WaIu{T0HEk2LoK|yrLi-HxuR_LE94IKt*k?&TKRSSks!Mg z8MABg-Qa5;GFr(@WTgK7)#nrYNavD}(rzpEmMQ5b1Md3X005)np zId$1;qH(H~=8V@()zT{pehl!Q$dXWihCwHFdEicJXrJS!8Be9$jO}*hFiDzg-H^d- z6T0z-wA^xy$Z^_mrhKkKpYGjPHB%f(aoiruUh!0djX#hH$f}!ty@M><ys5APCr90|(j4wOhj@@{>z4)cx6ot4$3hhLSP0x!;H=rL;3kc~*bCri`y2 zT0xSJy>vdyBbW~w{_QH@${o4zAZi|c`gE_u1G`MQ^3#n0+;!YNFfWIDd;|K-Tcxls zuU%Kf^u)O}wzl!^;U&TR1zhtqI>7o!hKvb8V6iov?%teJ^u{u{&f2>@EC{3sc&)$g z4$$L0idt_=?ppVTkiBN>HGlSftBw8KXZR8%^Bl5Kc`}oBp;&#NZrl-%3mO5Ajsr9d zQIKUDqn!0g4R?prfvHo~azOG1PGA?l`-Z(L$Q``)5^FNl!=Sk23Pk;7LAGX^QLRun z=swI`t{Gqt^45LTZT*dDVXr783;@MB5xU~qu2WxN_12T4CA`MgZrc3Ir*g!Ufoo6R z?PYCxlj%sirhB;GfRJ+rr9o&7sVPn&j9EvbdbJ|!09`h4@M8Jt1>FG%(U!d7{>rRh|=H7M{!pU~@{Swf9v(g9dLkw>((0k5u#x_d-*e{lWf zF`;DlrLL}_XCcw@VyvI$i$n<$djfT9e)xQKe0svCjL0tVT3)@r^nti#6Zm`%#I<_Nu9&0BnpC}+oh#{y3 zGz?a53>>7oYUIulUEon<8&9K6w|@0Du4E4`-CDUFJ!>?YeJeCOgJ|*MM89=4r1!Gd zWNLlWA(sPVa>Nf}Q=Qs+@B!i)=HyyA=hBVmtaIW~UI%5vbsa4kSB9O!~~Xk2kh4y95xM z&HD2PA=`iK;;FRn9aMevEXJBVI~(QP3=yRmY7^>Kkc4izMw8f|A8grHKVBx75$C1! zQ~PR6x-;>qj}Lg^T?Cnf6F_8~v|T?Y7krU+a^184F{CS0A8`nMa%&YLxm#a>^;S)q6HNOTE zPT?K9oNE+$;(GW^<||iqN^=T1TI+_K9gUN&t<;az40dxLm4IFjkfn%ewM)|Z3ZZLS zh58nPu%~9^pfnO%e%}e(Qb&hc_WTF$__`9RYARgm&|UjkAN10MI}5nw+Yc#@reE)v z-0OKxGRaRjYQyyFY+A>EaW2_~l*}p*tLhdhd!!Y3=ydo;W+fTN9;r=BF8msSc~(n#^Cf_fv|og9!}F_jDPE2k&Cy;z-Gst&_Y^M%wFcWs z^`t=I<>)AKpDupDy~kJ`+YNv(wZZ`aZsk(_zU4(GMftb&JKia7gteLnzrUrnn~@lz zzi`8rg;ACN?i;*>M~^bzyhv~|O?S}hf;pNfRf_XDT_b}m5%Mda0k_w&z!J!g=(uE( zvk}K5nH8`FSfOdrGcvk^*u@w;3lanSB?{k|PFidu39|Q`;+L;KrL;s;nm}|8+_J|q z$)qzyx^)w!po;EyeD^%xYUbmmVlfK4hIKpTv&T)c;;2oJoY=JdW9{x$(Yo}@Cq8RB z;0k{x@;1pl7^`G+MG&8~k_<5WD_Ni)0Jaar1%~d1bp398)KF*dcZs{936Dzr9T$K< z8D0g^kh%+)_%LUZut$n=i%hU?rf7X=iAkr2YV_pw8J~G`mhepbD zidYN^mN!rKT)bNySbyHJW<-%IA@bEvu>8dD*JlvnR)4yUJmxlx2TBT&E_0V1efb_mMG4D5(1jRC+shEZrkG+9m>84b|XI+EP`0%32jk$y8U0kOSz<3t7^EF zo1{1`W>v&z&q%Gtsc{w4P{ZCxcg=Z8V`S+fMf2(tcBc;z(y(N{hW-6-4(r_C2MLH` zVhE~!4gZC+;ffK&;zRWM-+O_<0VSa^j`ROf&`V#uk)f_+pf0l=*3Ycl3-SFN!EM$9 zV2=x{#wzf(RZTBQFIJaIA9u%c_P+mOog?`Gp2@6CyfAJ75a!u5GB3HBf54L-TN54;!oSPpl zNb05p=PDLofNkHi`q#~~4{?75nz~#Y!yw%Axby37r><+aj8>|uh-8j@Wjk9H0kSlv z-$fdmNNz_oOBje?ial$!c`0R?jVm(VAJ~j(H|^sK*(6G3j@S%Bkq=;`~ZgC zE>k|Ir@h^=3ofQtv)TCB@!t6u>_}SiTFYrTk>*C1ZyUZ3Ew>=XEaRlVS!}7KBFhtS zPh*c`;4R{!_(BhnthvwO6Z<%fqiCFCa>%Vfh~@cFf`WYwE82o*uB7s!U%o`71B9O5ixOW51wYh=+?uX)HAO|>$8)v_Kb{(8-(1ym=pKRj z=VkSrTje!;g!t@(u(EL|Xt6>?dSfF`(yiS@hCGxOJH4!x*Za^I01XMgzyt2Df0-i< z>v`B&%-e0;2WN95NftGIxtP%jSej3AAtjzy(v(+4T$RC}RpWI=v^u+m#ttvT^C`*( zHU%EcP65_7no|CY=w+Rn%*TDmksA4T$b0?p`-c~mJ{4^VFQvEkhh;(k4ab3kM9$jf z&DZvXlx$0DTAbolTDz7Ugp`?Ucw)%tT3=Mt`xF!i6KX8je;$R8ga~%zpVF@$ zrFUBmA3g)21AKqqx%d!2EK_`)=sf%Z_7;vMcKe|acROPm8v<)cGhOzHE*Ic|MbW#- z`uy7WSnNt+kXyMC|1qrj5oVfp8KznZ46&AaMReFN23&{X*${kQNH-SSF{vZV9kaDc zwmV*u5`^oRQ{B%zwUp;GR%5=4A>_g4B3S!doCj9?ICq8G{hj%|Er92cG_ZK!$oTi$ zc$$ZjP(IYz+Z_JQGr6ygaq95JwvCSL?UHU?R_URM?T0lp6dmVk!;T~b!Os_wYY+{$ zZ5<6mS}7~Tn-aSHH(AHlqc~&VCWca2gxoemx>i6WvhHFdf?|_mA1=2m#Pa8`x z&WPkRIe-{Ljm}m>{bn_rmm{X*aguF_&3KKWr>&dezbspv)EvT3JxK*OpjvePJ4F-+ zAMj6cFCJvk&vU60gVhxs?2S#w&l#Sq1t0-d6L8dZb_Y^O}?K3vUbQDV@ix!8W+7x#wP1HHa z*g2h}$_%JR8p4PObHRe%gxuPj_Jc!1fr9hm|Kj0}A<2lb1~2~1mI6Y96UTbjNjtuw ze{lWJO9*r$@XtNR3p{QjCFfl_Vxoo3l)sE^khT&U{6~kDS95RNUR=eA#J-Dg=PZg; zjH__EBX`qS=gDlHwY;&9YF~8 zZ*mcy59#oMn((=Q2kdm7C0|LMi~~|ejnYC;#ta+3`;bY+Hx)=r^+p}MHYb2+y;#Xn5|TFX77v=)|g{5u6}Ln?$S2W4&E^H%kV-1gX_ zlWpr{;Jn9}4`77=?C|f8^FIysubgm5hnNazv{+XL^GEqbpcmaZh3&nX_Sg2ZlV4Ga z*4kf<>b%62{0Xz+K@&GVqWMmEavlMxFaC3)vgEkG357KyVvn4it34$%R`;Udz2XTz zYvwS~gRvO*UH)*W#Rk83L&Q!R8 zFW%XY6zv0^x6m-&vlF zpgu7v=}Z4=-YGm%$6neno}?zN*o;6bIl3Ar!vjHP=Sx3-{G@VYyIj@3y zt5{t{Au}Q2EIoaJE;eq?x?$~U5y)tcYBWjP?+R}FrPAZ6t(hWCe{r*rjdupIl&u&B zipg~)2nf8PJSZ5Lf$LpSy#Ov(s-z=KR~xy6eyI2AtcTfA_XM3|=#Gg@ zlIYpxMoCUqib!H{|GQy+)N3@Dlv{AT-x=tW&or#GPT#UA=81MC!^#|p%&PtGG-@PpGFOFbO>j25FIBSTFa5& zEDHnVafYExM~npGCn6w%%+jC|)ELJ4avt}o1@tO3k-y$Sljh3EGa>>3v6jR6pMbrO z`=bAUB|oxIGU9M(oVf7TUyrUS-KewSyKO|x=(mjPk!^OOXtlIa*x;2i@URW8_$^9$ z!D_+jy}K*BfLsq&r{F$*&oVd9z;s?k#~JEAli~aGFY@5^pnCsEy8*`D%=Owoy|8E5 z6Prh_0N+VVS#cEZsvNey-s$J=#u-}QfF1PmWPT@w8#~9SfzwY#aw~dbJzMmzP4qG2 z>vHZaU~j6Q5gfy2tRPLj#F^o9(}qKoQ!Rrb{T0Q9m_T7nNYMMu{dyhjd&|5w{2;(`kiH%$*cm$<>8XXgGrmY4BR9%^d9yi1 zr$K`c(*6dM#kj4-W(({@ORmZ)VRTvL zcCAGJqfPI!K+Dl~5MF)j$V zD3T;?N@Y^oZddy0wZ*!vm0uBJboN7YOrxdamHt}Fi5Bf*crpq0E&9-JFY9HUf}XH; z54NBJdoLYXQRZ>O_aG=k`PByVQxiS_v;qCKO6cGXBvetqNY&$PyJ*Op(XNYw9$l2W zKHie>h)@+avn$r$`?RpC_Bz>6&N8|Tx*m`GMiyICLKhNT2%@1PYWGCP!cV`PhdxRcD{1;)WVP+px$IPRyU+qHV`^(mSQbavbj5K~$o4w6d zWm{+&VkurZPb-2cBmQ$dx;G^5w&+*xn(Vk?)yu{vxaX})ga8Tz(TXuk3c=+Ju2=y3>GhMj=r}j(eD*h6b3c0|_s3udB5mkr>M{qOx7j!2qYiv$gZ0jAXN~*oYf8 zRj555${UKS8RATjf4>!#hZ(aI6%WdWnsR&vg+D0&!s5265V}Z{#bRKS{ z&+LGovMPrifq9IyhJu{6pW&lC#g<`aYk%Ka!u;Gh*h__Jl2h3ds%SWJYBTOCcdk!0 zvw5o1RbI;*%=j3h+oQWRzVF^d2mB;>^OhnnTT}gxmYHBCH5DS3dNn| zP!BYAO1B~{>B{)LQ821m>5<68)z=Y)_IY4iINkR@E%j>Ha5sd$ShNs4-VD+0L+iOO z3wL;#XzSd&bcFB!&oQoScE+C*9t->Hi&Tn@tuQ1<6b?~8dK04NW7V`&txj~!gz2E) zy*>z1?1g}C<{2;4PE)%q_+hyA*CrX;0tC=p$iJUm;vIsNs}mh68gjrgR^dBv3=m=j z`-bO7LkQ|ZO<%aF?J>)!zc+cs2pLSIxR~70p9ihiBo}pKvm$5hAklS!Iq~xgp~82f z1PvM~X-rH5cXP?R*z*-Yok^#8d0Paa?Hc;$uXca*sCUGV&S86C>}Pu1-~-_cnKuy< zsu#G=f?`ZKg&2L%2`aGQis;-UNOOLna3Z z;G#(H_f%B)D)Hl1LH<_Gz#do973TxPc_;VxipjeEwcf=O-%G+g4UEV~9ii9&yioEG zXpcE=>P+ps8vzV(lElS0Bcr{Z{<$#q3jd=iG<8eNylM z2(&s&g;sP*+W3I|*G18%{9;z{c~Xi@6qjEGxUadfa;Ig(&pWn0FADVV(dGcQ-VUub zcK%$~89V?U|EfGsYZ67rY9dyBrQPjUjBV5pu(tIPvSgMkx)!2MiEZRK&E)^vu z05L<=9iqXEGIT2laVHSJ2a+c|ZUN8m8=H(5_`( z@yZ+`ImOk&Ck#h*b;^SRsf|HIVe245sV=Gn%-Y{(Mb^?=g6Vdu5OB zFMtk6v#Dvddx0SNZ3Q2#ILTQt!}Q@$6hw*xfc~j7hO*k&{8j(4uV@5h+A>IXfw`^}N7m8PW? z)}ce1X`DOh?rgs1vAgnaGS2Ux5phH#n4zzbT%XfTrrT0=tp6k^ zqm^oj;burd5`$@O73M`Sy^(pQ902#qBZD7#gt;IR{_<`6@5lNZGhbnDSzEqK0IEPx zV!%=?XpL~YW<>5m^E(=AN2;SZgq@EM{}b1jL(W~8LMg2G z^XA>e6oyTzEO@q|U{J^`;rZ#U%IR4aKfGQ7?VHhPfR+F*Yn7B^a>uRwmYbm`6xmC2 zSvazb*ulJ0FR3}o=NIR4-P-6bp>XeZ3`vTj0- zM4GE+Xl6)?Dgi5XX3HEahNgGY+)WxOeqbiNh#6)iQ4BoGfPOKL50mxYIW$I!Vc*YU zXhsKY-BLi*bIv{P`)iJR+xS@k4gTHbYbkd*2GV)L*s`dv2xn!3fjAg>1BDG4b~y6E8F=|%6?LQ5t1ezNdngXI z5j@h;>cQnVy96*3we#OIPnE|okUewyeoksn0gFM}1>J?hS|A7)%B$0!XILei^rGU^ zrR#qwG%p~R%o(~3pL}lwHn5!#1=-16L-0UC-VpKD+JXE1@_gPZTa^9Hh9JfwqE$hA zs>FA+11)b-SIGduj;E^ezrVXGBLZJ(qNMcNza>hDvTRb@O+Z5t3bXq$k_|&m41R!XAaM%3LK-WUP~lI1j`Ts;}hTYPRqFfI}WvQ(R&v?KG;Ty%dCwM z`$h(Mtda2gNLg7Vb(p8FY6>@O+Dxev$D4AC#p3olCiM^xO{Q)y$wN)9TnaYh-0jI1 zt~fg6dBpk7IUwCnR4%`8d5=p%95e%(~iag535Yu@^L?G zVxhlXgbY6udtvFa#sPAG^LkrSld3>eV@S1RHA9pc?c;DO@fj@i(~><@b@<0WWJvgt z1@JYPxDz1txAmSB3?z+uG4<#Hhmd&VPc!ckm3p!cHq-V0dA%a(@$5a7sOAEi50|@e zYMW$2&B@ICJjcAz^6Hb=?8ub~#DIvNA%Q}8G*5X37m>z>&A~V+0)a7vC*Nwe#9Yp=59em8^NYYV!%p^;4z>rx7ul&I46$r;?#&bgpjx zJUVazH5z1VKejKO<)_NlErzQ)%Cm=XA;(>x~)i3?cX&G zpqMtuL^9Ksj#8!R83%&3k{M#*DUPt;fao9Z{6|{xWzdl5mLOY*Y6CBQsy5- zA0G@#(Zu4RJv{YDQZo7kn~1LxZ;5c~MK+9Jg-)Bd+KV~^7%6GU25~M_s2hN$#Yot7 zSsaM`R;Bvi?n@GQkSLQ8Upoot=ihojKPlj0-T{4e-8&G{p|F<2>rSo}fw1g(uVG8}l? z1#v5dIta)TyRtBk@RWTy(bHfiDiP1FU8*2HAhYuCdo{P^bihjA;6$w*n66*Tn4Pj3 znOtp`bhgFiqW7XNwGxB|NJI$k7&&k^zH9ufAgw-~o1@m+W6Iy;s0d_{Jd?`QR|Sze zu2g8;)5jPPws@KsRA%_tL;Ngq&NYZOf+SEWu^Wi5ZOO=%G&b!mEFYTC5?lb}>F3Xc ze!!spXFG7Lfz@Vck*ZDlHB)$6d)--dq&I>I&bl(thM>mD-;I)^w9F&{S<^zB8~4Ai z6<3Tn1;qa07|REROh@tltStQJzbLAOF{k3X3Wn=^3Nr}S&w(We>;CLY-}DVp`uCzr zJ@cD9dwGN&aVP|>LEyriII%%tZ4NhJ@h0|ob`0Q&^@U_QjqmRBP{JuC#wy{By*E*I3kmV}R1Np#$%0^Tz#<<$3DFl6tuAm%u z4dr)(>|z5TqS_=*{*7jowmKK%r#|rLY!=U&!Qy)Waw0?~i8;K!sz@fm2W7(`KhJjeg)}~* z4Q;>}hUXUFZuE|Dl)w>|-rs$dJzzAPTDzUkKDF|`A8MTr4d?Oi)d8=i?YY?m*|oh;S&#Yc|e z9dTOh9YKO;!qDlx&j;zE$d*EZUfA*aOC+Sc(wXeah*+cr1sP*XcerS=cenl7<^M?HKCk z^35gTGZSfgIK=I3NK_^zlJ#jWJ}OzO?4%VLv(Fq`&=5wNc-NR$yKs75D`C=4L3Iob zBxuAzn}X0G2j=$rQu*F&2w9u|r}pqvN9?0KuCfJQ77?f?7GUGA!`0c_jus0mcPIM) zRKw~nXsBN)8xJ3_XyHK-wJ>oi+J2#Vf1>R~tDH0hOXr4SOwXBmUh5roTt)cPIig?;Sr*xvJx9CSgFx*k(+~Y19lm zpeeF;W%SmJ@fqIpD@A_qeZ@yM9Z475TH%(DwAYc3)UlXzMtk5GT@IJ0T#fpbr_Tgo zCJux=4mHM!-i9;YMx@mzvqLxOMy5jM%YWAA%+L8k$Q^Tv5|13pW5_w{Ddlu6w*`%= zf;qrKk!wc%F^prD>yx&Y)#}1`10z;Wjl9bIJIcS@e{dRU!e(OLbx$W4P5wrR!oz;}C-D9_VY>kbOBFd{0Wuv7h?<@5kkDS&w}ge|jr@DF zlJqhyal(F4s4DN{w__joxH8RFKW^#3It>`}$gx4-WZ~jN>6rja4;jUE(N1J~Wkd%) zsQ-`P5$w^?+JX6Xbv~KwFadP@E2Ee9&*0ViiEt#-#Nbwjx46!aWy=bJ{UtSXSWB$saDkTu9%8OZb9W`sN>BI@VwHvFU>tFgtueHgfO1 z=f&8Ym_o~__j{ce03+wNqc3ir-p-*#9H5<9J&T!Vqg}`G$ReaApxtP__gbWkKjW8N zc-zsO-%F$? z?sR4M>kN8_oX%`dM4M~f!1*U7I*Kp=?UVoXFiyPY1WiY2T-vW9$Q_s&B}M(UF+6I_ zhx)2hgegW5IO*C4%E{?ydxCxWU2KjAy>y?1z^*rem>>$$lzkx?Yld-YxG5-g8e!HX zO71-VSVVqNKM;h}QSpySAui+J3u3Fz?y1+FLhMe{3B`MN&nrPR`b@Vuu#mQ>H!iR1 zeoKH1aJVd+aeIK1^p~-w*S~a)-Sy)>s@{)k*q^ zttQykDz?2;A*YJmj^2(f2>%ydP3F*u(WQ}r+cdwZC-;X|< z=>Kau#Qde81kdPTQ<*uQ0&aw;fD&eoW@ej(-!|3PnNqtD2z9y-eS@6+@3b2*t)ABO zjS4N}0+&@l)#F5Qog)iWlocdKmd(@!gu!yG0#7wR>A?M6P&OD2lSou}#5?W%l<9Ih z`6g)N+olpM5F?8Nq}Qlmgp5qoxe2>IsCOCFl(yw7onHCnf$*?9xopdR9x!}KzjV2( z_jGVnU=k2!_t`*~k~YK&8r;cxwrCo--)-xKGpS)KLTjQ-4g@dy@_La7rUt9!RuR+m zi_~|Rr)WkuDikdJ@DeQfp$ha)FkP-m<_z^{o~RSPCy60)W$@IM2eAx`0_@m`ee!%^ zgsG9CSP31XXh~%{_CE(H&Ls)MR`0f>?1)GXkmHK8Yc!ExIf2Piz2zvn7aKbdu}=6$ zE)*jK)P(0U*||gMdB>~Kze{@fvzKT+U&__JXwa}9H{P&Q5i5_nDB$zyN<-3NVmOHj z0ZY0CUumr_+XF#B*Jb+*w)_Vi{Ofd(m_Ls6ccNO1Q!ZRK`;k&R!My6vG@P*oMX%~#rRpe}!4mpV6Kz&NOOxYsoImigM)Eo}_8mDY039BtYQEK4+_4&0}}; zXF)y7Nlh<+y$<&ELg*#}Q2No00&#B9^&VOKsO;Hj5)09XB$FdVFqNnVzAA@@iMQ?3Hu8io7Em#$_UU*buOshND5{20JLYNB0-6lxM1XwV2*1e*Oh)($Ys?= z@Zo6W;7-C+1C1SPR*NPReRu6dPkK15Eg-o#T@^AOCA^Z22(BXXty_@98ub$_EXURv zI6=J$51#2m2$2IcV;eB@_aA#5T!5AJy-&&eu6^xemj-Eo}Z_U3OW4RYU~X&1%Pc=D{5wROPbLx`8Q~ z79yu-^#1#u6`bMDoGL0v+B^5dvX<5)r>Cmti+dm$7J_tupb-V=NJF-=qq5b$e!l$l zngbDA=>Cjsrjv;~{5d^(g)(^-VMzkyQSGn1$lYi57i3t()+s?>I8UZHd$`KfEX+3G zO7D~Dj@pgpj@ukKUnz9pYQ)*u*zy#%s>|9kVI>1GdpF*FD0!=XzN}jTe6sKB+aHen z76RR5zTCj-W0VzSSK>6Yksc0oME&A_GagcGKCIv^ETWAQLi6=1Zr@zk$1oJ~%JPWn zj-Ni-p77(pI``_cHHU8J| zgWKhyfiQ22MeZm@0^y>CY%ka(_3|Hb|{RX?G#%PuNb(s-{xs>=eBnJd{^>Kz@oSdu+^ zgDNMz2e%0N6|<#yL*bs010y1`?Co*mNaqb`pnY6{yleA%I}hf)9f$K?j>Gw{M`8Rp zg`iJQu#_$POQP4Ku>Nb&*nc(X>^|GsthvD$mN;-L*~@e0y6}A@JEU2+8Pn}at%MUs z_96A1o7&8^Te4AmzkFSWT@lz81_aFlonVA{l$Hr;I6-|oQBJ6Jreb~@_n7f|$d36J z__-=LR^)4y{af0j_veQ_ge>eLYUhw{Xm@Xt*9)Rd!I^_QIPHtqa#Z#rj5Dv^&Kmwh z!kCis?cUcG_duXQu_b;RAN&$};%X-~MXPj{V00~3 z55neshiT5yw+L>;3RFqNa6V={6`KwwKeeVnHebmZ#=iJ#hD%8}yS?ny8rxz!Y}bv6 zm1qKUo&ubuuyAIs%epUW9*6nupc!iTz@Sal{r=$@?We!BtUTeqmX4gejaHu%NB6|@ zK3_L)`vHb$=FenIGZph0da2!#a{Gh2y$O7}c3&>DBj^6#P6!IxhVGXN^}yH3pk-8V zVB*Ep8d1x289}fMS~&p*;((hC=6fL3D~-(e46w|-sBq&sGb+sS#^LabA_`U;e?M^5 z@-qv>Tf#Yn(2eqk7h=`pm#$v}zjkysl?>^f0QAnDz1zqz)BCpz#*(+gUkG4&8$sGY<>{r7!`hZ|y(tLinL>mrD z{fW-B%8oRN?OPZr?uZIA$ns5HEgl&nwDGR+V(~6&YDoAFs19iF(|k_F<@UPh#OH6k zfg*z=1NO}xk@&w(H>|af$k%5>RHI+uhERALrla-_jsj%)e=po>mid z7S*N<$!gWU(*{Of2pYox3v+*WX!24}HH_QdX$r|S6~qMGl3+9(KA)SXGchYy#l%OM zv5RkIFSe_Be~tCUQ8Xrb(qUwQU&WptaJs~XvkxZu)BsJ)q40GqRQ^g4h%N#bIfxt( z+oT1OI(`WJ9$0UVyF@=D?y(5FkU!xhTn2Lx5>~E*;%J~ZFn4<0QPG5hB}_osxM#*wY3{c}kISS`0u+!vw9)RQ3m zI&HRkzz4-ds#sUWN1VEi%^DV5!miJ3H+U|%j9`Q6#fdy?HigO%B%-jO1Hi{1e3}VO z@3S(786-|(=t~#_1%b+72j@l>!1%Qs4e3?az%jwmcg(ZCP)fZ1z-n|EAi>t03)`ZI zQj4v$w#8C8G30A}P6XKX)eT7*-6>WToxfPytdi8abx%@9{G9=~c|tZ;XRk@Q!GD-B z+ooW1X@g4^)Sjo`UOQxBxF=ub&$LgzqjoXYbJ^JytPK0c4wI6t>ua-!wGxtIHMqv> zlHnek)iVt6XlTlSVUBwYSpc{L=uw1!b2a|_zOx!uHNm-4v0jqlTWlT;{%S7iFrMHS z8{HoC5i#Y7KC5Ml@oTz1fNqU*v^q)rqR9dM(#?T4*NA7Vz?jDg-gs+RcFs4t%4m|O zoJ^Mb--!}6uNDobi@cy*8%Ymi>eakhX+GqQLE1h9e3#K2pTL7;=uSz(>um1=zhAp>?apwM`ui<;Nu#)W@(6ITT9MF_A9>|Yh6M22E#8D^pcM(LN)!aoAe^+r7N&Q{K zQ783x5EFNAblR|wnRTHAJJV`QiZFv5Km15=xD63r;Ny|uEDW5;q7aTZAKpYt4XpZN zx1%Nse^X+~V|`i6G3(UVnS*?LK`Z=a@>0ML><4#>$l`t5FUkZ;Kw&?Wv*w!#!E80U zdo+A+m8=X?g+K$##nZPW$oOTTg{Mmd2v`P-9vsmvxDSti3{p0o0R)ujp=jJKoaYtq zh+P;=NaP^Z>bsmSzg%#CBD4uV@XAPLxSp2JwqcT_ntnUJQF#`WMfO?! z`Z{Jn5%J+iBVS8X2`RqucY&WH)&@C;`@;NDYenT=$)@V-?FpR^N!yV)`}9 zx9-9!VBpt7EFJ`OQ#5+n8A!{k>hb7cb+18$H=*Y7)h+Czat_CCLr@?LRm*{p$gVk@-5-+h$(X=zQ;;ynS4Rg|5YAPHsC zreS916c{)d>u@cOmyij=aQnT~P=HVkHtGOU3v((6$&07Q;>y>p?A}hE@JN!h+zVeE zBcWVLO}9d&ZuPVp)2-O|wyJ%n$@W%(P1E|OTfQi5XVNZ=m~#U;&(RI376bp2zmdG3 za{LN~2E%GwRnb^#<4}98ENKK=y3yLw&vg3&dLfejkS24gXzMF;AHB z&wgnBj+C1`8mfR9auVEj83JMUFs@KG@{m|>6#gIugq`P!Zj*$q#%C}wCoF6Gr+srH zQ6mP>;bT_oO!T;IROE5L;ta7h;E2h7;gW`K)*%j6v^a9zZLuh6S!~6~1jPlA>XaSNesn z*^vOk`24FzG$PttZBIi?v7D3Q*8^vDTCj(7JE50-Sc`|!J+%5^5Vimn@BL>d-dpHi4;1n{i9faPLI_-=X7@PV?8h#x077WAYcevdQjr zFJWx+i*qQWed^PPbO0vI9CMh>0oeyuQ{aiwmLbxRbJ%LFz>%vd&`uLz zO$Ees2D4#BIKn#`Q)Lp$*w6=5+bs63Owx(L(3q7=OkeBpKue44GW3?%SPv&Unvyq- z1gm?%gUUM9XWnb=opN1S8fk+-x$=ZG3qZ2*>4_>8?4=cszlA-{o&%k81zBQ|v3`x=>nN80dN- z7;*ESpvvHI)M~NKg@1Ub2xWOvPS}o)==qpgl$Me0@Yc@)z>xZE`KP{a%_i=5GC(Dd z;hEZa6=wx4z=VD8t#kmI=<@b|+uERFQIE>a@ebNYVA}6Qh@Qv!EjTM&<W2CMFuu z#7!Wwu+f5ll+zrgpO0u3IQ-#xZqT?_UQB>!uxjp&oagtU^e&J7rHk^pig`i=$DbeM zC1{@pXQeG`u2wLBXda-wx2eLMS~d=zkbNba@EqI!jyMebyQ#I^POyeU>td#0f_S?*nSM0K#ut zt$Xjv!6!2pn^$ju002*VsC}h4xr}QdHcc`{;D0fcLNLAp0C{YKhkBs&Cv~lhnM(=a zZr`GUXK48|zk?eoK!}|t{jHn2r1!~V0>yp3*BpInWkF?tGHabRfD6)ss`x)_d8d+PaxzhgJbzkHhE;~vNEfQ(ryw;v+#*p8F)mr6i+ zL~3qDX8&#w!;EU6qa@$8Q=^XbNUg@0!`lhg_30cpap-%7!m5jTeiLqhkwxTEq3oM5!`-0o`Z;~8(J6G5Gf}TUUX6q+2~~+bJab8b|1-Wsa<|_KluR-Jim?8)8HWTalqMWP&?-QioiUV?$!O zRK!S7vTCllbmrPM?qxA2NDe?xH{oj1hcT z00Ep$g-08907^KR2Sqd@1Yb*tv!gg|8T23o!A$9XA#9EF>mX2b^6CVpbs8E7W)8N`Y`h9df@QsRVcXqGAI$R8%H2OPW-U*zYu8 zIfDq^a#~b%RonV*Sty3ugxG*pqS=jHKnrY9Y#Ld|YDaGrJUPF-$3LDD-ut!X3mHL6 z$z-=>2n-I8`;I>NI@Mpy&pqhb`WNjQcJi!kK0IYwN$f$DBz~Jtf8NV)+*~f$M8{io z*Gr-12S2!?I{VjHC?cZY_SEyMezUR6CSX@7#lR1k6Hbvw06?-K);MEhpTaDX#_XHD znV@38mh|}@i-Fx0hdP(rF{j#&2HINDR;<6&YrQ+80Bf9mfO)~F%nbIB{-?(*-rFN` zyOAj-*>U)dm!JbDcd*mQN}H)g^VB}GoPyxCB?uJS;?bCuM2v2mPs0;v`b7;zM3n)9 z_e33&?mq3t=U^~1q_EsH&WZO^BNPqwc-)e*8>JIF@(3k_Jq5W=erkO0FHV)r@SXS) z>qpnItX=`(u6Hp808})CzE92<#VgNTQF#$X$0wViZ zT%N2ulTRD;!bE|f);*ES*FoUIf%Ok2r=2s*(I=dsP!rhU^kR*wSZLpI?Ch9aprZjV zI+>jqGC-C5-a%_`d zXxRZU_89z$#ZsfccG^W21PXuJ63|%S)bdd#Y66_}p|`r0smc5$nKDoslkk}(2+_ZE zb`i@LyZ{ahNdrM%rB*n6_fKJQ0c z74hc-zJxo6XnP72 zXevh_ZPj8`-}YlcskrDMVw1_C^3+ZQ7HyrFgDG*04n{T+k5xtt_-)^Wd~&1)ciQmv z4b*=nI=ialE9KZgtxIF^(s|R}Yv7#fYfyIz0wI3G9E~X3fCm>v03>I4cGlKH&DO+Jdo1TMk0WT%|CT`Y4g}Y%|n{= z5>2>|q2^E2_%GCgA@7lQoJCW0BoH`2h%4}IE%+zQM8d~G}r zH8gJuLpZC0s3smarJ7@Shw=-wcwD6~I$o*>3|t+2W`8per)}^Gyk4DV46jfT`gRlS zwi>@UPv1+q7&4VjjQgFXyCtiZfZ`2Kgcc~Qftc}Lwp^o<$?Yw3QE;NTtf<5rbDCU= zKfxP)Mw2xKWM2b76Pvq{9!@U$v?IyV=kuCQ9~>!NRIji!p-(S2=Mh}zJC+rDRKJ?{ z^gWJh;Ee=0QclI^4*GWM%7~v7yeZ#--yAJN*tK9yxZ9}USg_#zw7|vwss+|on@zSW z>m$>H~_(4MBIpYViV#NFnNX>gcda^ z4Ge!`K%|UM6;;wPuveLws5}wNAx6p{W(L6n6!n#l%Ajh%xsOjeL5gJ{vh&Y29IaLW zl)RCvZ=)@)yYs71P zi3Uup^2=;Mt{s%2lQ*b=RNM8niH9ndIWx&}!%S)DeZO{X`FFJrqORp?0wr8mEJ! zcK+}8GrI(!fd)P4xx{78u)e$AF#@eAxtgE@3xw%`Z*Ep#?RA6Gq% zf7_gU?Lx8NNam)q3ltk4N4g5nb#_V`LWTFWBpE|UP`>u0gD7b*rt8O2V_3y|s8EVF zm9BlTak}$7lln&z8=P*0yuhDg*HGc=P2Xh+5CJ=8y)qgBi1tlldq1amy-F0{c_-%hlS6f+VkE+Z_FER$^{2zC*E@xHY)5K~@<712wX23=WY+p(^>p4O40QPagb$P`|Hlwe!ntMqnW-6MVMLJ8e_oh#fr zn)b|=JH#>{mMg<;bKQ=COSmdN9d=A7RC_;EXHF9X+TbWVjU?ji#m`g0iy}o3bhMGI<-ocLb#dA8U6Rf&I-YxquDN!djnE4RK*u!b>E zuf#O6)NW?oDk&PLf28>1Bsie88=K}SrF;C!;ZnU-fZpk*ai+lNP&u_r;Mig#IH_`HAC-+D#ODZ8EmaCQ5?9fWHN zz~DncFVh1(I-S~o8Mwbr(P8br>Ufsgw~#M&ZmntWU~(+ol51!65FwYJ}_}E^v=0lE&{zwsZ1sbL5m$L0hRG zg<3A!T71nes2>U!ehSqgt~(?7C)70J?iO}nUv}1Mw-2IQ4;U2i zf`9-x^_&7gqs9e7!5TGnC|eLef6dLTRYLR&5KCpY`ECDFGk!n1VbZPXSa_*6*%34Zfk67Au*$UoDiGj^K?W#ctJm z3X0#pvIH^#W6IX4{{io==K*$&gYY1C8~^|S2)k#ec0AF6_F(i8$jh#OW#lz3v^QmZ zh)4~q=hSTW5+9OHUeAYjHXb%rWXBUXh!;kpa}j9>ZG^s5uz8BINxo+AH}4G;;~pJm zYel5fOw}=1%Na0ilNZX}b*6Qc zZ0PNt?bS74P^Vn*UC^_pK@ioh=)p43z3*gRISufzlnzU6DKWdL>UzL7Q-^UQyWP~? z?o3nu#A6?6#J)H@*KcrMlZs~cH+pMf>k73wycXGKY}rSp#b-bMYX~ke@J;rv^3G!q zNKr6~me730K;xzbBM7R0<>a0tCL!{WRnsKwrS;x((vcw1A~HiEZ@6PbN+xceBwOR9 z|H*%!USfot_RFuW2dNp6kN^Q;5)4w9r~nK;9J_yTgP}0Qn~-tItu=LO)U&n?`k)bG zmW#DbEKoLO{S0Mz_{3GVTx~Wv)UkJ-BtNRo?~-qKBop~Dyp7GfyJ0P z7s~zw z-%dp;QfoX0oE?d$SKe&D*&;KusC=iF=J>)dJmWq3XBqn^lH{J- z71$1xA>5!WC5J;E5!`{}b{S+5zzlb^2scn0^cl&(xttv&f~AGcL03JUO|HHGS$m_Cl(I{Cw%=eZxi zt+j>Du-h*QHoqVGPAfyJ7uCmjU-DtmV}%5eunQ7g0J!Bi`Fm$rCq0Uf9mtQ5Kw@^qmF?9f)C_}0<~4%cERvO@Ycl^{rMaev1wK2 zw6B2KMEQ#U)M#Zp;+(Mya_Xb+$oV)MF+7{oo;w@2fTN-mUzf2aAKe1B&zQ*oV zO-azC0X2{3S;d!Ob!o0&kiipFa;aGq_X@zD4!((h_lX|`H`XQSeWeKGgL;%ry!z>F zzAJKk$=4us-bw;4S|A<}pE6}F@vIUJwvH}5ggdO2jka*#<5Yv3?rGc+$V*`lVTo7a zXXdk!xr(+yh@Cr5PdPCY5K!XAO_D@&g^9I~q_#v+6gtNp(HgmJW}B{LUr9 zzJ-~To)rXld-pLb=?F5G>Gu%xt@3^uO)TOWU2u;~&A$`S8TUd(rV$Hx^GTl)*qG_G zI;W{y^r69eRHN3poBW3Zh!Y~0Rn1kppLd8yL~3T|HB%0g3TJJkUA|^88#5)BQ`#uDWcGPc;voL&T zk~9%PjX)U;zQLAodM@9mL2mOdCB=bOIA#U#1Jtc%Nqcf1xQ`fX2dI+8tOD^3h^3}+ zNJU{}tv$uv9C#3O`U5!bIfLM9K}re{7@-Z`uhK#zHB%q&LCy>Zz;#T$Nm%|f>uj}V z(`}w64L7(?sXP%26T+9TP;m`X7v#(=pX`EDKFA3-x#3d3gp`0-xSl{NJItBKuyR5G zGe@{k3qKxJOG(Hih1By{&bdREG8u2yU-qTcy`y(hdOo77x+u@Q=qLbXcJkfE)8-qY#d0%B>~|20+0IzIzO zazJ^PC21JG+pmu7GwI^K>8Yd0V9PeEw>{~9;!phQzuv!!)tOlx8f~!+Jzkf90b2hqNzWUk|51$JDtWCF_W6| z2U9}hfV{HM8<%1;i@V;=a_@b$h(fUVRv2%95q1;#V=_-n#()SeAPWUEy8Kd3x-_H# zLs2qIg+&l#u3q#SrP+$pmr3J%x1#TxnDl{LC?FZoS?{zt+_&Pa6Np7NH`1Ud$o$D= zYom_I#j%k;)*cDMA(CgU*eIaKd{KKt+GbtqBdg-s*-dE13)Qi=CyeaJQvOG^Vh#s%L{4?f4hEuVH&g~%Gh0|?x?Jr?y{Cgt}L zJ&$yLQE8tu8qUQA6)N7zi{f?B9)G8QTdpd6hXs*JKCR0hK3^~JtmCA#DO-PDwBbRzt?G&> z3wO`?%BM|a;T912&ZK*)YE!Gnd36+RwXiD680%!_W9*jj*q)qy|C4jmPL-Y&6|u;3 zk0M_!%Ij0P4*i7+`llqqis7!=t?a``ygpMVn~>X>t5iDGqhM34V8VgnGzxnlSmEqd zzuPr3M4857=iRh;1Ho~ZYWs^}1!s{3smHL5D_@g2Onr+w0^~*KpD`022Y46j=*&Gx zd-y0~;FpGN!^j;jGqH3zL9U5I731)DKcnvSGWo$2!En4LP%4x{hz8$!4m647Hcb_o zu&wys&6W7^RbBla`VGn-9$SA}F&`yR$lcxtB1he{Kg17CX(Mi2dEC>rSdsw{wenp% z)Vv-AKgiui|C)Jm3kk`e*^mokN=MXd<9e3-&;StCA3>yAB-`NIl6;+CIB4+F=_{zp zO$LF`Jc>%N7o9dTY59}@R6wi0w=h6x<-!mdvv@u!XTzdF7D&E$UqVT#_HmErxNJOZ z4*j&wdH6{F>^1wQBSt~sU?pBqX()hTu7+4&`3yJDhZE;q_c+g@5@CxoE&1F**E$LS zIa_1k9D}}Icg7IGz(N9l=7m1EPsdu1%wGHRr5Z3C7MIyN?_=Ot^V!CP8>x68H2@&x z$syRKO@@6=^J%E%sSPpa9F{*s)jhwq&I6I0CJ>S#XY}MgSG9$f0f49dl6!pZ;VKM8 zY%|W*jN44ffYy6{r3+U&MT=;KFP~z_~jO2X<9{MsA_vd-05|*#Z?GZA0 zhRqx>PGx@7^xM*qE>osY5qu8cmJs4)0snuGuTI=W+#?rO3=vPGuh&C`2eEm~21auy z!!WOC=)k^}e)nP^TYgoWzcZW)+l6NF`aiv|znw@yQzEN8T(Tj95hQuJ>5j1cI=9ES zd8mV*1kc4#f!x@TLb;kg%+cuHfPnPxi01{uLL-5QtrJL*VC!>sVWe=SI0# zS%;kAYgg6%%GVuUs6dNlx#~2<=VccICg*paf)B|-0IQ|jMbsq$Z7vVWVM&Pgg-KT-@f=NfP>^%o&d{}G+tE8 zbOW#W%q8}xLm;Ii)2y6aDzoC{G)H&Q$%Ig~a|ph!BuDLina=)uetqpeh`Jhh=CWSX(2rS*RCKrVynlM06^yn|mU$U$pDgkjR(fo`CqwBT3&zL(Xo?5t ztStpPR0H|87G0zjoG6qBxbyUCA$k!&RxRC%pq{}ke~f@lnfX6peKzcdDQQr@s20rm z9)SDg_{e_iE}96r%>Ac%7Ws4-Bk*nQ>&<}uGBx{5do@r!9|#Au`B;2$bGpaaYR8>G ztDgd_Z&lwjgwby&(iCRWg{EFGGBfbsgNkSI7GpTfl^;;v9*ld<92zE=`GELnBGmteyew^1^LA*UEPaPTL zl~0m>9)2*>rX+am@XoLLA))I%__K8V0=kzn?c$uwTi-KcUP;hqpiAMRIn^zp5VXD< zsBdx1x}PW01SfR49OhV+tv~*`lomvG6|ArNaA!6we3n`spp*J=<1(l&937Yk%depk zudsf9O^kdE&9K1RAUkkC-2+%CCrP|C7Y}$}9Ses}jINn8p>+iLo=4f4-7wh9PDncW zJ=s2bIF>W8V#pl+GC7iY=&NFE7pa*B@AKy`0dV4<$@LxqkgViI&PU*3HeJHcS;U zL$2biTjl8vNalg%ZP~y&xGz-Nr$~m9%lV+2txu!BNS}b6tk4Ju<;PppgpbDx-^cqY zOoYptd7R?;h`ZUI!IO4nHG&3(kMD!8kw+!K`k7od$WvIO=^iR@HQ(M!N45f8XFeh zI1EH0OzhpO;3V!97jtdv8d#&@L?6~U!dG1_1fW0b4-#dL@G;AC-uMSip45@D2`*}j zfcWFs)mMq7{_F4ytPJ4=@;1cG7>7Bt1Z^ByN&L`1vVJw&M~B9`8?C>=T<#p%8<#~Z zX33$g@*FS=Sv#k3&AdX=tV*_feoJb4D7v{oS9`DwNde8aXq#J92%8kIPghIOfOwYV zQ2qQxYnWIfQ@6!ok%|%^sjod@xC(^??#}!R6ar?rc~q=x^?*Hlecu{-eL8Et4mRM7 zab!DeNsM^*(GNIZEK__kLMX4&;~4R-!6X8hD02VMD3OfvFVO(tk@L!9dXZI`L8N|$ z)ZIQ`$gel7ez_mweGAi7?yu`*bF1Fj5NwBJgE3U|E&_0vWblB7!xYGX3}ovRU_{br zz7}7y^o+w|6M{E2b)ChAwP{~g^nJ1QxME4xcg!lFEkW>u2>*3LH%aO=|Hl2&IP@uH z-3QomgX<;o5FLb^IiVKQn znCtVnPQ1DOCt}m&NWW9$pB zKwf@3Ka`ne3cIYqPx$wS`I`+XIx%T;p;G5d5^dow1(?1P%h4 z9bSJKZeG@!!76V0E6iNdkshLcErv=n=d~z{5V#dR!}Ycbr~;_g@gzaqMhzhpqo~2_ ziG^DmHf$?x$6~%O6J=SVP%4b6b}NXm5#AN*41s3pGNK1889XrUtqdq~ohous9}fgF zjBP}yLOMjmo+mCr3N6}Kp_PTnKGwdw((F`VQUMmMb^=kqo~wtMk4E{`=tm9lqaQ`p z8d4(RXjKzFked>)3Fe$H#Kd3SE!Y-);6U{;c=cOsm0p*Sg1iLgQ4&B57VNnl@zZY{ zwQpUGs&*f#%e=tL%m0^Pc>Rz9j!Yy?00WF%~209NiQmV67k&WK~$7?kc4+3hCn}S!T!U^Pzn)2lTaQ|FnA{cWtjAk=&+GxzP+;61 zK{kCKp5+J};z`$hT0;`&xV@ETMM{#o8Pv;sc}CL`Z@;t zB>8JNeGD%__4$?G%t**CSPtjCja4716JeIuh$9%L!G`~SXa_CK<4sZYIWH*2Np|WM z-8vGIvS{pc01H&6U|oUsKCk)WO^fm=;U_YFU_(t&eUEmpw$kOKP~U5sR2M2Kfv9RP z*TXRqQg_XL81p82b7yPXpZaPy zui%h|haNI7=Z!!BkQ|4g5{e=&$w+}L`2DG`flNnJr#cSkO9k?QxU13(A|Eb-@j}dQ zPFqRnK)ViOWff<)J0C}SEIqf~PZHZDXhz;Vo`cP*oXTcx{&R5e= zF7nt}XmK^So?-+8q|aj!fOC$QQW?wj7fuYm{U?KtSfYDq@}}F!EDkpWdTvDv@K3xP zW-yBV0CPd58a)bL4`}Z4Zc4CWy5=7bFR~#VF~o`uhSdEBQ8Sieo$`Xnvw`dlzhXS{?CW$!%9a6a0L!i`XOBy8w8G+^Je77-*GD zY?Y|cDQ_id6)&2R5k0Oc4I4B3dj`jcj{qLp=u8tsL|x!r6I8U$Hs>lM9tob4qv&Vt zxM$fQcR1mGYK1wk)TGDJFawL-VZ8H~lsb21l)fx9i5=+m^2Re&hTTEl!7d}u`3g?6 z$^erw_9ft{N{14M!U(LU>~4yINbzSn&n90S z=;^8JzwZH=tD|W{rJqV!FjBADbHqF+ixO{VQNHF$O#(tY5fp0qjL24P@haK2?_X5w zn!pbQCEwJwP(2&q?(X`L#3aPCVj1*1<_8g;bZ|S3-GH^7fsuoEj&x#J6D!+3tB+3E zGVM~fT1B;s<&UXL?7!JVUS&wF>ka@=!yR8fOD_e|jiXCs8n~i9H1{v0UUvo{c7OR9 z_zhnwUQW7;q{-1T@FqV_?B=+Ov%(ZLU4-ABRDN={aEy}WHz#HnY_7iFeXv%_GQ zRhc%>_zaoDd31GtYw}fziiX=klj)?xP;)Xk25C8%tUD2sM)5xD0zd<8$Lm71iQaxA ztuIY!(DDv?%6VYb(=YPq=2>l7kZBeH0t#zpLC`wG;-PCG-4z9BwE9u5lwyT7h~UoB z{E(R@4)3b&eEX6rre_m31-ZX@1OJ|}$gX>j>42STWR$`b#m-*Z#n$pzJ=kGapr!_x z*8t35)%==chp@{L#dJFeX3BS6@FVndmpY+bfgbS*oRL6IQrCKKFL2)y$=z%NY@rVy z5azgeN80;SNke>NLU{0)o7b9PvP_>P2bH_X)7Hp@AXVWLuZ7-qH@RZu;&u>$eY7xn z1qtx9&Y9olV5I;TtxRI+^-Nw50&ih~JW_MPaU`q#O>>1USvQ;g4YrbTlEmQ=&bBQY zIW!qMavHIXLhrWowWZ7C z{hFH6ISZ7ryOO2+@+HPBu*n;J=%AF!jX@Qr(LsF9*Q}(8C|2S?3ZCYTRSW&VQNh;j*WS{3hY0!3|fJ34XP1iv9|4sUz)qy zduU_?B7uMF=N?-;E0aZFMR0M(#!{y_VF1tO=bbxg0GgpO%Ua0U^dUo?1iDg zdHSGiBIeVWMwbkD^@*jEgk2^diFP9h9p-aHHuJrD8!7ZW=z)j4yYN77V^MSWfT!qS zHVJ^wHYvoIz*wAYs$ZKzU+rwFh71No3swa`MMNvrto}S6%06uZ+wgV0u`wZmF7wa} zn&bP8acsc!V+`G^j5W#n5OSHXrKTfu{akMgp<#JktAjK!E^G2WwOJb~KZ{@&^0h7j z4{PM*7mWLvh!Iz90@LoZMP`_id|i107i7@Zl5@}rVmF=Mum$|!^-qil5pOf0R4jVH z+Yo+xmv^b!A)_aa9=zH&4JP(q8!Cfy-_LGI6MPesfmgp%(6)x2`d;o8SvCuy6QDjC-;-v}v4wo%I7T?!(9 z(w{9&+5zXCLolUtjoKR%?clur{y_H>i_8mYlEnyGE$U8tS+;>~$N~8B68a5gFcVN1 z>O8{{)?hi1{$A|>*a%E>pQL2OugU_BCah{v1{v$0Nwj$Q4Ck{u&p9KD5r(vz6iAOF zMd-|py?3n*D%t1b?EI7RhPU$1QIDjhC`+YH+C*)l84zyoa+P*4qa`{F(X?ksmgv|& zdH?|T<^8pHFAIGqa@`NAxCT8&QG3Rbq>zdalZMU6q~$8e_PS6P25z)sb1J~AkAgmG z;Ewo#SMB^^^s)tF8la%l<`bi73sLLEKuNt=`ueliD*Z z|6gJuehVcXlm17>SoU`!$B$QVZ^!0-xR7iCA*lM>-QULcV0?xmo?lLTaV0K05A?Z zHT6)!F&CV4W?*fx8Rq{^;e~mp3S2m30=s;)qtSoHgIta+UgrJbV$WHwacI3eJ#pB> zj|f+NO&t6Ay1SG1lCs4AdE$JZNsvk_3dDJef{ zj-|f*kkw@4$Wblc*~WAGxHZtyAOtmABQ(yU6sJ2DRoEDtLAPgSy(PU#DoU-91h)!(^fyxe&1cW&3GQEFU3dFD*V07JBWzEPeT;{1J}}R1GuA|R|xxBmvFC)+^`(G^C|nK zRVxviZ&fJ))Zr`k!-QR2d>Er9Vk>gbsDaH|GvNv%Dq+u5kcAlG$b{82f^RoYdU(ii z&1+4D;KxC{OXy1>$O*noy3dOA@lkEgrUT#b#i<|!Rx1M-TtEYT?J(|2c5*jrd|E0UWg^+^A(eMOW~C$Q9SnknO2O+d%mw6ZZ6#VO$AbZ+HB?1I4CdU|TR>o8 z=)b{`i3kne27M?F&p(Yhu;V)=HWTjkf%QuCi8cQ+M>_){Pc?(eElLN^&{$ctC5vAZ zuX@6D(pryAr%us1KPc2tAu$EbpJRLx;>lP z9E7J!UlF(4pkI4u-ImWMWX_jIcB(Ot0}N9*cPMPBJ{HA(0ZE8FlJ-Rg)pTJZA;KZP zGl2a6m|3K6doaUw9_A$F;i1C$qd`4AzMSwrxZ7Lkmq69&GuP>l&*BYJTv_o)<3xwu zAu+VqulnvQRt)Q|7gLC}qLxIXAWaF4!NS(OO_BG1G7@DF@xmmYQN^?dGK<1Drh{#5 znv8gWa!kO*GE;v_#Ut7l|A?Eb-EHoIa+kd4L|pYVEy{~!(qG}9`mS0M-H#CiimX~(X)nr`9;M9q0lKnUgOLt7~JNDD9^weTD`WjiZMuI zRNGzE)=0Wuw2TYtg65ky=$J%fMOg}N>QNU zfVo;=#&~z367yEKHqcN&#cn*BGd9{XtXq#4xhlElE?YUm$uZ zY{>msX)A04i5Vqg?ZMoAOH#zx4^nZorN;3XcZx2>SVN2~BLYb(m^g-GUTv|RRdZGAHe84{U@F}~ zVP}yGjR#X1Fm)vJceq$eAdI(jXLn_LOi!^2jHv*J^;hc zwpY4_dGf^zmK!OGJ1-OsxM?ejJD>bL8%HCoO?;W{?GHtTz>Bt(ehxrcgcSjDk%gC& zst?M>E#L(DD-d)@hL*#7wR}o^^Z0V6XQ((pA1gEBPl6qD*9?byBUb>N2a-_{LP=G- zPc{R(Ev&nxeMwWtq6gnbjRRR)3{K(#N6q|KH(?#q6^Rbb0g5sD>Yiu7uO+bG!XLn2 z*b%?9fhJrcKLna{iT!>D@;22s9KZ8ZyST;u%pV>C=8owT8uug}hiFZue7w2wy(2Uf z&2?j}Dbo$;TpCn+bAZYiHZmP%b~YDjhFhAQKADqTBEa zn=gl-{&Qqvq2^OIvKCkpQI*_%v8Jzf>nmO7eq}4u?AVy>+DVbNhYQSk!-#Eh1NRy)Q??w9^#=wnX$dD()Tk)HgO`-`V4OIQR9hq32Yv*GAqu02v&a_Ki}Qg82#J~D zE16=!UM&_XUzwt&bg?_v4`1^x^Ae@#6g{1d>DNmdE;s%=PIaX`xdqqUe$n>DTeA5R zHc)@-gmoP4{>w@Od(S#`JbvY9Q(_%4!53Iw=}>hEL^||$Y2`h?{aL;x=^grX8lvmv zOmb(ph@%_V`o01&_Z|OPstU4_0Gh}=07|j*%ZdSs!E%(7erA{BTXC@M<> zOQp!|P2>aAj(u9drpDhyr&m8XJ)~-!lL{+_!6i%VUOyt+GZk8mn;_c=ddBi$M!t`T zxXzUZVqVdOgE>2ZzniDL&3<;Gmv;W~FMqvi$RR1O_#Hf^U-~fM8gUwd|8#UiiP-CD zGQ!T#R4nKroL=0@LmJ5)3Pkr^j6~pPpbwOJh?Zf7ffc|GlR*nPS7L(JD`iLoRJP zEh5uK9IVV|z5_rBE#RjOhyeVK|05oHE2A zaAL}IWIsJEC*`ZHPOj@@@h^SKp+Cq~3$Gf`Z*l3s)zZu5xVow-@1Li;Gx|merc9mD z9vlepI)PqYM2|ZwdA81sJ>{CbKb~$EJ0NR7!`R)(7KL9Tz{N{*H_E~1x_KpK(gPf? zY_2*N!*1g_7R=Bs$JW62rPwdPR3VWraF~Ix@W~12ly-0&lL*JAYZ5mIi%$HgVMASp zX1HS6s9TSym?bdIoCHA& z8ujlF9B0H!;RIbm>?8j^D!}({P$?4=ue!8&V84@t(D`J%!xLt!`1V?%qHY(_Rk5>w zR)=O_3Pyu4ZwG{s2T~#hA1C!U6N0zLQx~t);J0Rt4?n4@UX-Hcy`qX9t7Pir_*DRL z*M>RZAB~TWGq|9&lgvS5-lt~XBNccfBdoeAzPFQXY}DLmJT)RVJ9s%h{^|&R-K6AY zh1d{8=2wNJ_O6s-2Z-AcmoYyU0YBDgj#bG%uOT~T$^KkClIC5*^_es^MKuhR(Gr`N z?kxdocQGVkoZ91hC;46cR+S@`spGUv{rv=J)(7XgxJ*hK+LWzIDw>vU*R&7^JL8!; z1!k%dYm&%Y@0|Qc-|_Xm!^X$JQFOZ~b|VhUVAi}7nQ$-L9N-z?&Ev1W@G>fQwAkB z{UJX{xzH2`Z z$S+fh(EBI4XhC8NkN9_G5F84~u?!YNY`g9V_>q;EjQndNM&EguMMRvo{{~b93zgFb z+6hH@`a4{aVW6{I;XJ5sIX9}8TdDj1!YiAI-W4~Ck7bz=EZe}gcWgCDHJQx zy6PSvFKZW^ItDMtU2+sw>^Xi*q2>wa)Ap~`(H!8NFY5M-)UsXUvpGBG<_t6(M74;t za+Jm(e}eWKZJyGq36ZJ)ELRWPc-O;$4Jn1^d!`ir;>&3d^tF^1a$fP&$Ftg+8Hat~ z2~H0E9)4<2J;qf7O2Xc$$4jSsU?bt@He7!^UMAF`Xx0QlXy$qHtkLa z>?-;Aj8))#@MxNEM0vjW>Y~#6H?#yUitl}ljD9$h2U^7lUnP`YOlZH5YhNB-HA~R5 zfZ8!@bLFm$#}e9HkvN$f*BSKx!0k9PYHt(MudnCwyd9>xQ2V) zaO$GgF6J!&tg973R2z#D(P}IEc4wxuw8#+s&$|tkp;{%I2O}C5yr4IBcKfPGpFQnqPq$lL=qTp7|>Za$n9 zt?DvP=}h3>@+0)0NVE&PaBDdW|Kk)pl6Yt0YF)15`4-32hFQv_Un-ObK9d4op`;(= z5UpY&QdOYp2tmsvcIR%ePU`Kww6{j!d_EpuF@$^JgD=LZ7UY-rg#e*R)D{OmyRG}E zr4b&^thuG62I-c|!6U9yr1(1Lto0HH$_FUFCPDE9aq_8D8`*sd3&qiv7mQ`?2r>DH{QH zaABsdZz`RYGlojvEH}HiXTj$XMHMahE2Y7bi0L?FJ+sP>@>U)f)q-H3UlAGUPi5&F z{&+5^bvk9Z!9T$~%345dvvd93f&I<59OTJqcmT2czf7FSw?Y(ZM_d5UqrNRmFkNt(~#Z+V0ecU>eC#0GQH<80|YVQZ~|6-^DWsb z??|S6{@w-E{&?66Ilsf9v$}p|E0B(K*e6sc+cQ!3nxmxT&I?&zs1$PBh z{l&@(%BXyW6%r5ToBDNPhaK2xZt^IyF=!cw&7I?P2>H#nrc?dK1O&6Io-(=#jWaI% zGncr36^8>(ujAR%Qm2WEHR?hWlz+p3Y<7Na4z&AxBOF2AdfHy+2mO%=?X(Yc()6Vu1rl)}qN zThKyF!MgkrrD#04b*7<!5WIwY`}`d`?;Nnrnid7j~Q|f@fEK+Lycks6RTI zIhCW4nt-o+z`L&2N@l-9VvEYeP9PATw}mCH7aXQsqZXBo-kS^FaFl`Rc01VxO23Ig zUF{MIzt7plj^5%)lIdMWNQt6sV8EV)VCm{-fVUYZ`E#SEpbBKkB&yXcT1&G)rOdN8 z`Yef?uNfDY^sSKDP!3!mk}r!Xqb@8J{OSw#_$=<6uS()%^OjVky9K}w$a2Xb%am0q zn?95Qn-(}W+KuK+XR7iI?mqvcnYGG|N0zGFTZT)bdy!#r&lCR=2UiU>x;M{r=Y44Z zF1H0BKL6@g082EAj45V^26Zxq;DTk4C?(h%vNTy*Rw#7Uyn=`Z2m$Plkaeb}E&41J zz-PIIfOrhp8G@|GH<$BvAwIc9T~qs|{sG5Ag3u8(h_IGYFbqHNzu{Tx!MUHcdhNsw zr{JY1%Pe*1DQ8!#rjb`0IgBcY6lySEjo`2f^GFX_(0vb}x9tZPku7#N1Y&zVwa`c7 zK4IwypW>5B9d^b)uZKH;wjm#o>bpWTpss{^Mb!~2pjN6Vn9?bcY~$q<)P`r`OO*=d zgY_pw+{md3%B8lb(18u@YkrAY=gS~zmiYD*xcZX?juneOuK8=v%S7DHK*{#k**7k8 zVaCsfXH=meCD)4q^!7`pXRVqKW*q#RZ4xJ{Q#}jJ?SXEHsQC05O#T?0`BLiHXm#kOcrZ*QB1AO7qlw74#(7G4jmxE zWQoTW6%bQs**T{lfY72qPEcUck!IWg8@bRE^m|npIcFa%a6kfTL|y$+%?%L;R7uWE z<5TPpLu@Zwn1jvQ17~Y7cnyu~k$L!tD8stN=%KGh!d~pb*oUC?q~gIjM^^Mb-Xl|m zMWG=A+1TO8=A%JM28ngU;@J9EIf9`-c+ut&wD=MhbuQ{7;En zkg3fg#P^xZ+<)vb7g0SZwuW@)Ky}2@P@QG92-}kpcpQ?G&sd-SkZay)MHj~Oasob9 zj!6T3r`{Ya)sgjEb>HxZB(yL=+>Ze6fihiE&vu*lG#-7)ko#qt9c9Enb-}*q)Ge@5 zE~F}6%nsO?oFg)gkwe;;$q>(9BdS*bOD^Z0VmjqH2!nH_9 zkM)?J47t`LVV;(PZnXzdqDJ${8Y7QkM(6E**Tqy8@I~hgDRX!xw}B7TX~VA9_2y(k zxBO+P0w<@F4lo57HYq)O_G8h}0$-pOl=DjzQ(c_acqb1QbTu|X`Ois~%`xSY(D58$ zQqpAz9*A^6O#U_|XJ_DyKk9k1d7-{CI z7UwaIxhixpJZU?So2ZtQR}WEKQ$40 zo}abA7~G~=G|nk5fvf!`@N%tbR{FnL%Yv+|#*>-w>$_zei#HhzBtuF8Z>KaDzD^_c z1oBSxvj3WO@dqH$vZTX__{ir4s)+3nt4x4F9J#ci+XGNV)Z;zQ(fiSQ9b`HR<;T;X zst>tb`MfS({R?J~gxN@Qcj_C!L(m+0a()H3`b0*9O>|<^rD)GjPCoM=7rsQM9T_}-E_auE)ujbXqF=347tFEi zvC;P@kOxh2t5u<3c`Vi1*;h`-qN0jNBR|$txgChnl^Elr9yc$1TAGj1&jLMq<;n|Q zpZ3`|B%`maBJJyWj6S^p$Ugq9986;m?MDa#B{Ey3(9aQ9V1qGnmSj)0bpo~JVDnA4 zIu_?gYXKX&HP&2Hk12jVcLMJ5t3t+vQ2}oga*9cH$+Ol6g=BDMR*`#6D2ME!oJxw4PTooQSko{3L7wA|P zSpmOWq1SlODyWL3c@Qx^)v{1>fB~6HlvNfCr{ChFL%xRQtNVWef9;LGx1y%?01lzS zM#_b6Tb+cQGY}+tk|$hZzk$`yyAfnX;kJ|8jCm4~?f>?dYq25JciiC<2Xe|d94A?Xq-D!En8?ty8tPpC zFi+3wdmR+%D4vSb%xq-+%f2D|tqAy0+6pX_OLxFWuS`0=7$b(TymAC5&7dY;{T%F8&HXy z%7CXmJ$-+vn(kk`@GPeo9vP=*pMY308Px)49Br@ieBqjIu>K|hA1yIESMGJc35%*` ze3(7PBT_m`@s!-mKQl#W!L(>>q;|qU7!N2ofM`^5^%uN*Vi}rKRkbRqyve^@<_<_ zH*;#AN#^*cfpnF2KT!;ZJqqD_h?MZ!-e}zQld8@kjlKQ@#V69Kjk(3wY?gYJ`ux0nQJDy;;OR>X3tl*G-J`I&QyYv~qwj)(f>oeh_MP2H`<9SsOZ zJIri*@F)wHze3dxpcF)IOzyRtoQA=M?` zqNR-vXbWay$R(tPUtD3-a*vB(Xf9qIdKUdd8hd-e$Sr8KA7H>{gJ;bwL=BdBnhG=# zWwiNVZgq|*Drk7RM{Bzt|5}pXWs{AEg6ZRO*Kr-iGa}p61?O_>T;Xl+@*V{mlgq6c z2U7lE$&4@CWvkXut^|1H=0HKFUZ`>J{GTYvCKvon8>DCGh{l*h{#TQ)=EaxA0fUO) z`cGF#S^FCIWzxaM5+teOv1Ehd70T8be(`nHhnUbb+;a>4emIjw=sOEz+_J*0XYoMI zv#XzNcS^E|5{vux=9T0wezjF6m|;Te9Ira#2O|?g0}Qi=dyjt@pvC!?N{Utf4bnS( z2TwNZ)^}%4zqgqn(&inRBcb>;=xyu(x$MvQJVV6&JV#xuY{nQMUT;BLC`E|00>uVn z7S7`P^|!6UI{s0{^uN-M*9Yf@!M9wqa9eOGgwokt90Ftq*jAD|E23bIWsg{86oUA`& zybDapD2}F)QGQw>=Xe2C94_*No1AfH{fz@671`^n;oAMB@Fd4*V1ODouVAU$<4K`P zoucQBuY?w#koL!mAjdOL7lCdukOJ$~0ydPGvBiaFgo|E+z)wyO5ZdN2* zbyZ5Fc#+WW&%;#Ue`vKS@r*V~cvDNi?Y_kaM#^4T8{cA8&BGSlJP}?IyS6X4vFxZI z4dlRAWzS|EFAVphPhLx|<9}JiiP;GkQsSDZ<0B^)wzB~sQptM1`^t55V3oW*MRN)d zZc$t21D>ysa(Qt7x&M`5E~#hPEc(;S3qqNDcJ&8u{=M05Ln-z8$M?WOaSplC8}Rn7 z;BuFfz4AxRehVn}7F7wo4#N5OCJiiL2OOKcgnIa`(l;~7M;$!WyPN{2z{UA#c=A9h zc6rA^CiG3*lXSipA@~-2isy;ZenvjQ3T|M(jsi@ngqJMN&;h1F{xIeoFkhPE31tKz zJ0s-kvLDyFH5erucb|cMjouQw**6comj-@zb@FknH-hyP0LqHgO|mAUypc2dGUwlC zf%@v_B!VgNN{yfto#8>-QzS9@%Y>0A4PMnSBRo`9gFT$xLGOhyh5bcNMbcAF|A}iDN7~)WMt9dmyi?& z`EU5E(ydwdTZ*?z@&_rE3~sR=}%P++2sd1>6+rcmc0R)d?DTBZ#Y(D#Rd9fEfI zy#tX4^tb~+trAzz3bzsMe;mJ2BO9jGJaYXjzR;uw4x-?n6N&)&r8I)Ol}#=`K3FF& zx(@MqXA=ygZ}kSh*^Zk=*2xE;OgbJiT{ynZb%qK@w-C0GAIT7-0a3xS1TI5}R?8V7 z4A>W9dU`MWFW_PSq`kT1EB{3Gx+ASMYS5n>#qR@$o-A7!8??ZIVWcc8V0+{pJIeVT zD9Okb;zctAJOPmW>@=tCLmZi6S23DYa$AZcXGiJn`-6d2N?p^uhuSRJh!t*1Zb$ed zx04C6$9nLG3A+u(;84fP7LiwmJaxF`*x*_ldV!x~9@8 zdOR?i#|6v%4`bzzxZ?e^p*bq&G$U5tR(sbHD4mbCxenTbdjfhNtm|z5=BTPi=I>d> z7WJ+h=zO-;o2P|l?)3$~zK??kyYU9p!Eh)!Lc~yLT}@?HcGw9f3E>F+F~ZH4q~)3; z@fX_CEh%*H2D+D%@s5SvR>;H|01oXYzZaz1x=Gs@4f?xRNg?<&L(7T{q+lt3h7cn1 zeqZ`HPWLp(DzYmC-?ztwyQiE1rAzw!)<~N~pQnC)jyze787L}#rYQ{9Boeuo4kSR1 z9%!fHT=K&-woim81a!Wl*xO8{X1=MVn6%^E?)b#i3c!auqFTGJ;+qC+iggAoTJje^ zhxiENa{(ivAss&sB!4Z^uwOXauorsFEdkp=RrD#tB-X|x*DwXIM#5-YbUDLQE1xk_)pc~Y;mH`byWTglLojMLw~ z5zq7KAFnwYOdc~@ zw>D5l*3}4F{cD4vdMr815{d7u_IC~#FhcCtv#v@-`u?!NM8sa;@%-FgGpokJ2VfFra4EW{(D3Q}{Ed0_7ss249qXRYe$$u>Tfi zPys47$lJ7gAfO=eQv{(ppulAY9|MF57gfrl1*1p%!flfSX$*j_UBv`B0S17Ky z&`2uhRLvbFVeP%c$FmmOWeyLAIL&}LFRGJ_e{Y`f4kw9S)fi?=lG@l`)<>>AZEW01 z(UhOl-G@r zIz!}*r+}~N)8_PuidkUyZ|CNvHSk}FbDAK_!!hppz!U_7oaUrS-M8}Ki-Xgky(KXE zas@PY?%Jy=Sc4xxkR)Y=+5z20*Ms7J37?bW7wmy`3kHn;JJZkc!rrPm(Nzu(F2wAk zIZ;ghh%w`0S4B7doz7-t(U)b^u=hg;AWtj$zbovSRxxprSPN#9!`Bzc>L@jhNT|`5X{o34O`pJe>SA?4w%Lc_`$b+( z-q-SB)geh=jjkg)cf5{M6_3iJ7tLcOd&?1bfehsCxIy4V~LM*$XJubMvf~B|_ zwrBp`_FnES2Lv|`8@6sd0urZ+E4a72E**R6F^Za=xkzD=Zui4qjqBm~Agkp>lp&56 zCOr2mIV!WvONWy&K$@mkol~u(G)jA&i;FZ32q~WV%xav;Yj+iEcoPU!!ZNrn&5{@y z4ii2%ys$_U^7}>&$QlCLFML$>+OLF;y0yrIE6@|bku&CNe=O^We3OBh)91zKyz1jr zSU;5e`A@%;{FrXEF>|PlxPr})X3wZbLs~bo%G;XH-z4K`;%Kl3ajbAgAIrhYi@~ky zj!Z>F{CQ{=R&ChfJmL~=Ahpu(4QGvY(z+PbHWqB`{!1OgAU<}C5k#@={@40Etc2Z+ zTs=;#p=szFySv*8$#U&G6e5E25bA#CqYOU3Z=b5e6WwplbL4m&Gp>JzUfMLw0sESM zN47$hzHK~V6`EsDe*LaCoQ5T+i@f^$vzEtE`y0Hw>hi6acG^XTmp6-MP#Z2Dsa#ei zap+mIAopvP{3^58GLFlIc0i`ntRJveK|ZksOS}LId|$oRiE=b-Q7p%;lR4S>w13)HmF~rp;475iB%3&L9sj|#N8nNc7K(n4ZBOkr+Sox zmalpd+A(&*Yj>n@*ptUppktc#%(w!ldU%7(XmzSX@mSAXD1YGJ3}M3!EZ$&RTtlKLOjrPWKe8!> zSE^1_kqv`m3mmb$=^<*IFcIgJcE{o*fMy^P`;A}VWG%y+aB%0LA+54AW4I!{-gXVP zvM6zN&tkBql)>WubGY#YIl9{1IlB666E;D*`cpVKO_6cZx@|ERG?JBR5xbruA*gm+ zc;Kyjk+Kv3dDFxPc}B~JIXS^ixV)w7`JH+flhFsx0)(L#wP}bjpj2ue^ z6xnxM#`8FMbo9g&h2*H8UF{b6@~0E(8BgMdNY*i#D_m$jX-`%x65pj*yrW^zT{1o# zago1Hen)>)OI2!(nh`QFr9-N^GI)sLBQM8P?sB546=drt9Vovsh*#tevV5L*#~Q_& z_ieYpDIEa-s7^mH$TFA?UuI+np#Obv&_Vas&jhp&@e=Sd^L@;B#$CeKNbxq1dKuGKz_jae#i*zrlp!@<#8RFLVnC zh#Iy4@~Wo9++j>mUV}i%=-foym{Js~HTqy6`WLy1pUhqW-!q@b^0`M zWEkj}m=P_-QUK-axOWQu8)VF9vR8EgDH#3;RdU*S2 zO%mO#fT((Ej;7(MDAXwav)8~6^_S|@v1Yb76Mo4k@*OTKfs#%u-V!LyCo}s z4#1Kt^FdfM!6uDA(l)+Uc#l|%Cxzh3y~Yr^?OAWqHTt~@d;jh-?U7BTb6|wb*OCty zZUAwCuS=*#Vf(W9?`3tARTjn4k#^lts(mpLRq_N!UOqRoVv_K9!Voq-U*HL;(+-U$NKC?mLC9|}R zaJN2VtK~8YxA5ms>Ld@TTL_N1(XBC_?o9>^^-NI{!GiIeVn^m`H1>Sy?|#7eV+TLs3yAb@{=;pZiTz%TafcG+{BZG*!(9UX}9L}b2mb3)Zh;)A^4L*B|G`Q7-j_uP@FVE`s#-3 zj##0jmV*qRxm;@JuAH(expuLihEXgHK$agFxM?v*EB{hII_0Bg3Ux|H-7pXL4`ntO zN$StL>SU(zZt?$>^+;24B71uNlk4X5W{K>~b*-m-w9dxLYR1q!w8l?AMINfbi{mLZ zKBdKc^&#=2kRJX@ir*Uh+BU`DiDLI?(dI)KwSE?}He_$!l*F(E99MNeXn>km^c`zw z-MFw#olZGr#kW3W@~8_vA-HCEbFqr#4Ii^k!R8t|<67qPhvk5|NgKz2GV zHIpTiwG38LJ`s5w>!}I^{2%l2>GfLbf%nTS&0D-^$J-hMa?=Wo1)PmEzWm0ci-8Dj z5b}A*c@|vn&w&gKM~Rb1XD3X+r%-X{Eb7TO1p83ZEi|V|7khjP^`*@ zW8EcCO8Tfyywbf?cL;%O4mGA$5~lN?KOV>CN+%LP!egybwV19@L{eE|c^|_{J+3e1 z|DuV1wP_NN1J&>V^pE*&ME7ErF7LzNtgfE-&g_-6!f^Hp_$&1{(p?UJ5LwotHr==0oVso!nB@3DeU7b->_ z8XSItF4@e~fApziooLby4fWFs3od@7!IG!O)=RlIZ*nto~PDAbP1VEmF)=W$v=p_Sy9_&J-+Y!iY#uJ~vU{N}!8K8+-4#`sXOzQd!gNLlG;gXQBR@zG%%NC*P6 zg;wzbqKw2-zm&BY;l#{y zGavWKh0q7Y(S9yJ{|EPB{25Gx3isOKscEVtNrS=LF8w;szo1FXO$uq?H%XnlOaj;F z@PuX~kVtcc$on{;$xlf`x0 z@wO^`*rIZb%KS4O72y#m|)y3>sfKK{em1FD5n-|2hD~jqa-+9Aoz-S7I&~Nn9A^kw+Uq zNn6)rYM&MdNwa6DGLHv9VVS&zwC5-SztuRter|yF+-=T~F8uo~RLbDi;rg9ChNDn9 z=ug+$kw`12g%{7!&!l+qVx#7^DOTY~E=zwkcY0U=04*&oo?@HD^=JEeF`%y3vTsFl zDR5CZEXfc7$SylC|yRx}!py247J63CtJ+OSH=%W&>&|rZy zc%`qdMC}8YB1j{y!ro=T?o4~ee1ZWNyRH~03b4++?F+qL0HOG(`nMI9;QWb0;!toR z&?alD8i_V^D{#U^ov@Y9b80OO&BM)`MK^{OM>+P9GcDLg`J+A^8`z;)dF|zQ>$S`| z&ojZ<_91cI)?n~jaR!+FVk2t@TM1j|H zJSS^##^d0bME(gm@A5V91?5t1`C7@svk1ccomkoOq=}XyI%cADP1Mpi(Z1FxMt4Lt zU7g5r$r;tJM!%F>Ha9_{LGq5-obt2Jxc^)S);_&KL%}7-Uk~q z(2sK+h}13Scv>5bj7<<4SUj85rtZdyB+bhL^Z|BQAlDRvW0(UY`RGc;HvBwN+*=$< zmUXl4jm&sHlgzmu&1`)>>6TD4Y$vd`DSKWi(MW!vZfX?t#cZUyqBZWFK6F~J;Ui;x zsE!I-KKo2Wdb?0?&Netnyu;O!$1w6l7a^~P^i}MdO1r@Z8@c=o>0cIaNEl&O2w96X zWUJH~Jx8UO!Y^i}CMX0wZ%9!uys*<_(o%C6gJvgxR1i_c+-n>Whw|`ppNttt#t@Kt z-u?GVPKf?>B9xDN8;88cgq?<$GD7sEUl}ycVb;;(Odbf&#J`s^!|6=^x4lFsc4BCN(!u23$i8_|7I;i-xE+bg>u65Hp&vVI zhH>4akwBKui~L@;lg4Za38XDpnBU?Fawo9Q&&i@4)ZOCK;N;d_xsqD?vAeY0>&fIk?zs^3T5^F= zO*e+>qBl}}LLgMQ0_|_~u@Vfp;nSfcDl_9wP5tx|iR)<+0f${lj}xosuNbbIU6; z5eY{1gEP9OTr{(T0Gw1zkTBHJp$Q5k{h8 zJ=4T6E;~u~dBhfiXaY_=FJAj2Xs7!$;=hE+dGDC*8xcn^xM%Gt9Y~iz2GC$w?ua3X zo;yg2SDlZYT&>u6D28yJOwS1TvR^$l*9VpLx}Q zg#e5%%2Zngr+0>oLcv+SYX_(dKqvAapcYml3Q|bx=uc0-(qRG#4NtE(i;_uj;WGLo+7zv&XdO^07K_10Hgr(%7y68J%fym1bt?7oO&^s zOG%XD&d@7Xn(V~9K70W00&y{jh!w)`aaX`a%DgZ71*${^R0j0eunI} z`{0V!#)WAm7Y;?gfX{V-4DiDPh`#WMzQp+&KnFu4lo+1HMzAG*l104_T-elgM)hwD z946`-W*69FnaMJN+>EeaN~1($B$^=R zq95CQ`g?Gmnr*ql11#{nxi$OOma;y5$DoXg- z7Hq0F8v(W4tdZP_*bgu=b&79d+MlQxsKG(_d3EXmmQ(6hMn836tJjNqcFqpB-Fl504bC_);cJsSt0P# z7%55qtzHjD%#vhn=^_}G=RH>@ejuq_s2X^%scSGuK3$JHq5cEwP)3%6Y=AkjCKeYj zl1;G7hJi3mWCJR^Ma14mN+spNQxC;MHTRf1IEiOPj=mABr48)xDcos2CC;vJ2<4RdiRs!^_|2=Pdp%@%;6O~oj*ex zDv2LLtb^dGpeR!sRsm)0CSYHhE`ROH-BLnV@qWI*F_B}! z3Db`G4CJh~J|3yN`=&k|VFa18nX9{RpZY8wlQ6$YHPk)xk3mcWW^nc=uBm9X)n@j^ zokC@HxjmIIQ+MaFA}tX=>?JRE68c|!otL#<(R}Lj;!qzLMB)z@=yWp18k^h@8Pe!n zPt>b@aP#c;N9b_)6d!rkGvKYj6{|F1+J5fqZOF97H2 z`Hs~Rk-dal_>MyuaQrl(~KE$7TU2>0la1&C2COP^cb8pEU^ z%&Y2A{%d7b#1^nN#NpCw)8462O6AF3-3Q-xECy?(S!wW6x+ce|-V4L-qK7oX#K}0# zkgTNZYm!KpvIAk8S1fst$^&8-=jX>0pxtaeuKfn;=?wm=GIf{*GPb^pJRRv(hfu*u zB#{@@Ek$577M0aD9XuhL;0jlPRigi@nmiMrTc62Z5o2YP=8QtE%^(%L;apIyOoD}F ztAK$F148H991l(i%4jD$q#UX_H-?p~3(GEkCiU!zLRZW%r5l^Nk$D|8uYrm17Wh@j z9BjV|$orE`*?Olf7?J5I4!y7hwoy5U>)9CWJjeyRcSvX=@qYr37uiB<;4SA6fp<;2 z%_9hV43-TNwW|~t@|?)gXPXqhJG+8(oiG8uwy`^IEDL8YYv8~ioa}Ywb8~X~|ATC;0`$|TiGeAv zqq*`JdqC~~bPV{L1FeC7di)hQK3tY2@0Tbt&KN#hsP0wKqYcVr`R=`(P3bJjq7lL7 zqRYc7WsZWgs%jYso~lQEY^#9F5Lx!K83C4CGN$P~*Vq`hq_{4>L5`evp!&X28HK8= zXxidir$;eo?Cvs~GX5cKqJJ3)+CAD2_}aUHT+7)>CjY7x|-x}YU80O zn+ROUVX%2j0g?c_qx{@Y2u-DlINMdgaA=}yx-(7l#Pb~0Z?fnrKDd_l7K2LP zH4l3wR?#(pHMpP5oF=StGOtqu>Ql6O-NRW?-GDZDw1%s>w2Ikp&j)R9c&fvY zMQJgbwz8E<$dE;Gt>!kkT6uJ2QSD=)74cTSezCe>7fUUuWkr7n$M;yH(Jx>nIiewD zDbpm0UXW%@k(F@d?UET{6tr_>%YuHRBFpQcjBodT@SQh`W0z@1yK;>uRcwo?t9%fB zK3$jftGl|gTIVagm@U>8nY3f0!I8Et*H{Lw8Ilub2_GgnP$mOoZ80n~A5zC83+b`A%M+Sxej%Szslg2fMVz%>#~XH3C1NL!n@TJ5ma2Gy+Vb@CE8w(rPo*E z5&@>ft2dou`KwgB6|MKMRU1J669y5Rz_Wuf+a&#;J!-B$&W&s+weMh#GH#oTt`Kyv0{3+0t}ubiKXhSK$mUy! z0Moia(5>my2_`uTu{r>RackQ);<}3oS|1PjbfgRoA$+@|XG0qXx8F#f09Ik=CqA~I zy|cI(tCSlvPSYkn=H@4M%8hGtZ&G4>2norS#B2PV$8v;o z2ENxD>4`A*nNAEJ-zn94*3zpQ5CgVI^Xg3T_%nrsZWI4Usd(+a4dx=IcM%XiwMV2o zYos@@>JT_P2+g~aK!2O6Hy6VHx<viAHenMr9!ZiW*Rm0bfkLpRGdhz8XB*|A_)KRw@utwF_W+qXzLdPq z04+SuKbzvYWd29KC6&Biks+tha?+ehk+3i3zqWpM!?}5|ii4{4)58{|B)zWNjh96XO_rNP#UC!{hfdkF zH_HK`C&zJpmB6Ev;4&|i0MyZvfv-;k~qaB4EV#nED zM@%K2m<8=`b-$$XfSKc`Ic>RU5KjhU*N&D6c`SqA{FPXu|HI$YhuVQPGU*<%;l z5y24?4d%3&tzmrqX$qXQ;AVUQRzSOv3XyY?+ z9p=>Go)y&RXR>DIY)=&(YDYVOK~M|%-c|&2W4yyF+7oCYkJAB%L?ea%m6#|P1H&pU zY?N@Rt`S@(%ihLme6b3vd}GIB8E{AzC_g2iL(-gBrz3a3NGfJ;Ch5yATkXGBZ6!Uo z=Rk;JB2;<_h{O1@ZbfwM#bE*L#H|!}h+70(7Ag3TJrPh5i;8uVy_arx4{ zii&{K=YL&PmJMz>Z9HRxVxg3?OVXuPprJ~J?YGhawi_GpiXp{XC`>QP!U+1_`nZN!bsR|lzK{4-dqJ|M=X?q`NMD6V7DJP z(8lth!et#0G|qGL8Zj^z4a-x3TB1+}IqQN~I}O-GK+Q(`2}i1f{&Y-7s%Z<5{HH$W$>G$EQR&{4JCh+z`o)`s6v^DPQpi%ag&KU|aqoGvA_{EFhqQZ5) zkS10ccXpm!0GCOtjjzs$`2!0+gJTVjU?c)-aXTF*_rYd_yjU^p5ukbuf*qlovdosG zpR|0wI7_yy!}vWz%xSnAq~P*k2yaelUB{N-0IZi8rpV(iu8>!#ZqoMri$a1}L$`zm zL|HM*1IZ7^;4~hth&OEF^XdThyS?`3wGf9<*(f>dWw6!9h^yMw!CcNqVUTO(O)nh= z?h5-zkSo)UJNY}6atYZq$v5PnV+ZbPK=5+82+QFXjaVlqRg1}HEmwV_m&hug`|=!8 zN9U=_kDL62MMcZf)RTLVV@RJW&=PQ*PD*6?W;Hboz$`8z^v~}dWsMvS2_KgSdam-l zBv^4pdqEh8@QKo3{?jd~sz`M6G?F@}d;xuef+(sMErHRsAFestk!t%3<>ptylVP|{ zlkh{0$QXkNgIe|7J|5;@NA=x2oXi(0TOo_lFy7-kDw$kT$k=UT94oW?vzRDbNO&u{ z*XmonyA<$Jll?X8bF|xpZhAtl2OPQz1m0D#k|0fz7>58{)a!!FaqAs1#X74j54}zG z?DxRMLHxP>YLZsx*EKghrK_GWa`bW_6{W^y#C56kT&q3y}!pyAgSz7ec7roi>LAtQmC^gC6Jxa2@s-l7-ltI{t*htnyefi7R#(oz04 zPn69KU$)U#WRJcac!F{Y5rMQr6obI9_Eyj2ob(sDt$+&Z56PtaZJgptPuD>Dse;fn ziwN^8F!i_5hsD5XyJ*2tpQ{YF{=)cLr*R7!m-b%99z8Js8;kKBU%V_nRQ?u`JFhbA zGaB33C>!dbNHMmHc|q2Kw^o<3BM2e@{*&Qv0Yfmy?nE4Xm$uMvExmq_C6SVItb=@f znokcb{kCRtyeK*^eY9MUf!@p_Z)_3q0lh_TNgN~>EUB%}b;(&y^29#;C)Pwi_3EwO zgx_&3T$Zd7){nC`Qe6Ywtn>g2(&6ER!kZtaXp3>uEQF~MK^=D}VGx1+sGOD4*fEP* zq`wq{xwd5~uMYy*dWt(nk0id0W^6eO=G#NfM^g@o-$vN;>3Uf5+BXqpSXpXMBv?&a zV7r(#Gg;v)~T zv`LC{z=|G)=x|V@T3wdT?XMKeD&2bd_lJ`$GD{sK2K@Dew1-#~o!aR(HH?L=c=vwo{L)O}klLqn6VC&7S z)e(XqTDRFV*V5?iLox8KU^Ej~SgHnS+=TefhMkO?CDtGPU&NDYZWJE0vcIc>o^x4X zrbs>^T&>_7HdzyGBzpCW!&{etIUpcq4TDZM`lXj7lx!zbvOZfmIu^MIv=gwZJn-0y zS?Cd8klZYNv8&1DU^i~F=Z-ASBO$bMYu6@i^{m)8x?__IGS0^blp{ee>Z)9RP;kiXg=!WU#>JJ7K!FiU#e?TC{8a_}x_>;&8umvn68w!Y?1!d;?G1OtKOn6r$be3t&uDI!594Po5aexc zo5d&p=;3uJNIrU>xNj2vsI;{%CFc8faYpW>)SaAeA$1u1!ZhcvmHX%x;vy%8eRdozw^NnSH@KbETHok`6 zt^6#+yb9^iR+1iG+O!EWw9$1a-6zK9$i{&#q9d&V%Zs;5dC>@I7XL}A2{cJ~QgN5A z5iAi4&go?A(kQz*HVg}nXh$E!l2ig;Z&)G6GG1L zkSisQTrheU{GmS~?8_HfS9ZTlX9}_Ud!-rQ{B<^DpiA;wiw(~4n-Qku(QZ!90w*Cc zZXUUp9rogM&4)*feS!??f1FZyWV7&E*+VevSX`t`^uv!%(|pr!(4t*N<8^jUA&TVJ zxBH@>u2t9VMbfKDNeqdnFkgqoia($N2t;x3DmAUDbaA7@F-KKTU;*Pv)R zG3`y?8R%n(eNCP3J`w>zIVM!9yezMVBRgBF6Wc8EpYuA6$Ssf<+}oXq&kLgUJoa>< z^dRNBC(I~0J5zzhC`|!qOZzuHH|pnr>%-u?(gH|oV;2Cyq+&&;L&GHlXOfqomoJ!nm9yLxtEg9x8zZ5ivCj_v^T{; z1bOClKQ0eMopTSb27z7Wh&>LMr(BKtm8Z`HVI~fQ zJPtL+iQa}Y-bR67RCs#}#*+{rhO`G!Z^*Lc3!3&C{rP-8_EMS4dNY0ZC>+`00==Jt z`v8$6Po7ktr;x2%CPYl#nLmq~^$KzZI<&QIhrq9OGc2N5WYlB`HrI0y58vAlYisXT z62RZA9C!c`t`}T){zLvAkCKyK-Znc=;KKz?BfN;pXCWY@`SiR6*N^zb!OCIF9V{$2 zVTd8Im!-OV#3Ja&_Nj=W%1yyPt<|5+4pFOV9-_f`5874T^852vh;`P?I}v$J!y**m z$OOaBp@T~br5Mk9c$>$%E61^)x;2ayMcaoX0u&jz*L?U2gOqiDsPfy(5*CGr{yO>> zTWfJFPJ@tf(K8bUa1EkeN?koOMNkH0xm|tRor#rwD&YB8ST}NZ<<4vH%TkHTK=i8j z<548tspOiAvVP9Hz(#@zK*j7dZ6;}NUJS4%Vq~E1c?7ZWd9y6oit)Ho)&9_Va5jrr zRq{(MEo6yoshU!4(&ZOegkZcHa(>Jbhb)Fr?5v@;0&ns3=8bzEeRDzYo0N5uWh+O~ zcM)E@WacjU&3K?NOmO&34QP&aOpJF36b|#wT@?GXDXYw32AsV+c#sa&_x&V8X$QjT zT!v=X*$k7Kk;a6YY5}q%l`UM1OpRqN5=R?Xeu&+BCy6uPFAMm{Ov!v(c0R(p2ByA> z%8il!Cmd8r$=q51tT|0HcQH;h%9n7n;xEU@hKTjXCNO|BOHuR^vQK< z_S_a5ZCD}8SXr3JV{R(F(yTuTII9qV3%6$1MrGH`7Ms&JfA^?x3tjPoHQ~SQF{J)r z%|w~K?%z3Qfz0^h3cVUbKwD!TLw5$Zxrx(2>aLiQ-ZrQB-NQ|PyXKlbbx0ecVM9xX8MIK7Jh`tCE6Q07&KU8qF{=g1 z^8q$Jwh8U51+kbjN8IlD-Ya={Q`9xE7?UcxiGC+x1t+_@htt60KsNXp>jZJ~BS$5p!aGAPkm~g35@GFoq}RrDp6qQ;<$CHl?z9dA8mT zY&PG;cG)g2=kRs1es~1rAZK)|BvOR<|NG$DQVe3?{1eBH%#gP@%2aiY~0o*XbLUO$lwhUV^k{4}~&&#wKIfA-!#GCL)rwd*IgR?UIZ<60Po zUtrsG{uD-n6JxjK0`PFC9yE9CQ_eC&gPxwj-bU4H7p6c>1Ej2}ELkhiTlQEG4-F^m z+=UF8isU(hUV4K*?9q$;yq!iaBr)RNjjSTlXs zQat)@(P#PD+KvUYU-~g{ryUs-Lz=K;8@M|)loP&;)TuYyRXO$6T`09DX-ljv$bpT= zv@;5!Z(H zidtK}DNGpLO3x|~sQTl;tOY?M%c=s z20BA!EV^tcP|qB$aTC6g9#0_tVq#0%oJiiMYdx7q&R*GF31)cAUn;=a^>M&mRO#Vs zW$#?h{G;0Doqgugh|rU@dCc3!(mOYR4YpxNFI4m#hb8x!J6C{d8e1T{)f4A8+>d$@ z6V;S*h7ZWc9vL#9Tt)x!@M|Gn_waVO6)zfnt3k8IPof3zhk5f+B~saq5o9MrSONXo zzwhBjH2tc+GTc!XeTlHtirgv&Oa-#O<2EvuiD>@XD^wj>9TuK4;lUF^swkUiB=lme zW8v)a3r4;cNrxUABo|B6#4L2nXZ*Gw4SoMSjwOHTOv)Knn zBrP?xC*19z5|4^|O~<#kWIzi(E!-f$3S1(TljC&?c>AViInhL!pg8D22KK6-F0Ilm zJLUP!H)#vQq4>H1jm6Ae5ZE^9{rN_{M?0Kgd}$WbpF+PJV3N0Ew?`^jh9OX(+eBp1 z7>eP1j|c0aV#(-YLJ3~cW3AoGLF6jVrkL{P17UmY4ocuM)oslTRYIv|rgeY&EQ{_n z%}XH`gcllWQ3i091Nu~|DQ5+-{|&hR&b zTap8`n~+Ru77m|`%94G{rPggMT~Zp?txXEpz(w74k}XT^Tc>)N`Kt`G?jx5>i_4-m z5bM+WPi2;2;|#9Dv5{eo9F!8|1t4}LFvl6-6-hJ@w^QK2(J#qU;CDV2%@+$P+>U?b zsTqJO#n~yHQ)%TWoybE!Sqw(>; z0|SvG%XfFL0x602~TM0o+PItr`NoUMCVWQH%#A*PrK4 z1B^A39EH^OxF%BSC@!{A<4|6Gj=ENM*FM}n91#A2#yyUx1|ZcOoS3^1%lTSbIR=d8 zM6P7u27R7FP-!T$+5H{U5j(aAlQYKI9^8SFp~`*>(y{yy3%R9%ZH(%6wg^BZSXUXA zgT5c5O)@lVHLWKQ1Jnxc#{YLoxlViFf~^gTg8x|?uFs){a-y$bE0$+eEf?b5q8CWv zJzd=dq`%l49N22P>#ya6oiR)xamS>``ld(M3|r`bnDUJkV{- zCmN5Cwy&cV4FSb^*s1j&MNXz+cx+D=I#zroeMp!RAV|0ZDG@I1>w@F`i7)T%&v$=* z)<%r~4FGSV-R(pC-omTj83AD7WN>F6vBzeld7}9tN@o3AwDo!L>W1~}A z7+mDfX1yz)?qdE3S^9X)>P0~}LTeWbXdD-@`B4&;i}lww=tGl?GkZp|FSD^Rzr(OI zk8`1ZkU<43ae5K#BIi475y)O>eT&G>(-G?PvWeG1#J2JBh1GtCP}Nz4>#_64qu$^z zJ64RLuTI3q85TB7?>?h#H>Dcw-FuU9LgP{J8TM^~^od$T74brPXuq-u<(;T(H0X_g zLxB9^R0pTyaZ+{7{I@+iq*rTmylMRK2<)r{R#w$gCu5}AR1o5>170!Lm8E<5N7dfxQti|BoZ~-b)Q|87cfjLhe&p zTP2$po*URSh2!Jl@0gY6%;+zT;qLFPVErbsv)qU|h5-@qt`6S>ApW5Yf!mkbXC|Fg zg?n?)-40`HOGzX;Y<*C&wt_$UxHEXYx-(6`4C^ctt6628G7PhD_{lv`HpZ)XxYxkN z45n;#;*QrMkIPv`&vdu594zrkiyZ{(QT=0sc~qACexzf2R=P)oaxq?HgO5ETei5*# z&b=bH`b#(I78D4{ol1SH{S}`9kteHj#&rr35ufbLUb=&Xa9B%BNv&g|8H0(IingOvdzVSaedW&=RflfKt zBoc$sM_lxW`^BoySVQNc!z1_C(Q^=dqow=4uz#f!V+#Bmv+mCX+=TE;GAJAqO}LU5cOps;;>VC?@MwqcXsh#1 zc6ir&E`Nw!HOX+FU$%xv%3oY5uHQJmtPaU<{UmdGJPsE*-f0yc? z!|mtYaLDHDK2c`F6F5F;HdN=q$e>`av}%ASqc_NU7y61@cA~}PgU&e? z0bhTYu(9cZ!5o3l&8#NOapkRSNJdsu%U}}Sh-I00_iQCcjSYChe4Rzv_)!semUc?D z!kvVQIMr=NF?}Ar*5|t=wT4JK62c{+fYq0$sC4N{kJ7(7f~1~$drii93(YbFEqQjC zU%?6xm)aw`Oz>OQvM>aQmPh)DWoKgh)!T(>PW9?_$%Aom zgzcXd6A@}5H#N7Y6xzs8R0;f%&Wjwq^<1gQf@7(5M)E*YFQ5gZJt-0lOw16TzL+TU zYPoH~c3~rz2Xvw9v|Lwq_`liO3NRvyMlk1uf!s#WzyS?Ei4pl--s(@CtZlG+YYyc) zq5KH6j?!I>$y-txm4bYO)|z5REgHHm_%FaApYGEnfCnJmD=2Eqm_0#raMC<5`mGQn zDq51}t+ExGXc#gN3~G%6UZ|9Mfz|ldH9?FhTXTDw;WgkLm(d_^DS#T*eD45v039^A zkdZYF6D zfX(A)Gegol75Hc{ZEpBAaPCq1kcO+(KzP%@eV@=`_=JK$9T)Xrc@~Z@V{i%5V?#Ap z=5ULbT*heXV?*y-F;i?(;xz7IJt>`rLj?7LQ`uNC-0xDGjZ47Ll(xuSTdMO+NB-p1 zUZ)lDb3;;+7q{Lnb!0d`Dm-?nhd`iPfk?%@Q$j<0YE{k*VZJE~RepEF^pjb8t~E6_;XrcK=o`1|qEvT6<3KF6tO zFe1hoBRcMjiFq#WyP!9V{>?SHKa(G?g@~OZGq4cbiM9wb-#%ypp48+mhms`0ggtjO z@cJ0KnRsYE`Qr)MAj@^1HawI6ve0Rqg+3%zR9|nhg2IhBkSr0HAV6oC;W*)TImX}4 z>kkN$<$tS^v}Ot*v=|_fecD_p;XFI%_qKhNO2t@54Xz*7H(JzwuNCaG4o8B%^bLp` z$D~)PcL_iV0Ia~fYN6E|?lU@TC`CSRL(tAU3}M=YHBNgMKgJ-L{-9F(O3UpYm7U<= zSk`CjN5*WrNvzh0jB&r}R&FzvW#F*D=mp-sE+0AYrD2x|hTiAW3Y1x9zp)@uH_8Yn zOdM$T^jYz?ik|pcM8_|hhauNOvzIzLcc4;f<&MUnC@=#e_=x)~DRf%Lzd zK^=HMxa6Vnd&`j#JUv2U)zyX8PKE9{=VhO0nty-xHwnNxhSVoO{SU3#IJTKy^ z${cr@g+Fp_CyNuegM8Rsi42MG;W>C|n7)KGlr|&scpD^>ejcYUbk8@K3I!N*`GLjn zgg?#FUYWrAYidyUrO1VNfqke|sF139g_$7iLpj%vX7*eiptLo-#zX?o?}M-1XfzO& zWD-y)Kxd+Mm1B%6YXD`>mrwX4(OpdME##l@)fKgNYz_iPP-8W%f={V;IC({GF%N+$ zfEjwxYz5wJpn5rBR85FUiZWH(`joK=D_ZlX{fTT@!USfQ*?S(K1`2nlYGEu~Y4kg} zqs~svwnm{$cqQh$8H#U}>Wq2yMXKq2?zOQN`FZLn;u^dmy8uE)eiX|LSeO9b>XT@( zE%~b;-4%3)AnMVk($ef%=2yZcF{m4~t##ULN<+smnK+x+Nm4!PFn2UN=&iDB`9Pij zj5Wj7L+ArfpN!NDPCAE56oh#QBOcr4QEkI{w8g(a(k4TLzd#OK|9EEoW%YURU}#_A z&54AsB_0D}+R;5uxOmOwG^>oizq&SOug9@`x{yU$h|X^|Ifu-T!pWP1Hu@(^JFr7` zl?^Aqtd~#bzIAndt5UWJ%t`iU;|gA_uUcbngL1fODDAr&Fo5?HpU|Ouqs*@E@Wqip zeY|9Q<+fO5OuC|bScildW#Zm(A&SR5kTQ0t^d566)7i7$F0cEu;&b?EfuBukthsGL zQ$`KXPe}OTFCNvDPnpAPz^yo`>!vVyh#CSno6;KYfaP~f6lb>Y+?3%*#*qxz_aRT5 z9g8qeO|M?x_EZJYnbRTWf`b77f*m}^d&SP0m}27ee*nW|59KD;T(@wa|aEc|lkSO6OH zO27L{iDLchg;x>WAf>?9vE4!CVJ~Ri+VSu31w$7hw`^7ixQB0G?r1CActlkLy`DYg zI=1J8bYV`P{nOAxPAu7K$*CWA@=Ho1S!h?-JBBnvMzM~W2?pDu;Bw`&xj|!|Z|-D_ z62bu(qVf$+Sqw8J)k1S0qtNl$&K5mdw|eAFFpKUM_{Wa5;!?w_UGr-`W)bdz2Cy~z z7mEkRnJlWy-5;T0F#<}UH8mu1lzil8B9~8K0UG2Txd+-jBXn3Z{JvS2>b8lmzQ;O7 zreYg#XpKNsl>Rd_#zA-r@~~rV53cQ2svZ>Fh}!C>S~`NmgBlKPWrcUEZfuraAj~-u z0eQg|QfESxQN^bS9x0e=_sqSz$4|N*kIpuH_^RP7nG3$wOt^MZP(Jmzf)xkyXhDG>#WfZJ zKy(9xu%4qi#idFKp}Qs_hY3u8m+L|_Z_-%_p`mX-8b~bvJB`)T=W*1GiP=OtfmD2C z6@AY?d{$6VWsJ7jBOWiJC0$v&1yB-~8)zodi3V;y{4yVm09p_@crY!!GY3 zGv5{4mRdkz3}Z%^r{r7ohoq@i;#-^OgVUW(SlzIstq}R+zn*`QF`q|Xb(H|)k=hmr z-u4)o_w5{$aA}$5SSvpkOsO^SxvmBaI9zQA_)A z#CWSm@9R;O)~lQ(&5$4km4$M(;ue22NRX1q+d$uDie}7WH1upv#&v&w_%S zRIdOt4=K-FfZT+Gp8X-@JV!zTwoiPRDh!gAC z4J6-K?!gY6N8SBssf%bP`P9%8$HRq{f0Ixs(J?1h7_RpQiFz4@;qH;ppyOC3tz4x+ zcnhJd8-4k<8>j}?&8O4O<&$$H-;D6^%$@(2Ixbn{Hs%^0$_nRcR?J$ z=P9s*8N3RvYOlrXe@X^wMzB$kfqO#Q70n$Bm5Lu{TPc6o2Xz(5Zu@VX=3DquwFN-~ zgSrgb@J&xY#?Ip!91TU<%!EBVm`H4gvH2DRi_@L>bx5m+mu(>?9jUV-9d(928J=WH z^j3ckIG9jTT;R>4a2q#GwAd#qR0IBL&I+d1{twO7WXN#UiD%svx2EvMY0MoB#$HgE zQiAR{zbQ_-kb2A#yyWpoyWQYZVIHv~ckIVp)@)>PrTSzD1!Z6t6)pDn*xd_btej(G<~+QUA(SD;z}W_h z*9E-zY5Q70chSjIMh>0=Q51ePqHWuQ=-p{vNDc9#Rj%+9wACh8g>XvpoMdLUoL@ta zxGrs)<-MsKNh@99tw;KM5Z}O?R;_7PHV7fWLY z?UqfJvIr~}EfK4->^Lx!(cnqrI9tRX<#ifZe(3T|dhxjc~Y>rS|lm=P`r}JT&ER5y^*X0p^lG-5%4ZC?jpx`ty)iD%9L)d{m^QFSZI6 zE655>K-eo_`fP%hPtf-)6tDI$cXy>};8VM=u($;7g_nbgAg*SgGNtw#4_F8tp9Jq# zWitb=Zn9%G81IblnsNZv@%xUb!Crkf|Hk>)%q?VSKNts;@IR7MUpmRYbPuwzWm{1 z-4u7;67Tk^D0BaO-I$xNGk$Sm(w%iL$(*&fCEiTC(EoOBq!H-jIJcHVEb5g&)kH<8XBqgNN!+Lj<4LLfyr-`yz12`GAQU=_rEI`{$Nn)#;R4u&i6T0NHM53Ym}j2 zg=Zb?8rsl@Sal{5+~7IUPC&SQe0ti6QxeEu7?Q@lqo<%w!J}^R)7UO$y0(ChwU zf-0xG#zuwlc#O%dZ^<*1C={1G+pM4u^SCWgx$7U-mia^OZtt+Ufepzss0%47{B;};=kpAkM!fO+?x!CB+t4jqCEJ%sx-#ugCSJ!8YS=B>82;tt!ATN7CQbdO+(M=Z$7WtxmUZ>t^338 z30t{s3jy5tpEer_ufqgrmk0N41O(Q9>t3O^H2f=Jz+6|itf@wqcWvtmZ_60Ew=%1Z zt*WAA{tSi!^-}s$K$( zFy=!buq3>4ywb1^Q07}3vre1_;Dval z4u)JT`EA%=wTAKpOd7I9jVK80k0^X|8Pfe^=?(ZaOyWO5CeE(2!2%UFu_i5Fx8_cu zYztq&^CF1Nu6Eydx4HI+|M}kQ(A^a2QDY){%&hzIvCTQD5; z-kDKJg5-38>bV zh{v%F;(Jya#(w=0l6Jqj&sRTq9mL0~vfgn%u@GoxK5vPH!O%(84<4Ge&~rgTQC#+= z_rb_rX*!hgH0+*KxmJaEk{EYxy+a~7gwhLE+!VOrJ%xZ99h97WQ7S+b`U%kk0O);D zi+P!&g}xx@ zwm0;C&wplp-}}wn0do>@!9b~q5?wl(Jbr`iyDH{F&BDf#mmp|xRL}MQ6m4b&k3?YV zO(FM3?g5e0E@y-+HjF<=bOaNo;WWMuZ!eVL2YaubuR5H6)nE9C51-&$nO1|M{Rz8P zzsgr8%Rs=7-!lStghtbewc;8}(^Q<5dUd|KoD*1rU1t37qtlqBQSVHq`kRUzEKQHe z0wnn$-s6edaig8k}8L2J8gvuzEFq#dGQ2N}`@h8XqVh{@7h`r$v7Wc->)5KLt6z0!Veum6TOim?a+b5%TUm@r)YHh~ z@;PHj$J{-%_gE*w{Fb+3a-^?kgOIibg@4}yH~*1hX7!n|&6C0`4<_7*FMpzaq_q`5 z(*#B-{GLmgx|(?74GAmMr}d|QN{uxIf)oLs3@28^-9g=O%EaH>&h%+9L<pED14?2reU;zSFn?EN@Ew5WJkbPJinIzR(VJXX-qAvV zbYw?&B`?drw=UyvObBwuJ>a;?@4!n#MSY9x9jVL>Xnxr+Gc!4vJ|H~$$qu_tqHP?S zD&{#bfHj!JeLAt)?ed&)^sGXHczVTlhJB@Y+{+v4SIxelo(RsQ$ z3-gbN2>v`O223_Rlp@|CEPc)#fLIHlhPxf^O?=3CgD&)-TREK@FX-7#q9C1Bj$Xh8 zlTwsxXOgm)(4iY^^^;evedDb|e>&Ko>UB^O$q9dztFNK))P4PvZ{vXc0JCo82DwnH z7}3uN002D;g3jC-t-I>Ie536sgI>!BQjz|9-AOm@g3hl`M{>OCyL9E`k0o@-0fKOr zMs*Ay8gn3HY7Pv8wCYlvOVf5g?r2$Z(bKUOl@&<)%|5Y<`za3;WsS=FiUldE5#iEt zSP?aCfsOz-f%OQV*$F#IiFnRyve_{^Sv0%qR$L^zZUp^!Q!tk~`>7`3bp?lXL?`|N zRD^Sjkq4anm~exROn0?D#ELuB&DMJ6jbcT8D2>{TWj43CvU4(%1KUu+jso|>v9(vG zRXx=#W3hU{$j+ibWOoH*U3RXPtzIsCQ*F}yD2@Tambw~lwfsTlu}WXK{wc3&a?VVU z+=_!}s@7}#7Tyf+Gqi1s3Tx6x!9IJN!> z^^68=$K#X-mw`r=F_#<;DLFJHllZ#{GJZkf2o8aLSD;X7Wf>XUED;%7^PvpR)c7Lw zsPk{eK61=VgdjLTk5H&`;=($Ccki(b*k4BNBUkNq?ndSN<$&aPf&jVOyzuO z3J#+XbM9+&9}9+T0%r@^cC7c`Cy!V}%X`JX2M|XV7`rXSm8kzOD&Z1ZgspYIWpM@I zX($eGaCAmI=fE86q)Nm}`G>KW{ZOFOsb29IQil|f^hZSc8OrTrvsjW~URxveR5_4d ztW$a5qA%DR0D(5ODiBNeA96o$A7FpB$hLe5uaxsgd9~NliSeem=E9#8J-i}f)34$L zQ#XdjT=T|$bhnnvMx^RvDSzbaAayqXAp9vuy7G~N=Lb&zzJ}wa`Si9#8))*@r*ZF^ znNC|{(GQB!xBKLuJI1^(8R7N;^?^ggCh7tQrYw1ELSKjdm3-7-ifb>K?IY4^1ElAn zn=SAqq-ATaxOaVKl;eW$7b^}l&8;U%*QUQ!i23tzALY4ImgO4x$IE9KfNrw5qx>cX zYh*Ag$2?_2Bj%}zYBnpmDZ!+of4awLFK!kKRj8ldCSs*!C z?(a|sK=<~_0w!_-M|UQ`GZopA4UvyFQ;U-hM>}#tyW}3aOPNzU4l(o%{lj^jTWrjAGvLR`e74Nc0V4wbbkWW>v zh5OcoAI$e!ppIH^nUrWCO~YMy2GI$il>Yp=$r=Vmi2tH`EfAKkpKLnnCC>KZxmwEF z$gB^#yH)N>!&H7gX62C!g^D$mKqq;d-dt1K-RELQ6!^ zfbO=0HM;%(t*0q(9z@`D$m4U$e}$`U&d(!iHE)%MKdD=1wT9lGCq3DUGkkKat4o{x zw0OwpVn_QKMRLblO2%;O`4!>q-L33HeT)ppm5tWjA@cPYYi%(PBfRDW50hQ=3^-9^ z6@sDtQg*UV99T|}?;jm@XpuCsinO+j_Ly7xYvM^T`NS$uH-jI|QPr|Wj^(!R5f+mk zOfUEDFk=%Jx$h=BV+w;=7t!s3cJbqDS6{{Ez7>#Q4zmRiQx)K|4!e-!Wn7_-#}oEf z!9J=b6O3O=wiy>3w|AdhDY$+ugo`g@EH1BFbFaC?^atUq!4(PdiliXEdDy9?4IR!W zuHA|<6#ni0H8Mjdf#P;m9gw&^hVl+V+Bt}*OA~_P4K#XRZghU}7d#&T0MSeXHGMZ- zkv(TiP+u8gl*lEwm4QBKkia0=Fwkfzdnk^Ua?)=wxu#v-677G{vlEeQiJwekf^DD3 zdtG<18eWTgeAg|#jgMM5Ck;PbN74Jk5eNY1d%YP4I{F1oC$+A;n=}%{I%H+UgDPVv zfb1H>{z^-`V7%q#IKla~pK^YTg-$rZ|#i)KiHLszAWEVW>GbqKp>`$-f%veM zrtC`u?tX9Rb%k%57~G-r5T74~q>QY)kn^lGnOk8KTYN*DiKDY%d+v@WWJm4oZ# z{a!u|g8bc0c|AB;AbLPI8*fs-&x6@`y7eFgaU49Lme@c@M`e$)2TR*n0)lPs-%P0WO^^5c{Eowan!SE8LkdmGU9=%kJ`p`u^;0+8!`bq6U@kv(l z+fv)*7D}rv7OeJ-#6`@1UWQsMz#@{L(%0R8Uiz2q2Vwbf{&)#{!#eJF6}}9={5-&f zd1gZQ*FrbmaT#6E7F!4?BeYdqi7wukb;C!v&zEUM+h5>fxb}44&?FFr{a9k8hSFc_ zli@6Q-%$h+D3VfyI-h4vxXeJhV4=_tk-^m+$Ovge|0uqZSmc!J`T$;5AB*j>G^Q8EmMZg-Sc+ zPip3PmJa(cqz17$^~7k~_zDql0=%gdWPx?`><_f#h9VOs9VjpUZJ5&vPunIxS1y7V z6}SmbZ7WSg$QZr4Lwz;Jwk&8IxCjl0x?e{?gU)%^rgx7uE(9Gu51BzwLhe(>PGSQu znBP+svOsgM4oX#`p1l$K^55$LEzgvZ+)l*<(sTh$+<$?zFIvMjWd2p8!E@9XaQkAs z=#Q+*`k^gB&GX;J`BAEX{cGDSHFNPeD_wXSe9zP|Og{M@x|rt8skW}~BtN)uz!bgG zBYc1Leipz5wZ9NZ2c*-zVBmM7@MDPojh^^w&~g}jZ84E5-^6Cf^=2dLD5Yn{Y2|!Q zyTf-6oT*@wfQ=#)%{(c#n;u+dNr`h{@K?DvymRR^@ol_Rb+)3{tRQC~`anQzZzFQN zYnHZrTJT8K04CVyL`vdN+30i$Fk|O!XKU3Vh2<7pc*(DvZ7}Kio~<5}by}|LQjLha z?cCN3^l&#`3cvaRwH~zWw2SSj64afzB3Z-Y5+i%%`e*cUWG`O$29;~t;}Kc}FDRe$ zi-j|Xp@3NY$~pa{cQ>8HLByZS^$>XXzogw=(z=Di0!)XJ=4Vp_6XojC9iYi*)gwKO z%hK_lK3O<^!GJ@CM`}ex3FuKN01|9~U%P(bk`yW7{?Uc2ew1`eoRt){*z(t?)UMmr zlzGqvgL;MZS|}Dg z2sE~U`!?+A;gHiXybVTv{cg09v*-`ZbhQ{}5oCO5-rS75Dc+wx$eD7M8#1H|Scvjt zXF6=&m%SnHDGcqoT(l`ejdU*+W%c*@06zy{H7g@A{P z^i__SauIqTPuW#q2<)q^9>gi9U@tO7AZn0%9f)9B)b=z%)x!bQha2014iOPJz@e&| zZ@@Vj%p9%*TWZy<%T3T*=y?wJ_q^wPkdqd$~-+I2arO0?q7bRGV&Fn>K zx)BT>b>qo6DPPv5HakmDV(Hiu`H~h(Yk**_F{k~}8%C>H=AryAQFCB@i>m~_WQS@T z-Jm}y4KV%j-wlKJO0{@hZ^c`f8J-l`%m|qs$IQF*l@<9bM#X#vkK_Elx7vl!B+XlU}vl7`oq4zi2_TO)Rh=!D<@7 z)PD$Qdi##(*0KdX2n2VNd1GW+FNn1#og1;D#^ivoDyX#Aa|j;;WiW51LS^c)?F2!t zrn&lcjL@vPCS;;ERpMe><27=q?opu1r?T>b7u_QE+w4iL>j2C0q92cM=+pe%DuKw~ z^^^Unl9Fk^z+NJdOr1V*K`gYjDTYJ)d{p5}m(9$vGr#p|L5(PE;(gfJkDfcPK%~%R zTlizxbgyV*%~;#07N=R}`tRA{f&OrOOt%c9 z(3u|;cQ+c?aZ&bj#oZYC9jk*ScGJs(B~-To%VKu9_hTY^9-jls0ap!xppPwkk8{87xNML_F z`s6S#)S#xEDqlOt+hEfPk$pl<>16H4+pqL7(%F{dLw)hkko~tvepG246AI#@9rJoA zAMTdjeVf~t>6#7mH2?6+9o56El~k|R>0%Wb+ib0QOoTbs6@;8=xs}{vf;qOhOAtne zUN208SE^zO5>i#B-D{B5l6hh{iNMsnGxmlH-IY^!IKrqD6S^mDaiCDlbS zx1B=Kz4Ig8osfZl)Iqs$9i@9hIP8R=RfzB!baY#K`(oG(OmnL8s;P|$0~F_NI-pL(gesg|rh%?<8W-K^7J;0P-b z{M?2{`hgFoSGAu@&_|Lznt~HHT{$606z7GJ`%Qh}C}8!5SkW(6lHn`xa{qm*lNu1K zlWf2wJ-vAwgj6-QllHYLz{a<ml&|ta(Qp^BR7;l{fdJR-X{MwFYEMm$t%d&drNM*bw5CgN_-iAl^YC|W z0yx0!qhxLl%)*$mH>V!xkHjfc2#$W)hl~}@O)`;h>Q6)6*UJet;gOrd1a$ce}Wg^e7|`sV1_=HZuT+zsw*4 z)kCUI%qOvK%^o`k2Yq2nTSEc=)6Zc_L?Ir29@6*-h_L~P?PpEE|6K4k_KztFkCCg| zI_iVkKK}IFxdU(G+;{QzW|SXRZnQ5T=5-Wj>ZHgP8kBgoCDILv^uJ$U>|r{A0-(JY z|Hz=ij~)V`fwbuW~&xAMgh}T5$=?)JRjxbJHaJ490U%)dZ z444-zM;Kq60??czP*xYzDlzmd*i10&HRPdiJ=$0Wjl)+r!I;*^FiZ0g?r?W3{-4pG zPrc_M6gGQ+lzTaq)gZlx)7)8qE6Hp*B-d*9S40!l|Grq0Us8mgNIabhS~*D4PGcO27C*0t-3v_=_n!# zN>~co6{+Ml{dmqo9(wsl7}G-M@M(f*T0gi2UvK%i37&>v@mQ zl_rDG@7d@1Mw7P6kqzYOdU}lbD^r<%H_pXtvcDWH1B&u*+8x`H zF0>6QqG}v0pi}uycwAz_REGqq_A%5$?49G@>;c9W1@N}OyxZ8>E7?87@y}#Owc*Sy z09jo?X6}s~FjIg`wjS3EnNlUBvo0LE0k7L=w0Up78`oipJU$8;#FDVrf~8%m;Xz6$ zHUBN+JvD~*pFQC7pE#j}5X?M;k_o7>H+Fmo@(a=4o+fDntt^~Bth7H~O83$a`0=1v zIX$tx2kgXl8*7~vDWB0v7g*+;W!fm)eLG+ZCl^LT!cQ*3p5);bXm1EPEzE9Gh0JFL zIxg377&I4t9c|};ACiX2VJm5Rd6T03qo$3UI)viZm#o{t@->Y3pRNCgfWb#x?ne{C z`GTwjl*Xr%7tvu|Mr&tc5=37`fxgLKL{;xZmXt`r{0Gi4@THN=t1nJpktX|0{V@!5 za`A7v&n>5c?C3SFC?!n@jo&x$a^R}Vw9X3pi4DionG~-4IYB7Aa))0GwEw$DZZ@#P zz&WGVzR~WIjkNJL77^g%UXly7q{hF|;UO!@WyK+7iDI$laRX<58A^x|^ZjYS?8YK( z-KK~xzh>7V>ciE6yB*<}`X}_fIqqEC^e%0@B&`kYU=jESo4>In`)AqkL*vVrAdzWM zjg|e3NJR|-5x8B?qXW$^LOJo+CVnklTR|?M-R*|Y{~k-sU%olFDo+D82o-9A>az9r zM-B<~JKEk2L&nPo8;(*?(9!}#(`zKDO+M(2gSEn676yS@g`Tu@VMFuaJVW%nL+)Cq z+LuH~F?$tWG}i{Ots#ycx^!+2xr>SLdMREROV~g*iOgceX+qbv2XwY&ZRjVS{}_#< zsSw9pl)n+k?wnITlNyofJOnYX_k0bG>?BXx{ly9kTPcyi0kFD89lC~zlWR!w zKCF<=1*=Sle!zYX)_p6Erin6m*>F#-4GkGpBv3^2)?iyuVvQ!+gnZfIkJ|Q#uNWma z2D8hO;B-C)Sn9g~d$eO!oWZTf=Z&#iWEW!4KAz~1Wv*EzAmStxsFcH}i>)SYwIg;4 zZB{)o1spLLJ_;oFfD#d`^X-7*hp~>hlYwjnyJ5mV{BzU#mxEA$ZadZ3Nn*`Q3XqHE zYe7Zit1QKnZOpVrIf#)2mJ5uMtPx#f*lC0XqxvF80dE zq2XZmxl;tI@_pG!BBw4Q^x+Pt-I@ zXly~EgcpDH?ROP{hLenGOIuVXxRjwI%+j4W?OP%v)5=G?abLnTuW8YZg{A~2;x`Zz zioOMHgf(D)3xocx+Y|-g-xKbq;8BP0Q zD_RRiwgv{u#=3H&7;!xdJU!%=*@+A&c{k72#Ph7mZvnX#!L9iR3}9cQ1mpHIYDvMl z3jWuVKjA67cmx{l8%&1W9m29Hc?U;x{f&=Dtre}81mYJeDCxF&%~;G(9;-ybO}-}} z*A~|auAu!nDl)0&9Je%ryVrZOLO9w5EJ`xCaid71hEa}qGv)RTVt1_nEJd)9101CH z@Rx^YHfzW4dpG*sy9|Q|Be1UHp}aA1HUSFowB0TnuSEjggkE2d<>mCis611@YUXyB z?5ddd5hAo73IB}c@}6Vs!2BrQv;GjFM&TUBHt)42hdLHX@nOmj8VH)`_dGqJQ7~q? zLdun@jZNGeX&c^iU)i;JT9Z&g^mw8j&<_7+Qy!!bh|xhFw7ID0lp=UatH0+bRZwqDHk%$ z4UFte{aeuL!|fXiJxv_Nqq7ZAC};q`#^9|M+T|-Qh$Gd)+10Du>7^kaIKCC?&MDDW z`nbHKtxj!j1j$IPBTm%;4_IfKvV|=1t+5${?X>+^9E+CuVo8^`q0~F>0xr%_u#vCE z+F29*l&lxe%XF%O+LPN=3z0^(j-RVT_cg2vBfA^L-qj<~4=nR8Aqoi|q0<^6ow0j+ z1KWy8Y_zLGkame3#S*S+^DJ_CZI@#4@qQ$I99mC5@8$6WiRV zPPhGl=>;L_5rLI^{Ga*18?2)>`^Gcy%9Vq+x8^n{^X;Df=$Bg&E4f2SMH`Pt)=ltCYb;jV5=~Ueg{uZ%q>5!{iN4gC!LEro3prwu~c-*AK0JvdHwzcsHs zgpv)Ih79K31=daXLqd0OJXbk9=b$JPt#de%%n8*^ik@g`o=?%0UA)Hjeqx1u3_E@q zzb#W1_$ezUBkpx8q&7{XEr+9GIp|L8k}&dA&^Fh{hjgVdcIROl&1Z>C5Pk-#g-v|! zzq0Z^ZZU`e-rB>kYB|rBk9ctZC6u(7%vuqkIyj^LChJDJ1D$ZYKb8^BMaR%j!?gv> z%~K$>q0Z;7{y-}6GA=cp&O|%G33Sb)@dQvRHC<4;Q&53ZSDoVB$ILh+W-{3hy4*zHpSs%AdXuK>y7$^Xj` z-$<+f(N&w)T=ggd0Va#q8l2+b^{acymU_T%ak1*6!Tj9{p|L^gT$h?GT(XxO-O(wc z&njQ6v)~>7K^K}8Rx6n>tIhuZsZ)`vQ!Rh4cke&Yti>S2d}pO9la~4SAN7I-(Js?< ze_}&xYy5ZiL3jzDU!j=L;2k5KeS?FRip8aIy7F#q#QmsVLOzG?cjYUnB$^6$9Lb9W z`c53BE|bSW>3!k3IO@kDIWCr%J#JpTVD+!?w$Z&^jmQMgn~zhy+K+avWr*jqunc=A zqBVE}d^{%_{7q*^|93?(MrZFat3s>PJud-$u;gR-u6gErUG4zu6g?>(6m z7Tzd2H@zQ71`ewGCpufo3Ln-3--K(jmcDKp@zX4=bTJa$=EVyC6jVo%GKcX3;qCv@ z_uqeY#bbOYX3$BSlM!>)P)9-VmsZ(BiY^Lum`_&3e!;Kv4{{Ek{NZBeEtQEIOc#cL z>3apCk=VSC&?n%rz4CIj1V@ApVoE4ssU0_ojZ3V`EzfYV)4|_N{}dxr!$1$YYZ^06 zOnt-)M>>&mceBAn^hWmEPlJE269!En0gYUF8PB>MQA?z7i?s;CjHzC;FkwIwHhEV7>iGy_;GY#fe3KoW{d`nsRxvNGk zur|^#FgJNw62bu~A`%q6V=V_5WlX@!y@e<*P4^p`otcw7@TJ%LHg?{_xum}u#A53% z*9l(QwoKf4N$YX|X@0$fNSzyu-c1Ur1<3A%ipzNW8^}!=74t|DSl;C8r`K-Ueo(Jh z;ohXQl&wQ22#P^|hG?+5pXHn~v+GO&yr5UGjz$rlj23cS znRp8$;R}BzG)rAp8Vl><13Kle;^vlYx*KxhRz%7%zK@@6Yq&*ulvXGmGOVyI1?m3e0$ z?=crK$To=CmIDc>txYkYp=JCF$Ry$zZ+2wRQT^^6E5&t{Q;v}DDrwwh*W55#bD7O^ zfNiRm3YTEaaaE6QPKF09jU!WSYh6F5-N3&BCd601PC}=^NGNd(1LH=>i zrsys%)ohgxk1JnQ=y?KZ2RyeOe*pE zzPA5KUu}mr`xfQU018#zdT39gA-+y1VTm~kgQyX9!Rv;J``cfKkQwlmSUC>49A({_YSiUe++FIEd&5mzQYVTh%BfMY`lKj~)7~BpZ#K4Jjlo zVoH(I#wL-et)gh$PX3e%^bu~yuPv--kHM7$L_pLj^-40J>SP%lBBOCE@V@--S8cjAE`0Dt- z$`k@}O{w5777-V3yiy#x8M)4G?~k#1Wa0I19OYo_ZTOwB?yPpN@vCT7#XNaalL0zt z(H1L&>)QBt+>GZ6PR^l`A)y)c%}`4@Z}(l8Jsly4GQ3%(t~eqou|}lmj=-37!W;7ySR=>TzxtCiM zkpupvTQfc~P$F|10I%#J1-&9|s9;OQX-F!U1D(+~%NJ2_ad(?6*m$|9p-CV2K)7Dwor!Cf--a%!j#xqryG~4u+3Z zNe`)&_|_1A8zfkZDFxJyHWQckt{ki2<*Q~^&cMRe|Y^Lp7MX%0RQkP!2h?sRRrjNwf_e^34r|nsb^wj1j_$!iQnG<0C4_)OZ?Bl Of&35e_ao*%^uGXJ2 { + String rid = exchange.getRequest().getHeaders().getFirst("X-Request-Id"); + if (rid == null || rid.isBlank()) { + rid = UUID.randomUUID().toString(); + } + final String finalRequestId = rid; + + var mutated = exchange.mutate() + .request(exchange.getRequest().mutate() + .header("X-Request-Id", finalRequestId) + .build()) + .build(); + + return chain.filter(mutated) + .then(Mono.fromRunnable(() -> + mutated.getResponse().getHeaders().set("X-Request-Id", finalRequestId) + )); + }; + } +} \ No newline at end of file diff --git a/spot-gateway/src/main/java/com/example/spot/SpotGatewayApplication.java b/spot-gateway/src/main/java/com/example/spot/SpotGatewayApplication.java index 3c3d94d9..ca269de2 100644 --- a/spot-gateway/src/main/java/com/example/spot/SpotGatewayApplication.java +++ b/spot-gateway/src/main/java/com/example/spot/SpotGatewayApplication.java @@ -1,9 +1,9 @@ -package com.example.spot; +package com.example.Spot; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; -@SpringBootApplication +@SpringBootApplication(scanBasePackages = "com.example.Spot") public class SpotGatewayApplication { public static void main(String[] args) { diff --git a/spot-gateway/src/main/java/com/example/spot/filter/GatewayFilterConfig.java b/spot-gateway/src/main/java/com/example/spot/filter/GatewayFilterConfig.java index e0f716fd..f0ec6dda 100644 --- a/spot-gateway/src/main/java/com/example/spot/filter/GatewayFilterConfig.java +++ b/spot-gateway/src/main/java/com/example/spot/filter/GatewayFilterConfig.java @@ -1,4 +1,4 @@ -package com.example.spot.filter; +package com.example.Spot.filter; import java.util.UUID; diff --git a/spot-gateway/src/test/java/com/example/Spot/SpotGatewayApplicationTests.java b/spot-gateway/src/test/java/com/example/Spot/SpotGatewayApplicationTests.java new file mode 100644 index 00000000..66a66588 --- /dev/null +++ b/spot-gateway/src/test/java/com/example/Spot/SpotGatewayApplicationTests.java @@ -0,0 +1,13 @@ +package com.example.Spot; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class SpotGatewayApplicationTests { + + @Test + void contextLoads() { + } + +} diff --git a/spot-gateway/src/test/java/com/example/spot/SpotGatewayApplicationTests.java b/spot-gateway/src/test/java/com/example/spot/SpotGatewayApplicationTests.java index e409c789..66a66588 100644 --- a/spot-gateway/src/test/java/com/example/spot/SpotGatewayApplicationTests.java +++ b/spot-gateway/src/test/java/com/example/spot/SpotGatewayApplicationTests.java @@ -1,4 +1,4 @@ -package com.example.spot; +package com.example.Spot; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; diff --git a/spot-order/build.gradle b/spot-order/build.gradle index 9c7f09a1..229c7daf 100644 --- a/spot-order/build.gradle +++ b/spot-order/build.gradle @@ -59,6 +59,9 @@ dependencies { // postgreSQL implementation 'org.postgresql:postgresql' + + // kafka + implementation 'org.springframework.kafka:spring-kafka' } tasks.named('test') { diff --git a/spot-order/src/main/java/com/example/Spot/SpotOrderApplication.java b/spot-order/src/main/java/com/example/Spot/SpotOrderApplication.java index 00d25bd5..7b9c7dc8 100644 --- a/spot-order/src/main/java/com/example/Spot/SpotOrderApplication.java +++ b/spot-order/src/main/java/com/example/Spot/SpotOrderApplication.java @@ -4,7 +4,7 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.cloud.openfeign.EnableFeignClients; -@SpringBootApplication +@SpringBootApplication(scanBasePackages = "com.example.Spot") @EnableFeignClients public class SpotOrderApplication { diff --git a/spot-order/src/main/java/com/example/Spot/global/common/Role.java b/spot-order/src/main/java/com/example/Spot/global/common/Role.java new file mode 100644 index 00000000..a008bc7b --- /dev/null +++ b/spot-order/src/main/java/com/example/Spot/global/common/Role.java @@ -0,0 +1,9 @@ +package com.example.Spot.global.common; + +public enum Role { + CUSTOMER, + OWNER, + CHEF, + MANAGER, + MASTER +} diff --git a/spot-order/src/main/java/com/example/Spot/global/infrastructure/config/security/CustomUserDetails.java b/spot-order/src/main/java/com/example/Spot/global/infrastructure/config/security/CustomUserDetails.java new file mode 100644 index 00000000..280beb9c --- /dev/null +++ b/spot-order/src/main/java/com/example/Spot/global/infrastructure/config/security/CustomUserDetails.java @@ -0,0 +1,58 @@ +package com.example.Spot.global.infrastructure.config.security; + +import java.util.Collection; +import java.util.List; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import com.example.Spot.global.common.Role; + +public class CustomUserDetails implements UserDetails { + + private final Integer userId; + private final Role role; + + public CustomUserDetails(Integer userId, Role role) { + this.userId = userId; + this.role = role; + } + + public Integer getUserId() { + return userId; + } + + public Role getRole() { + return role; + } + + @Override + public Collection getAuthorities() { + // ROLE_ prefix 맞춰서 제공 + return List.of(new SimpleGrantedAuthority("ROLE_" + role.name())); + } + + @Override + public String getPassword() { + return ""; + } + + @Override + public String getUsername() { + return String.valueOf(userId); + } + + @Override public boolean isAccountNonExpired() { + return true; + } + @Override public boolean isAccountNonLocked() { + return true; + } + @Override public boolean isCredentialsNonExpired() { + return true; + } + @Override public boolean isEnabled() { + return true; + } +} diff --git a/spot-order/src/main/java/com/example/Spot/global/infrastructure/config/security/JWTFilter.java b/spot-order/src/main/java/com/example/Spot/global/infrastructure/config/security/JWTFilter.java new file mode 100644 index 00000000..514a89d9 --- /dev/null +++ b/spot-order/src/main/java/com/example/Spot/global/infrastructure/config/security/JWTFilter.java @@ -0,0 +1,104 @@ +package com.example.Spot.global.infrastructure.config.security; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +import com.example.Spot.global.common.Role; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + + +public class JWTFilter extends OncePerRequestFilter { + + private static final Logger LOGGER = LoggerFactory.getLogger(JWTFilter.class); + + private final JWTUtil jwtUtil; + + public JWTFilter(JWTUtil jwtUtil) { + this.jwtUtil = jwtUtil; + } + + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + + final String method = request.getMethod(); + final String uri = request.getRequestURI(); + final String authorization = request.getHeader("Authorization"); + + // 1) 필터 타는지 무조건 보이게 + LOGGER.info("[JWTFilter] hit {} {} hasAuthHeader={}", method, uri, authorization != null); + + // 2) Bearer 없으면 통과 (permitAll이면 컨트롤러 principal=null 정상) + if (authorization == null || !authorization.startsWith("Bearer ")) { + LOGGER.info("[JWTFilter] no bearer -> pass through {} {}", method, uri); + filterChain.doFilter(request, response); + return; + } + + String token = authorization.substring(7).trim(); + LOGGER.info("[JWTFilter] bearer token length={} {} {}", token.length(), method, uri); + + try { + if (jwtUtil.isExpired(token)) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + return; + } + + String type = jwtUtil.getTokenType(token); + if (type == null || !"access".equals(type)) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + return; + } + + Integer userId = jwtUtil.getUserId(token); + Role role = jwtUtil.getRole(token); + LOGGER.info("[JWT] role={}", role); + + if (userId == null || role == null) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + return; + } + + CustomUserDetails principal = new CustomUserDetails(userId, role); + + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(principal, null, principal.getAuthorities()); + + SecurityContextHolder.getContext().setAuthentication(authentication); + + filterChain.doFilter(request, response); + + } catch (Exception e) { + LOGGER.error("[JWTFilter] exception while validating token {} {}", method, uri, e); + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + } + } + + private Collection toAuthorities(List roles) { + List authorities = new ArrayList<>(); + for (String r : roles) { + authorities.add(new SimpleGrantedAuthority("ROLE_" + r)); + } + return authorities; + } + + +} diff --git a/spot-order/src/main/java/com/example/Spot/global/infrastructure/config/security/JWTUtil.java b/spot-order/src/main/java/com/example/Spot/global/infrastructure/config/security/JWTUtil.java new file mode 100644 index 00000000..45b8f3dd --- /dev/null +++ b/spot-order/src/main/java/com/example/Spot/global/infrastructure/config/security/JWTUtil.java @@ -0,0 +1,95 @@ +package com.example.Spot.global.infrastructure.config.security; + +import java.nio.charset.StandardCharsets; +import java.util.Date; + +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import com.example.Spot.global.common.Role; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; + + +@Component +public class JWTUtil { + + private final SecretKey secretKey; + + public JWTUtil(@Value("${spring.jwt.secret}") String secret) { + this.secretKey = new SecretKeySpec( + secret.getBytes(StandardCharsets.UTF_8), + Jwts.SIG.HS256.key().build().getAlgorithm() + ); + } + + // 내부에서 공통으로 쓰는 "서명 검증 + Claims 파싱" + private Claims parseClaims(String token) { + return Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token) + .getPayload(); + } + + // (지금 단계) subject를 userId로 쓰고 있으니 그대로 Integer로 반환 + public Integer getUserId(String token) { + String sub = parseClaims(token).getSubject(); + if (sub == null || sub.isBlank()) { + return null; + } + return Integer.valueOf(sub); + } + + // (Cognito 전환 대비) subject를 그대로 String으로도 꺼낼 수 있게 제공 + public String getSubject(String token) { + return parseClaims(token).getSubject(); + } + + // role claim (String) -> Role enum + public Role getRole(String token) { + String roleStr = parseClaims(token).get("role", String.class); + if (roleStr == null || roleStr.isBlank()) { + return null; + } + return Role.valueOf(roleStr); + } + + // access/refresh 구분 + public String getTokenType(String token) { + return parseClaims(token).get("type", String.class); + } + + // 만료 여부 (서명 검증 포함된 parseClaims를 쓰므로 안전) + public boolean isExpired(String token) { + Date exp = parseClaims(token).getExpiration(); + return exp != null && exp.before(new Date()); + } + + // Access Token + public String createJwt(Integer userId, Role role, Long expiredMs) { + return Jwts.builder() + .subject(userId.toString()) // ✅ 지금 단계: subject=userId + .claim("role", role.name()) + .claim("type", "access") + .issuedAt(new Date(System.currentTimeMillis())) + .expiration(new Date(System.currentTimeMillis() + expiredMs)) + .signWith(secretKey) + .compact(); + } + + // Refresh Token + public String createRefreshToken(Integer userId, long expiredMs) { + return Jwts.builder() + .subject(userId.toString()) + .claim("type", "refresh") + .issuedAt(new Date(System.currentTimeMillis())) + .expiration(new Date(System.currentTimeMillis() + expiredMs)) + .signWith(secretKey) + .compact(); + } +} diff --git a/spot-order/src/main/java/com/example/Spot/order/presentation/controller/ChefOrderController.java b/spot-order/src/main/java/com/example/Spot/order/presentation/controller/ChefOrderController.java index 7f4937b0..45b4e576 100644 --- a/spot-order/src/main/java/com/example/Spot/order/presentation/controller/ChefOrderController.java +++ b/spot-order/src/main/java/com/example/Spot/order/presentation/controller/ChefOrderController.java @@ -12,8 +12,8 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import com.example.Spot.global.infrastructure.config.security.CustomUserDetails; import com.example.Spot.global.presentation.ApiResponse; -import com.example.Spot.infra.auth.security.CustomUserDetails; import com.example.Spot.order.application.service.OrderService; import com.example.Spot.order.presentation.code.OrderSuccessCode; import com.example.Spot.order.presentation.dto.response.OrderResponseDto; diff --git a/spot-order/src/main/java/com/example/Spot/order/presentation/controller/CustomerOrderController.java b/spot-order/src/main/java/com/example/Spot/order/presentation/controller/CustomerOrderController.java index 53ff3077..f89b3aa4 100644 --- a/spot-order/src/main/java/com/example/Spot/order/presentation/controller/CustomerOrderController.java +++ b/spot-order/src/main/java/com/example/Spot/order/presentation/controller/CustomerOrderController.java @@ -22,8 +22,8 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import com.example.Spot.global.infrastructure.config.security.CustomUserDetails; import com.example.Spot.global.presentation.ApiResponse; -import com.example.Spot.infra.auth.security.CustomUserDetails; import com.example.Spot.order.application.service.OrderService; import com.example.Spot.order.domain.enums.OrderStatus; import com.example.Spot.order.presentation.code.OrderSuccessCode; diff --git a/spot-order/src/main/java/com/example/Spot/order/presentation/controller/OwnerOrderController.java b/spot-order/src/main/java/com/example/Spot/order/presentation/controller/OwnerOrderController.java index b0e3d960..e84bb62b 100644 --- a/spot-order/src/main/java/com/example/Spot/order/presentation/controller/OwnerOrderController.java +++ b/spot-order/src/main/java/com/example/Spot/order/presentation/controller/OwnerOrderController.java @@ -21,8 +21,8 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import com.example.Spot.global.infrastructure.config.security.CustomUserDetails; import com.example.Spot.global.presentation.ApiResponse; -import com.example.Spot.infra.auth.security.CustomUserDetails; import com.example.Spot.order.application.service.OrderService; import com.example.Spot.order.domain.enums.OrderStatus; import com.example.Spot.order.presentation.code.OrderSuccessCode; diff --git a/spot-order/src/main/java/com/example/Spot/order/presentation/swagger/CustomerOrderApi.java b/spot-order/src/main/java/com/example/Spot/order/presentation/swagger/CustomerOrderApi.java index 398797bb..33e29173 100644 --- a/spot-order/src/main/java/com/example/Spot/order/presentation/swagger/CustomerOrderApi.java +++ b/spot-order/src/main/java/com/example/Spot/order/presentation/swagger/CustomerOrderApi.java @@ -13,8 +13,8 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; +import com.example.Spot.global.infrastructure.config.security.CustomUserDetails; import com.example.Spot.global.presentation.ApiResponse; -import com.example.Spot.infra.auth.security.CustomUserDetails; import com.example.Spot.order.domain.enums.OrderStatus; import com.example.Spot.order.presentation.dto.request.OrderCancelRequestDto; import com.example.Spot.order.presentation.dto.request.OrderCreateRequestDto; diff --git a/spot-order/src/main/java/com/example/Spot/order/presentation/swagger/OwnerOrderApi.java b/spot-order/src/main/java/com/example/Spot/order/presentation/swagger/OwnerOrderApi.java index ca0ef56c..d482eef3 100644 --- a/spot-order/src/main/java/com/example/Spot/order/presentation/swagger/OwnerOrderApi.java +++ b/spot-order/src/main/java/com/example/Spot/order/presentation/swagger/OwnerOrderApi.java @@ -13,8 +13,8 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; +import com.example.Spot.global.infrastructure.config.security.CustomUserDetails; import com.example.Spot.global.presentation.ApiResponse; -import com.example.Spot.infra.auth.security.CustomUserDetails; import com.example.Spot.order.domain.enums.OrderStatus; import com.example.Spot.order.presentation.dto.request.OrderAcceptRequestDto; import com.example.Spot.order.presentation.dto.request.OrderCancelRequestDto; diff --git a/spot-order/src/main/resources/application.properties b/spot-order/src/main/resources/application.properties index dfcd5237..4b7bf89a 100644 --- a/spot-order/src/main/resources/application.properties +++ b/spot-order/src/main/resources/application.properties @@ -2,5 +2,5 @@ server.port=8082 spring.application.name=spot-order # Feign Client URLs -feign.store.url=http://localhost:8082 +feign.store.url=http://localhost:8083 feign.payment.url=http://localhost:8084 diff --git a/spot-payment/Docs/image/fe_be_payment_flow.png b/spot-payment/Docs/image/fe_be_payment_flow.png new file mode 100644 index 0000000000000000000000000000000000000000..9cf09a126fcd6a415a85d00e7da4bf23f41b688d GIT binary patch literal 102289 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 literal 0 HcmV?d00001 diff --git a/spot-payment/Docs/image/fe_be_payment_flow_detail.png b/spot-payment/Docs/image/fe_be_payment_flow_detail.png new file mode 100644 index 0000000000000000000000000000000000000000..8824a134866b79c5c31d8936347d7556bb82f9a9 GIT binary patch literal 221145 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 +

Spot Payment

+ + +### FE에서 BE까지 결제 흐름 +Toss Payment에서 제공해준 BillingKey가 있는 경우와 없는 경우에 따라 결제 로직이 달라집니다. +![](./Docs/image/fe_be_payment_flow.png) + +### 세부 결제 흐름 +![](./Docs/image/fe_be_payment_flow_detail.png) \ No newline at end of file diff --git a/spot-payment/build.gradle b/spot-payment/build.gradle index 2d73289f..ab80bc1c 100644 --- a/spot-payment/build.gradle +++ b/spot-payment/build.gradle @@ -60,6 +60,9 @@ dependencies { // postgreSQL implementation 'org.postgresql:postgresql' + + // kafka + implementation 'org.springframework.kafka:spring-kafka' } tasks.named('test') { diff --git a/spot-payment/src/main/java/com/example/Spot/SpotPaymentApplication.java b/spot-payment/src/main/java/com/example/Spot/SpotPaymentApplication.java index 6777a4aa..d5518ee4 100644 --- a/spot-payment/src/main/java/com/example/Spot/SpotPaymentApplication.java +++ b/spot-payment/src/main/java/com/example/Spot/SpotPaymentApplication.java @@ -4,7 +4,7 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.cloud.openfeign.EnableFeignClients; -@SpringBootApplication +@SpringBootApplication(scanBasePackages = "com.example.Spot") @EnableFeignClients(basePackages = "com.example.Spot.global.feign") public class SpotPaymentApplication { diff --git a/spot-payment/src/main/java/com/example/Spot/global/common/Role.java b/spot-payment/src/main/java/com/example/Spot/global/common/Role.java new file mode 100644 index 00000000..a008bc7b --- /dev/null +++ b/spot-payment/src/main/java/com/example/Spot/global/common/Role.java @@ -0,0 +1,9 @@ +package com.example.Spot.global.common; + +public enum Role { + CUSTOMER, + OWNER, + CHEF, + MANAGER, + MASTER +} diff --git a/spot-payment/src/main/java/com/example/Spot/global/infrastructure/config/security/CustomUserDetails.java b/spot-payment/src/main/java/com/example/Spot/global/infrastructure/config/security/CustomUserDetails.java new file mode 100644 index 00000000..280beb9c --- /dev/null +++ b/spot-payment/src/main/java/com/example/Spot/global/infrastructure/config/security/CustomUserDetails.java @@ -0,0 +1,58 @@ +package com.example.Spot.global.infrastructure.config.security; + +import java.util.Collection; +import java.util.List; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import com.example.Spot.global.common.Role; + +public class CustomUserDetails implements UserDetails { + + private final Integer userId; + private final Role role; + + public CustomUserDetails(Integer userId, Role role) { + this.userId = userId; + this.role = role; + } + + public Integer getUserId() { + return userId; + } + + public Role getRole() { + return role; + } + + @Override + public Collection getAuthorities() { + // ROLE_ prefix 맞춰서 제공 + return List.of(new SimpleGrantedAuthority("ROLE_" + role.name())); + } + + @Override + public String getPassword() { + return ""; + } + + @Override + public String getUsername() { + return String.valueOf(userId); + } + + @Override public boolean isAccountNonExpired() { + return true; + } + @Override public boolean isAccountNonLocked() { + return true; + } + @Override public boolean isCredentialsNonExpired() { + return true; + } + @Override public boolean isEnabled() { + return true; + } +} diff --git a/spot-payment/src/main/java/com/example/Spot/global/infrastructure/config/security/JWTFilter.java b/spot-payment/src/main/java/com/example/Spot/global/infrastructure/config/security/JWTFilter.java new file mode 100644 index 00000000..d8dec9fd --- /dev/null +++ b/spot-payment/src/main/java/com/example/Spot/global/infrastructure/config/security/JWTFilter.java @@ -0,0 +1,105 @@ +package com.example.Spot.global.infrastructure.config.security; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +import com.example.Spot.global.common.Role; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + + + +public class JWTFilter extends OncePerRequestFilter { + + private static final Logger LOGGER = LoggerFactory.getLogger(JWTFilter.class); + + private final JWTUtil jwtUtil; + + public JWTFilter(JWTUtil jwtUtil) { + this.jwtUtil = jwtUtil; + } + + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + + final String method = request.getMethod(); + final String uri = request.getRequestURI(); + final String authorization = request.getHeader("Authorization"); + + // 1) 필터 타는지 무조건 보이게 + LOGGER.info("[JWTFilter] hit {} {} hasAuthHeader={}", method, uri, authorization != null); + + // 2) Bearer 없으면 통과 (permitAll이면 컨트롤러 principal=null 정상) + if (authorization == null || !authorization.startsWith("Bearer ")) { + LOGGER.info("[JWTFilter] no bearer -> pass through {} {}", method, uri); + filterChain.doFilter(request, response); + return; + } + + String token = authorization.substring(7).trim(); + LOGGER.info("[JWTFilter] bearer token length={} {} {}", token.length(), method, uri); + + try { + if (jwtUtil.isExpired(token)) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + return; + } + + String type = jwtUtil.getTokenType(token); + if (type == null || !"access".equals(type)) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + return; + } + + Integer userId = jwtUtil.getUserId(token); + Role role = jwtUtil.getRole(token); + LOGGER.info("[JWT] role={}", role); + + if (userId == null || role == null) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + return; + } + + CustomUserDetails principal = new CustomUserDetails(userId, role); + + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(principal, null, principal.getAuthorities()); + + SecurityContextHolder.getContext().setAuthentication(authentication); + + filterChain.doFilter(request, response); + + } catch (Exception e) { + LOGGER.error("[JWTFilter] exception while validating token {} {}", method, uri, e); + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + } + } + + private Collection toAuthorities(List roles) { + List authorities = new ArrayList<>(); + for (String r : roles) { + authorities.add(new SimpleGrantedAuthority("ROLE_" + r)); + } + return authorities; + } + + +} diff --git a/spot-payment/src/main/java/com/example/Spot/global/infrastructure/config/security/JWTUtil.java b/spot-payment/src/main/java/com/example/Spot/global/infrastructure/config/security/JWTUtil.java new file mode 100644 index 00000000..e5ba4fbd --- /dev/null +++ b/spot-payment/src/main/java/com/example/Spot/global/infrastructure/config/security/JWTUtil.java @@ -0,0 +1,94 @@ +package com.example.Spot.global.infrastructure.config.security; + +import java.nio.charset.StandardCharsets; +import java.util.Date; + +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import com.example.Spot.global.common.Role; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; + +@Component +public class JWTUtil { + + private final SecretKey secretKey; + + public JWTUtil(@Value("${spring.jwt.secret}") String secret) { + this.secretKey = new SecretKeySpec( + secret.getBytes(StandardCharsets.UTF_8), + Jwts.SIG.HS256.key().build().getAlgorithm() + ); + } + + // 내부에서 공통으로 쓰는 "서명 검증 + Claims 파싱" + private Claims parseClaims(String token) { + return Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token) + .getPayload(); + } + + // (지금 단계) subject를 userId로 쓰고 있으니 그대로 Integer로 반환 + public Integer getUserId(String token) { + String sub = parseClaims(token).getSubject(); + if (sub == null || sub.isBlank()) { + return null; + } + return Integer.valueOf(sub); + } + + // (Cognito 전환 대비) subject를 그대로 String으로도 꺼낼 수 있게 제공 + public String getSubject(String token) { + return parseClaims(token).getSubject(); + } + + // role claim (String) -> Role enum + public Role getRole(String token) { + String roleStr = parseClaims(token).get("role", String.class); + if (roleStr == null || roleStr.isBlank()) { + return null; + } + return Role.valueOf(roleStr); + } + + // access/refresh 구분 + public String getTokenType(String token) { + return parseClaims(token).get("type", String.class); + } + + // 만료 여부 (서명 검증 포함된 parseClaims를 쓰므로 안전) + public boolean isExpired(String token) { + Date exp = parseClaims(token).getExpiration(); + return exp != null && exp.before(new Date()); + } + + // Access Token + public String createJwt(Integer userId, Role role, Long expiredMs) { + return Jwts.builder() + .subject(userId.toString()) // ✅ 지금 단계: subject=userId + .claim("role", role.name()) + .claim("type", "access") + .issuedAt(new Date(System.currentTimeMillis())) + .expiration(new Date(System.currentTimeMillis() + expiredMs)) + .signWith(secretKey) + .compact(); + } + + // Refresh Token + public String createRefreshToken(Integer userId, long expiredMs) { + return Jwts.builder() + .subject(userId.toString()) + .claim("type", "refresh") + .issuedAt(new Date(System.currentTimeMillis())) + .expiration(new Date(System.currentTimeMillis() + expiredMs)) + .signWith(secretKey) + .compact(); + } +} diff --git a/spot-payment/src/main/java/com/example/Spot/payments/application/service/PaymentService.java b/spot-payment/src/main/java/com/example/Spot/payments/application/service/PaymentService.java index 8546aaf3..38b61a61 100644 --- a/spot-payment/src/main/java/com/example/Spot/payments/application/service/PaymentService.java +++ b/spot-payment/src/main/java/com/example/Spot/payments/application/service/PaymentService.java @@ -366,4 +366,52 @@ public boolean hasBillingAuth(Integer userId) { return exists; } + + // ************************** // + // FE 결제 완료 후 결제 내역 저장 // + // ************************** // + @Transactional + public PaymentResponseDto.SavedPaymentHistory savePaymentHistory(PaymentRequestDto.SavePaymentHistory request) { + // 사용자 존재 확인 + validateUserExists(request.userId()); + // 주문 존재 확인 + validateOrderExists(request.orderId()); + + // Payment 엔티티 생성 및 저장 + PaymentEntity payment = PaymentEntity.builder() + .userId(request.userId()) + .orderId(request.orderId()) + .title(request.title()) + .content(request.content()) + .paymentMethod(request.paymentMethod()) + .totalAmount(request.paymentAmount()) + .build(); + + PaymentEntity savedPayment = paymentRepository.save(payment); + + // PaymentHistory에 DONE 상태로 저장 + PaymentHistoryEntity history = PaymentHistoryEntity.builder() + .paymentId(savedPayment.getId()) + .status(PaymentHistoryEntity.PaymentStatus.DONE) + .build(); + + paymentHistoryRepository.save(history); + + // PaymentKey 저장 + PaymentKeyEntity paymentKey = PaymentKeyEntity.builder() + .paymentId(savedPayment.getId()) + .paymentKey(request.paymentKey()) + .confirmedAt(LocalDateTime.now()) + .build(); + + paymentKeyRepository.save(paymentKey); + + return PaymentResponseDto.SavedPaymentHistory.builder() + .paymentId(savedPayment.getId()) + .orderId(savedPayment.getOrderId()) + .status("DONE") + .amount(savedPayment.getTotalAmount()) + .savedAt(LocalDateTime.now()) + .build(); + } } diff --git a/spot-payment/src/main/java/com/example/Spot/payments/domain/entity/PaymentKeyEntity.java b/spot-payment/src/main/java/com/example/Spot/payments/domain/entity/PaymentKeyEntity.java index 2937a44b..cb377390 100644 --- a/spot-payment/src/main/java/com/example/Spot/payments/domain/entity/PaymentKeyEntity.java +++ b/spot-payment/src/main/java/com/example/Spot/payments/domain/entity/PaymentKeyEntity.java @@ -30,13 +30,13 @@ public class PaymentKeyEntity extends BaseEntity { private UUID id; @Column(nullable = false, updatable = false, name = "payment_id") - private UUID paymentId; // final 제거 + private UUID paymentId; @Column(nullable = false, updatable = false, name = "payment_key") - private String paymentKey; // final 제거 + private String paymentKey; @Column(updatable = false, nullable = false, name = "confirmed_at") - private LocalDateTime confirmedAt; // final 제거 및 오타 수정 + private LocalDateTime confirmedAt; @Builder public PaymentKeyEntity(UUID paymentId, String paymentKey, LocalDateTime confirmedAt) { diff --git a/spot-payment/src/main/java/com/example/Spot/payments/domain/entity/PaymentRetryEntity.java b/spot-payment/src/main/java/com/example/Spot/payments/domain/entity/PaymentRetryEntity.java index 3d1901cd..1662dae1 100644 --- a/spot-payment/src/main/java/com/example/Spot/payments/domain/entity/PaymentRetryEntity.java +++ b/spot-payment/src/main/java/com/example/Spot/payments/domain/entity/PaymentRetryEntity.java @@ -138,17 +138,17 @@ private LocalDateTime calculateNextRetryTime(int attemptNumber, RetryStrategy st } public enum RetryStatus { - PENDING, // 재시도 대기 중 + PENDING, // 재시도 대기 중 IN_PROGRESS, // 재시도 진행 중 - SUCCEEDED, // 재시도 성공 - EXHAUSTED, // 최대 횟수 초과 - ABANDONED // 수동으로 중단됨 + SUCCEEDED, // 재시도 성공 + EXHAUSTED, // 최대 횟수 초과 + ABANDONED // 수동으로 중단됨 } public enum RetryStrategy { - FIXED_INTERVAL, // 고정 간격 (5분마다) - LINEAR_BACKOFF, // 선형 증가 (5, 10, 15, 20분...) + FIXED_INTERVAL, // 고정 간격 (5분마다) + LINEAR_BACKOFF, // 선형 증가 (5, 10, 15, 20분...) EXPONENTIAL_BACKOFF, // 지수 증가 (5, 10, 20, 40분...) - CUSTOM // 커스텀 전략 + CUSTOM // 커스텀 전략 } } diff --git a/spot-payment/src/main/java/com/example/Spot/payments/presentation/controller/PaymentController.java b/spot-payment/src/main/java/com/example/Spot/payments/presentation/controller/PaymentController.java index 603d20d9..4e6836b0 100644 --- a/spot-payment/src/main/java/com/example/Spot/payments/presentation/controller/PaymentController.java +++ b/spot-payment/src/main/java/com/example/Spot/payments/presentation/controller/PaymentController.java @@ -11,9 +11,9 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import com.example.Spot.global.infrastructure.config.security.CustomUserDetails; import com.example.Spot.global.presentation.ApiResponse; import com.example.Spot.global.presentation.code.GeneralSuccessCode; -import com.example.Spot.infra.auth.security.CustomUserDetails; import com.example.Spot.payments.application.service.PaymentService; import com.example.Spot.payments.presentation.dto.request.PaymentRequestDto; import com.example.Spot.payments.presentation.dto.response.PaymentResponseDto; @@ -103,4 +103,14 @@ public ApiResponse checkBillingKeyExists( System.out.println("빌링키 존재 여부: " + exists + " (UserId: " + principal.getUserId() + ")"); return ApiResponse.onSuccess(GeneralSuccessCode.GOOD_REQUEST, exists); } + + @PostMapping("/history") + @PreAuthorize("hasAnyRole('CUSTOMER', 'OWNER', 'MANAGER', 'MASTER')") + public ApiResponse savePaymentHistory( + @Valid @RequestBody PaymentRequestDto.SavePaymentHistory request, + @AuthenticationPrincipal CustomUserDetails principal + ) { + PaymentResponseDto.SavedPaymentHistory response = paymentService.savePaymentHistory(request); + return ApiResponse.onSuccess(GeneralSuccessCode.GOOD_REQUEST, response); + } } diff --git a/spot-payment/src/main/java/com/example/Spot/payments/presentation/dto/request/PaymentRequestDto.java b/spot-payment/src/main/java/com/example/Spot/payments/presentation/dto/request/PaymentRequestDto.java index bae9fa6b..bf6c9aa6 100644 --- a/spot-payment/src/main/java/com/example/Spot/payments/presentation/dto/request/PaymentRequestDto.java +++ b/spot-payment/src/main/java/com/example/Spot/payments/presentation/dto/request/PaymentRequestDto.java @@ -41,4 +41,15 @@ public record SaveBillingKey( @Schema(description = "사용자 ID", example = "1") @NotNull Integer userId, @Schema(description = "고객 키", example = "customer_1") @NotNull String customerKey, @Schema(description = "인증 키", example = "authKey_xxxxx") @NotNull String authKey) {} + + @Builder + @Schema(description = "결제 내역 저장 요청 (FE 결제 완료 후)") + public record SavePaymentHistory( + @Schema(description = "사용자 ID", example = "1") @NotNull Integer userId, + @Schema(description = "주문 ID", example = "123e4567-e89b-12d3-a456-426614174000") @NotNull UUID orderId, + @Schema(description = "결제 제목", example = "치킨 주문 결제") @NotNull String title, + @Schema(description = "결제 내용", example = "후라이드 치킨 1마리") @NotNull String content, + @Schema(description = "결제 방법", example = "CREDIT_CARD") @NotNull PaymentMethod paymentMethod, + @Schema(description = "결제 금액 (원 단위)", example = "18000") @NotNull Long paymentAmount, + @Schema(description = "Toss Payment Key", example = "tgen_20240105153000abcd") @NotNull String paymentKey) {} } diff --git a/spot-payment/src/main/java/com/example/Spot/payments/presentation/dto/response/PaymentResponseDto.java b/spot-payment/src/main/java/com/example/Spot/payments/presentation/dto/response/PaymentResponseDto.java index 7bad0e0e..e807891f 100644 --- a/spot-payment/src/main/java/com/example/Spot/payments/presentation/dto/response/PaymentResponseDto.java +++ b/spot-payment/src/main/java/com/example/Spot/payments/presentation/dto/response/PaymentResponseDto.java @@ -81,4 +81,13 @@ public record SavedBillingKey( @Schema(description = "고객 키", example = "customer_1") String customerKey, @Schema(description = "빌링키", example = "billing_key_xxxxx") String billingKey, @Schema(description = "저장 시간", example = "2024-01-05T15:30:00") LocalDateTime savedAt) {} + + @Builder + @Schema(description = "결제 내역 저장 응답") + public record SavedPaymentHistory( + @Schema(description = "결제 ID", example = "123e4567-e89b-12d3-a456-426614174000") UUID paymentId, + @Schema(description = "주문 ID", example = "123e4567-e89b-12d3-a456-426614174000") UUID orderId, + @Schema(description = "결제 상태", example = "DONE") String status, + @Schema(description = "결제 금액", example = "18000") Long amount, + @Schema(description = "저장 시간", example = "2024-01-05T15:30:00") LocalDateTime savedAt) {} } diff --git a/spot-payment/src/main/java/com/example/Spot/payments/presentation/swagger/PaymentApi.java b/spot-payment/src/main/java/com/example/Spot/payments/presentation/swagger/PaymentApi.java index ee08977f..b793f88c 100644 --- a/spot-payment/src/main/java/com/example/Spot/payments/presentation/swagger/PaymentApi.java +++ b/spot-payment/src/main/java/com/example/Spot/payments/presentation/swagger/PaymentApi.java @@ -6,8 +6,8 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestBody; +import com.example.Spot.global.infrastructure.config.security.CustomUserDetails; import com.example.Spot.global.presentation.ApiResponse; -import com.example.Spot.infra.auth.security.CustomUserDetails; import com.example.Spot.payments.presentation.dto.request.PaymentRequestDto; import com.example.Spot.payments.presentation.dto.response.PaymentResponseDto; diff --git a/spot-store/src/main/java/com/example/Spot/global/infrastructure/config/security/CustomUserDetails.java b/spot-store/src/main/java/com/example/Spot/global/infrastructure/config/security/CustomUserDetails.java new file mode 100644 index 00000000..280beb9c --- /dev/null +++ b/spot-store/src/main/java/com/example/Spot/global/infrastructure/config/security/CustomUserDetails.java @@ -0,0 +1,58 @@ +package com.example.Spot.global.infrastructure.config.security; + +import java.util.Collection; +import java.util.List; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import com.example.Spot.global.common.Role; + +public class CustomUserDetails implements UserDetails { + + private final Integer userId; + private final Role role; + + public CustomUserDetails(Integer userId, Role role) { + this.userId = userId; + this.role = role; + } + + public Integer getUserId() { + return userId; + } + + public Role getRole() { + return role; + } + + @Override + public Collection getAuthorities() { + // ROLE_ prefix 맞춰서 제공 + return List.of(new SimpleGrantedAuthority("ROLE_" + role.name())); + } + + @Override + public String getPassword() { + return ""; + } + + @Override + public String getUsername() { + return String.valueOf(userId); + } + + @Override public boolean isAccountNonExpired() { + return true; + } + @Override public boolean isAccountNonLocked() { + return true; + } + @Override public boolean isCredentialsNonExpired() { + return true; + } + @Override public boolean isEnabled() { + return true; + } +} diff --git a/spot-store/src/main/java/com/example/Spot/global/infrastructure/config/security/JWTFilter.java b/spot-store/src/main/java/com/example/Spot/global/infrastructure/config/security/JWTFilter.java new file mode 100644 index 00000000..2e7a8a3b --- /dev/null +++ b/spot-store/src/main/java/com/example/Spot/global/infrastructure/config/security/JWTFilter.java @@ -0,0 +1,101 @@ +package com.example.Spot.global.infrastructure.config.security; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +import com.example.Spot.global.common.Role; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + + +public class JWTFilter extends OncePerRequestFilter { + + private static final Logger LOGGER = LoggerFactory.getLogger(JWTFilter.class); + + private final JWTUtil jwtUtil; + + public JWTFilter(JWTUtil jwtUtil) { + this.jwtUtil = jwtUtil; + } + + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + + final String method = request.getMethod(); + final String uri = request.getRequestURI(); + final String authorization = request.getHeader("Authorization"); + + // ✅ 1) 필터 타는지 무조건 보이게 + LOGGER.info("[JWTFilter] hit {} {} hasAuthHeader={}", method, uri, authorization != null); + + // ✅ 2) Bearer 없으면 통과 (permitAll이면 컨트롤러 principal=null 정상) + if (authorization == null || !authorization.startsWith("Bearer ")) { + LOGGER.info("[JWTFilter] no bearer -> pass through {} {}", method, uri); + filterChain.doFilter(request, response); + return; + } + + String token = authorization.substring(7).trim(); + LOGGER.info("[JWTFilter] bearer token length={} {} {}", token.length(), method, uri); + + try { + if (jwtUtil.isExpired(token)) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + return; + } + + String type = jwtUtil.getTokenType(token); + if (type == null || !"access".equals(type)) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + return; + } + + Integer userId = jwtUtil.getUserId(token); + Role role = jwtUtil.getRole(token); + + if (userId == null || role == null) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + return; + } + + CustomUserDetails principal = new CustomUserDetails(userId, role); + + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(principal, null, principal.getAuthorities()); + + SecurityContextHolder.getContext().setAuthentication(authentication); + + filterChain.doFilter(request, response); + + } catch (Exception e) { + LOGGER.error("[JWTFilter] exception while validating token {} {}", method, uri, e); + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + } + } + + private Collection toAuthorities(List roles) { + List authorities = new ArrayList<>(); + for (String r : roles) { + authorities.add(new SimpleGrantedAuthority("ROLE_" + r)); + } + return authorities; + } +} diff --git a/spot-store/src/main/java/com/example/Spot/global/infrastructure/config/security/JWTUtil.java b/spot-store/src/main/java/com/example/Spot/global/infrastructure/config/security/JWTUtil.java new file mode 100644 index 00000000..e5ba4fbd --- /dev/null +++ b/spot-store/src/main/java/com/example/Spot/global/infrastructure/config/security/JWTUtil.java @@ -0,0 +1,94 @@ +package com.example.Spot.global.infrastructure.config.security; + +import java.nio.charset.StandardCharsets; +import java.util.Date; + +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import com.example.Spot.global.common.Role; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; + +@Component +public class JWTUtil { + + private final SecretKey secretKey; + + public JWTUtil(@Value("${spring.jwt.secret}") String secret) { + this.secretKey = new SecretKeySpec( + secret.getBytes(StandardCharsets.UTF_8), + Jwts.SIG.HS256.key().build().getAlgorithm() + ); + } + + // 내부에서 공통으로 쓰는 "서명 검증 + Claims 파싱" + private Claims parseClaims(String token) { + return Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token) + .getPayload(); + } + + // (지금 단계) subject를 userId로 쓰고 있으니 그대로 Integer로 반환 + public Integer getUserId(String token) { + String sub = parseClaims(token).getSubject(); + if (sub == null || sub.isBlank()) { + return null; + } + return Integer.valueOf(sub); + } + + // (Cognito 전환 대비) subject를 그대로 String으로도 꺼낼 수 있게 제공 + public String getSubject(String token) { + return parseClaims(token).getSubject(); + } + + // role claim (String) -> Role enum + public Role getRole(String token) { + String roleStr = parseClaims(token).get("role", String.class); + if (roleStr == null || roleStr.isBlank()) { + return null; + } + return Role.valueOf(roleStr); + } + + // access/refresh 구분 + public String getTokenType(String token) { + return parseClaims(token).get("type", String.class); + } + + // 만료 여부 (서명 검증 포함된 parseClaims를 쓰므로 안전) + public boolean isExpired(String token) { + Date exp = parseClaims(token).getExpiration(); + return exp != null && exp.before(new Date()); + } + + // Access Token + public String createJwt(Integer userId, Role role, Long expiredMs) { + return Jwts.builder() + .subject(userId.toString()) // ✅ 지금 단계: subject=userId + .claim("role", role.name()) + .claim("type", "access") + .issuedAt(new Date(System.currentTimeMillis())) + .expiration(new Date(System.currentTimeMillis() + expiredMs)) + .signWith(secretKey) + .compact(); + } + + // Refresh Token + public String createRefreshToken(Integer userId, long expiredMs) { + return Jwts.builder() + .subject(userId.toString()) + .claim("type", "refresh") + .issuedAt(new Date(System.currentTimeMillis())) + .expiration(new Date(System.currentTimeMillis() + expiredMs)) + .signWith(secretKey) + .compact(); + } +} diff --git a/spot-store/src/main/java/com/example/Spot/menu/presentation/controller/MenuController.java b/spot-store/src/main/java/com/example/Spot/menu/presentation/controller/MenuController.java index c71b6e68..518e80f2 100644 --- a/spot-store/src/main/java/com/example/Spot/menu/presentation/controller/MenuController.java +++ b/spot-store/src/main/java/com/example/Spot/menu/presentation/controller/MenuController.java @@ -15,9 +15,9 @@ import org.springframework.web.bind.annotation.RestController; import com.example.Spot.global.common.Role; +import com.example.Spot.global.infrastructure.config.security.CustomUserDetails; import com.example.Spot.global.presentation.ApiResponse; import com.example.Spot.global.presentation.code.GeneralSuccessCode; -import com.example.Spot.infra.auth.security.CustomUserDetails; import com.example.Spot.menu.application.service.MenuService; import com.example.Spot.menu.presentation.dto.request.CreateMenuRequestDto; import com.example.Spot.menu.presentation.dto.request.UpdateMenuHiddenRequestDto; @@ -44,7 +44,7 @@ public ApiResponse> getMenus( ) { Integer userId = principal.getUserId(); - Role userRole = principal.getUserRole(); + Role userRole = principal.getRole(); List data = menuService.getMenus(storeId, userId, userRole); return ApiResponse.onSuccess(GeneralSuccessCode.GOOD_REQUEST, data); @@ -59,7 +59,7 @@ public ApiResponse getMenuDetail( ) { Integer userId = principal.getUserId(); - Role userRole = principal.getUserRole(); + Role userRole = principal.getRole(); MenuResponseDto response = menuService.getMenuDetail(storeId, menuId, userId, userRole); return ApiResponse.onSuccess(GeneralSuccessCode.GOOD_REQUEST, response); @@ -75,7 +75,7 @@ public ApiResponse createMenu( ) { Integer userId = principal.getUserId(); - Role userRole = principal.getUserRole(); + Role userRole = principal.getRole(); CreateMenuResponseDto data = menuService.createMenu(storeId, request, userId, userRole); @@ -93,7 +93,7 @@ public ApiResponse updateMenu( ) { Integer userId = principal.getUserId(); - Role userRole = principal.getUserRole(); + Role userRole = principal.getRole(); MenuAdminResponseDto data = menuService.updateMenu(storeId, menuId, request, userId, userRole); @@ -109,7 +109,7 @@ public ApiResponse deleteMenu( @AuthenticationPrincipal CustomUserDetails principal ) { Integer userId = principal.getUserId(); - Role userRole = principal.getUserRole(); + Role userRole = principal.getRole(); menuService.deleteMenu(menuId, userId, userRole); @@ -125,7 +125,7 @@ public ApiResponse hiddenMenu( @AuthenticationPrincipal CustomUserDetails principal ) { Integer userId = principal.getUserId(); - Role userRole = principal.getUserRole(); + Role userRole = principal.getRole(); menuService.hiddenMenu(menuId, request, userId, userRole); diff --git a/spot-store/src/main/java/com/example/Spot/menu/presentation/controller/MenuOptionController.java b/spot-store/src/main/java/com/example/Spot/menu/presentation/controller/MenuOptionController.java index 395218a2..ef9c9ec9 100644 --- a/spot-store/src/main/java/com/example/Spot/menu/presentation/controller/MenuOptionController.java +++ b/spot-store/src/main/java/com/example/Spot/menu/presentation/controller/MenuOptionController.java @@ -13,9 +13,9 @@ import org.springframework.web.bind.annotation.RestController; import com.example.Spot.global.common.Role; +import com.example.Spot.global.infrastructure.config.security.CustomUserDetails; import com.example.Spot.global.presentation.ApiResponse; import com.example.Spot.global.presentation.code.GeneralSuccessCode; -import com.example.Spot.infra.auth.security.CustomUserDetails; import com.example.Spot.menu.application.service.MenuOptionService; import com.example.Spot.menu.presentation.dto.request.CreateMenuOptionRequestDto; import com.example.Spot.menu.presentation.dto.request.UpdateMenuOptionHiddenRequestDto; @@ -43,7 +43,7 @@ public ApiResponse createMenuOption( @AuthenticationPrincipal CustomUserDetails principal ) { Integer userId = principal.getUserId(); - Role userRole = principal.getUserRole(); + Role userRole = principal.getRole(); CreateMenuOptionResponseDto data = menuOptionService.createMenuOption(storeId, menuId, userId, userRole, request); return ApiResponse.onSuccess(GeneralSuccessCode.GOOD_REQUEST, data); @@ -60,7 +60,7 @@ public ApiResponse updateMenuOption( @Valid @RequestBody UpdateMenuOptionRequestDto request ) { Integer userId = principal.getUserId(); - Role userRole = principal.getUserRole(); + Role userRole = principal.getRole(); MenuOptionAdminResponseDto data = menuOptionService.updateMenuOption(storeId, menuId, optionId, userId, userRole, request); return ApiResponse.onSuccess(GeneralSuccessCode.GOOD_REQUEST, data); @@ -75,7 +75,7 @@ public ApiResponse deleteMenuOption( @AuthenticationPrincipal CustomUserDetails principal ) { Integer userId = principal.getUserId(); - Role userRole = principal.getUserRole(); + Role userRole = principal.getRole(); menuOptionService.deleteMenuOption(storeId, menuId, optionId, userId, userRole); return ApiResponse.onSuccess(GeneralSuccessCode.GOOD_REQUEST, "해당 옵션이 삭제되었습니다."); @@ -91,7 +91,7 @@ public ApiResponse hiddenMenuOption( @AuthenticationPrincipal CustomUserDetails principal ) { Integer userId = principal.getUserId(); - Role userRole = principal.getUserRole(); + Role userRole = principal.getRole(); menuOptionService.hiddenMenuOption(storeId, menuId, optionId, userId, userRole, request); return ApiResponse.onSuccess(GeneralSuccessCode.GOOD_REQUEST, "해당 메뉴의 옵션을 숨김 처리하였습니다."); diff --git a/spot-store/src/main/java/com/example/Spot/menu/presentation/swagger/MenuApi.java b/spot-store/src/main/java/com/example/Spot/menu/presentation/swagger/MenuApi.java index f38c98e1..675c1d1e 100644 --- a/spot-store/src/main/java/com/example/Spot/menu/presentation/swagger/MenuApi.java +++ b/spot-store/src/main/java/com/example/Spot/menu/presentation/swagger/MenuApi.java @@ -7,8 +7,8 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestBody; +import com.example.Spot.global.infrastructure.config.security.CustomUserDetails; import com.example.Spot.global.presentation.ApiResponse; -import com.example.Spot.infra.auth.security.CustomUserDetails; import com.example.Spot.menu.presentation.dto.request.CreateMenuRequestDto; import com.example.Spot.menu.presentation.dto.request.UpdateMenuHiddenRequestDto; import com.example.Spot.menu.presentation.dto.request.UpdateMenuRequestDto; diff --git a/spot-store/src/main/java/com/example/Spot/review/presentation/controller/ReviewController.java b/spot-store/src/main/java/com/example/Spot/review/presentation/controller/ReviewController.java index 34a775f1..55ad8b02 100644 --- a/spot-store/src/main/java/com/example/Spot/review/presentation/controller/ReviewController.java +++ b/spot-store/src/main/java/com/example/Spot/review/presentation/controller/ReviewController.java @@ -19,9 +19,9 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import com.example.Spot.global.infrastructure.config.security.CustomUserDetails; import com.example.Spot.global.presentation.ApiResponse; import com.example.Spot.global.presentation.code.GeneralSuccessCode; -import com.example.Spot.infra.auth.security.CustomUserDetails; import com.example.Spot.review.application.service.ReviewService; import com.example.Spot.review.presentation.dto.request.ReviewCreateRequest; import com.example.Spot.review.presentation.dto.request.ReviewUpdateRequest; @@ -101,8 +101,9 @@ public ResponseEntity> deleteReview( @PathVariable UUID reviewId, @AuthenticationPrincipal CustomUserDetails principal) { - String role = principal.getRole(); - boolean isAdmin = "MASTER".equals(role) || "MANAGER".equals(role); + boolean isAdmin = principal != null && + ("MANAGER".equals(principal.getRole()) || "MASTER".equals(principal.getRole())); + reviewService.deleteReview(reviewId, principal.getUserId(), isAdmin); return ResponseEntity.ok( diff --git a/spot-store/src/main/java/com/example/Spot/store/presentation/controller/CategoryController.java b/spot-store/src/main/java/com/example/Spot/store/presentation/controller/CategoryController.java index 4d25206a..6177363d 100644 --- a/spot-store/src/main/java/com/example/Spot/store/presentation/controller/CategoryController.java +++ b/spot-store/src/main/java/com/example/Spot/store/presentation/controller/CategoryController.java @@ -18,7 +18,7 @@ import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; -import com.example.Spot.infra.auth.security.CustomUserDetails; +import com.example.Spot.global.infrastructure.config.security.CustomUserDetails; import com.example.Spot.store.application.service.CategoryService; import com.example.Spot.store.presentation.dto.request.CategoryRequestDTO; import com.example.Spot.store.presentation.dto.response.CategoryResponseDTO; diff --git a/spot-store/src/main/java/com/example/Spot/store/presentation/swagger/CategoryApi.java b/spot-store/src/main/java/com/example/Spot/store/presentation/swagger/CategoryApi.java index 3442b570..2a369ca5 100644 --- a/spot-store/src/main/java/com/example/Spot/store/presentation/swagger/CategoryApi.java +++ b/spot-store/src/main/java/com/example/Spot/store/presentation/swagger/CategoryApi.java @@ -7,7 +7,7 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestBody; -import com.example.Spot.infra.auth.security.CustomUserDetails; +import com.example.Spot.global.infrastructure.config.security.CustomUserDetails; import com.example.Spot.store.presentation.dto.request.CategoryRequestDTO; import com.example.Spot.store.presentation.dto.response.CategoryResponseDTO; diff --git a/spot-user/src/main/java/com/example/Spot/auth/jwt/JWTFilter.java b/spot-user/src/main/java/com/example/Spot/auth/jwt/JWTFilter.java index 7aa37acb..8bab7666 100644 --- a/spot-user/src/main/java/com/example/Spot/auth/jwt/JWTFilter.java +++ b/spot-user/src/main/java/com/example/Spot/auth/jwt/JWTFilter.java @@ -50,7 +50,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse try { // refresh 토큰은 SecurityContext에 올리지 않음 if (jwtUtil.isExpired(token)) { - filterChain.doFilter(request, response); + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); return; } String type = jwtUtil.getTokenType(token); diff --git a/spot-user/src/main/java/com/example/Spot/infra/feign/exception/RemoteCallFailedException.java b/spot-user/src/main/java/com/example/Spot/infra/feign/exception/RemoteCallFailedException.java new file mode 100644 index 00000000..95053004 --- /dev/null +++ b/spot-user/src/main/java/com/example/Spot/infra/feign/exception/RemoteCallFailedException.java @@ -0,0 +1,7 @@ +package com.example.Spot.infra.feign.exception; + +public class RemoteCallFailedException extends RuntimeException { + public RemoteCallFailedException(String methodKey, int status) { + super("Remote call failed: " + methodKey + " status=" + status); + } +} diff --git a/spot-user/src/main/java/com/example/Spot/infra/feign/exception/RemoteConflictException.java b/spot-user/src/main/java/com/example/Spot/infra/feign/exception/RemoteConflictException.java new file mode 100644 index 00000000..1bdba453 --- /dev/null +++ b/spot-user/src/main/java/com/example/Spot/infra/feign/exception/RemoteConflictException.java @@ -0,0 +1,7 @@ +package com.example.Spot.infra.feign.exception; + +public class RemoteConflictException extends RuntimeException { + public RemoteConflictException(String methodKey) { + super("Remote conflict: " + methodKey); + } +} diff --git a/spot-user/src/main/java/com/example/Spot/infra/feign/exception/RemoteNotFoundException.java b/spot-user/src/main/java/com/example/Spot/infra/feign/exception/RemoteNotFoundException.java new file mode 100644 index 00000000..ca1b83c3 --- /dev/null +++ b/spot-user/src/main/java/com/example/Spot/infra/feign/exception/RemoteNotFoundException.java @@ -0,0 +1,7 @@ +package com.example.Spot.infra.feign.exception; + +public class RemoteNotFoundException extends RuntimeException { + public RemoteNotFoundException(String methodKey) { + super("Remote resource not found: " + methodKey); + } +} diff --git a/spot-user/src/main/java/com/example/Spot/infra/feign/exception/RemoteServiceUnavailableException.java b/spot-user/src/main/java/com/example/Spot/infra/feign/exception/RemoteServiceUnavailableException.java new file mode 100644 index 00000000..54cb22c8 --- /dev/null +++ b/spot-user/src/main/java/com/example/Spot/infra/feign/exception/RemoteServiceUnavailableException.java @@ -0,0 +1,7 @@ +package com.example.Spot.infra.feign.exception; + +public class RemoteServiceUnavailableException extends RuntimeException { + public RemoteServiceUnavailableException(String methodKey) { + super("Remote service unavailable: " + methodKey); + } +} diff --git a/spot-user/src/main/java/com/example/Spot/user/presentation/controller/UserController.java b/spot-user/src/main/java/com/example/Spot/user/presentation/controller/UserController.java index 4e8aa8c8..df076e6b 100644 --- a/spot-user/src/main/java/com/example/Spot/user/presentation/controller/UserController.java +++ b/spot-user/src/main/java/com/example/Spot/user/presentation/controller/UserController.java @@ -14,6 +14,7 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import com.example.Spot.auth.security.CustomUserDetails; import com.example.Spot.user.application.service.UserService; import com.example.Spot.user.presentation.dto.request.UserUpdateRequestDTO; import com.example.Spot.user.presentation.dto.response.UserResponseDTO; @@ -49,10 +50,12 @@ public UserResponseDTO update( @PreAuthorize("isAuthenticated()") @DeleteMapping("/me") public void delete(Authentication authentication) { - Integer loginUserId = (Integer) authentication.getPrincipal(); + CustomUserDetails principal = (CustomUserDetails) authentication.getPrincipal(); + Integer loginUserId = principal.getUserId(); userService.deleteMe(loginUserId); } + @Override @PreAuthorize("hasAnyRole('MASTER','OWNER','MANAGER')") @GetMapping("/search") From 8220af20ab635ebf7f85689ddb61a79c557ce896 Mon Sep 17 00:00:00 2001 From: dbswjd7 Date: Sun, 25 Jan 2026 00:29:37 +0900 Subject: [PATCH 66/77] =?UTF-8?q?feat(#221):=20aop=20=EC=A0=81=EC=9A=A9?= =?UTF-8?q?=EC=97=90=20=EB=94=B0=EB=A5=B8=20feign=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- spot-gateway/build.gradle | 1 + spot-order/build.gradle | 2 + .../application/service/OrderServiceImpl.java | 6 +++ .../order/infrastructure/aop/OrderAspect.java | 9 +++++ spot-payment/build.gradle | 2 + .../application/service/PaymentService.java | 26 +++++-------- .../controller/PaymentController.java | 4 +- spot-store/build.gradle | 2 + .../example/Spot/global/feign/UserClient.java | 4 ++ .../application/service/ReviewService.java | 8 +++- .../application/service/StoreService.java | 18 +++++++-- .../application/service/UserCallService.java | 23 +++++++++++ .../store/infrastructure/aop/StoreAspect.java | 38 +++++++++++++------ spot-user/build.gradle | 5 +++ .../exception/RemoteCallFailedException.java | 2 +- .../exception/RemoteConflictException.java | 2 +- .../exception/RemoteNotFoundException.java | 2 +- .../RemoteServiceUnavailableException.java | 2 +- .../controller/InternalUserController.java | 8 ++++ 19 files changed, 127 insertions(+), 37 deletions(-) create mode 100644 spot-store/src/main/java/com/example/Spot/store/application/service/UserCallService.java rename spot-user/src/main/java/com/example/Spot/{infra/feign => global/feign/config}/exception/RemoteCallFailedException.java (79%) rename spot-user/src/main/java/com/example/Spot/{infra/feign => global/feign/config}/exception/RemoteConflictException.java (75%) rename spot-user/src/main/java/com/example/Spot/{infra/feign => global/feign/config}/exception/RemoteNotFoundException.java (76%) rename spot-user/src/main/java/com/example/Spot/{infra/feign => global/feign/config}/exception/RemoteServiceUnavailableException.java (78%) diff --git a/spot-gateway/build.gradle b/spot-gateway/build.gradle index 131eda53..fb0dffca 100644 --- a/spot-gateway/build.gradle +++ b/spot-gateway/build.gradle @@ -11,4 +11,5 @@ dependencies { implementation "org.springframework.cloud:spring-cloud-starter-gateway-server-webflux:4.3.3" testImplementation "org.springframework.boot:spring-boot-starter-test" testImplementation "org.hamcrest:hamcrest:2.2" + implementation 'org.springframework.boot:spring-boot-starter-actuator' } diff --git a/spot-order/build.gradle b/spot-order/build.gradle index 229c7daf..9da9a77b 100644 --- a/spot-order/build.gradle +++ b/spot-order/build.gradle @@ -34,6 +34,8 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-cache' implementation 'org.springframework.boot:spring-boot-starter-aop' implementation 'org.springframework.cloud:spring-cloud-starter-openfeign' + implementation 'io.github.resilience4j:resilience4j-spring-boot3:2.2.0' + implementation 'org.springframework.boot:spring-boot-starter-actuator' compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' diff --git a/spot-order/src/main/java/com/example/Spot/order/application/service/OrderServiceImpl.java b/spot-order/src/main/java/com/example/Spot/order/application/service/OrderServiceImpl.java index acee5a89..a2900b61 100644 --- a/spot-order/src/main/java/com/example/Spot/order/application/service/OrderServiceImpl.java +++ b/spot-order/src/main/java/com/example/Spot/order/application/service/OrderServiceImpl.java @@ -38,6 +38,9 @@ import com.example.Spot.order.presentation.dto.response.OrderResponseDto; import com.example.Spot.order.presentation.dto.response.OrderStatsResponseDto; +import io.github.resilience4j.bulkhead.annotation.Bulkhead; +import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker; +import io.github.resilience4j.retry.annotation.Retry; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -318,6 +321,9 @@ public OrderResponseDto storeCancelOrder(UUID orderId, String reason) { return OrderResponseDto.from(order); } + @CircuitBreaker(name = "payment_ready_create") + @Bulkhead(name = "payment_ready_create", type = Bulkhead.Type.SEMAPHORE) + @Retry(name = "payment_ready_create") private void cancelPaymentIfExists(UUID orderId, String cancelReason) { try { // Payment 서비스에서 결제 정보 조회 diff --git a/spot-order/src/main/java/com/example/Spot/order/infrastructure/aop/OrderAspect.java b/spot-order/src/main/java/com/example/Spot/order/infrastructure/aop/OrderAspect.java index be75a507..e86fc276 100644 --- a/spot-order/src/main/java/com/example/Spot/order/infrastructure/aop/OrderAspect.java +++ b/spot-order/src/main/java/com/example/Spot/order/infrastructure/aop/OrderAspect.java @@ -20,6 +20,9 @@ import com.example.Spot.order.presentation.dto.request.OrderItemOptionRequestDto; import com.example.Spot.order.presentation.dto.request.OrderItemRequestDto; +import io.github.resilience4j.bulkhead.annotation.Bulkhead; +import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker; +import io.github.resilience4j.retry.annotation.Retry; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -34,6 +37,9 @@ public class OrderAspect { private final OrderRepository orderRepository; @Around("@annotation(validateStoreAndMenu)") + @CircuitBreaker(name = "store_menus_validation") + @Bulkhead(name = "store_menus_validation", type = Bulkhead.Type.SEMAPHORE) + @Retry(name = "store_menus_validation") public Object handleValidateStoreAndMenu( ProceedingJoinPoint joinPoint, ValidateStoreAndMenu validateStoreAndMenu) throws Throwable { @@ -140,6 +146,9 @@ public Object handlePaymentCancelTrace( } @Around("@annotation(storeOwnershipRequired)") + @CircuitBreaker(name = "store_me_ownership") + @Bulkhead(name = "store_me_ownership", type = Bulkhead.Type.SEMAPHORE) + @Retry(name = "store_me_ownership") public Object handleStoreOwnershipRequired( ProceedingJoinPoint joinPoint, StoreOwnershipRequired storeOwnershipRequired) throws Throwable { diff --git a/spot-payment/build.gradle b/spot-payment/build.gradle index ab80bc1c..a06ec670 100644 --- a/spot-payment/build.gradle +++ b/spot-payment/build.gradle @@ -28,6 +28,8 @@ dependencyManagement { dependencies { // OpenFeign implementation 'org.springframework.cloud:spring-cloud-starter-openfeign' + implementation 'io.github.resilience4j:resilience4j-spring-boot3:2.2.0' + implementation 'org.springframework.boot:spring-boot-starter-actuator' implementation 'org.springframework.boot:spring-boot-starter' implementation 'org.springframework.boot:spring-boot-starter-security' diff --git a/spot-payment/src/main/java/com/example/Spot/payments/application/service/PaymentService.java b/spot-payment/src/main/java/com/example/Spot/payments/application/service/PaymentService.java index 38b61a61..1ab13fba 100644 --- a/spot-payment/src/main/java/com/example/Spot/payments/application/service/PaymentService.java +++ b/spot-payment/src/main/java/com/example/Spot/payments/application/service/PaymentService.java @@ -13,7 +13,6 @@ import com.example.Spot.global.feign.StoreClient; import com.example.Spot.global.feign.UserClient; import com.example.Spot.global.feign.dto.OrderResponse; -import com.example.Spot.global.feign.dto.UserResponse; import com.example.Spot.global.presentation.advice.ResourceNotFoundException; import com.example.Spot.payments.domain.entity.PaymentEntity; import com.example.Spot.payments.domain.entity.PaymentHistoryEntity; @@ -32,6 +31,9 @@ import com.example.Spot.payments.presentation.dto.request.PaymentRequestDto; import com.example.Spot.payments.presentation.dto.response.PaymentResponseDto; +import io.github.resilience4j.bulkhead.annotation.Bulkhead; +import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker; +import io.github.resilience4j.retry.annotation.Retry; import lombok.RequiredArgsConstructor; @Service @@ -60,16 +62,12 @@ public class PaymentService { // ready -> createPaymentBillingApprove -> confirmBillingPayment @Ready - public UUID ready(PaymentRequestDto.Confirm request) { - - validateUserExists(request.userId()); - validateOrderExists(request.orderId()); - - PaymentEntity payment = createPayment(request.userId(), request.orderId(), request); - + public UUID ready(Integer userId, UUID orderId, PaymentRequestDto.Confirm request) { + PaymentEntity payment = createPayment(userId, orderId, request); return payment.getId(); } + // ******* // // 결제 승인 // // ******* // @@ -179,16 +177,12 @@ private void validateOrderExists(UUID orderId) { } } - // User 서비스 호출 - 사용자 조회 - private UserResponse findUser(Integer userId) { - UserResponse user = userClient.getUserById(userId); - if (user == null) { - throw new ResourceNotFoundException("[PaymentService] 사용자를 찾을 수 없습니다."); - } - return user; - } + // User 서비스 호출 - 사용자 존재 확인 + @CircuitBreaker(name = "user_validate_activeUser") + @Bulkhead(name = "user_validate_activeUser", type = Bulkhead.Type.SEMAPHORE) + @Retry(name = "user_validate_activeUser") private void validateUserExists(Integer userId) { if (!userClient.existsById(userId)) { throw new ResourceNotFoundException("[PaymentService] 사용자를 찾을 수 없습니다."); diff --git a/spot-payment/src/main/java/com/example/Spot/payments/presentation/controller/PaymentController.java b/spot-payment/src/main/java/com/example/Spot/payments/presentation/controller/PaymentController.java index 4e6836b0..dfd1fda6 100644 --- a/spot-payment/src/main/java/com/example/Spot/payments/presentation/controller/PaymentController.java +++ b/spot-payment/src/main/java/com/example/Spot/payments/presentation/controller/PaymentController.java @@ -36,7 +36,9 @@ public ApiResponse confirmPayment( @Valid @RequestBody PaymentRequestDto.Confirm request, @AuthenticationPrincipal CustomUserDetails principal ) { - UUID paymentId = paymentService.ready(request); + + Integer userId = principal.getUserId(); + UUID paymentId = paymentService.ready(userId, orderId, request); PaymentResponseDto.Confirm response = paymentService.createPaymentBillingApprove(paymentId); return ApiResponse.onSuccess(GeneralSuccessCode.GOOD_REQUEST, response); } diff --git a/spot-store/build.gradle b/spot-store/build.gradle index bf99603a..07aa87c8 100644 --- a/spot-store/build.gradle +++ b/spot-store/build.gradle @@ -37,6 +37,8 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-redis' implementation 'org.springframework.boot:spring-boot-starter-cache' implementation 'org.springframework.cloud:spring-cloud-starter-openfeign' + implementation 'io.github.resilience4j:resilience4j-spring-boot3:2.2.0' + implementation 'org.springframework.boot:spring-boot-starter-actuator' compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' diff --git a/spot-store/src/main/java/com/example/Spot/global/feign/UserClient.java b/spot-store/src/main/java/com/example/Spot/global/feign/UserClient.java index ec5cde2c..64bd6761 100644 --- a/spot-store/src/main/java/com/example/Spot/global/feign/UserClient.java +++ b/spot-store/src/main/java/com/example/Spot/global/feign/UserClient.java @@ -12,4 +12,8 @@ public interface UserClient { @GetMapping("/api/users/{userId}") UserResponse getUser(@PathVariable("userId") Integer userId); + // 계정 상태/접근 가능 여부만 확인 + @GetMapping("/api/internal/users/{userId}/validate") + void validate(@PathVariable("userId") Integer userId); + } diff --git a/spot-store/src/main/java/com/example/Spot/review/application/service/ReviewService.java b/spot-store/src/main/java/com/example/Spot/review/application/service/ReviewService.java index f02b10ae..f21262c9 100644 --- a/spot-store/src/main/java/com/example/Spot/review/application/service/ReviewService.java +++ b/spot-store/src/main/java/com/example/Spot/review/application/service/ReviewService.java @@ -18,6 +18,9 @@ import com.example.Spot.store.domain.entity.StoreEntity; import com.example.Spot.store.domain.repository.StoreRepository; +import io.github.resilience4j.bulkhead.annotation.Bulkhead; +import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker; +import io.github.resilience4j.retry.annotation.Retry; import jakarta.persistence.EntityNotFoundException; import lombok.RequiredArgsConstructor; @@ -31,13 +34,16 @@ public class ReviewService { private final UserClient userClient; @Transactional + @CircuitBreaker(name = "user_validate_activeUser") + @Bulkhead(name = "user_validate_activeUser", type = Bulkhead.Type.SEMAPHORE) + @Retry(name = "user_validate_activeUser") public ReviewResponse createReview(ReviewCreateRequest request, Integer userId) { // 가게 존재 확인 StoreEntity store = storeRepository.findByIdAndIsDeletedFalse(request.storeId()) .orElseThrow(() -> new EntityNotFoundException("가게를 찾을 수 없습니다.")); // 사용자 존재 확인 (Feign Client로 검증) - userClient.getUser(userId); + userClient.validate(userId); // 리뷰 생성 ReviewEntity review = ReviewEntity.builder() diff --git a/spot-store/src/main/java/com/example/Spot/store/application/service/StoreService.java b/spot-store/src/main/java/com/example/Spot/store/application/service/StoreService.java index 5431e328..b23a1356 100644 --- a/spot-store/src/main/java/com/example/Spot/store/application/service/StoreService.java +++ b/spot-store/src/main/java/com/example/Spot/store/application/service/StoreService.java @@ -3,6 +3,7 @@ import java.util.List; import java.util.UUID; + import org.springframework.beans.factory.annotation.Value; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; @@ -11,6 +12,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import com.example.Spot.global.feign.UserClient; import com.example.Spot.global.presentation.advice.DuplicateResourceException; import com.example.Spot.menu.domain.entity.MenuEntity; import com.example.Spot.menu.domain.repository.MenuRepository; @@ -20,7 +22,6 @@ import com.example.Spot.store.domain.entity.StoreEntity; import com.example.Spot.store.domain.repository.CategoryRepository; import com.example.Spot.store.domain.repository.StoreRepository; -import com.example.Spot.store.domain.repository.StoreUserRepository; import com.example.Spot.store.infrastructure.aop.AdminOnly; import com.example.Spot.store.infrastructure.aop.StoreValidationContext; import com.example.Spot.store.infrastructure.aop.ValidateStoreAuthority; @@ -30,6 +31,9 @@ import com.example.Spot.store.presentation.dto.response.StoreDetailResponse; import com.example.Spot.store.presentation.dto.response.StoreListResponse; +import io.github.resilience4j.bulkhead.annotation.Bulkhead; +import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker; +import io.github.resilience4j.retry.annotation.Retry; import jakarta.persistence.EntityNotFoundException; import lombok.RequiredArgsConstructor; @@ -44,14 +48,17 @@ public class StoreService { private final StoreRepository storeRepository; private final CategoryRepository categoryRepository; private final MenuRepository menuRepository; - private final StoreUserRepository storeUserRepository; + private final UserCallService userCallService; // ******* // // 매장 생성 // // ******* // @Transactional public UUID createStore(StoreCreateRequest dto, Integer userId) { - + + // user 상태 검증 + userCallService.validateActiveUser(userId); + if (storeRepository.existsByRoadAddressAndAddressDetailAndNameAndIsDeletedFalse( dto.roadAddress(), dto.addressDetail(), dto.name())) { throw new DuplicateResourceException( @@ -73,6 +80,7 @@ public UUID createStore(StoreCreateRequest dto, Integer userId) { return storeRepository.save(store).getId(); } + // *********** // // 매장 상세 조회 // // *********** // @@ -233,4 +241,8 @@ private Page convertToPageResponse(Page stores, return new PageImpl<>(filteredContent, pageable, filteredContent.size()); } + + + + } diff --git a/spot-store/src/main/java/com/example/Spot/store/application/service/UserCallService.java b/spot-store/src/main/java/com/example/Spot/store/application/service/UserCallService.java new file mode 100644 index 00000000..a60b6116 --- /dev/null +++ b/spot-store/src/main/java/com/example/Spot/store/application/service/UserCallService.java @@ -0,0 +1,23 @@ +package com.example.Spot.store.application.service; // 너희 패키지에 맞춰 + +import com.example.Spot.global.feign.UserClient; +import org.springframework.stereotype.Service; + +import io.github.resilience4j.bulkhead.annotation.Bulkhead; +import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker; +import io.github.resilience4j.retry.annotation.Retry; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class UserCallService { + + private final UserClient userClient; + + @CircuitBreaker(name = "user_validate_activeUser") + @Bulkhead(name = "user_validate_activeUser", type = Bulkhead.Type.SEMAPHORE) + @Retry(name = "user_validate_activeUser") + public void validateActiveUser(Integer userId) { + userClient.validate(userId); + } +} diff --git a/spot-store/src/main/java/com/example/Spot/store/infrastructure/aop/StoreAspect.java b/spot-store/src/main/java/com/example/Spot/store/infrastructure/aop/StoreAspect.java index 6b2decc1..2c9d7f2a 100644 --- a/spot-store/src/main/java/com/example/Spot/store/infrastructure/aop/StoreAspect.java +++ b/spot-store/src/main/java/com/example/Spot/store/infrastructure/aop/StoreAspect.java @@ -8,7 +8,8 @@ import org.springframework.security.access.AccessDeniedException; import org.springframework.stereotype.Component; -import com.example.Spot.global.feign.UserClient; +import com.example.Spot.global.common.Role; +import com.example.Spot.global.infrastructure.config.security.CustomUserDetails; import com.example.Spot.store.domain.entity.StoreEntity; import com.example.Spot.store.domain.repository.StoreRepository; @@ -16,15 +17,13 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; + @Slf4j @Aspect @Component @RequiredArgsConstructor public class StoreAspect { - // User Roll을 확인하는 로직이 너무 많음 - - private final UserClient userClient; private final StoreRepository storeRepository; @Around("@annotation(adminOnly)") @@ -32,11 +31,12 @@ public Object handleAdminOnly( ProceedingJoinPoint joinPoint, AdminOnly adminOnly) throws Throwable { - Integer userId = (Integer) joinPoint.getArgs()[0]; - - log.debug("[관리자 권한 검증] UserId: {}", userId); + CustomUserDetails principal = extractPrincipal(joinPoint); + if (principal == null) { + throw new AccessDeniedException("인증이 필요합니다."); + } - String role = userClient.getUser(userId).getRole(); + Role role = principal.getRole(); boolean isAdmin = "MASTER".equals(role) || "MANAGER".equals(role); if (!isAdmin) { @@ -58,12 +58,17 @@ public Object handleValidateStoreAuthority( ValidateStoreAuthority validateStoreAuthority) throws Throwable { UUID storeId = (UUID) joinPoint.getArgs()[0]; - Integer userId = (Integer) joinPoint.getArgs()[1]; + CustomUserDetails principal = extractPrincipal(joinPoint); + if (principal == null) { + throw new AccessDeniedException("인증이 필요합니다."); + } + + Integer userId = (Integer) joinPoint.getArgs()[0]; + Role role = principal.getRole(); + boolean isAdmin = "MASTER".equals(role) || "MANAGER".equals(role); - log.debug("[매장 권한 검증] StoreId: {}, UserId: {}", storeId, userId); - String role = userClient.getUser(userId).getRole(); - boolean isAdmin = "MANAGER".equals(role) || "MASTER".equals(role); + log.debug("[매장 권한 검증] StoreId: {}, UserId: {}", storeId, userId); StoreEntity store = storeRepository.findByIdWithDetails(storeId, isAdmin) .orElseThrow(() -> new EntityNotFoundException("매장을 찾을 수 없거나 접근 권한이 없습니다.")); @@ -86,4 +91,13 @@ public Object handleValidateStoreAuthority( StoreValidationContext.clearAll(); } } + private CustomUserDetails extractPrincipal(ProceedingJoinPoint joinPoint) { + for (Object arg : joinPoint.getArgs()) { + if (arg instanceof CustomUserDetails cud) { + return cud; + } + } + return null; + } + } diff --git a/spot-user/build.gradle b/spot-user/build.gradle index b2e1d5b0..7e4a2c2a 100644 --- a/spot-user/build.gradle +++ b/spot-user/build.gradle @@ -34,6 +34,8 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-cache' implementation 'org.springframework.boot:spring-boot-starter-aop' implementation 'org.springframework.cloud:spring-cloud-starter-openfeign' + implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'io.github.resilience4j:resilience4j-spring-boot3:2.2.0' compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' @@ -60,6 +62,9 @@ dependencies { // postgreSQL implementation 'org.postgresql:postgresql' + // Resilience4J + + } tasks.named('test') { diff --git a/spot-user/src/main/java/com/example/Spot/infra/feign/exception/RemoteCallFailedException.java b/spot-user/src/main/java/com/example/Spot/global/feign/config/exception/RemoteCallFailedException.java similarity index 79% rename from spot-user/src/main/java/com/example/Spot/infra/feign/exception/RemoteCallFailedException.java rename to spot-user/src/main/java/com/example/Spot/global/feign/config/exception/RemoteCallFailedException.java index 95053004..06472b4d 100644 --- a/spot-user/src/main/java/com/example/Spot/infra/feign/exception/RemoteCallFailedException.java +++ b/spot-user/src/main/java/com/example/Spot/global/feign/config/exception/RemoteCallFailedException.java @@ -1,4 +1,4 @@ -package com.example.Spot.infra.feign.exception; +package com.example.Spot.global.feign.config.exception; public class RemoteCallFailedException extends RuntimeException { public RemoteCallFailedException(String methodKey, int status) { diff --git a/spot-user/src/main/java/com/example/Spot/infra/feign/exception/RemoteConflictException.java b/spot-user/src/main/java/com/example/Spot/global/feign/config/exception/RemoteConflictException.java similarity index 75% rename from spot-user/src/main/java/com/example/Spot/infra/feign/exception/RemoteConflictException.java rename to spot-user/src/main/java/com/example/Spot/global/feign/config/exception/RemoteConflictException.java index 1bdba453..1195fa06 100644 --- a/spot-user/src/main/java/com/example/Spot/infra/feign/exception/RemoteConflictException.java +++ b/spot-user/src/main/java/com/example/Spot/global/feign/config/exception/RemoteConflictException.java @@ -1,4 +1,4 @@ -package com.example.Spot.infra.feign.exception; +package com.example.Spot.global.feign.config.exception; public class RemoteConflictException extends RuntimeException { public RemoteConflictException(String methodKey) { diff --git a/spot-user/src/main/java/com/example/Spot/infra/feign/exception/RemoteNotFoundException.java b/spot-user/src/main/java/com/example/Spot/global/feign/config/exception/RemoteNotFoundException.java similarity index 76% rename from spot-user/src/main/java/com/example/Spot/infra/feign/exception/RemoteNotFoundException.java rename to spot-user/src/main/java/com/example/Spot/global/feign/config/exception/RemoteNotFoundException.java index ca1b83c3..6bb32e0a 100644 --- a/spot-user/src/main/java/com/example/Spot/infra/feign/exception/RemoteNotFoundException.java +++ b/spot-user/src/main/java/com/example/Spot/global/feign/config/exception/RemoteNotFoundException.java @@ -1,4 +1,4 @@ -package com.example.Spot.infra.feign.exception; +package com.example.Spot.global.feign.config.exception; public class RemoteNotFoundException extends RuntimeException { public RemoteNotFoundException(String methodKey) { diff --git a/spot-user/src/main/java/com/example/Spot/infra/feign/exception/RemoteServiceUnavailableException.java b/spot-user/src/main/java/com/example/Spot/global/feign/config/exception/RemoteServiceUnavailableException.java similarity index 78% rename from spot-user/src/main/java/com/example/Spot/infra/feign/exception/RemoteServiceUnavailableException.java rename to spot-user/src/main/java/com/example/Spot/global/feign/config/exception/RemoteServiceUnavailableException.java index 54cb22c8..c35e5ef0 100644 --- a/spot-user/src/main/java/com/example/Spot/infra/feign/exception/RemoteServiceUnavailableException.java +++ b/spot-user/src/main/java/com/example/Spot/global/feign/config/exception/RemoteServiceUnavailableException.java @@ -1,4 +1,4 @@ -package com.example.Spot.infra.feign.exception; +package com.example.Spot.global.feign.config.exception; public class RemoteServiceUnavailableException extends RuntimeException { public RemoteServiceUnavailableException(String methodKey) { diff --git a/spot-user/src/main/java/com/example/Spot/internal/controller/InternalUserController.java b/spot-user/src/main/java/com/example/Spot/internal/controller/InternalUserController.java index 296692e0..e4c7ce0d 100644 --- a/spot-user/src/main/java/com/example/Spot/internal/controller/InternalUserController.java +++ b/spot-user/src/main/java/com/example/Spot/internal/controller/InternalUserController.java @@ -29,4 +29,12 @@ public ResponseEntity getUserById(@PathVariable Integer us public ResponseEntity existsById(@PathVariable Integer userId) { return ResponseEntity.ok(userRepository.existsById(userId)); } + @GetMapping("/{userId}/validate") + public ResponseEntity validate(@PathVariable Integer userId) { + if (!userRepository.existsById(userId)) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.ok().build(); + } + } From 86cab175b450665b416782fa76899ae72d4c6cc0 Mon Sep 17 00:00:00 2001 From: dbswjd7 Date: Sun, 25 Jan 2026 02:01:55 +0900 Subject: [PATCH 67/77] =?UTF-8?q?feat(#221):=20cognito=EB=A5=BC=20aws=20ga?= =?UTF-8?q?teway=EC=97=90=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- infra/terraform/cognito/cognito.tf | 33 ---- infra/terraform/cognito/cognito_client.tf | 19 -- infra/terraform/cognito/iam.tf | 74 -------- infra/terraform/cognito/lambda.tf | 27 --- infra/terraform/environments/dev/main.tf | 15 ++ infra/terraform/environments/dev/variables.tf | 8 + infra/terraform/modules/api-gateway/main.tf | 47 +++++ .../modules/api-gateway/variables.tf | 10 ++ .../{ => modules}/cognito/.terraform.lock.hcl | 0 .../cognito/lambda/post_confirm.py | 0 .../{ => modules}/cognito/lambda/pre_token.py | 0 infra/terraform/modules/cognito/main.tf | 165 ++++++++++++++++++ .../terraform/{ => modules}/cognito/output.tf | 0 .../{ => modules}/cognito/variable.tf | 3 + 14 files changed, 248 insertions(+), 153 deletions(-) delete mode 100644 infra/terraform/cognito/cognito.tf delete mode 100644 infra/terraform/cognito/cognito_client.tf delete mode 100644 infra/terraform/cognito/iam.tf delete mode 100644 infra/terraform/cognito/lambda.tf rename infra/terraform/{ => modules}/cognito/.terraform.lock.hcl (100%) rename infra/terraform/{ => modules}/cognito/lambda/post_confirm.py (100%) rename infra/terraform/{ => modules}/cognito/lambda/pre_token.py (100%) create mode 100644 infra/terraform/modules/cognito/main.tf rename infra/terraform/{ => modules}/cognito/output.tf (100%) rename infra/terraform/{ => modules}/cognito/variable.tf (83%) diff --git a/infra/terraform/cognito/cognito.tf b/infra/terraform/cognito/cognito.tf deleted file mode 100644 index 1ac5c105..00000000 --- a/infra/terraform/cognito/cognito.tf +++ /dev/null @@ -1,33 +0,0 @@ -resource "aws_cognito_user_pool" "pool" { - name = "spot_cognito_user_pool" - - schema { - name = "user_id" - attribute_data_type = "String" - mutable = true - required = false - } - - schema { - name = "role" - attribute_data_type = "String" - mutable = true - required = false - } - - - lambda_config { - post_confirmation = aws_lambda_function.post_confirm.arn - - # access token customization을 위해 "pre_token_generation_config" 사용(Trigger event version V2_0) - pre_token_generation_config { - lambda_arn = aws_lambda_function.pre_token.arn - lambda_version = "V2_0" - } - } - - -} - - - diff --git a/infra/terraform/cognito/cognito_client.tf b/infra/terraform/cognito/cognito_client.tf deleted file mode 100644 index e7f3e49b..00000000 --- a/infra/terraform/cognito/cognito_client.tf +++ /dev/null @@ -1,19 +0,0 @@ - -resource "aws_cognito_user_pool_client" "app_client" { - name = "spot-app-client" - user_pool_id = aws_cognito_user_pool.pool.id - - generate_secret = false - - explicit_auth_flows = [ - "ALLOW_USER_PASSWORD_AUTH" - ] - - refresh_token_rotation { - feature = "ENABLED" - # 네트워크 장애 고려한 기존 refresh token 10초 유예시간 - retry_grace_period_seconds = 10 - } -} - - diff --git a/infra/terraform/cognito/iam.tf b/infra/terraform/cognito/iam.tf deleted file mode 100644 index 9172a5fa..00000000 --- a/infra/terraform/cognito/iam.tf +++ /dev/null @@ -1,74 +0,0 @@ -data "aws_iam_policy_document" "lambda_assume" { - statement { - effect = "Allow" - actions = ["sts:AssumeRole"] - principals { - type = "Service" - identifiers = ["lambda.amazonaws.com"] - } - } -} - -# Post Confirmation Role -resource "aws_iam_role" "lambda_post_confirm" { - name = "spot-lambda-post-confirm" - assume_role_policy = data.aws_iam_policy_document.lambda_assume.json -} - -resource "aws_iam_role_policy" "lambda_post_confirm_policy" { - role = aws_iam_role.lambda_post_confirm.id - policy = jsonencode({ - Version = "2012-10-17" - Statement = [ - # logs - { - Effect = "Allow" - Action = ["logs:CreateLogGroup","logs:CreateLogStream","logs:PutLogEvents"] - Resource = "*" - }, - # Cognito custom attribute 업데이트용 - { - Effect = "Allow" - Action = ["cognito-idp:AdminUpdateUserAttributes"] - Resource = aws_cognito_user_pool.pool.arn - } - ] - }) -} - -# Pre Token Role -resource "aws_iam_role" "lambda_pre_token" { - name = "spot-lambda-pre-token" - assume_role_policy = data.aws_iam_policy_document.lambda_assume.json -} - -resource "aws_iam_role_policy" "lambda_pre_token_policy" { - role = aws_iam_role.lambda_pre_token.id - policy = jsonencode({ - Version = "2012-10-17" - Statement = [ - { - Effect = "Allow" - Action = ["logs:CreateLogGroup","logs:CreateLogStream","logs:PutLogEvents"] - Resource = "*" - } - ] - }) -} - -# Cognito가 Lambda 호출하는 permission 연결(AccessDenied 방지) -resource "aws_lambda_permission" "allow_cognito_post_confirm" { - statement_id = "AllowCognitoInvokePostConfirm" - action = "lambda:InvokeFunction" - function_name = aws_lambda_function.post_confirm.function_name - principal = "cognito-idp.amazonaws.com" - source_arn = aws_cognito_user_pool.pool.arn -} - -resource "aws_lambda_permission" "allow_cognito_pre_token" { - statement_id = "AllowCognitoInvokePreToken" - action = "lambda:InvokeFunction" - function_name = aws_lambda_function.pre_token.function_name - principal = "cognito-idp.amazonaws.com" - source_arn = aws_cognito_user_pool.pool.arn -} diff --git a/infra/terraform/cognito/lambda.tf b/infra/terraform/cognito/lambda.tf deleted file mode 100644 index ec4b0e0f..00000000 --- a/infra/terraform/cognito/lambda.tf +++ /dev/null @@ -1,27 +0,0 @@ -resource "aws_lambda_function" "post_confirm" { - function_name = "spot-post-confirm" - role = aws_iam_role.lambda_post_confirm.arn - handler = "post_confirm.lambda_handler" - runtime = "python3.12" - timeout = 10 - - filename = "${path.module}/lambda/post_confirm.zip" - source_code_hash = filebase64sha256("${path.module}/lambda/post_confirm.zip") - - environment { - variables = { - USER_SERVICE_URL = var.user_service_url - } - } -} - -resource "aws_lambda_function" "pre_token" { - function_name = "spot-pre-token" - role = aws_iam_role.lambda_pre_token.arn - handler = "pre_token.lambda_handler" - runtime = "python3.12" - timeout = 5 - - filename = "${path.module}/lambda/pre_token.zip" - source_code_hash = filebase64sha256("${path.module}/lambda/pre_token.zip") -} diff --git a/infra/terraform/environments/dev/main.tf b/infra/terraform/environments/dev/main.tf index ce945b67..46656434 100644 --- a/infra/terraform/environments/dev/main.tf +++ b/infra/terraform/environments/dev/main.tf @@ -131,6 +131,9 @@ module "api_gateway" { subnet_ids = module.network.private_subnet_ids ecs_security_group_id = module.ecs.security_group_id alb_listener_arn = module.alb.listener_arn + + cognito_issuer = module.cognito.cognito_issuer_url + cognito_audience = module.cognito.cognito_app_client_id } # ============================================================================= @@ -211,3 +214,15 @@ module "monitoring" { # Redis 모니터링 (선택) redis_cluster_id = "${local.name_prefix}-redis-001" } + + +# ============================================================================= +# Cognito +# ============================================================================= +module "cognito" { + source = "../../modules/cognito" + + aws_region = var.region + name_prefix = local.name_prefix + user_service_url = var.user_service_url +} \ No newline at end of file diff --git a/infra/terraform/environments/dev/variables.tf b/infra/terraform/environments/dev/variables.tf index 26c9692d..1df19217 100644 --- a/infra/terraform/environments/dev/variables.tf +++ b/infra/terraform/environments/dev/variables.tf @@ -319,3 +319,11 @@ variable "service_active_regions" { type = string default = "종로구" } + +# ============================================================================= +# Cognito +# ============================================================================= + +variable "user_service_url" { + type = string +} diff --git a/infra/terraform/modules/api-gateway/main.tf b/infra/terraform/modules/api-gateway/main.tf index 51ceee57..18ca65be 100644 --- a/infra/terraform/modules/api-gateway/main.tf +++ b/infra/terraform/modules/api-gateway/main.tf @@ -40,8 +40,39 @@ resource "aws_apigatewayv2_route" "main" { api_id = aws_apigatewayv2_api.main.id route_key = "ANY /{proxy+}" target = "integrations/${aws_apigatewayv2_integration.main.id}" + + authorization_type = "JWT" + authorizer_id = aws_apigatewayv2_authorizer.cognito_jwt.id +} + +resource "aws_apigatewayv2_route" "options" { + api_id = aws_apigatewayv2_api.main.id + route_key = "OPTIONS /{proxy+}" + target = "integrations/${aws_apigatewayv2_integration.main.id}" + + authorization_type = "NONE" +} + +# 로그인 / 회원가입을 위한 공개 라우트 +# 개발 이후 삭제할 것 +resource "aws_apigatewayv2_route" "public_api_join" { + api_id = aws_apigatewayv2_api.main.id + route_key = "POST /api/join" + target = "integrations/${aws_apigatewayv2_integration.main.id}" + + authorization_type = "NONE" +} + +resource "aws_apigatewayv2_route" "public_api_login" { + api_id = aws_apigatewayv2_api.main.id + route_key = "POST /api/login" + target = "integrations/${aws_apigatewayv2_integration.main.id}" + + authorization_type = "NONE" } + + # ============================================================================= # Stage # ============================================================================= @@ -52,3 +83,19 @@ resource "aws_apigatewayv2_stage" "main" { tags = merge(var.common_tags, { Name = "${var.name_prefix}-stage" }) } + +# ============================================================================= +# Cognito +# ============================================================================= +resource "aws_apigatewayv2_authorizer" "cognito_jwt" { + api_id = aws_apigatewayv2_api.main.id + name = "${var.name_prefix}-cognito-jwt" + authorizer_type = "JWT" + + identity_sources = ["$request.header.Authorization"] + + jwt_configuration { + issuer = var.cognito_issuer + audience = [var.cognito_audience] + } +} diff --git a/infra/terraform/modules/api-gateway/variables.tf b/infra/terraform/modules/api-gateway/variables.tf index 76e0691c..54e138ba 100644 --- a/infra/terraform/modules/api-gateway/variables.tf +++ b/infra/terraform/modules/api-gateway/variables.tf @@ -23,3 +23,13 @@ variable "alb_listener_arn" { description = "ALB Listener ARN" type = string } + +variable "cognito_issuer" { + description = "Cognito Issuer URL (https://cognito-idp..amazonaws.com/)" + type = string +} + +variable "cognito_audience" { + description = "Cognito App Client ID (audience)" + type = string +} diff --git a/infra/terraform/cognito/.terraform.lock.hcl b/infra/terraform/modules/cognito/.terraform.lock.hcl similarity index 100% rename from infra/terraform/cognito/.terraform.lock.hcl rename to infra/terraform/modules/cognito/.terraform.lock.hcl diff --git a/infra/terraform/cognito/lambda/post_confirm.py b/infra/terraform/modules/cognito/lambda/post_confirm.py similarity index 100% rename from infra/terraform/cognito/lambda/post_confirm.py rename to infra/terraform/modules/cognito/lambda/post_confirm.py diff --git a/infra/terraform/cognito/lambda/pre_token.py b/infra/terraform/modules/cognito/lambda/pre_token.py similarity index 100% rename from infra/terraform/cognito/lambda/pre_token.py rename to infra/terraform/modules/cognito/lambda/pre_token.py diff --git a/infra/terraform/modules/cognito/main.tf b/infra/terraform/modules/cognito/main.tf new file mode 100644 index 00000000..5f5f6ccb --- /dev/null +++ b/infra/terraform/modules/cognito/main.tf @@ -0,0 +1,165 @@ +# ============================================================================= +# Cognito +# ============================================================================= +resource "aws_cognito_user_pool" "pool" { + name = "spot_cognito_user_pool" + + schema { + name = "user_id" + attribute_data_type = "String" + mutable = true + required = false + } + + schema { + name = "role" + attribute_data_type = "String" + mutable = true + required = false + } + + + lambda_config { + post_confirmation = aws_lambda_function.post_confirm.arn + + # access token customization을 위해 "pre_token_generation_config" 사용(Trigger event version V2_0) + pre_token_generation_config { + lambda_arn = aws_lambda_function.pre_token.arn + lambda_version = "V2_0" + } + } +} + +# ============================================================================= +# Cognito Client +# ============================================================================= + +resource "aws_cognito_user_pool_client" "app_client" { + name = "spot-app-client" + user_pool_id = aws_cognito_user_pool.pool.id + + generate_secret = false # FE로 로그인/회원가입 + + explicit_auth_flows = [ + "ALLOW_USER_PASSWORD_AUTH" + ] + + refresh_token_rotation { + feature = "ENABLED" + # 네트워크 장애 고려한 기존 refresh token 10초 유예시간 + retry_grace_period_seconds = 10 + } +} + + + +# ============================================================================= +# iam +# ============================================================================= +data "aws_iam_policy_document" "lambda_assume" { + statement { + effect = "Allow" + actions = ["sts:AssumeRole"] + principals { + type = "Service" + identifiers = ["lambda.amazonaws.com"] + } + } +} + +# Post Confirmation Role +resource "aws_iam_role" "lambda_post_confirm" { + name = "spot-lambda-post-confirm" + assume_role_policy = data.aws_iam_policy_document.lambda_assume.json +} + +resource "aws_iam_role_policy" "lambda_post_confirm_policy" { + role = aws_iam_role.lambda_post_confirm.id + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + # logs + { + Effect = "Allow" + Action = ["logs:CreateLogGroup","logs:CreateLogStream","logs:PutLogEvents"] + Resource = "*" + }, + # Cognito custom attribute 업데이트용 + { + Effect = "Allow" + Action = ["cognito-idp:AdminUpdateUserAttributes"] + Resource = aws_cognito_user_pool.pool.arn + } + ] + }) +} + +# Pre Token Role +resource "aws_iam_role" "lambda_pre_token" { + name = "spot-lambda-pre-token" + assume_role_policy = data.aws_iam_policy_document.lambda_assume.json +} + +resource "aws_iam_role_policy" "lambda_pre_token_policy" { + role = aws_iam_role.lambda_pre_token.id + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = ["logs:CreateLogGroup","logs:CreateLogStream","logs:PutLogEvents"] + Resource = "*" + } + ] + }) +} + +# Cognito가 Lambda 호출하는 permission 연결(AccessDenied 방지) +resource "aws_lambda_permission" "allow_cognito_post_confirm" { + statement_id = "AllowCognitoInvokePostConfirm" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.post_confirm.function_name + principal = "cognito-idp.amazonaws.com" + source_arn = aws_cognito_user_pool.pool.arn +} + +resource "aws_lambda_permission" "allow_cognito_pre_token" { + statement_id = "AllowCognitoInvokePreToken" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.pre_token.function_name + principal = "cognito-idp.amazonaws.com" + source_arn = aws_cognito_user_pool.pool.arn +} + + + +# ============================================================================= +# lambda +# ============================================================================= +resource "aws_lambda_function" "post_confirm" { + function_name = "spot-post-confirm" + role = aws_iam_role.lambda_post_confirm.arn + handler = "post_confirm.lambda_handler" + runtime = "python3.12" + timeout = 10 + + filename = "${path.module}/lambda/post_confirm.zip" + source_code_hash = filebase64sha256("${path.module}/lambda/post_confirm.zip") + + environment { + variables = { + USER_SERVICE_URL = var.user_service_url + } + } +} + +resource "aws_lambda_function" "pre_token" { + function_name = "spot-pre-token" + role = aws_iam_role.lambda_pre_token.arn + handler = "pre_token.lambda_handler" + runtime = "python3.12" + timeout = 5 + + filename = "${path.module}/lambda/pre_token.zip" + source_code_hash = filebase64sha256("${path.module}/lambda/pre_token.zip") +} diff --git a/infra/terraform/cognito/output.tf b/infra/terraform/modules/cognito/output.tf similarity index 100% rename from infra/terraform/cognito/output.tf rename to infra/terraform/modules/cognito/output.tf diff --git a/infra/terraform/cognito/variable.tf b/infra/terraform/modules/cognito/variable.tf similarity index 83% rename from infra/terraform/cognito/variable.tf rename to infra/terraform/modules/cognito/variable.tf index 00dfb4dd..d39bf667 100644 --- a/infra/terraform/cognito/variable.tf +++ b/infra/terraform/modules/cognito/variable.tf @@ -8,3 +8,6 @@ variable "user_service_url" { type = string default = "http://user-service.internal:8080" } +variable "name_prefix" { + type = string +} \ No newline at end of file From 5e08421553e2c1205b7079ed4c40ba94609ccb33 Mon Sep 17 00:00:00 2001 From: dbswjd7 Date: Sun, 25 Jan 2026 16:07:36 +0900 Subject: [PATCH 68/77] =?UTF-8?q?feat(#221):=20resilience4J=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../example/spot/SpotGatewayApplication.java | 13 ------- .../spot/filter/GatewayFilterConfig.java | 35 ------------------- .../spot/SpotGatewayApplicationTests.java | 13 ------- .../example/Spot/global/feign/MenuClient.java | 2 +- .../Spot/global/feign/PaymentClient.java | 2 +- .../Spot/global/feign/StoreClient.java | 2 +- .../config/security/SecurityConfig.java | 2 +- .../src/main/resources/application.properties | 4 +-- .../Spot/global/feign/OrderClient.java | 2 +- .../Spot/global/feign/StoreClient.java | 2 +- .../example/Spot/global/feign/UserClient.java | 2 +- .../config/security/SecurityConfig.java | 2 +- .../src/main/resources/application.properties | 6 ++-- .../example/Spot/global/feign/UserClient.java | 2 +- .../config/security/SecurityConfig.java | 2 +- .../application/service/StoreService.java | 4 --- .../application/service/UserCallService.java | 5 +-- .../Spot/global/feign/OrderClient.java | 2 +- .../Spot/global/feign/StoreAdminClient.java | 2 +- .../Spot/global/feign/StoreClient.java | 2 +- .../config/security/SecurityConfig.java | 2 +- 21 files changed, 22 insertions(+), 86 deletions(-) delete mode 100644 spot-gateway/src/main/java/com/example/spot/SpotGatewayApplication.java delete mode 100644 spot-gateway/src/main/java/com/example/spot/filter/GatewayFilterConfig.java delete mode 100644 spot-gateway/src/test/java/com/example/spot/SpotGatewayApplicationTests.java diff --git a/spot-gateway/src/main/java/com/example/spot/SpotGatewayApplication.java b/spot-gateway/src/main/java/com/example/spot/SpotGatewayApplication.java deleted file mode 100644 index ca269de2..00000000 --- a/spot-gateway/src/main/java/com/example/spot/SpotGatewayApplication.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.example.Spot; - -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; - -@SpringBootApplication(scanBasePackages = "com.example.Spot") -public class SpotGatewayApplication { - - public static void main(String[] args) { - SpringApplication.run(SpotGatewayApplication.class, args); - } - -} diff --git a/spot-gateway/src/main/java/com/example/spot/filter/GatewayFilterConfig.java b/spot-gateway/src/main/java/com/example/spot/filter/GatewayFilterConfig.java deleted file mode 100644 index f0ec6dda..00000000 --- a/spot-gateway/src/main/java/com/example/spot/filter/GatewayFilterConfig.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.example.Spot.filter; - -import java.util.UUID; - -import org.springframework.cloud.gateway.filter.GlobalFilter; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -import reactor.core.publisher.Mono; - -@Configuration -public class GatewayFilterConfig { - - @Bean - public GlobalFilter requestIdFilter() { - return (exchange, chain) -> { - String rid = exchange.getRequest().getHeaders().getFirst("X-Request-Id"); - if (rid == null || rid.isBlank()) { - rid = UUID.randomUUID().toString(); - } - final String finalRequestId = rid; - - var mutated = exchange.mutate() - .request(exchange.getRequest().mutate() - .header("X-Request-Id", finalRequestId) - .build()) - .build(); - - return chain.filter(mutated) - .then(Mono.fromRunnable(() -> - mutated.getResponse().getHeaders().set("X-Request-Id", finalRequestId) - )); - }; - } -} \ No newline at end of file diff --git a/spot-gateway/src/test/java/com/example/spot/SpotGatewayApplicationTests.java b/spot-gateway/src/test/java/com/example/spot/SpotGatewayApplicationTests.java deleted file mode 100644 index 66a66588..00000000 --- a/spot-gateway/src/test/java/com/example/spot/SpotGatewayApplicationTests.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.example.Spot; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -class SpotGatewayApplicationTests { - - @Test - void contextLoads() { - } - -} diff --git a/spot-order/src/main/java/com/example/Spot/global/feign/MenuClient.java b/spot-order/src/main/java/com/example/Spot/global/feign/MenuClient.java index b45d1a21..e8b98a91 100644 --- a/spot-order/src/main/java/com/example/Spot/global/feign/MenuClient.java +++ b/spot-order/src/main/java/com/example/Spot/global/feign/MenuClient.java @@ -9,7 +9,7 @@ import com.example.Spot.global.feign.dto.MenuOptionResponse; import com.example.Spot.global.feign.dto.MenuResponse; -@FeignClient(name = "menu-service", url = "${feign.store.url}") +@FeignClient(name = "spot-menu", url = "${feign.store.url}") public interface MenuClient { @GetMapping("/api/internal/menus/{menuId}") diff --git a/spot-order/src/main/java/com/example/Spot/global/feign/PaymentClient.java b/spot-order/src/main/java/com/example/Spot/global/feign/PaymentClient.java index fad4bd15..05e74400 100644 --- a/spot-order/src/main/java/com/example/Spot/global/feign/PaymentClient.java +++ b/spot-order/src/main/java/com/example/Spot/global/feign/PaymentClient.java @@ -11,7 +11,7 @@ import com.example.Spot.global.feign.dto.PaymentCancelRequest; import com.example.Spot.global.feign.dto.PaymentResponse; -@FeignClient(name = "payment-service", url = "${feign.payment.url}") +@FeignClient(name = "spot-payment", url = "${feign.payment.url}") public interface PaymentClient { @GetMapping("/api/internal/payments/order/{orderId}") diff --git a/spot-order/src/main/java/com/example/Spot/global/feign/StoreClient.java b/spot-order/src/main/java/com/example/Spot/global/feign/StoreClient.java index 3bf8898b..e3d60961 100644 --- a/spot-order/src/main/java/com/example/Spot/global/feign/StoreClient.java +++ b/spot-order/src/main/java/com/example/Spot/global/feign/StoreClient.java @@ -10,7 +10,7 @@ import com.example.Spot.global.feign.dto.StoreResponse; import com.example.Spot.global.feign.dto.StoreUserResponse; -@FeignClient(name = "store-service", url = "${feign.store.url}") +@FeignClient(name = "spot-store", url = "${feign.store.url}") public interface StoreClient { @GetMapping("/api/stores/{storeId}") diff --git a/spot-order/src/main/java/com/example/Spot/global/infrastructure/config/security/SecurityConfig.java b/spot-order/src/main/java/com/example/Spot/global/infrastructure/config/security/SecurityConfig.java index 30e9a199..d4f01d81 100644 --- a/spot-order/src/main/java/com/example/Spot/global/infrastructure/config/security/SecurityConfig.java +++ b/spot-order/src/main/java/com/example/Spot/global/infrastructure/config/security/SecurityConfig.java @@ -30,7 +30,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); http.authorizeHttpRequests(auth -> auth - .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() + .requestMatchers(HttpMethod.OPTIONS, "/**", "/actuator/**").permitAll() .requestMatchers( "/", "/swagger-ui/**", "/v3/api-docs/**" ).permitAll() diff --git a/spot-order/src/main/resources/application.properties b/spot-order/src/main/resources/application.properties index 4b7bf89a..e6fb5f6a 100644 --- a/spot-order/src/main/resources/application.properties +++ b/spot-order/src/main/resources/application.properties @@ -2,5 +2,5 @@ server.port=8082 spring.application.name=spot-order # Feign Client URLs -feign.store.url=http://localhost:8083 -feign.payment.url=http://localhost:8084 +feign.store.url=http://spot-store:8083 +feign.payment.url=http://spot-payment:8084 diff --git a/spot-payment/src/main/java/com/example/Spot/global/feign/OrderClient.java b/spot-payment/src/main/java/com/example/Spot/global/feign/OrderClient.java index 516cb347..5796f531 100644 --- a/spot-payment/src/main/java/com/example/Spot/global/feign/OrderClient.java +++ b/spot-payment/src/main/java/com/example/Spot/global/feign/OrderClient.java @@ -8,7 +8,7 @@ import com.example.Spot.global.feign.dto.OrderResponse; -@FeignClient(name = "order-service", url = "${feign.order.url}") +@FeignClient(name = "spot-order", url = "${feign.order.url}") public interface OrderClient { @GetMapping("/api/internal/orders/{orderId}") diff --git a/spot-payment/src/main/java/com/example/Spot/global/feign/StoreClient.java b/spot-payment/src/main/java/com/example/Spot/global/feign/StoreClient.java index 35baed34..78988a8e 100644 --- a/spot-payment/src/main/java/com/example/Spot/global/feign/StoreClient.java +++ b/spot-payment/src/main/java/com/example/Spot/global/feign/StoreClient.java @@ -8,7 +8,7 @@ import com.example.Spot.global.feign.dto.StoreUserResponse; -@FeignClient(name = "store-service", url = "${feign.store.url}") +@FeignClient(name = "spot-store", url = "${feign.store.url}") public interface StoreClient { @GetMapping("/api/internal/store-users/exists") diff --git a/spot-payment/src/main/java/com/example/Spot/global/feign/UserClient.java b/spot-payment/src/main/java/com/example/Spot/global/feign/UserClient.java index 459f8fa1..7f8adae1 100644 --- a/spot-payment/src/main/java/com/example/Spot/global/feign/UserClient.java +++ b/spot-payment/src/main/java/com/example/Spot/global/feign/UserClient.java @@ -6,7 +6,7 @@ import com.example.Spot.global.feign.dto.UserResponse; -@FeignClient(name = "user-service", url = "${feign.user.url}") +@FeignClient(name = "spot-user", url = "${feign.user.url}") public interface UserClient { @GetMapping("/api/internal/users/{userId}") diff --git a/spot-payment/src/main/java/com/example/Spot/global/infrastructure/config/security/SecurityConfig.java b/spot-payment/src/main/java/com/example/Spot/global/infrastructure/config/security/SecurityConfig.java index 49219b46..efce0bb4 100644 --- a/spot-payment/src/main/java/com/example/Spot/global/infrastructure/config/security/SecurityConfig.java +++ b/spot-payment/src/main/java/com/example/Spot/global/infrastructure/config/security/SecurityConfig.java @@ -29,7 +29,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); http.authorizeHttpRequests(auth -> auth - .requestMatchers("OPTIONS", "/**").permitAll() + .requestMatchers("OPTIONS", "/**", "/actuator/**").permitAll() .requestMatchers( "/", "/swagger-ui/**", "/v3/api-docs/**", "/api/stores/**", "/api/categories/**" diff --git a/spot-payment/src/main/resources/application.properties b/spot-payment/src/main/resources/application.properties index 0b48ba31..f7a1b63d 100644 --- a/spot-payment/src/main/resources/application.properties +++ b/spot-payment/src/main/resources/application.properties @@ -2,6 +2,6 @@ server.port=8084 spring.application.name=spot-payment # Feign Client URLs -feign.user.url=http://localhost:8081 -feign.store.url=http://localhost:8083 -feign.order.url=http://localhost:8082 +feign.user.url=http://spot-user:8081 +feign.store.url=http://spot-store:8083 +feign.order.url=http://spot-order:8082 diff --git a/spot-store/src/main/java/com/example/Spot/global/feign/UserClient.java b/spot-store/src/main/java/com/example/Spot/global/feign/UserClient.java index 64bd6761..e91c3ece 100644 --- a/spot-store/src/main/java/com/example/Spot/global/feign/UserClient.java +++ b/spot-store/src/main/java/com/example/Spot/global/feign/UserClient.java @@ -6,7 +6,7 @@ import com.example.Spot.global.feign.dto.UserResponse; -@FeignClient(name = "user-service", url = "${feign.user.url}") +@FeignClient(name = "spot-user", url = "${feign.user.url}") public interface UserClient { @GetMapping("/api/users/{userId}") diff --git a/spot-store/src/main/java/com/example/Spot/global/infrastructure/config/security/SecurityConfig.java b/spot-store/src/main/java/com/example/Spot/global/infrastructure/config/security/SecurityConfig.java index 41d50e32..3613a6bf 100644 --- a/spot-store/src/main/java/com/example/Spot/global/infrastructure/config/security/SecurityConfig.java +++ b/spot-store/src/main/java/com/example/Spot/global/infrastructure/config/security/SecurityConfig.java @@ -32,7 +32,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); http.authorizeHttpRequests(auth -> auth - .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() + .requestMatchers(HttpMethod.OPTIONS, "/**", "/actuator/**").permitAll() .requestMatchers( "/", "/swagger-ui/**", "/v3/api-docs/**" ).permitAll() diff --git a/spot-store/src/main/java/com/example/Spot/store/application/service/StoreService.java b/spot-store/src/main/java/com/example/Spot/store/application/service/StoreService.java index b23a1356..ce1e4543 100644 --- a/spot-store/src/main/java/com/example/Spot/store/application/service/StoreService.java +++ b/spot-store/src/main/java/com/example/Spot/store/application/service/StoreService.java @@ -12,7 +12,6 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import com.example.Spot.global.feign.UserClient; import com.example.Spot.global.presentation.advice.DuplicateResourceException; import com.example.Spot.menu.domain.entity.MenuEntity; import com.example.Spot.menu.domain.repository.MenuRepository; @@ -31,9 +30,6 @@ import com.example.Spot.store.presentation.dto.response.StoreDetailResponse; import com.example.Spot.store.presentation.dto.response.StoreListResponse; -import io.github.resilience4j.bulkhead.annotation.Bulkhead; -import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker; -import io.github.resilience4j.retry.annotation.Retry; import jakarta.persistence.EntityNotFoundException; import lombok.RequiredArgsConstructor; diff --git a/spot-store/src/main/java/com/example/Spot/store/application/service/UserCallService.java b/spot-store/src/main/java/com/example/Spot/store/application/service/UserCallService.java index a60b6116..407633f1 100644 --- a/spot-store/src/main/java/com/example/Spot/store/application/service/UserCallService.java +++ b/spot-store/src/main/java/com/example/Spot/store/application/service/UserCallService.java @@ -1,8 +1,9 @@ -package com.example.Spot.store.application.service; // 너희 패키지에 맞춰 +package com.example.Spot.store.application.service; -import com.example.Spot.global.feign.UserClient; import org.springframework.stereotype.Service; +import com.example.Spot.global.feign.UserClient; + import io.github.resilience4j.bulkhead.annotation.Bulkhead; import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker; import io.github.resilience4j.retry.annotation.Retry; diff --git a/spot-user/src/main/java/com/example/Spot/global/feign/OrderClient.java b/spot-user/src/main/java/com/example/Spot/global/feign/OrderClient.java index 6db702d6..376b42bc 100644 --- a/spot-user/src/main/java/com/example/Spot/global/feign/OrderClient.java +++ b/spot-user/src/main/java/com/example/Spot/global/feign/OrderClient.java @@ -7,7 +7,7 @@ import com.example.Spot.global.feign.dto.OrderPageResponse; import com.example.Spot.global.feign.dto.OrderStatsResponse; -@FeignClient(name = "order-service", url = "${feign.order.url}") +@FeignClient(name = "spot-order", url = "${feign.order.url}") public interface OrderClient { @GetMapping("/api/admin/orders") diff --git a/spot-user/src/main/java/com/example/Spot/global/feign/StoreAdminClient.java b/spot-user/src/main/java/com/example/Spot/global/feign/StoreAdminClient.java index 9468d198..72e2b9ec 100644 --- a/spot-user/src/main/java/com/example/Spot/global/feign/StoreAdminClient.java +++ b/spot-user/src/main/java/com/example/Spot/global/feign/StoreAdminClient.java @@ -12,7 +12,7 @@ import com.example.Spot.admin.presentation.dto.AdminStoreListResponseDto; import com.example.Spot.global.feign.dto.StorePageResponse; -@FeignClient(name = "store-service", contextId = "storeAdminClient", url = "${feign.store.url}") +@FeignClient(name = "spot-store", contextId = "storeAdminClient", url = "${feign.store.url}") public interface StoreAdminClient { @GetMapping("/api/internal/admin/stores") diff --git a/spot-user/src/main/java/com/example/Spot/global/feign/StoreClient.java b/spot-user/src/main/java/com/example/Spot/global/feign/StoreClient.java index 6b183b03..296acd0b 100644 --- a/spot-user/src/main/java/com/example/Spot/global/feign/StoreClient.java +++ b/spot-user/src/main/java/com/example/Spot/global/feign/StoreClient.java @@ -12,7 +12,7 @@ import com.example.Spot.global.feign.dto.StorePageResponse; import com.example.Spot.global.feign.dto.StoreResponse; -@FeignClient(name = "store-service", contextId = "storeClient", url = "${feign.store.url}") +@FeignClient(name = "spot-store", contextId = "storeClient", url = "${feign.store.url}") public interface StoreClient { @GetMapping("/api/stores") diff --git a/spot-user/src/main/java/com/example/Spot/global/infrastructure/config/security/SecurityConfig.java b/spot-user/src/main/java/com/example/Spot/global/infrastructure/config/security/SecurityConfig.java index 70c4a8be..4de5276a 100644 --- a/spot-user/src/main/java/com/example/Spot/global/infrastructure/config/security/SecurityConfig.java +++ b/spot-user/src/main/java/com/example/Spot/global/infrastructure/config/security/SecurityConfig.java @@ -68,7 +68,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { // 경로별 인가 작업 http .authorizeHttpRequests(auth -> auth - .requestMatchers("OPTIONS", "/**").permitAll() + .requestMatchers("OPTIONS", "/**", "/actuator/**").permitAll() // 누구나 접근 가능 (로그인, 회원가입, 토큰 갱신, 가게 조회, 카테고리 조회) .requestMatchers("/api/login", "/", "/api/join", "/api/auth/refresh", "/swagger-ui/*", "v3/api-docs", "/v3/api-docs/*", "/api/stores", "/api/stores/*", "/api/stores/search", "/api/categories", "/api/categories/**").permitAll() From b6098ce0a951ad0b6e6f6c08629b89d2d9ed0e97 Mon Sep 17 00:00:00 2001 From: dbswjd7 Date: Sun, 25 Jan 2026 23:11:23 +0900 Subject: [PATCH 69/77] =?UTF-8?q?feat(#221):=20spring=20gateway=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=20=EC=97=90=EB=9F=AC=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../example/Spot/SpotGatewayApplication.java | 13 ------- .../Spot/filter/GatewayFilterConfig.java | 35 ------------------- .../Spot/SpotGatewayApplicationTests.java | 13 ------- 3 files changed, 61 deletions(-) delete mode 100644 spot-gateway/src/main/java/com/example/Spot/SpotGatewayApplication.java delete mode 100644 spot-gateway/src/main/java/com/example/Spot/filter/GatewayFilterConfig.java delete mode 100644 spot-gateway/src/test/java/com/example/Spot/SpotGatewayApplicationTests.java diff --git a/spot-gateway/src/main/java/com/example/Spot/SpotGatewayApplication.java b/spot-gateway/src/main/java/com/example/Spot/SpotGatewayApplication.java deleted file mode 100644 index ca269de2..00000000 --- a/spot-gateway/src/main/java/com/example/Spot/SpotGatewayApplication.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.example.Spot; - -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; - -@SpringBootApplication(scanBasePackages = "com.example.Spot") -public class SpotGatewayApplication { - - public static void main(String[] args) { - SpringApplication.run(SpotGatewayApplication.class, args); - } - -} diff --git a/spot-gateway/src/main/java/com/example/Spot/filter/GatewayFilterConfig.java b/spot-gateway/src/main/java/com/example/Spot/filter/GatewayFilterConfig.java deleted file mode 100644 index f0ec6dda..00000000 --- a/spot-gateway/src/main/java/com/example/Spot/filter/GatewayFilterConfig.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.example.Spot.filter; - -import java.util.UUID; - -import org.springframework.cloud.gateway.filter.GlobalFilter; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -import reactor.core.publisher.Mono; - -@Configuration -public class GatewayFilterConfig { - - @Bean - public GlobalFilter requestIdFilter() { - return (exchange, chain) -> { - String rid = exchange.getRequest().getHeaders().getFirst("X-Request-Id"); - if (rid == null || rid.isBlank()) { - rid = UUID.randomUUID().toString(); - } - final String finalRequestId = rid; - - var mutated = exchange.mutate() - .request(exchange.getRequest().mutate() - .header("X-Request-Id", finalRequestId) - .build()) - .build(); - - return chain.filter(mutated) - .then(Mono.fromRunnable(() -> - mutated.getResponse().getHeaders().set("X-Request-Id", finalRequestId) - )); - }; - } -} \ No newline at end of file diff --git a/spot-gateway/src/test/java/com/example/Spot/SpotGatewayApplicationTests.java b/spot-gateway/src/test/java/com/example/Spot/SpotGatewayApplicationTests.java deleted file mode 100644 index 66a66588..00000000 --- a/spot-gateway/src/test/java/com/example/Spot/SpotGatewayApplicationTests.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.example.Spot; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -class SpotGatewayApplicationTests { - - @Test - void contextLoads() { - } - -} From 971a1318489aa0ca7d464da4ff1b4a7e2a9b2d7c Mon Sep 17 00:00:00 2001 From: dbswjd7 Date: Mon, 26 Jan 2026 15:09:34 +0900 Subject: [PATCH 70/77] =?UTF-8?q?feat(#224):=20gateway=20=EB=B3=B5?= =?UTF-8?q?=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- spot-gateway/build.gradle | 1 - .../example/Spot/SpotGatewayApplication.java | 13 +++++++ .../Spot/filter/GatewayFilterConfig.java | 35 +++++++++++++++++++ .../Spot/SpotGatewayApplicationTests.java | 13 +++++++ 4 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 spot-gateway/src/main/java/com/example/Spot/SpotGatewayApplication.java create mode 100644 spot-gateway/src/main/java/com/example/Spot/filter/GatewayFilterConfig.java create mode 100644 spot-gateway/src/test/java/com/example/Spot/SpotGatewayApplicationTests.java diff --git a/spot-gateway/build.gradle b/spot-gateway/build.gradle index fb0dffca..131eda53 100644 --- a/spot-gateway/build.gradle +++ b/spot-gateway/build.gradle @@ -11,5 +11,4 @@ dependencies { implementation "org.springframework.cloud:spring-cloud-starter-gateway-server-webflux:4.3.3" testImplementation "org.springframework.boot:spring-boot-starter-test" testImplementation "org.hamcrest:hamcrest:2.2" - implementation 'org.springframework.boot:spring-boot-starter-actuator' } diff --git a/spot-gateway/src/main/java/com/example/Spot/SpotGatewayApplication.java b/spot-gateway/src/main/java/com/example/Spot/SpotGatewayApplication.java new file mode 100644 index 00000000..ca269de2 --- /dev/null +++ b/spot-gateway/src/main/java/com/example/Spot/SpotGatewayApplication.java @@ -0,0 +1,13 @@ +package com.example.Spot; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication(scanBasePackages = "com.example.Spot") +public class SpotGatewayApplication { + + public static void main(String[] args) { + SpringApplication.run(SpotGatewayApplication.class, args); + } + +} diff --git a/spot-gateway/src/main/java/com/example/Spot/filter/GatewayFilterConfig.java b/spot-gateway/src/main/java/com/example/Spot/filter/GatewayFilterConfig.java new file mode 100644 index 00000000..f0ec6dda --- /dev/null +++ b/spot-gateway/src/main/java/com/example/Spot/filter/GatewayFilterConfig.java @@ -0,0 +1,35 @@ +package com.example.Spot.filter; + +import java.util.UUID; + +import org.springframework.cloud.gateway.filter.GlobalFilter; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import reactor.core.publisher.Mono; + +@Configuration +public class GatewayFilterConfig { + + @Bean + public GlobalFilter requestIdFilter() { + return (exchange, chain) -> { + String rid = exchange.getRequest().getHeaders().getFirst("X-Request-Id"); + if (rid == null || rid.isBlank()) { + rid = UUID.randomUUID().toString(); + } + final String finalRequestId = rid; + + var mutated = exchange.mutate() + .request(exchange.getRequest().mutate() + .header("X-Request-Id", finalRequestId) + .build()) + .build(); + + return chain.filter(mutated) + .then(Mono.fromRunnable(() -> + mutated.getResponse().getHeaders().set("X-Request-Id", finalRequestId) + )); + }; + } +} \ No newline at end of file diff --git a/spot-gateway/src/test/java/com/example/Spot/SpotGatewayApplicationTests.java b/spot-gateway/src/test/java/com/example/Spot/SpotGatewayApplicationTests.java new file mode 100644 index 00000000..66a66588 --- /dev/null +++ b/spot-gateway/src/test/java/com/example/Spot/SpotGatewayApplicationTests.java @@ -0,0 +1,13 @@ +package com.example.Spot; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class SpotGatewayApplicationTests { + + @Test + void contextLoads() { + } + +} From 8d651fbb067502f5c72c84ba5fcb29fe43c1ad90 Mon Sep 17 00:00:00 2001 From: dbswjd7 Date: Mon, 26 Jan 2026 15:23:39 +0900 Subject: [PATCH 71/77] feat(#224): spring gateway + terraform --- infra/terraform/environments/dev/main.tf | 80 ++-- infra/terraform/environments/dev/provider.tf | 26 +- infra/terraform/environments/dev/variables.tf | 28 +- infra/terraform/environments/prod/main.tf | 295 +++++++++++++-- .../terraform/environments/prod/variables.tf | 351 ++++++++++++++++-- infra/terraform/modules/alb/main.tf | 186 +++++++--- infra/terraform/modules/alb/outputs.tf | 25 +- infra/terraform/modules/alb/variables.tf | 30 ++ infra/terraform/modules/api-gateway/main.tf | 199 ++++++++-- .../terraform/modules/api-gateway/outputs.tf | 23 ++ .../modules/api-gateway/variables.tf | 39 +- .../modules/cognito/.terraform.lock.hcl | 24 -- .../modules/cognito/lambda/post_confirm.py | 60 --- .../modules/cognito/lambda/pre_token.py | 36 -- infra/terraform/modules/cognito/main.tf | 165 -------- infra/terraform/modules/cognito/output.tf | 11 - infra/terraform/modules/cognito/variable.tf | 13 - infra/terraform/modules/database/main.tf | 120 +++++- infra/terraform/modules/database/outputs.tf | 23 ++ infra/terraform/modules/database/variables.tf | 63 ++++ infra/terraform/modules/ecs/codedeploy.tf | 93 +++++ infra/terraform/modules/ecs/main.tf | 260 ++++++++++--- infra/terraform/modules/ecs/outputs.tf | 13 + infra/terraform/modules/ecs/variables.tf | 92 +++-- infra/terraform/modules/kafka/main.tf | 218 +++++++++++ infra/terraform/modules/kafka/outputs.tf | 39 ++ .../modules/kafka/user-data-cluster.sh | 84 +++++ infra/terraform/modules/kafka/user-data.sh | 60 +++ infra/terraform/modules/kafka/variables.tf | 112 ++++++ infra/terraform/modules/network/main.tf | 78 +++- infra/terraform/modules/network/outputs.tf | 28 ++ infra/terraform/modules/network/variables.tf | 15 + .../terraform/modules/parameter-store/main.tf | 97 +++++ .../modules/parameter-store/outputs.tf | 59 +++ .../modules/parameter-store/variables.tf | 62 ++++ spot-gateway/build.gradle | 6 +- 36 files changed, 2534 insertions(+), 579 deletions(-) delete mode 100644 infra/terraform/modules/cognito/.terraform.lock.hcl delete mode 100644 infra/terraform/modules/cognito/lambda/post_confirm.py delete mode 100644 infra/terraform/modules/cognito/lambda/pre_token.py delete mode 100644 infra/terraform/modules/cognito/main.tf delete mode 100644 infra/terraform/modules/cognito/output.tf delete mode 100644 infra/terraform/modules/cognito/variable.tf create mode 100644 infra/terraform/modules/ecs/codedeploy.tf create mode 100644 infra/terraform/modules/kafka/main.tf create mode 100644 infra/terraform/modules/kafka/outputs.tf create mode 100644 infra/terraform/modules/kafka/user-data-cluster.sh create mode 100644 infra/terraform/modules/kafka/user-data.sh create mode 100644 infra/terraform/modules/kafka/variables.tf create mode 100644 infra/terraform/modules/parameter-store/main.tf create mode 100644 infra/terraform/modules/parameter-store/outputs.tf create mode 100644 infra/terraform/modules/parameter-store/variables.tf diff --git a/infra/terraform/environments/dev/main.tf b/infra/terraform/environments/dev/main.tf index 46656434..1cb06bfa 100644 --- a/infra/terraform/environments/dev/main.tf +++ b/infra/terraform/environments/dev/main.tf @@ -93,31 +93,41 @@ module "ecs" { services = var.services enable_service_connect = var.enable_service_connect + standby_mode = var.standby_mode # Database 연결 정보 db_endpoint = module.database.endpoint db_name = var.db_name db_username = var.db_username - db_password = var.db_password # Redis 연결 정보 redis_endpoint = module.elasticache.redis_endpoint - # JWT 설정 - jwt_secret = var.jwt_secret + # Kafka 연결 정보 + kafka_bootstrap_servers = module.kafka.bootstrap_servers + + # Parameter Store ARNs (민감 정보 주입) + parameter_arns = { + db_password = module.parameters.db_password_arn + jwt_secret = module.parameters.jwt_secret_arn + mail_password = module.parameters.mail_password_arn + toss_secret_key = module.parameters.toss_secret_key_arn + } + + # JWT 설정 (비민감 정보) jwt_expire_ms = var.jwt_expire_ms refresh_token_expire_days = var.refresh_token_expire_days - # Mail 설정 + # Mail 설정 (비민감 정보) mail_username = var.mail_username - mail_password = var.mail_password - # Toss 결제 설정 - toss_secret_key = var.toss_secret_key + # Toss 결제 설정 (비민감 정보) toss_customer_key = var.toss_customer_key # 서비스 설정 service_active_regions = var.service_active_regions + + depends_on = [module.parameters] } # ============================================================================= @@ -131,9 +141,6 @@ module "api_gateway" { subnet_ids = module.network.private_subnet_ids ecs_security_group_id = module.ecs.security_group_id alb_listener_arn = module.alb.listener_arn - - cognito_issuer = module.cognito.cognito_issuer_url - cognito_audience = module.cognito.cognito_app_client_id } # ============================================================================= @@ -191,6 +198,47 @@ module "elasticache" { engine_version = var.redis_engine_version } +# ============================================================================= +# Kafka (EC2 - KRaft Mode) +# ============================================================================= +module "kafka" { + source = "../../modules/kafka" + + name_prefix = local.name_prefix + common_tags = local.common_tags + vpc_id = module.network.vpc_id + vpc_cidr = module.network.vpc_cidr + subnet_id = module.network.public_subnet_a_id # NAT 문제로 public 사용 + allowed_security_group_ids = [module.ecs.security_group_id] + assign_public_ip = true + + instance_type = var.kafka_instance_type + volume_size = var.kafka_volume_size + log_retention_hours = var.kafka_log_retention_hours +} + +# ============================================================================= +# Parameter Store (Secrets & Dynamic Infrastructure Values) +# ============================================================================= +module "parameters" { + source = "../../modules/parameter-store" + + project = var.project + environment = var.environment + common_tags = local.common_tags + + # 민감 정보 (SecureString) + db_password = var.db_password + jwt_secret = var.jwt_secret + mail_password = var.mail_password + toss_secret_key = var.toss_secret_key + + # 동적 인프라 값 (RDS만 - Redis는 순환 의존성 방지를 위해 제외) + db_endpoint = module.database.endpoint + + depends_on = [module.database] +} + # ============================================================================= # CloudWatch Monitoring (Updated for MSA) # ============================================================================= @@ -214,15 +262,3 @@ module "monitoring" { # Redis 모니터링 (선택) redis_cluster_id = "${local.name_prefix}-redis-001" } - - -# ============================================================================= -# Cognito -# ============================================================================= -module "cognito" { - source = "../../modules/cognito" - - aws_region = var.region - name_prefix = local.name_prefix - user_service_url = var.user_service_url -} \ No newline at end of file diff --git a/infra/terraform/environments/dev/provider.tf b/infra/terraform/environments/dev/provider.tf index 5e14f7d0..a6e68b57 100644 --- a/infra/terraform/environments/dev/provider.tf +++ b/infra/terraform/environments/dev/provider.tf @@ -6,6 +6,10 @@ terraform { source = "hashicorp/aws" version = "~> 5.0" } + # postgresql = { + # source = "cyrilgdn/postgresql" + # version = "~> 1.21" + # } } # 원격 상태 저장소 (팀 협업 시 활성화) @@ -26,15 +30,15 @@ provider "aws" { } } -provider "postgresql" { - host = var.db_endpoint - username = var.db_username - password = var.db_password - database = var.db_name - sslmode = "require" -} +# provider "postgresql" { +# host = var.db_endpoint +# username = var.db_username +# password = var.db_password +# database = var.db_name +# sslmode = "require" +# } -resource "postgresql_schema" "users" { - name = "users" # var.services["user"].environment_vars["DB_SCHEMA"] 값과 일치해야 함 - owner = var.db_username -} \ No newline at end of file +# resource "postgresql_schema" "users" { +# name = "users" # var.services["user"].environment_vars["DB_SCHEMA"] 값과 일치해야 함 +# owner = var.db_username +# } \ No newline at end of file diff --git a/infra/terraform/environments/dev/variables.tf b/infra/terraform/environments/dev/variables.tf index 1df19217..09471ef0 100644 --- a/infra/terraform/environments/dev/variables.tf +++ b/infra/terraform/environments/dev/variables.tf @@ -321,9 +321,31 @@ variable "service_active_regions" { } # ============================================================================= -# Cognito +# Standby Mode (비용 절감) # ============================================================================= +variable "standby_mode" { + description = "스탠바이 모드 (true면 모든 서비스 desired_count = 0)" + type = bool + default = false +} + +# ============================================================================= +# Kafka 설정 +# ============================================================================= +variable "kafka_instance_type" { + description = "Kafka EC2 인스턴스 타입" + type = string + default = "t3.small" +} + +variable "kafka_volume_size" { + description = "Kafka EBS 볼륨 크기 (GB)" + type = number + default = 20 +} -variable "user_service_url" { - type = string +variable "kafka_log_retention_hours" { + description = "Kafka 메시지 보관 시간" + type = number + default = 168 # 7일 } diff --git a/infra/terraform/environments/prod/main.tf b/infra/terraform/environments/prod/main.tf index 0554ce4a..8189ce33 100644 --- a/infra/terraform/environments/prod/main.tf +++ b/infra/terraform/environments/prod/main.tf @@ -1,5 +1,14 @@ # ============================================================================= -# Network +# Spot Production Environment +# ============================================================================= + +# ============================================================================= +# Data Sources +# ============================================================================= +data "aws_caller_identity" "current" {} + +# ============================================================================= +# Network (with NAT Gateway) # ============================================================================= module "network" { source = "../../modules/network" @@ -11,78 +20,145 @@ module "network" { private_subnet_cidrs = var.private_subnet_cidrs availability_zones = var.availability_zones nat_instance_type = var.nat_instance_type + + # Production: NAT Gateway with Elastic IP + use_nat_gateway = var.use_nat_gateway + single_nat_gateway = var.single_nat_gateway } # ============================================================================= -# Database +# Database (Multi-AZ with Read Replica) # ============================================================================= module "database" { source = "../../modules/database" - name_prefix = local.name_prefix - common_tags = local.common_tags - vpc_id = module.network.vpc_id - vpc_cidr = module.network.vpc_cidr - subnet_ids = module.network.private_subnet_ids - db_name = var.db_name - username = var.db_username - password = var.db_password - instance_class = var.db_instance_class - allocated_storage = var.db_allocated_storage - engine_version = var.db_engine_version + name_prefix = local.name_prefix + common_tags = local.common_tags + vpc_id = module.network.vpc_id + vpc_cidr = module.network.vpc_cidr + subnet_ids = module.network.private_subnet_ids + db_name = var.db_name + username = var.db_username + password = var.db_password + instance_class = var.db_instance_class + allocated_storage = var.db_allocated_storage + engine_version = var.db_engine_version + max_allocated_storage = var.db_allocated_storage * 2 + + # Production settings + multi_az = var.db_multi_az + create_read_replica = var.db_create_read_replica + backup_retention_period = var.db_backup_retention_period + deletion_protection = var.db_deletion_protection + performance_insights_enabled = var.db_performance_insights + monitoring_interval = var.db_monitoring_interval } # ============================================================================= -# ECR +# ECR (Multiple Repositories) # ============================================================================= module "ecr" { source = "../../modules/ecr" - project = var.project - name_prefix = local.name_prefix - common_tags = local.common_tags + project = var.project + name_prefix = local.name_prefix + common_tags = local.common_tags + service_names = toset(keys(var.services)) } # ============================================================================= -# ALB +# ALB (Direct routing to microservices) # ============================================================================= module "alb" { source = "../../modules/alb" - name_prefix = local.name_prefix - common_tags = local.common_tags - vpc_id = module.network.vpc_id - vpc_cidr = module.network.vpc_cidr - subnet_ids = module.network.private_subnet_ids - container_port = var.container_port - health_check_path = var.health_check_path + name_prefix = local.name_prefix + common_tags = local.common_tags + vpc_id = module.network.vpc_id + vpc_cidr = module.network.vpc_cidr + subnet_ids = module.network.private_subnet_ids + + services = { + for k, v in var.services : k => { + container_port = v.container_port + health_check_path = v.health_check_path + path_patterns = v.path_patterns + priority = v.priority + } + } + + # Production settings + enable_https = var.enable_https + certificate_arn = var.certificate_arn + enable_blue_green = var.enable_blue_green } # ============================================================================= -# ECS +# ECS (Without Gateway + Blue/Green Deployment) # ============================================================================= module "ecs" { source = "../../modules/ecs" project = var.project + environment = var.environment name_prefix = local.name_prefix common_tags = local.common_tags region = var.region vpc_id = module.network.vpc_id - subnet_ids = module.network.private_subnet_ids # prod는 private subnet 사용 - ecr_repository_url = module.ecr.repository_url + subnet_ids = module.network.private_subnet_ids + ecr_repository_urls = module.ecr.repository_urls alb_security_group_id = module.alb.security_group_id - target_group_arn = module.alb.target_group_arn - alb_listener_arn = module.alb.listener_arn - container_port = var.container_port - cpu = var.ecs_cpu - memory = var.ecs_memory - desired_count = var.ecs_desired_count - assign_public_ip = false # prod는 private + target_group_arns = module.alb.target_group_arns + target_group_names = module.alb.target_group_names + alb_listener_arn = var.enable_https && var.certificate_arn != null ? module.alb.https_listener_arn : module.alb.listener_arn + assign_public_ip = false + + services = var.services + excluded_services = [] + enable_service_connect = var.enable_service_connect + standby_mode = var.standby_mode + + # Blue/Green Deployment + enable_blue_green = var.enable_blue_green + green_target_group_arns = module.alb.green_target_group_arns + + # Database + db_endpoint = module.database.endpoint + db_name = var.db_name + db_username = var.db_username + + # Redis + redis_endpoint = module.elasticache.redis_endpoint + + # Kafka + kafka_bootstrap_servers = module.kafka.bootstrap_servers + + # Parameter Store ARNs + parameter_arns = { + db_password = module.parameters.db_password_arn + jwt_secret = module.parameters.jwt_secret_arn + mail_password = module.parameters.mail_password_arn + toss_secret_key = module.parameters.toss_secret_key_arn + } + + # JWT Settings + jwt_expire_ms = var.jwt_expire_ms + refresh_token_expire_days = var.refresh_token_expire_days + + # Mail Settings + mail_username = var.mail_username + + # Toss Settings + toss_customer_key = var.toss_customer_key + + # Service Settings + service_active_regions = var.service_active_regions + + depends_on = [module.parameters] } # ============================================================================= -# API Gateway +# API Gateway (with Cognito Authentication) # ============================================================================= module "api_gateway" { source = "../../modules/api-gateway" @@ -91,5 +167,150 @@ module "api_gateway" { common_tags = local.common_tags subnet_ids = module.network.private_subnet_ids ecs_security_group_id = module.ecs.security_group_id - alb_listener_arn = module.alb.listener_arn + alb_listener_arn = var.enable_https && var.certificate_arn != null ? module.alb.https_listener_arn : module.alb.listener_arn + + # Cognito Authentication + enable_cognito = var.enable_cognito + cognito_user_pool_name = "${local.name_prefix}-user-pool" + cognito_callback_urls = var.cognito_callback_urls + cognito_logout_urls = var.cognito_logout_urls + + public_routes = [ + "/api/auth/*", + "/api/login", + "/api/join", + "/health", + "/actuator/health" + ] + + protected_route_patterns = [ + "/api/users/*", + "/api/orders/*", + "/api/stores/*", + "/api/payments/*", + "/api/admin/*" + ] +} + +# ============================================================================= +# DNS (Route 53 + ACM) +# ============================================================================= +module "dns" { + source = "../../modules/dns" + + name_prefix = local.name_prefix + common_tags = local.common_tags + domain_name = var.domain_name + create_api_domain = var.create_api_domain + api_gateway_id = module.api_gateway.api_id +} + +# ============================================================================= +# WAF (Web Application Firewall) +# ============================================================================= +module "waf" { + source = "../../modules/waf" + + name_prefix = local.name_prefix + common_tags = local.common_tags + api_gateway_stage_arn = module.api_gateway.stage_arn + rate_limit = var.waf_rate_limit +} + +# ============================================================================= +# S3 (Static files / Logs) +# ============================================================================= +module "s3" { + source = "../../modules/s3" + + name_prefix = local.name_prefix + common_tags = local.common_tags + account_id = data.aws_caller_identity.current.account_id + region = var.region + log_transition_days = var.s3_log_transition_days + log_expiration_days = var.s3_log_expiration_days +} + +# ============================================================================= +# ElastiCache (Redis with Replication) +# ============================================================================= +module "elasticache" { + source = "../../modules/elasticache" + + name_prefix = local.name_prefix + common_tags = local.common_tags + vpc_id = module.network.vpc_id + subnet_ids = module.network.private_subnet_ids + allowed_security_group_ids = [module.ecs.security_group_id] + node_type = var.redis_node_type + num_cache_clusters = var.redis_num_cache_clusters + engine_version = var.redis_engine_version +} + +# ============================================================================= +# Kafka (3-Broker KRaft Cluster) +# ============================================================================= +module "kafka" { + source = "../../modules/kafka" + + name_prefix = local.name_prefix + common_tags = local.common_tags + vpc_id = module.network.vpc_id + vpc_cidr = module.network.vpc_cidr + subnet_ids = module.network.private_subnet_ids + allowed_security_group_ids = [module.ecs.security_group_id] + assign_public_ip = false + + # 3-Broker Cluster + broker_count = var.kafka_broker_count + instance_type = var.kafka_instance_type + volume_size = var.kafka_volume_size + log_retention_hours = var.kafka_log_retention_hours + create_private_dns = true +} + +# ============================================================================= +# Parameter Store (Secrets) +# ============================================================================= +module "parameters" { + source = "../../modules/parameter-store" + + project = var.project + environment = var.environment + common_tags = local.common_tags + + # Sensitive data (SecureString) + db_password = var.db_password + jwt_secret = var.jwt_secret + mail_password = var.mail_password + toss_secret_key = var.toss_secret_key + + # Dynamic infrastructure values + db_endpoint = module.database.endpoint + + depends_on = [module.database] +} + +# ============================================================================= +# CloudWatch Monitoring +# ============================================================================= +module "monitoring" { + source = "../../modules/monitoring" + + name_prefix = local.name_prefix + common_tags = local.common_tags + alert_email = var.alert_email + + # ECS Monitoring + ecs_cluster_name = module.ecs.cluster_name + ecs_service_name = module.ecs.service_names["user"] + + # RDS Monitoring + rds_instance_id = module.database.instance_id + + # ALB Monitoring + alb_arn_suffix = module.alb.arn_suffix + + # Redis Monitoring + redis_cluster_id = "${local.name_prefix}-redis-001" } diff --git a/infra/terraform/environments/prod/variables.tf b/infra/terraform/environments/prod/variables.tf index 4c8f08da..c59f962b 100644 --- a/infra/terraform/environments/prod/variables.tf +++ b/infra/terraform/environments/prod/variables.tf @@ -1,5 +1,5 @@ # ============================================================================= -# 프로젝트 기본 설정 +# Project Settings # ============================================================================= variable "project" { description = "프로젝트 이름" @@ -20,12 +20,12 @@ variable "region" { } # ============================================================================= -# 네트워크 설정 +# Network Settings # ============================================================================= variable "vpc_cidr" { description = "VPC CIDR 블록" type = string - default = "10.1.0.0/16" # prod는 다른 CIDR + default = "10.1.0.0/16" } variable "public_subnet_cidrs" { @@ -33,6 +33,7 @@ variable "public_subnet_cidrs" { type = map(string) default = { "a" = "10.1.1.0/24" + "c" = "10.1.2.0/24" } } @@ -54,14 +55,27 @@ variable "availability_zones" { } } +# NAT Gateway (Production) +variable "use_nat_gateway" { + description = "NAT Gateway 사용 (true) vs NAT Instance (false)" + type = bool + default = true +} + +variable "single_nat_gateway" { + description = "단일 NAT Gateway 사용 (비용 절감 vs HA)" + type = bool + default = false # Production: AZ별 NAT Gateway for HA +} + variable "nat_instance_type" { - description = "NAT Instance 타입" + description = "NAT Instance 타입 (NAT Gateway 미사용시)" type = string - default = "t3.micro" # prod는 더 큰 타입 + default = "t3.micro" } # ============================================================================= -# 데이터베이스 설정 +# Database Settings # ============================================================================= variable "db_name" { description = "데이터베이스 이름" @@ -84,13 +98,13 @@ variable "db_password" { variable "db_instance_class" { description = "RDS 인스턴스 클래스" type = string - default = "db.t3.small" # prod는 더 큰 타입 + default = "db.t3.small" } variable "db_allocated_storage" { description = "RDS 스토리지 크기 (GB)" type = number - default = 50 # prod는 더 큰 용량 + default = 50 } variable "db_engine_version" { @@ -99,38 +113,325 @@ variable "db_engine_version" { default = "16" } +# Production Database Settings +variable "db_multi_az" { + description = "Multi-AZ 배포" + type = bool + default = true +} + +variable "db_create_read_replica" { + description = "Read Replica 생성" + type = bool + default = true +} + +variable "db_backup_retention_period" { + description = "백업 보관 기간 (일)" + type = number + default = 14 +} + +variable "db_deletion_protection" { + description = "삭제 보호" + type = bool + default = true +} + +variable "db_performance_insights" { + description = "Performance Insights 활성화" + type = bool + default = true +} + +variable "db_monitoring_interval" { + description = "Enhanced Monitoring 간격 (초)" + type = number + default = 60 +} + +# ============================================================================= +# Services Configuration (MSA - Gateway 제외) +# ============================================================================= +variable "services" { + description = "MSA 서비스 구성 맵 (Gateway 제외)" + type = map(object({ + container_port = number + cpu = string + memory = string + desired_count = number + health_check_path = string + path_patterns = list(string) + priority = number + environment_vars = map(string) + })) + default = { + "user" = { + container_port = 8081 + cpu = "512" + memory = "1024" + desired_count = 2 + health_check_path = "/actuator/health" + path_patterns = ["/api/users/*", "/api/users", "/api/auth/*", "/api/admin/*", "/api/login", "/api/join"] + priority = 100 + environment_vars = { + SERVICE_NAME = "spot-user" + DB_SCHEMA = "users" + } + } + "order" = { + container_port = 8082 + cpu = "512" + memory = "1024" + desired_count = 2 + health_check_path = "/actuator/health" + path_patterns = ["/api/orders/*", "/api/orders"] + priority = 200 + environment_vars = { + SERVICE_NAME = "spot-order" + DB_SCHEMA = "orders" + } + } + "store" = { + container_port = 8083 + cpu = "512" + memory = "1024" + desired_count = 2 + health_check_path = "/actuator/health" + path_patterns = ["/api/stores/*", "/api/stores", "/api/categories/*", "/api/reviews/*", "/api/menus/*"] + priority = 300 + environment_vars = { + SERVICE_NAME = "spot-store" + DB_SCHEMA = "stores" + } + } + "payment" = { + container_port = 8084 + cpu = "512" + memory = "1024" + desired_count = 2 + health_check_path = "/actuator/health" + path_patterns = ["/api/payments/*", "/api/payments"] + priority = 400 + environment_vars = { + SERVICE_NAME = "spot-payment" + DB_SCHEMA = "payments" + } + } + } +} + +variable "enable_service_connect" { + description = "ECS Service Connect 활성화" + type = bool + default = true +} + +variable "standby_mode" { + description = "스탠바이 모드 (모든 서비스 desired_count = 0)" + type = bool + default = false +} + # ============================================================================= -# ECS 설정 +# Blue/Green Deployment # ============================================================================= -variable "ecs_cpu" { - description = "ECS Task CPU" +variable "enable_blue_green" { + description = "Blue/Green 배포 활성화 (CodeDeploy)" + type = bool + default = true +} + +# ============================================================================= +# Kafka Settings (3-Broker Cluster) +# ============================================================================= +variable "kafka_broker_count" { + description = "Kafka 브로커 수" + type = number + default = 3 +} + +variable "kafka_instance_type" { + description = "Kafka EC2 인스턴스 타입" + type = string + default = "t3.small" +} + +variable "kafka_volume_size" { + description = "Kafka EBS 볼륨 크기 (GB)" + type = number + default = 50 +} + +variable "kafka_log_retention_hours" { + description = "Kafka 메시지 보관 시간" + type = number + default = 168 +} + +# ============================================================================= +# Redis Settings +# ============================================================================= +variable "redis_node_type" { + description = "ElastiCache 노드 타입" type = string - default = "512" # prod는 더 큰 사양 + default = "cache.t3.small" } -variable "ecs_memory" { - description = "ECS Task Memory" +variable "redis_num_cache_clusters" { + description = "Redis 클러스터 수 (Replication Group)" + type = number + default = 2 +} + +variable "redis_engine_version" { + description = "Redis 엔진 버전" type = string - default = "1024" # prod는 더 큰 사양 + default = "7.1" +} + +# ============================================================================= +# API Gateway + Cognito +# ============================================================================= +variable "enable_cognito" { + description = "Cognito 인증 활성화" + type = bool + default = true +} + +variable "cognito_callback_urls" { + description = "Cognito OAuth 콜백 URL" + type = list(string) + default = ["https://localhost:3000/callback"] +} + +variable "cognito_logout_urls" { + description = "Cognito 로그아웃 URL" + type = list(string) + default = ["https://localhost:3000"] } -variable "ecs_desired_count" { - description = "ECS 희망 태스크 수" +# ============================================================================= +# HTTPS Settings +# ============================================================================= +variable "enable_https" { + description = "HTTPS 활성화" + type = bool + default = false +} + +variable "certificate_arn" { + description = "ACM 인증서 ARN" + type = string + default = null +} + +# ============================================================================= +# JWT Settings +# ============================================================================= +variable "jwt_secret" { + description = "JWT 시크릿 키" + type = string + sensitive = true +} + +variable "jwt_expire_ms" { + description = "JWT 만료 시간 (밀리초)" + type = number + default = 3600000 +} + +variable "refresh_token_expire_days" { + description = "리프레시 토큰 만료 일수" + type = number + default = 14 +} + +# ============================================================================= +# Mail Settings +# ============================================================================= +variable "mail_username" { + description = "SMTP 사용자 이름" + type = string + default = "" +} + +variable "mail_password" { + description = "SMTP 비밀번호" + type = string + sensitive = true + default = "" +} + +# ============================================================================= +# Toss Payments Settings +# ============================================================================= +variable "toss_customer_key" { + description = "Toss Payments 고객 키" + type = string + default = "customer_1" +} + +variable "toss_secret_key" { + description = "Toss Payments 시크릿 키" + type = string + sensitive = true + default = "" +} + +# ============================================================================= +# Service Settings +# ============================================================================= +variable "service_active_regions" { + description = "서비스 활성 지역" + type = string + default = "종로구" +} + +# ============================================================================= +# DNS Settings +# ============================================================================= +variable "domain_name" { + description = "도메인 이름" + type = string + default = "" +} + +variable "create_api_domain" { + description = "API 도메인 생성 여부" + type = bool + default = false +} + +# ============================================================================= +# WAF Settings +# ============================================================================= +variable "waf_rate_limit" { + description = "WAF 요청 제한 (5분당)" + type = number + default = 2000 +} + +# ============================================================================= +# S3 Settings +# ============================================================================= +variable "s3_log_transition_days" { + description = "S3 로그 Glacier 전환 일수" type = number - default = 2 # prod는 고가용성 + default = 30 } -variable "container_port" { - description = "컨테이너 포트" +variable "s3_log_expiration_days" { + description = "S3 로그 만료 일수" type = number - default = 8080 + default = 365 } # ============================================================================= -# ALB 설정 +# Monitoring Settings # ============================================================================= -variable "health_check_path" { - description = "헬스체크 경로" +variable "alert_email" { + description = "알림 이메일" type = string - default = "/health" + default = "" } diff --git a/infra/terraform/modules/alb/main.tf b/infra/terraform/modules/alb/main.tf index 95571321..8b6c2f68 100644 --- a/infra/terraform/modules/alb/main.tf +++ b/infra/terraform/modules/alb/main.tf @@ -1,27 +1,38 @@ - # ============================================================================= - # ALB Security Group - # ============================================================================= - resource "aws_security_group" "alb_sg" { - name = "${var.name_prefix}-alb-sg" - vpc_id = var.vpc_id +# ============================================================================= +# ALB Security Group +# ============================================================================= +resource "aws_security_group" "alb_sg" { + name = "${var.name_prefix}-alb-sg" + vpc_id = var.vpc_id + + ingress { + from_port = 80 + to_port = 80 + protocol = "tcp" + cidr_blocks = [var.vpc_cidr] + } - ingress { - from_port = 80 - to_port = 80 + # HTTPS 인바운드 (Production) + dynamic "ingress" { + for_each = var.enable_https ? [1] : [] + content { + from_port = 443 + to_port = 443 protocol = "tcp" cidr_blocks = [var.vpc_cidr] } + } - egress { - from_port = 0 - to_port = 0 - protocol = "-1" - cidr_blocks = ["0.0.0.0/0"] - } - - tags = merge(var.common_tags, { Name = "${var.name_prefix}-alb-sg" }) + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] } + tags = merge(var.common_tags, { Name = "${var.name_prefix}-alb-sg" }) +} + # ============================================================================= # Application Load Balancer (Internal) # ============================================================================= @@ -63,43 +74,130 @@ }) } - # ============================================================================= - # ALB Listener (Default action returns 404) - # ============================================================================= - resource "aws_lb_listener" "main" { - load_balancer_arn = aws_lb.main.arn - port = 80 - protocol = "HTTP" - - default_action { - type = "fixed-response" - fixed_response { +# ============================================================================= +# Green Target Groups (for Blue/Green Deployment) +# ============================================================================= +resource "aws_lb_target_group" "services_green" { + for_each = var.enable_blue_green ? var.services : {} + + name = "${var.name_prefix}-${each.key}-tg-g" + port = each.value.container_port + protocol = "HTTP" + vpc_id = var.vpc_id + target_type = "ip" + + health_check { + enabled = true + healthy_threshold = 2 + unhealthy_threshold = 3 + timeout = 10 + interval = 30 + path = each.value.health_check_path + matcher = "200" + } + + tags = merge(var.common_tags, { + Name = "${var.name_prefix}-${each.key}-tg-green" + Service = each.key + Color = "green" + }) +} + +# ============================================================================= +# ALB Listener - HTTP (Default action returns 404 or redirects to HTTPS) +# ============================================================================= +resource "aws_lb_listener" "main" { + load_balancer_arn = aws_lb.main.arn + port = 80 + protocol = "HTTP" + + default_action { + type = var.enable_https && var.certificate_arn != null ? "redirect" : "fixed-response" + + dynamic "redirect" { + for_each = var.enable_https && var.certificate_arn != null ? [1] : [] + content { + port = "443" + protocol = "HTTPS" + status_code = "HTTP_301" + } + } + + dynamic "fixed_response" { + for_each = var.enable_https && var.certificate_arn != null ? [] : [1] + content { content_type = "application/json" message_body = jsonencode({ error = "Not Found", message = "No matching route" }) status_code = "404" } } } +} + +# ============================================================================= +# ALB Listener - HTTPS (Production) +# ============================================================================= +resource "aws_lb_listener" "https" { + count = var.enable_https && var.certificate_arn != null ? 1 : 0 + + load_balancer_arn = aws_lb.main.arn + port = 443 + protocol = "HTTPS" + ssl_policy = var.ssl_policy + certificate_arn = var.certificate_arn + + default_action { + type = "fixed-response" + fixed_response { + content_type = "application/json" + message_body = jsonencode({ error = "Not Found", message = "No matching route" }) + status_code = "404" + } + } +} - # ============================================================================= - # ALB Listener Rules (Path-based Routing) - # ============================================================================= - resource "aws_lb_listener_rule" "services" { - for_each = var.services +# ============================================================================= +# ALB Listener Rules - HTTP (Path-based Routing) +# ============================================================================= +resource "aws_lb_listener_rule" "services" { + for_each = var.enable_https && var.certificate_arn != null ? {} : var.services - listener_arn = aws_lb_listener.main.arn - priority = each.value.priority + listener_arn = aws_lb_listener.main.arn + priority = each.value.priority - action { - type = "forward" - target_group_arn = aws_lb_target_group.services[each.key].arn - } + action { + type = "forward" + target_group_arn = aws_lb_target_group.services[each.key].arn + } - condition { - path_pattern { - values = each.value.path_patterns - } + condition { + path_pattern { + values = each.value.path_patterns } + } - tags = merge(var.common_tags, { Service = each.key }) + tags = merge(var.common_tags, { Service = each.key }) +} + +# ============================================================================= +# ALB Listener Rules - HTTPS (Path-based Routing for Production) +# ============================================================================= +resource "aws_lb_listener_rule" "services_https" { + for_each = var.enable_https && var.certificate_arn != null ? var.services : {} + + listener_arn = aws_lb_listener.https[0].arn + priority = each.value.priority + + action { + type = "forward" + target_group_arn = aws_lb_target_group.services[each.key].arn } + + condition { + path_pattern { + values = each.value.path_patterns + } + } + + tags = merge(var.common_tags, { Service = each.key }) +} diff --git a/infra/terraform/modules/alb/outputs.tf b/infra/terraform/modules/alb/outputs.tf index 7433a5c0..8c50db0e 100644 --- a/infra/terraform/modules/alb/outputs.tf +++ b/infra/terraform/modules/alb/outputs.tf @@ -13,11 +13,21 @@ output "target_group_arns" { value = { for k, v in aws_lb_target_group.services : k => v.arn } } +output "target_group_names" { + description = "Target Group 이름 맵" + value = { for k, v in aws_lb_target_group.services : k => v.name } +} + output "listener_arn" { - description = "Listener ARN" + description = "Listener ARN (HTTP)" value = aws_lb_listener.main.arn } +output "https_listener_arn" { + description = "HTTPS Listener ARN" + value = var.enable_https && var.certificate_arn != null ? aws_lb_listener.https[0].arn : null +} + output "security_group_id" { description = "ALB 보안그룹 ID" value = aws_security_group.alb_sg.id @@ -32,3 +42,16 @@ output "target_group_arn_suffixes" { description = "Target Group ARN suffix 맵 (CloudWatch용)" value = { for k, v in aws_lb_target_group.services : k => v.arn_suffix } } + +# ============================================================================= +# Blue/Green Outputs +# ============================================================================= +output "green_target_group_arns" { + description = "Green Target Group ARN 맵 (Blue/Green용)" + value = var.enable_blue_green ? { for k, v in aws_lb_target_group.services_green : k => v.arn } : {} +} + +output "green_target_group_names" { + description = "Green Target Group 이름 맵" + value = var.enable_blue_green ? { for k, v in aws_lb_target_group.services_green : k => v.name } : {} +} diff --git a/infra/terraform/modules/alb/variables.tf b/infra/terraform/modules/alb/variables.tf index 867602ab..f228910a 100644 --- a/infra/terraform/modules/alb/variables.tf +++ b/infra/terraform/modules/alb/variables.tf @@ -33,3 +33,33 @@ variable "services" { priority = number })) } + +# ============================================================================= +# HTTPS Settings +# ============================================================================= +variable "enable_https" { + description = "HTTPS 활성화" + type = bool + default = false +} + +variable "certificate_arn" { + description = "ACM 인증서 ARN" + type = string + default = null +} + +variable "ssl_policy" { + description = "SSL 정책" + type = string + default = "ELBSecurityPolicy-TLS13-1-2-2021-06" +} + +# ============================================================================= +# Blue/Green Deployment Settings +# ============================================================================= +variable "enable_blue_green" { + description = "Blue/Green 배포용 추가 Target Group 생성" + type = bool + default = false +} diff --git a/infra/terraform/modules/api-gateway/main.tf b/infra/terraform/modules/api-gateway/main.tf index 18ca65be..27713dd5 100644 --- a/infra/terraform/modules/api-gateway/main.tf +++ b/infra/terraform/modules/api-gateway/main.tf @@ -5,9 +5,121 @@ resource "aws_apigatewayv2_api" "main" { name = "${var.name_prefix}-api" protocol_type = "HTTP" + cors_configuration { + allow_origins = ["*"] + allow_methods = ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"] + allow_headers = ["Content-Type", "Authorization", "X-Requested-With"] + expose_headers = ["X-Request-Id"] + max_age = 3600 + allow_credentials = false + } + tags = merge(var.common_tags, { Name = "${var.name_prefix}-api" }) } +# ============================================================================= +# Cognito User Pool +# ============================================================================= +resource "aws_cognito_user_pool" "main" { + count = var.enable_cognito ? 1 : 0 + name = var.cognito_user_pool_name != null ? var.cognito_user_pool_name : "${var.name_prefix}-user-pool" + + username_attributes = ["email"] + auto_verified_attributes = ["email"] + + password_policy { + minimum_length = 8 + require_lowercase = true + require_numbers = true + require_symbols = true + require_uppercase = true + temporary_password_validity_days = 7 + } + + email_configuration { + email_sending_account = "COGNITO_DEFAULT" + } + + mfa_configuration = "OPTIONAL" + + software_token_mfa_configuration { + enabled = true + } + + account_recovery_setting { + recovery_mechanism { + name = "verified_email" + priority = 1 + } + } + + schema { + name = "email" + attribute_data_type = "String" + mutable = true + required = true + string_attribute_constraints { + min_length = 1 + max_length = 256 + } + } + + tags = merge(var.common_tags, { Name = "${var.name_prefix}-user-pool" }) +} + +resource "aws_cognito_user_pool_client" "main" { + count = var.enable_cognito ? 1 : 0 + name = "${var.name_prefix}-api-client" + user_pool_id = aws_cognito_user_pool.main[0].id + + generate_secret = false + allowed_oauth_flows_user_pool_client = true + allowed_oauth_flows = ["code", "implicit"] + allowed_oauth_scopes = ["email", "openid", "profile"] + callback_urls = var.cognito_callback_urls + logout_urls = var.cognito_logout_urls + supported_identity_providers = ["COGNITO"] + + explicit_auth_flows = [ + "ALLOW_REFRESH_TOKEN_AUTH", + "ALLOW_USER_SRP_AUTH", + "ALLOW_USER_PASSWORD_AUTH" + ] + + access_token_validity = 1 + id_token_validity = 1 + refresh_token_validity = 30 + + token_validity_units { + access_token = "hours" + id_token = "hours" + refresh_token = "days" + } +} + +resource "aws_cognito_user_pool_domain" "main" { + count = var.enable_cognito ? 1 : 0 + domain = var.name_prefix + user_pool_id = aws_cognito_user_pool.main[0].id +} + +# ============================================================================= +# JWT Authorizer +# ============================================================================= +resource "aws_apigatewayv2_authorizer" "cognito" { + count = var.enable_cognito ? 1 : 0 + + api_id = aws_apigatewayv2_api.main.id + authorizer_type = "JWT" + name = "${var.name_prefix}-cognito-authorizer" + identity_sources = ["$request.header.Authorization"] + + jwt_configuration { + audience = [aws_cognito_user_pool_client.main[0].id] + issuer = "https://${aws_cognito_user_pool.main[0].endpoint}" + } +} + # ============================================================================= # VPC Link # ============================================================================= @@ -20,7 +132,7 @@ resource "aws_apigatewayv2_vpc_link" "main" { } # ============================================================================= -# Integration (VPC Link → ALB) +# Integration (VPC Link -> ALB) # ============================================================================= resource "aws_apigatewayv2_integration" "main" { api_id = aws_apigatewayv2_api.main.id @@ -34,45 +146,40 @@ resource "aws_apigatewayv2_integration" "main" { } # ============================================================================= -# Route +# Routes (Public - No Auth Required) # ============================================================================= -resource "aws_apigatewayv2_route" "main" { - api_id = aws_apigatewayv2_api.main.id - route_key = "ANY /{proxy+}" - target = "integrations/${aws_apigatewayv2_integration.main.id}" +resource "aws_apigatewayv2_route" "public" { + for_each = var.enable_cognito ? toset(var.public_routes) : toset([]) - authorization_type = "JWT" - authorizer_id = aws_apigatewayv2_authorizer.cognito_jwt.id -} - -resource "aws_apigatewayv2_route" "options" { api_id = aws_apigatewayv2_api.main.id - route_key = "OPTIONS /{proxy+}" + route_key = "ANY ${each.value}" target = "integrations/${aws_apigatewayv2_integration.main.id}" - - authorization_type = "NONE" } -# 로그인 / 회원가입을 위한 공개 라우트 -# 개발 이후 삭제할 것 -resource "aws_apigatewayv2_route" "public_api_join" { - api_id = aws_apigatewayv2_api.main.id - route_key = "POST /api/join" - target = "integrations/${aws_apigatewayv2_integration.main.id}" +# ============================================================================= +# Routes (Protected - Auth Required) +# ============================================================================= +resource "aws_apigatewayv2_route" "protected" { + for_each = var.enable_cognito ? toset(var.protected_route_patterns) : toset([]) - authorization_type = "NONE" + api_id = aws_apigatewayv2_api.main.id + route_key = "ANY ${each.value}" + target = "integrations/${aws_apigatewayv2_integration.main.id}" + authorizer_id = aws_apigatewayv2_authorizer.cognito[0].id + authorization_type = "JWT" } -resource "aws_apigatewayv2_route" "public_api_login" { +# ============================================================================= +# Route (Fallback - When Cognito Disabled) +# ============================================================================= +resource "aws_apigatewayv2_route" "main" { + count = var.enable_cognito ? 0 : 1 + api_id = aws_apigatewayv2_api.main.id - route_key = "POST /api/login" + route_key = "ANY /{proxy+}" target = "integrations/${aws_apigatewayv2_integration.main.id}" - - authorization_type = "NONE" } - - # ============================================================================= # Stage # ============================================================================= @@ -81,21 +188,37 @@ resource "aws_apigatewayv2_stage" "main" { name = "$default" auto_deploy = true + access_log_settings { + destination_arn = aws_cloudwatch_log_group.api_logs.arn + format = jsonencode({ + requestId = "$context.requestId" + ip = "$context.identity.sourceIp" + requestTime = "$context.requestTime" + httpMethod = "$context.httpMethod" + routeKey = "$context.routeKey" + status = "$context.status" + protocol = "$context.protocol" + responseLength = "$context.responseLength" + integrationError = "$context.integrationErrorMessage" + authorizerError = "$context.authorizer.error" + }) + } + + default_route_settings { + detailed_metrics_enabled = true + throttling_burst_limit = 5000 + throttling_rate_limit = 2000 + } + tags = merge(var.common_tags, { Name = "${var.name_prefix}-stage" }) } # ============================================================================= -# Cognito +# CloudWatch Log Group for API Gateway # ============================================================================= -resource "aws_apigatewayv2_authorizer" "cognito_jwt" { - api_id = aws_apigatewayv2_api.main.id - name = "${var.name_prefix}-cognito-jwt" - authorizer_type = "JWT" +resource "aws_cloudwatch_log_group" "api_logs" { + name = "/aws/apigateway/${var.name_prefix}" + retention_in_days = 30 - identity_sources = ["$request.header.Authorization"] - - jwt_configuration { - issuer = var.cognito_issuer - audience = [var.cognito_audience] - } + tags = var.common_tags } diff --git a/infra/terraform/modules/api-gateway/outputs.tf b/infra/terraform/modules/api-gateway/outputs.tf index 37cbf0c0..a098edd1 100644 --- a/infra/terraform/modules/api-gateway/outputs.tf +++ b/infra/terraform/modules/api-gateway/outputs.tf @@ -17,3 +17,26 @@ output "execution_arn" { description = "API Gateway Execution ARN" value = aws_apigatewayv2_api.main.execution_arn } + +# ============================================================================= +# Cognito Outputs +# ============================================================================= +output "cognito_user_pool_id" { + description = "Cognito User Pool ID" + value = var.enable_cognito ? aws_cognito_user_pool.main[0].id : null +} + +output "cognito_user_pool_client_id" { + description = "Cognito User Pool Client ID" + value = var.enable_cognito ? aws_cognito_user_pool_client.main[0].id : null +} + +output "cognito_user_pool_endpoint" { + description = "Cognito User Pool Endpoint" + value = var.enable_cognito ? aws_cognito_user_pool.main[0].endpoint : null +} + +output "cognito_domain" { + description = "Cognito Domain URL" + value = var.enable_cognito ? "https://${aws_cognito_user_pool_domain.main[0].domain}.auth.ap-northeast-2.amazoncognito.com" : null +} diff --git a/infra/terraform/modules/api-gateway/variables.tf b/infra/terraform/modules/api-gateway/variables.tf index 54e138ba..98af3525 100644 --- a/infra/terraform/modules/api-gateway/variables.tf +++ b/infra/terraform/modules/api-gateway/variables.tf @@ -24,12 +24,41 @@ variable "alb_listener_arn" { type = string } -variable "cognito_issuer" { - description = "Cognito Issuer URL (https://cognito-idp..amazonaws.com/)" - type = string +# ============================================================================= +# Cognito Settings +# ============================================================================= +variable "enable_cognito" { + description = "Cognito 인증 활성화" + type = bool + default = false } -variable "cognito_audience" { - description = "Cognito App Client ID (audience)" +variable "cognito_user_pool_name" { + description = "Cognito User Pool 이름" type = string + default = null +} + +variable "cognito_callback_urls" { + description = "OAuth 콜백 URL 목록" + type = list(string) + default = ["https://localhost:3000/callback"] +} + +variable "cognito_logout_urls" { + description = "로그아웃 URL 목록" + type = list(string) + default = ["https://localhost:3000"] +} + +variable "public_routes" { + description = "인증이 필요없는 공개 라우트 패턴" + type = list(string) + default = ["/api/auth/*", "/health", "/actuator/health"] +} + +variable "protected_route_patterns" { + description = "보호된 라우트 패턴 목록" + type = list(string) + default = ["/api/*"] } diff --git a/infra/terraform/modules/cognito/.terraform.lock.hcl b/infra/terraform/modules/cognito/.terraform.lock.hcl deleted file mode 100644 index 65ccddcd..00000000 --- a/infra/terraform/modules/cognito/.terraform.lock.hcl +++ /dev/null @@ -1,24 +0,0 @@ -# This file is maintained automatically by "terraform init". -# Manual edits may be lost in future updates. - -provider "registry.terraform.io/hashicorp/aws" { - version = "6.28.0" - hashes = [ - "h1:RwoFuX1yGMVaKJaUmXDKklEaQ/yUCEdt5k2kz+/g08c=", - "zh:0ba0d5eb6e0c6a933eb2befe3cdbf22b58fbc0337bf138f95bf0e8bb6e6df93e", - "zh:23eacdd4e6db32cf0ff2ce189461bdbb62e46513978d33c5de4decc4670870ec", - "zh:307b06a15fc00a8e6fd243abde2cbe5112e9d40371542665b91bec1018dd6e3c", - "zh:37a02d5b45a9d050b9642c9e2e268297254192280df72f6e46641daca52e40ec", - "zh:3da866639f07d92e734557d673092719c33ede80f4276c835bf7f231a669aa33", - "zh:480060b0ba310d0f6b6a14d60b276698cb103c48fd2f7e2802ae47c963995ec6", - "zh:57796453455c20db80d9168edbf125bf6180e1aae869de1546a2be58e4e405ec", - "zh:69139cba772d4df8de87598d8d8a2b1b4b254866db046c061dccc79edb14e6b9", - "zh:7312763259b859ff911c5452ca8bdf7d0be6231c5ea0de2df8f09d51770900ac", - "zh:8d2d6f4015d3c155d7eb53e36f019a729aefb46ebfe13f3a637327d3a1402ecc", - "zh:94ce589275c77308e6253f607de96919b840c2dd36c44aa798f693c9dd81af42", - "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", - "zh:adaceec6a1bf4f5df1e12bd72cf52b72087c72efed078aef636f8988325b1a8b", - "zh:d37be1ce187d94fd9df7b13a717c219964cd835c946243f096c6b230cdfd7e92", - "zh:fe6205b5ca2ff36e68395cb8d3ae10a3728f405cdbcd46b206a515e1ebcf17a1", - ] -} diff --git a/infra/terraform/modules/cognito/lambda/post_confirm.py b/infra/terraform/modules/cognito/lambda/post_confirm.py deleted file mode 100644 index fed84205..00000000 --- a/infra/terraform/modules/cognito/lambda/post_confirm.py +++ /dev/null @@ -1,60 +0,0 @@ -import os -import json -import urllib.request -import urllib.error -import boto3 - -cognito = boto3.client("cognito-idp") - -USER_SERVICE_URL = os.environ.get("USER_SERVICE_URL", "").rstrip("/") - -def _post_json(url: str, payload: dict) -> dict: - data = json.dumps(payload).encode("utf-8") - req = urllib.request.Request(url, data=data, method="POST") - req.add_header("Content-Type", "application/json") - with urllib.request.urlopen(req, timeout=5) as resp: - body = resp.read().decode("utf-8") - return json.loads(body) if body else {} - -def lambda_handler(event, context): - # user-service 연동 전이면 즉시 실패 - if not USER_SERVICE_URL: - raise Exception("USER_SERVICE_URL is not set. Block sign-up until user-service is ready.") - - user_pool_id = event["userPoolId"] - username = event["userName"] - attrs = event.get("request", {}).get("userAttributes", {}) - - sub = attrs.get("sub") - email = attrs.get("email") - role = attrs.get("custom:role") or "CUSTOMER" - - if not sub: - raise Exception("Missing 'sub' in userAttributes") - - # 1) User DB 생성(실패하면 예외로 회원가입 흐름 막힘) - try: - created = _post_json( - f"{USER_SERVICE_URL}/internal/users", - {"cognitoSub": sub, "email": email, "role": role} - ) - except urllib.error.HTTPError as e: - raise Exception(f"user-service returned HTTPError: {e.code}") from e - except Exception as e: - raise Exception("Failed to call user-service") from e - - user_id = created.get("userId") - if user_id is None: - raise Exception("user-service response missing userId") - - # 2) Cognito custom attribute 업데이트 - cognito.admin_update_user_attributes( - UserPoolId=user_pool_id, - Username=username, - UserAttributes=[ - {"Name": "custom:user_id", "Value": str(user_id)}, - {"Name": "custom:role", "Value": str(role)}, - ], - ) - - return event diff --git a/infra/terraform/modules/cognito/lambda/pre_token.py b/infra/terraform/modules/cognito/lambda/pre_token.py deleted file mode 100644 index 3df7efe5..00000000 --- a/infra/terraform/modules/cognito/lambda/pre_token.py +++ /dev/null @@ -1,36 +0,0 @@ -def lambda_handler(event, context): - # request/userAttributes 안전 처리 - req = event.get("request") or {} - attrs = req.get("userAttributes") or {} - - user_id = attrs.get("custom:user_id") - role = attrs.get("custom:role") or "CUSTOMER" - - # response가 None일 수 있으니 강제로 dict로 만든다 - if not isinstance(event.get("response"), dict): - event["response"] = {} - - resp = event["response"] - - # claimsAndScopeOverrideDetails도 None일 수 있으니 강제로 dict - if not isinstance(resp.get("claimsAndScopeOverrideDetails"), dict): - resp["claimsAndScopeOverrideDetails"] = {} - - cas = resp["claimsAndScopeOverrideDetails"] - - if not isinstance(cas.get("accessTokenGeneration"), dict): - cas["accessTokenGeneration"] = {} - - atg = cas["accessTokenGeneration"] - - if not isinstance(atg.get("claimsToAddOrOverride"), dict): - atg["claimsToAddOrOverride"] = {} - - claims = atg["claimsToAddOrOverride"] - - # Access Token에 커스텀 클레임 주입 - if user_id is not None: - claims["user_id"] = str(user_id) - claims["role"] = str(role) - - return event diff --git a/infra/terraform/modules/cognito/main.tf b/infra/terraform/modules/cognito/main.tf deleted file mode 100644 index 5f5f6ccb..00000000 --- a/infra/terraform/modules/cognito/main.tf +++ /dev/null @@ -1,165 +0,0 @@ -# ============================================================================= -# Cognito -# ============================================================================= -resource "aws_cognito_user_pool" "pool" { - name = "spot_cognito_user_pool" - - schema { - name = "user_id" - attribute_data_type = "String" - mutable = true - required = false - } - - schema { - name = "role" - attribute_data_type = "String" - mutable = true - required = false - } - - - lambda_config { - post_confirmation = aws_lambda_function.post_confirm.arn - - # access token customization을 위해 "pre_token_generation_config" 사용(Trigger event version V2_0) - pre_token_generation_config { - lambda_arn = aws_lambda_function.pre_token.arn - lambda_version = "V2_0" - } - } -} - -# ============================================================================= -# Cognito Client -# ============================================================================= - -resource "aws_cognito_user_pool_client" "app_client" { - name = "spot-app-client" - user_pool_id = aws_cognito_user_pool.pool.id - - generate_secret = false # FE로 로그인/회원가입 - - explicit_auth_flows = [ - "ALLOW_USER_PASSWORD_AUTH" - ] - - refresh_token_rotation { - feature = "ENABLED" - # 네트워크 장애 고려한 기존 refresh token 10초 유예시간 - retry_grace_period_seconds = 10 - } -} - - - -# ============================================================================= -# iam -# ============================================================================= -data "aws_iam_policy_document" "lambda_assume" { - statement { - effect = "Allow" - actions = ["sts:AssumeRole"] - principals { - type = "Service" - identifiers = ["lambda.amazonaws.com"] - } - } -} - -# Post Confirmation Role -resource "aws_iam_role" "lambda_post_confirm" { - name = "spot-lambda-post-confirm" - assume_role_policy = data.aws_iam_policy_document.lambda_assume.json -} - -resource "aws_iam_role_policy" "lambda_post_confirm_policy" { - role = aws_iam_role.lambda_post_confirm.id - policy = jsonencode({ - Version = "2012-10-17" - Statement = [ - # logs - { - Effect = "Allow" - Action = ["logs:CreateLogGroup","logs:CreateLogStream","logs:PutLogEvents"] - Resource = "*" - }, - # Cognito custom attribute 업데이트용 - { - Effect = "Allow" - Action = ["cognito-idp:AdminUpdateUserAttributes"] - Resource = aws_cognito_user_pool.pool.arn - } - ] - }) -} - -# Pre Token Role -resource "aws_iam_role" "lambda_pre_token" { - name = "spot-lambda-pre-token" - assume_role_policy = data.aws_iam_policy_document.lambda_assume.json -} - -resource "aws_iam_role_policy" "lambda_pre_token_policy" { - role = aws_iam_role.lambda_pre_token.id - policy = jsonencode({ - Version = "2012-10-17" - Statement = [ - { - Effect = "Allow" - Action = ["logs:CreateLogGroup","logs:CreateLogStream","logs:PutLogEvents"] - Resource = "*" - } - ] - }) -} - -# Cognito가 Lambda 호출하는 permission 연결(AccessDenied 방지) -resource "aws_lambda_permission" "allow_cognito_post_confirm" { - statement_id = "AllowCognitoInvokePostConfirm" - action = "lambda:InvokeFunction" - function_name = aws_lambda_function.post_confirm.function_name - principal = "cognito-idp.amazonaws.com" - source_arn = aws_cognito_user_pool.pool.arn -} - -resource "aws_lambda_permission" "allow_cognito_pre_token" { - statement_id = "AllowCognitoInvokePreToken" - action = "lambda:InvokeFunction" - function_name = aws_lambda_function.pre_token.function_name - principal = "cognito-idp.amazonaws.com" - source_arn = aws_cognito_user_pool.pool.arn -} - - - -# ============================================================================= -# lambda -# ============================================================================= -resource "aws_lambda_function" "post_confirm" { - function_name = "spot-post-confirm" - role = aws_iam_role.lambda_post_confirm.arn - handler = "post_confirm.lambda_handler" - runtime = "python3.12" - timeout = 10 - - filename = "${path.module}/lambda/post_confirm.zip" - source_code_hash = filebase64sha256("${path.module}/lambda/post_confirm.zip") - - environment { - variables = { - USER_SERVICE_URL = var.user_service_url - } - } -} - -resource "aws_lambda_function" "pre_token" { - function_name = "spot-pre-token" - role = aws_iam_role.lambda_pre_token.arn - handler = "pre_token.lambda_handler" - runtime = "python3.12" - timeout = 5 - - filename = "${path.module}/lambda/pre_token.zip" - source_code_hash = filebase64sha256("${path.module}/lambda/pre_token.zip") -} diff --git a/infra/terraform/modules/cognito/output.tf b/infra/terraform/modules/cognito/output.tf deleted file mode 100644 index 02e7a99d..00000000 --- a/infra/terraform/modules/cognito/output.tf +++ /dev/null @@ -1,11 +0,0 @@ -output "cognito_user_pool_id" { - value = aws_cognito_user_pool.pool.id -} - -output "cognito_app_client_id" { - value = aws_cognito_user_pool_client.app_client.id -} - -output "cognito_issuer_url" { - value = "https://cognito-idp.${var.aws_region}.amazonaws.com/${aws_cognito_user_pool.pool.id}" -} diff --git a/infra/terraform/modules/cognito/variable.tf b/infra/terraform/modules/cognito/variable.tf deleted file mode 100644 index d39bf667..00000000 --- a/infra/terraform/modules/cognito/variable.tf +++ /dev/null @@ -1,13 +0,0 @@ -variable "aws_region" { - type = string - description = "ap-northeast-2" - default = "ap-northeast-2" -} - -variable "user_service_url" { - type = string - default = "http://user-service.internal:8080" -} -variable "name_prefix" { - type = string -} \ No newline at end of file diff --git a/infra/terraform/modules/database/main.tf b/infra/terraform/modules/database/main.tf index 8b813b21..7fa13ed9 100644 --- a/infra/terraform/modules/database/main.tf +++ b/infra/terraform/modules/database/main.tf @@ -32,25 +32,127 @@ resource "aws_db_subnet_group" "main" { tags = merge(var.common_tags, { Name = "${var.name_prefix}-db-subnet-group" }) } +# ============================================================================= +# Enhanced Monitoring IAM Role +# ============================================================================= +resource "aws_iam_role" "rds_monitoring" { + count = var.monitoring_interval > 0 ? 1 : 0 + name = "${var.name_prefix}-rds-monitoring-role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { + Service = "monitoring.rds.amazonaws.com" + } + }] + }) + + tags = var.common_tags +} + +resource "aws_iam_role_policy_attachment" "rds_monitoring" { + count = var.monitoring_interval > 0 ? 1 : 0 + role = aws_iam_role.rds_monitoring[0].name + policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonRDSEnhancedMonitoringRole" +} + +# ============================================================================= +# RDS Parameter Group (Production tuning) +# ============================================================================= +resource "aws_db_parameter_group" "main" { + name = "${var.name_prefix}-pg16" + family = "postgres16" + + parameter { + name = "log_statement" + value = "ddl" + } + + parameter { + name = "log_min_duration_statement" + value = "1000" + } + + parameter { + name = "shared_preload_libraries" + value = "pg_stat_statements" + apply_method = "pending-reboot" + } + + tags = merge(var.common_tags, { Name = "${var.name_prefix}-pg16" }) +} + # ============================================================================= # RDS Instance (PostgreSQL) # ============================================================================= resource "aws_db_instance" "main" { - identifier = "${var.name_prefix}-db" - allocated_storage = var.allocated_storage - engine = "postgres" - engine_version = var.engine_version - instance_class = var.instance_class - db_name = var.db_name - username = var.username - password = var.password + identifier = "${var.name_prefix}-db" + allocated_storage = var.allocated_storage + max_allocated_storage = var.max_allocated_storage + engine = "postgres" + engine_version = var.engine_version + instance_class = var.instance_class + db_name = var.db_name + username = var.username + password = var.password db_subnet_group_name = aws_db_subnet_group.main.name vpc_security_group_ids = [aws_security_group.db_sg.id] + parameter_group_name = aws_db_parameter_group.main.name - skip_final_snapshot = true + # Production settings + multi_az = var.multi_az publicly_accessible = false storage_type = "gp3" + storage_encrypted = var.storage_encrypted + + # Backup settings + backup_retention_period = var.backup_retention_period + backup_window = var.backup_window + maintenance_window = var.maintenance_window + skip_final_snapshot = !var.deletion_protection + final_snapshot_identifier = var.deletion_protection ? "${var.name_prefix}-db-final-snapshot" : null + delete_automated_backups = !var.deletion_protection + deletion_protection = var.deletion_protection + copy_tags_to_snapshot = true + + # Monitoring + performance_insights_enabled = var.performance_insights_enabled + monitoring_interval = var.monitoring_interval + monitoring_role_arn = var.monitoring_interval > 0 ? aws_iam_role.rds_monitoring[0].arn : null + + # Logging + enabled_cloudwatch_logs_exports = ["postgresql", "upgrade"] tags = merge(var.common_tags, { Name = "${var.name_prefix}-db" }) } + +# ============================================================================= +# Read Replica +# ============================================================================= +resource "aws_db_instance" "replica" { + count = var.create_read_replica ? 1 : 0 + + identifier = "${var.name_prefix}-db-replica" + replicate_source_db = aws_db_instance.main.identifier + instance_class = var.instance_class + vpc_security_group_ids = [aws_security_group.db_sg.id] + parameter_group_name = aws_db_parameter_group.main.name + + publicly_accessible = false + storage_encrypted = var.storage_encrypted + skip_final_snapshot = true + + # Monitoring + performance_insights_enabled = var.performance_insights_enabled + monitoring_interval = var.monitoring_interval + monitoring_role_arn = var.monitoring_interval > 0 ? aws_iam_role.rds_monitoring[0].arn : null + + tags = merge(var.common_tags, { + Name = "${var.name_prefix}-db-replica" + Role = "replica" + }) +} diff --git a/infra/terraform/modules/database/outputs.tf b/infra/terraform/modules/database/outputs.tf index 0ca10b61..1bd1e080 100644 --- a/infra/terraform/modules/database/outputs.tf +++ b/infra/terraform/modules/database/outputs.tf @@ -27,3 +27,26 @@ output "instance_id" { description = "RDS 인스턴스 ID (CloudWatch용)" value = aws_db_instance.main.identifier } + +output "arn" { + description = "RDS ARN" + value = aws_db_instance.main.arn +} + +# ============================================================================= +# Read Replica Outputs +# ============================================================================= +output "replica_endpoint" { + description = "Read Replica 엔드포인트" + value = var.create_read_replica ? aws_db_instance.replica[0].endpoint : null +} + +output "replica_hostname" { + description = "Read Replica 호스트명" + value = var.create_read_replica ? aws_db_instance.replica[0].address : null +} + +output "replica_jdbc_url" { + description = "Read Replica JDBC URL" + value = var.create_read_replica ? "jdbc:postgresql://${aws_db_instance.replica[0].endpoint}/${aws_db_instance.main.db_name}" : null +} diff --git a/infra/terraform/modules/database/variables.tf b/infra/terraform/modules/database/variables.tf index 5a66a947..0eb08d5f 100644 --- a/infra/terraform/modules/database/variables.tf +++ b/infra/terraform/modules/database/variables.tf @@ -58,3 +58,66 @@ variable "engine_version" { type = string default = "16" } + +# ============================================================================= +# Production Settings +# ============================================================================= +variable "multi_az" { + description = "Multi-AZ 배포 여부" + type = bool + default = false +} + +variable "create_read_replica" { + description = "Read Replica 생성 여부" + type = bool + default = false +} + +variable "backup_retention_period" { + description = "백업 보관 기간 (일)" + type = number + default = 7 +} + +variable "backup_window" { + description = "백업 시간 (UTC)" + type = string + default = "03:00-04:00" +} + +variable "maintenance_window" { + description = "유지보수 시간 (UTC)" + type = string + default = "Mon:04:00-Mon:05:00" +} + +variable "deletion_protection" { + description = "삭제 보호 활성화" + type = bool + default = false +} + +variable "performance_insights_enabled" { + description = "Performance Insights 활성화" + type = bool + default = false +} + +variable "monitoring_interval" { + description = "Enhanced Monitoring 간격 (초, 0이면 비활성화)" + type = number + default = 0 +} + +variable "storage_encrypted" { + description = "스토리지 암호화" + type = bool + default = true +} + +variable "max_allocated_storage" { + description = "Auto Scaling 최대 스토리지 (GB), null이면 비활성화" + type = number + default = null +} diff --git a/infra/terraform/modules/ecs/codedeploy.tf b/infra/terraform/modules/ecs/codedeploy.tf new file mode 100644 index 00000000..93757b8f --- /dev/null +++ b/infra/terraform/modules/ecs/codedeploy.tf @@ -0,0 +1,93 @@ +# ============================================================================= +# CodeDeploy Application (for Blue/Green ECS Deployment) +# ============================================================================= +resource "aws_codedeploy_app" "main" { + count = var.enable_blue_green ? 1 : 0 + compute_platform = "ECS" + name = "${var.name_prefix}-ecs-app" + + tags = var.common_tags +} + +# ============================================================================= +# CodeDeploy IAM Role +# ============================================================================= +resource "aws_iam_role" "codedeploy" { + count = var.enable_blue_green ? 1 : 0 + name = "${var.name_prefix}-codedeploy-role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { + Service = "codedeploy.amazonaws.com" + } + }] + }) + + tags = var.common_tags +} + +resource "aws_iam_role_policy_attachment" "codedeploy" { + count = var.enable_blue_green ? 1 : 0 + role = aws_iam_role.codedeploy[0].name + policy_arn = "arn:aws:iam::aws:policy/AWSCodeDeployRoleForECS" +} + +# ============================================================================= +# CodeDeploy Deployment Groups (per service) +# ============================================================================= +resource "aws_codedeploy_deployment_group" "services" { + for_each = var.enable_blue_green ? local.active_services : {} + + app_name = aws_codedeploy_app.main[0].name + deployment_group_name = "${var.name_prefix}-${each.key}-dg" + deployment_config_name = var.deployment_config + service_role_arn = aws_iam_role.codedeploy[0].arn + + auto_rollback_configuration { + enabled = true + events = ["DEPLOYMENT_FAILURE"] + } + + blue_green_deployment_config { + deployment_ready_option { + action_on_timeout = "CONTINUE_DEPLOYMENT" + } + + terminate_blue_instances_on_deployment_success { + action = "TERMINATE" + termination_wait_time_in_minutes = var.termination_wait_time + } + } + + deployment_style { + deployment_option = "WITH_TRAFFIC_CONTROL" + deployment_type = "BLUE_GREEN" + } + + ecs_service { + cluster_name = aws_ecs_cluster.main.name + service_name = aws_ecs_service.services[each.key].name + } + + load_balancer_info { + target_group_pair_info { + prod_traffic_route { + listener_arns = [var.alb_listener_arn] + } + + target_group { + name = var.target_group_names[each.key] + } + + target_group { + name = lookup(var.target_group_names, "${each.key}-green", "${var.name_prefix}-${each.key}-tg-g") + } + } + } + + tags = merge(var.common_tags, { Service = each.key }) +} diff --git a/infra/terraform/modules/ecs/main.tf b/infra/terraform/modules/ecs/main.tf index 9919c18e..ed6cfbf7 100644 --- a/infra/terraform/modules/ecs/main.tf +++ b/infra/terraform/modules/ecs/main.tf @@ -1,3 +1,14 @@ +# ============================================================================= +# Local Variables +# ============================================================================= +locals { + # Gateway 및 excluded_services에 포함된 서비스 필터링 + active_services = { + for k, v in var.services : k => v + if !contains(var.excluded_services, k) + } +} + # ============================================================================= # Cloud Map (Service Discovery Namespace) # ============================================================================= @@ -135,6 +146,40 @@ resource "aws_iam_role_policy_attachment" "ecs_task_execution_role_policy" { policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy" } +# ============================================================================= +# SSM Parameter Store 읽기 권한 (Secrets 주입용) +# ============================================================================= +resource "aws_iam_role_policy" "ecs_task_execution_ssm" { + name = "${var.name_prefix}-ecs-ssm-policy" + role = aws_iam_role.ecs_task_execution_role.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = [ + "ssm:GetParameters", + "ssm:GetParameter" + ] + Resource = "arn:aws:ssm:${var.region}:*:parameter/${var.project}/${var.environment}/*" + }, + { + Effect = "Allow" + Action = [ + "kms:Decrypt" + ] + Resource = "*" + Condition = { + StringEquals = { + "kms:ViaService" = "ssm.${var.region}.amazonaws.com" + } + } + } + ] + }) +} + # ============================================================================= # IAM Role for ECS Task (Application level) # ============================================================================= @@ -229,6 +274,21 @@ resource "aws_ecs_task_definition" "services" { value = "6379" } ], + # Kafka 환경 변수 (gateway 제외) + each.key != "gateway" && var.kafka_bootstrap_servers != "" ? [ + { + name = "SPRING_KAFKA_BOOTSTRAP_SERVERS" + value = var.kafka_bootstrap_servers + }, + { + name = "SPRING_KAFKA_CONSUMER_GROUP_ID" + value = "${var.project}-${each.key}" + }, + { + name = "SPRING_KAFKA_CONSUMER_AUTO_OFFSET_RESET" + value = "earliest" + } + ] : [], # 백엔드 서비스 전용 (gateway 제외) - DB, JPA, JWT 설정 each.key != "gateway" ? [ { @@ -239,10 +299,6 @@ resource "aws_ecs_task_definition" "services" { name = "SPRING_DATASOURCE_USERNAME" value = var.db_username }, - { - name = "SPRING_DATASOURCE_PASSWORD" - value = var.db_password - }, { name = "SPRING_JPA_HIBERNATE_DDL_AUTO" value = "update" @@ -255,10 +311,6 @@ resource "aws_ecs_task_definition" "services" { name = "SPRING_JPA_PROPERTIES_HIBERNATE_DIALECT" value = "org.hibernate.dialect.PostgreSQLDialect" }, - { - name = "SPRING_JWT_SECRET" - value = var.jwt_secret - }, { name = "SPRING_JWT_EXPIRE_MS" value = tostring(var.jwt_expire_ms) @@ -305,10 +357,6 @@ resource "aws_ecs_task_definition" "services" { name = "SPRING_MAIL_USERNAME" value = var.mail_username }, - { - name = "SPRING_MAIL_PASSWORD" - value = var.mail_password - }, { name = "SPRING_MAIL_PROPERTIES_MAIL_SMTP_AUTH" value = "true" @@ -324,84 +372,163 @@ resource "aws_ecs_task_definition" "services" { name = "TOSS_PAYMENTS_BASE_URL" value = var.toss_base_url }, - { - name = "TOSS_PAYMENTS_SECRET_KEY" - value = var.toss_secret_key - }, { name = "TOSS_PAYMENTS_CUSTOMER_KEY" value = var.toss_customer_key } ] : [], - # Gateway 전용 설정 - Spring Cloud Gateway 라우트 + # Gateway 전용 설정 - Spring Cloud Gateway 라우트 (WebFlux 버전용 새 property 이름) each.key == "gateway" ? [ + # User Service - Auth 관련 { - name = "SPRING_CLOUD_GATEWAY_ROUTES_0_ID" + name = "SPRING_CLOUD_GATEWAY_SERVER_WEBFLUX_ROUTES_0_ID" + value = "user-login" + }, + { + name = "SPRING_CLOUD_GATEWAY_SERVER_WEBFLUX_ROUTES_0_URI" + value = "http://user.${var.project}.local:${var.services["user"].container_port}" + }, + { + name = "SPRING_CLOUD_GATEWAY_SERVER_WEBFLUX_ROUTES_0_PREDICATES_0" + value = "Path=/api/login" + }, + { + name = "SPRING_CLOUD_GATEWAY_SERVER_WEBFLUX_ROUTES_1_ID" + value = "user-join" + }, + { + name = "SPRING_CLOUD_GATEWAY_SERVER_WEBFLUX_ROUTES_1_URI" + value = "http://user.${var.project}.local:${var.services["user"].container_port}" + }, + { + name = "SPRING_CLOUD_GATEWAY_SERVER_WEBFLUX_ROUTES_1_PREDICATES_0" + value = "Path=/api/join" + }, + { + name = "SPRING_CLOUD_GATEWAY_SERVER_WEBFLUX_ROUTES_2_ID" value = "user-auth" }, { - name = "SPRING_CLOUD_GATEWAY_ROUTES_0_URI" + name = "SPRING_CLOUD_GATEWAY_SERVER_WEBFLUX_ROUTES_2_URI" value = "http://user.${var.project}.local:${var.services["user"].container_port}" }, { - name = "SPRING_CLOUD_GATEWAY_ROUTES_0_PREDICATES_0" - value = "Path=/api/login,/api/join,/api/auth/refresh" + name = "SPRING_CLOUD_GATEWAY_SERVER_WEBFLUX_ROUTES_2_PREDICATES_0" + value = "Path=/api/auth/**" }, + # User Service - Users & Admin { - name = "SPRING_CLOUD_GATEWAY_ROUTES_1_ID" + name = "SPRING_CLOUD_GATEWAY_SERVER_WEBFLUX_ROUTES_3_ID" value = "user-service" }, { - name = "SPRING_CLOUD_GATEWAY_ROUTES_1_URI" + name = "SPRING_CLOUD_GATEWAY_SERVER_WEBFLUX_ROUTES_3_URI" value = "http://user.${var.project}.local:${var.services["user"].container_port}" }, { - name = "SPRING_CLOUD_GATEWAY_ROUTES_1_PREDICATES_0" + name = "SPRING_CLOUD_GATEWAY_SERVER_WEBFLUX_ROUTES_3_PREDICATES_0" value = "Path=/api/users/**" }, { - name = "SPRING_CLOUD_GATEWAY_ROUTES_2_ID" + name = "SPRING_CLOUD_GATEWAY_SERVER_WEBFLUX_ROUTES_4_ID" + value = "admin-service" + }, + { + name = "SPRING_CLOUD_GATEWAY_SERVER_WEBFLUX_ROUTES_4_URI" + value = "http://user.${var.project}.local:${var.services["user"].container_port}" + }, + { + name = "SPRING_CLOUD_GATEWAY_SERVER_WEBFLUX_ROUTES_4_PREDICATES_0" + value = "Path=/api/admin/**" + }, + # Store Service + { + name = "SPRING_CLOUD_GATEWAY_SERVER_WEBFLUX_ROUTES_5_ID" value = "store-service" }, { - name = "SPRING_CLOUD_GATEWAY_ROUTES_2_URI" + name = "SPRING_CLOUD_GATEWAY_SERVER_WEBFLUX_ROUTES_5_URI" + value = "http://store.${var.project}.local:${var.services["store"].container_port}" + }, + { + name = "SPRING_CLOUD_GATEWAY_SERVER_WEBFLUX_ROUTES_5_PREDICATES_0" + value = "Path=/api/stores/**" + }, + { + name = "SPRING_CLOUD_GATEWAY_SERVER_WEBFLUX_ROUTES_6_ID" + value = "category-service" + }, + { + name = "SPRING_CLOUD_GATEWAY_SERVER_WEBFLUX_ROUTES_6_URI" + value = "http://store.${var.project}.local:${var.services["store"].container_port}" + }, + { + name = "SPRING_CLOUD_GATEWAY_SERVER_WEBFLUX_ROUTES_6_PREDICATES_0" + value = "Path=/api/categories/**" + }, + { + name = "SPRING_CLOUD_GATEWAY_SERVER_WEBFLUX_ROUTES_7_ID" + value = "review-service" + }, + { + name = "SPRING_CLOUD_GATEWAY_SERVER_WEBFLUX_ROUTES_7_URI" + value = "http://store.${var.project}.local:${var.services["store"].container_port}" + }, + { + name = "SPRING_CLOUD_GATEWAY_SERVER_WEBFLUX_ROUTES_7_PREDICATES_0" + value = "Path=/api/reviews/**" + }, + { + name = "SPRING_CLOUD_GATEWAY_SERVER_WEBFLUX_ROUTES_8_ID" + value = "menu-service" + }, + { + name = "SPRING_CLOUD_GATEWAY_SERVER_WEBFLUX_ROUTES_8_URI" value = "http://store.${var.project}.local:${var.services["store"].container_port}" }, { - name = "SPRING_CLOUD_GATEWAY_ROUTES_2_PREDICATES_0" - value = "Path=/api/stores/**,/api/categories/**,/api/reviews/**" + name = "SPRING_CLOUD_GATEWAY_SERVER_WEBFLUX_ROUTES_8_PREDICATES_0" + value = "Path=/api/menus/**" }, + # Order Service { - name = "SPRING_CLOUD_GATEWAY_ROUTES_3_ID" + name = "SPRING_CLOUD_GATEWAY_SERVER_WEBFLUX_ROUTES_9_ID" value = "order-service" }, { - name = "SPRING_CLOUD_GATEWAY_ROUTES_3_URI" + name = "SPRING_CLOUD_GATEWAY_SERVER_WEBFLUX_ROUTES_9_URI" value = "http://order.${var.project}.local:${var.services["order"].container_port}" }, { - name = "SPRING_CLOUD_GATEWAY_ROUTES_3_PREDICATES_0" + name = "SPRING_CLOUD_GATEWAY_SERVER_WEBFLUX_ROUTES_9_PREDICATES_0" value = "Path=/api/orders/**" }, + # Payment Service { - name = "SPRING_CLOUD_GATEWAY_ROUTES_4_ID" + name = "SPRING_CLOUD_GATEWAY_SERVER_WEBFLUX_ROUTES_10_ID" value = "payment-service" }, { - name = "SPRING_CLOUD_GATEWAY_ROUTES_4_URI" + name = "SPRING_CLOUD_GATEWAY_SERVER_WEBFLUX_ROUTES_10_URI" value = "http://payment.${var.project}.local:${var.services["payment"].container_port}" }, { - name = "SPRING_CLOUD_GATEWAY_ROUTES_4_PREDICATES_0" + name = "SPRING_CLOUD_GATEWAY_SERVER_WEBFLUX_ROUTES_10_PREDICATES_0" value = "Path=/api/payments/**" }, + # Actuator 설정 (새 property 이름) { name = "MANAGEMENT_ENDPOINTS_WEB_EXPOSURE_INCLUDE" - value = "*" + value = "health,info,gateway" }, { - name = "MANAGEMENT_ENDPOINT_GATEWAY_ENABLED" - value = "true" + name = "MANAGEMENT_ENDPOINT_GATEWAY_ACCESS" + value = "unrestricted" + }, + # 디버깅용 로깅 + { + name = "LOGGING_LEVEL_ORG_SPRINGFRAMEWORK_CLOUD_GATEWAY" + value = "DEBUG" } ] : [], # 서비스별 커스텀 환경 변수 @@ -411,6 +538,37 @@ resource "aws_ecs_task_definition" "services" { }] ) + # ============================================================= + # Secrets (Parameter Store에서 주입) + # ============================================================= + secrets = concat( + # 백엔드 서비스 (gateway 제외) - DB 비밀번호, JWT 시크릿 + each.key != "gateway" ? [ + { + name = "SPRING_DATASOURCE_PASSWORD" + valueFrom = var.parameter_arns.db_password + }, + { + name = "SPRING_JWT_SECRET" + valueFrom = var.parameter_arns.jwt_secret + } + ] : [], + # Mail 비밀번호 (user 서비스) + each.key == "user" && var.parameter_arns.mail_password != null ? [ + { + name = "SPRING_MAIL_PASSWORD" + valueFrom = var.parameter_arns.mail_password + } + ] : [], + # Toss 시크릿 키 (payment 서비스) + each.key == "payment" && var.parameter_arns.toss_secret_key != null ? [ + { + name = "TOSS_PAYMENTS_SECRET_KEY" + valueFrom = var.parameter_arns.toss_secret_key + } + ] : [] + ) + logConfiguration = { logDriver = "awslogs" options = { @@ -434,19 +592,20 @@ resource "aws_ecs_task_definition" "services" { } # ============================================================================= -# ECS Services (per service) +# ECS Services (per service) - Active Services Only # ============================================================================= resource "aws_ecs_service" "services" { - for_each = var.services + for_each = local.active_services name = "${var.project}-${each.key}-service" cluster = aws_ecs_cluster.main.id task_definition = aws_ecs_task_definition.services[each.key].arn - desired_count = each.value.desired_count + desired_count = var.standby_mode ? 0 : each.value.desired_count launch_type = "FARGATE" + # 모든 active 서비스를 ALB에 연결 dynamic "load_balancer" { - for_each = each.key == "gateway" ? [1] : [] + for_each = contains(keys(var.target_group_arns), each.key) ? [1] : [] content { target_group_arn = var.target_group_arns[each.key] container_name = "${var.project}-${each.key}-container" @@ -460,6 +619,23 @@ resource "aws_ecs_service" "services" { assign_public_ip = var.assign_public_ip } + # Blue/Green 배포 컨트롤러 + dynamic "deployment_controller" { + for_each = var.enable_blue_green ? [1] : [] + content { + type = "CODE_DEPLOY" + } + } + + # 기본 롤링 배포 설정 (Blue/Green 비활성화시) + dynamic "deployment_circuit_breaker" { + for_each = var.enable_blue_green ? [] : [1] + content { + enable = true + rollback = true + } + } + # Service Connect Configuration dynamic "service_connect_configuration" { for_each = var.enable_service_connect ? [1] : [] @@ -503,6 +679,6 @@ resource "aws_ecs_service" "services" { tags = merge(var.common_tags, { Service = each.key }) lifecycle { - ignore_changes = [desired_count] + ignore_changes = var.enable_blue_green ? [task_definition, load_balancer] : [] } } diff --git a/infra/terraform/modules/ecs/outputs.tf b/infra/terraform/modules/ecs/outputs.tf index 1526a970..fc8710db 100644 --- a/infra/terraform/modules/ecs/outputs.tf +++ b/infra/terraform/modules/ecs/outputs.tf @@ -42,3 +42,16 @@ output "service_discovery_namespace_arn" { description = "Service Discovery Namespace ARN" value = aws_service_discovery_private_dns_namespace.main.arn } + +# ============================================================================= +# CodeDeploy Outputs +# ============================================================================= +output "codedeploy_app_name" { + description = "CodeDeploy Application 이름" + value = var.enable_blue_green ? aws_codedeploy_app.main[0].name : null +} + +output "codedeploy_deployment_group_names" { + description = "CodeDeploy Deployment Group 이름 맵" + value = var.enable_blue_green ? { for k, v in aws_codedeploy_deployment_group.services : k => v.deployment_group_name } : {} +} diff --git a/infra/terraform/modules/ecs/variables.tf b/infra/terraform/modules/ecs/variables.tf index 744c36e9..3ecbf149 100644 --- a/infra/terraform/modules/ecs/variables.tf +++ b/infra/terraform/modules/ecs/variables.tf @@ -102,6 +102,19 @@ variable "log_retention_days" { default = 30 } +# ============================================================================= +# Parameter Store ARNs (Secrets 주입용) +# ============================================================================= +variable "parameter_arns" { + description = "Parameter Store ARN 맵" + type = object({ + db_password = string + jwt_secret = string + mail_password = optional(string) + toss_secret_key = optional(string) + }) +} + # ============================================================================= # Database Settings # ============================================================================= @@ -121,12 +134,6 @@ variable "db_username" { sensitive = true } -variable "db_password" { - description = "데이터베이스 비밀번호" - type = string - sensitive = true -} - # ============================================================================= # Redis Settings # ============================================================================= @@ -137,14 +144,17 @@ variable "redis_endpoint" { } # ============================================================================= -# JWT Settings +# Kafka Settings # ============================================================================= -variable "jwt_secret" { - description = "JWT 시크릿 키" +variable "kafka_bootstrap_servers" { + description = "Kafka Bootstrap Servers" type = string - sensitive = true + default = "" } +# ============================================================================= +# JWT Settings +# ============================================================================= variable "jwt_expire_ms" { description = "JWT 만료 시간 (밀리초)" type = number @@ -178,13 +188,6 @@ variable "mail_username" { default = "" } -variable "mail_password" { - description = "SMTP 비밀번호" - type = string - sensitive = true - default = "" -} - # ============================================================================= # Toss Payments Settings # ============================================================================= @@ -194,13 +197,6 @@ variable "toss_base_url" { default = "https://api.tosspayments.com" } -variable "toss_secret_key" { - description = "Toss Payments 시크릿 키" - type = string - sensitive = true - default = "" -} - variable "toss_customer_key" { description = "Toss Payments 고객 키" type = string @@ -215,3 +211,51 @@ variable "service_active_regions" { type = string default = "종로구" } + +# ============================================================================= +# Standby Mode (비용 절감) +# ============================================================================= +variable "standby_mode" { + description = "스탠바이 모드 (true면 모든 서비스 desired_count = 0)" + type = bool + default = false +} + +# ============================================================================= +# Blue/Green Deployment +# ============================================================================= +variable "enable_blue_green" { + description = "Blue/Green 배포 활성화 (CodeDeploy)" + type = bool + default = false +} + +variable "excluded_services" { + description = "배포에서 제외할 서비스 목록 (예: gateway)" + type = list(string) + default = [] +} + +variable "target_group_names" { + description = "ALB Target Group 이름 맵" + type = map(string) + default = {} +} + +variable "green_target_group_arns" { + description = "Green Target Group ARN 맵 (Blue/Green용)" + type = map(string) + default = {} +} + +variable "deployment_config" { + description = "CodeDeploy 배포 구성" + type = string + default = "CodeDeployDefault.ECSAllAtOnce" +} + +variable "termination_wait_time" { + description = "이전 태스크 종료 대기 시간 (분)" + type = number + default = 5 +} diff --git a/infra/terraform/modules/kafka/main.tf b/infra/terraform/modules/kafka/main.tf new file mode 100644 index 00000000..0bc11bb5 --- /dev/null +++ b/infra/terraform/modules/kafka/main.tf @@ -0,0 +1,218 @@ +# ============================================================================= +# Kafka EC2 Module (KRaft Mode - Single/Multi Broker) +# ============================================================================= + +locals { + kafka_port = 9092 + kraft_port = 9093 + internal_port = 9094 + + # 브로커 배치: AZ-a에 1개, AZ-c에 2개 + brokers = var.broker_count > 1 ? { + "1" = { subnet_index = 0, az_suffix = "a" } + "2" = { subnet_index = 1, az_suffix = "c" } + "3" = { subnet_index = 1, az_suffix = "c" } + } : { + "1" = { subnet_index = 0, az_suffix = "a" } + } + + # 사용할 서브넷 결정 + effective_subnet_ids = length(var.subnet_ids) > 0 ? var.subnet_ids : (var.subnet_id != null ? [var.subnet_id] : []) +} + +# ============================================================================= +# Security Group +# ============================================================================= +resource "aws_security_group" "kafka" { + name = "${var.name_prefix}-kafka-sg" + vpc_id = var.vpc_id + + # Kafka 클라이언트 포트 (ECS에서 접근) + ingress { + from_port = local.kafka_port + to_port = local.kafka_port + protocol = "tcp" + security_groups = var.allowed_security_group_ids + } + + # 브로커 간 클라이언트 통신 (replication) + ingress { + from_port = local.kafka_port + to_port = local.kafka_port + protocol = "tcp" + self = true + } + + # KRaft Controller 포트 (브로커 간 controller 통신) + ingress { + from_port = local.kraft_port + to_port = local.kraft_port + protocol = "tcp" + self = true + } + + # Internal 리스너 (브로커 간 복제 통신) + ingress { + from_port = local.internal_port + to_port = local.internal_port + protocol = "tcp" + self = true + } + + # SSH (디버깅용 - 선택적) + dynamic "ingress" { + for_each = var.enable_ssh ? [1] : [] + content { + from_port = 22 + to_port = 22 + protocol = "tcp" + cidr_blocks = [var.vpc_cidr] + } + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = merge(var.common_tags, { Name = "${var.name_prefix}-kafka-sg" }) +} + +# ============================================================================= +# IAM Role (CloudWatch Logs, SSM 등) +# ============================================================================= +resource "aws_iam_role" "kafka" { + name = "${var.name_prefix}-kafka-role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { + Service = "ec2.amazonaws.com" + } + }] + }) + + tags = var.common_tags +} + +resource "aws_iam_role_policy_attachment" "kafka_ssm" { + role = aws_iam_role.kafka.name + policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore" +} + +resource "aws_iam_role_policy_attachment" "kafka_cloudwatch" { + role = aws_iam_role.kafka.name + policy_arn = "arn:aws:iam::aws:policy/CloudWatchAgentServerPolicy" +} + +resource "aws_iam_instance_profile" "kafka" { + name = "${var.name_prefix}-kafka-profile" + role = aws_iam_role.kafka.name +} + +# ============================================================================= +# EC2 Instances (Multi-Broker Support) +# ============================================================================= +data "aws_ami" "amazon_linux_2023" { + most_recent = true + owners = ["amazon"] + + filter { + name = "name" + values = ["al2023-ami-*-x86_64"] + } + + filter { + name = "virtualization-type" + values = ["hvm"] + } +} + +resource "aws_instance" "kafka" { + for_each = local.brokers + + ami = data.aws_ami.amazon_linux_2023.id + instance_type = var.instance_type + subnet_id = local.effective_subnet_ids[each.value.subnet_index] + vpc_security_group_ids = [aws_security_group.kafka.id] + iam_instance_profile = aws_iam_instance_profile.kafka.name + + associate_public_ip_address = var.assign_public_ip + + root_block_device { + volume_type = "gp3" + volume_size = var.volume_size + iops = 3000 + throughput = 125 + delete_on_termination = var.broker_count == 1 # prod에서는 데이터 보존 + encrypted = true + } + + user_data = base64encode(templatefile( + var.broker_count > 1 ? "${path.module}/user-data-cluster.sh" : "${path.module}/user-data.sh", + { + kafka_version = var.kafka_version + kafka_cluster_id = var.cluster_id + kafka_broker_host = "${var.name_prefix}-kafka-${each.key}" + kafka_port = local.kafka_port + kraft_port = local.kraft_port + internal_port = local.internal_port + log_retention_hours = var.log_retention_hours + log_retention_gb = var.log_retention_gb + node_id = each.key + broker_count = var.broker_count + } + )) + + tags = merge(var.common_tags, { + Name = "${var.name_prefix}-kafka-${each.key}" + BrokerId = each.key + AZ = each.value.az_suffix + }) + + lifecycle { + ignore_changes = [ami, user_data] + } +} + +# ============================================================================= +# Route53 Private DNS (서비스 디스커버리용) +# ============================================================================= +resource "aws_route53_zone" "kafka" { + count = var.create_private_dns ? 1 : 0 + + name = "kafka.internal" + + vpc { + vpc_id = var.vpc_id + } + + tags = merge(var.common_tags, { Name = "${var.name_prefix}-kafka-zone" }) +} + +# 개별 브로커 DNS 레코드 +resource "aws_route53_record" "kafka_brokers" { + for_each = var.create_private_dns ? local.brokers : {} + + zone_id = aws_route53_zone.kafka[0].zone_id + name = "kafka-${each.key}" + type = "A" + ttl = 60 + records = [aws_instance.kafka[each.key].private_ip] +} + +# 클러스터 부트스트랩 레코드 (모든 브로커 IP) +resource "aws_route53_record" "kafka_bootstrap" { + count = var.create_private_dns ? 1 : 0 + + zone_id = aws_route53_zone.kafka[0].zone_id + name = "bootstrap" + type = "A" + ttl = 60 + records = [for k, v in aws_instance.kafka : v.private_ip] +} diff --git a/infra/terraform/modules/kafka/outputs.tf b/infra/terraform/modules/kafka/outputs.tf new file mode 100644 index 00000000..52d3b4e6 --- /dev/null +++ b/infra/terraform/modules/kafka/outputs.tf @@ -0,0 +1,39 @@ +# ============================================================================= +# Kafka Module Outputs +# ============================================================================= + +output "instance_ids" { + description = "Kafka EC2 인스턴스 ID 맵" + value = { for k, v in aws_instance.kafka : k => v.id } +} + +output "private_ips" { + description = "Kafka 프라이빗 IP 맵" + value = { for k, v in aws_instance.kafka : k => v.private_ip } +} + +output "bootstrap_servers" { + description = "Kafka Bootstrap Servers (ECS 환경변수용)" + value = join(",", [for k, v in aws_instance.kafka : "${v.private_ip}:9092"]) +} + +output "security_group_id" { + description = "Kafka 보안그룹 ID" + value = aws_security_group.kafka.id +} + +output "broker_count" { + description = "Kafka 브로커 수" + value = var.broker_count +} + +# Private DNS endpoints +output "dns_bootstrap_endpoint" { + description = "Kafka Bootstrap DNS 엔드포인트" + value = var.create_private_dns ? "bootstrap.kafka.internal:9092" : null +} + +output "dns_broker_endpoints" { + description = "개별 브로커 DNS 엔드포인트" + value = var.create_private_dns ? { for k, v in local.brokers : k => "kafka-${k}.kafka.internal:9092" } : {} +} diff --git a/infra/terraform/modules/kafka/user-data-cluster.sh b/infra/terraform/modules/kafka/user-data-cluster.sh new file mode 100644 index 00000000..4f3fa21e --- /dev/null +++ b/infra/terraform/modules/kafka/user-data-cluster.sh @@ -0,0 +1,84 @@ +#!/bin/bash +set -ex + +# ============================================================================= +# Kafka KRaft Mode Setup (Multi-Broker Cluster for Production) +# ============================================================================= + +exec > >(tee /var/log/user-data.log) 2>&1 + +echo "=== Installing Docker ===" +dnf update -y +dnf install -y docker jq +systemctl enable docker +systemctl start docker +usermod -aG docker ec2-user + +echo "=== Creating Kafka directories ===" +mkdir -p /data/kafka +chmod 777 /data/kafka + +echo "=== Getting Instance Metadata ===" +TOKEN=$(curl -s -X PUT "http://169.254.169.254/latest/api/token" -H "X-aws-ec2-metadata-token-ttl-seconds: 21600") +PRIVATE_IP=$(curl -s -H "X-aws-ec2-metadata-token: $TOKEN" http://169.254.169.254/latest/meta-data/local-ipv4) + +NODE_ID=${node_id} +BROKER_COUNT=${broker_count} + +echo "=== Starting Kafka (KRaft Mode - Node $NODE_ID of $BROKER_COUNT) ===" + +# Cluster configuration based on broker count +if [ "$BROKER_COUNT" -gt 1 ]; then + REPLICATION_FACTOR=3 + MIN_ISR=2 +else + REPLICATION_FACTOR=1 + MIN_ISR=1 +fi + +# For multi-broker, use DNS names for quorum voters +# Initial setup uses placeholder - actual IPs are resolved via Route53 +if [ "$BROKER_COUNT" -gt 1 ]; then + # Using private IPs directly since Route53 may not be ready at boot time + # The controller quorum voters will be configured using the broker's own IP + # and will discover other brokers through the cluster + CONTROLLER_QUORUM_VOTERS="1@kafka-1.kafka.internal:${kraft_port},2@kafka-2.kafka.internal:${kraft_port},3@kafka-3.kafka.internal:${kraft_port}" +else + CONTROLLER_QUORUM_VOTERS="1@localhost:${kraft_port}" +fi + +docker run -d \ + --name kafka \ + --restart unless-stopped \ + --network host \ + -e KAFKA_CFG_NODE_ID=$NODE_ID \ + -e KAFKA_CFG_PROCESS_ROLES=broker,controller \ + -e KAFKA_CFG_CONTROLLER_QUORUM_VOTERS=$CONTROLLER_QUORUM_VOTERS \ + -e KAFKA_CFG_LISTENERS=PLAINTEXT://:${kafka_port},CONTROLLER://:${kraft_port},INTERNAL://:${internal_port} \ + -e KAFKA_CFG_ADVERTISED_LISTENERS=PLAINTEXT://$${PRIVATE_IP}:${kafka_port},INTERNAL://$${PRIVATE_IP}:${internal_port} \ + -e KAFKA_CFG_LISTENER_SECURITY_PROTOCOL_MAP=CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT,INTERNAL:PLAINTEXT \ + -e KAFKA_CFG_CONTROLLER_LISTENER_NAMES=CONTROLLER \ + -e KAFKA_CFG_INTER_BROKER_LISTENER_NAME=INTERNAL \ + -e KAFKA_KRAFT_CLUSTER_ID=${kafka_cluster_id} \ + -e KAFKA_CFG_LOG_RETENTION_HOURS=${log_retention_hours} \ + -e KAFKA_CFG_LOG_RETENTION_BYTES=$((${log_retention_gb} * 1024 * 1024 * 1024)) \ + -e KAFKA_CFG_AUTO_CREATE_TOPICS_ENABLE=true \ + -e KAFKA_CFG_NUM_PARTITIONS=3 \ + -e KAFKA_CFG_DEFAULT_REPLICATION_FACTOR=$REPLICATION_FACTOR \ + -e KAFKA_CFG_MIN_INSYNC_REPLICAS=$MIN_ISR \ + -e KAFKA_CFG_OFFSETS_TOPIC_REPLICATION_FACTOR=$REPLICATION_FACTOR \ + -e KAFKA_CFG_TRANSACTION_STATE_LOG_REPLICATION_FACTOR=$REPLICATION_FACTOR \ + -e KAFKA_CFG_TRANSACTION_STATE_LOG_MIN_ISR=$MIN_ISR \ + -v /data/kafka:/bitnami/kafka \ + bitnami/kafka:${kafka_version} + +echo "=== Waiting for Kafka to start ===" +sleep 45 + +# Health check +docker logs kafka + +echo "=== Kafka Node $NODE_ID setup complete ===" +echo "Bootstrap servers: $${PRIVATE_IP}:${kafka_port}" +echo "Broker count: $BROKER_COUNT" +echo "Replication factor: $REPLICATION_FACTOR" diff --git a/infra/terraform/modules/kafka/user-data.sh b/infra/terraform/modules/kafka/user-data.sh new file mode 100644 index 00000000..ae11f083 --- /dev/null +++ b/infra/terraform/modules/kafka/user-data.sh @@ -0,0 +1,60 @@ +#!/bin/bash +set -ex + +# ============================================================================= +# Kafka KRaft Mode Setup (Single Node for Dev) +# ============================================================================= + +# 로그 설정 +exec > >(tee /var/log/user-data.log) 2>&1 + +echo "=== Installing Docker ===" +dnf update -y +dnf install -y docker +systemctl enable docker +systemctl start docker + +# Docker 그룹에 ec2-user 추가 +usermod -aG docker ec2-user + +echo "=== Creating Kafka directories ===" +mkdir -p /data/kafka +chmod 777 /data/kafka + +echo "=== Starting Kafka (KRaft Mode) ===" +# 호스트의 private IP 가져오기 +PRIVATE_IP=$(TOKEN=$(curl -s -X PUT "http://169.254.169.254/latest/api/token" -H "X-aws-ec2-metadata-token-ttl-seconds: 21600") && curl -s -H "X-aws-ec2-metadata-token: $TOKEN" http://169.254.169.254/latest/meta-data/local-ipv4) + +docker run -d \ + --name kafka \ + --restart unless-stopped \ + -p ${kafka_port}:9092 \ + -p ${kraft_port}:9093 \ + -e KAFKA_CFG_NODE_ID=1 \ + -e KAFKA_CFG_PROCESS_ROLES=broker,controller \ + -e KAFKA_CFG_CONTROLLER_QUORUM_VOTERS=1@localhost:${kraft_port} \ + -e KAFKA_CFG_LISTENERS=PLAINTEXT://:9092,CONTROLLER://:9093 \ + -e KAFKA_CFG_ADVERTISED_LISTENERS=PLAINTEXT://$${PRIVATE_IP}:9092 \ + -e KAFKA_CFG_LISTENER_SECURITY_PROTOCOL_MAP=CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT \ + -e KAFKA_CFG_CONTROLLER_LISTENER_NAMES=CONTROLLER \ + -e KAFKA_CFG_INTER_BROKER_LISTENER_NAME=PLAINTEXT \ + -e KAFKA_KRAFT_CLUSTER_ID=${kafka_cluster_id} \ + -e KAFKA_CFG_LOG_RETENTION_HOURS=${log_retention_hours} \ + -e KAFKA_CFG_LOG_RETENTION_BYTES=$((${log_retention_gb} * 1024 * 1024 * 1024)) \ + -e KAFKA_CFG_AUTO_CREATE_TOPICS_ENABLE=true \ + -e KAFKA_CFG_NUM_PARTITIONS=3 \ + -e KAFKA_CFG_DEFAULT_REPLICATION_FACTOR=1 \ + -e KAFKA_CFG_OFFSETS_TOPIC_REPLICATION_FACTOR=1 \ + -e KAFKA_CFG_TRANSACTION_STATE_LOG_REPLICATION_FACTOR=1 \ + -e KAFKA_CFG_TRANSACTION_STATE_LOG_MIN_ISR=1 \ + -v /data/kafka:/bitnami/kafka \ + bitnami/kafka:${kafka_version} + +echo "=== Waiting for Kafka to start ===" +sleep 30 + +# 헬스체크 +docker logs kafka + +echo "=== Kafka setup complete ===" +echo "Bootstrap servers: $${PRIVATE_IP}:9092" diff --git a/infra/terraform/modules/kafka/variables.tf b/infra/terraform/modules/kafka/variables.tf new file mode 100644 index 00000000..0ae646aa --- /dev/null +++ b/infra/terraform/modules/kafka/variables.tf @@ -0,0 +1,112 @@ +# ============================================================================= +# Project Settings +# ============================================================================= +variable "name_prefix" { + description = "리소스 네이밍 프리픽스" + type = string +} + +variable "common_tags" { + description = "공통 태그" + type = map(string) + default = {} +} + +# ============================================================================= +# Network Settings +# ============================================================================= +variable "vpc_id" { + description = "VPC ID" + type = string +} + +variable "vpc_cidr" { + description = "VPC CIDR (SSH 접근용)" + type = string +} + +variable "subnet_id" { + description = "Kafka EC2 서브넷 ID (단일 브로커용, deprecated)" + type = string + default = null +} + +variable "subnet_ids" { + description = "Kafka EC2 서브넷 ID 목록 (멀티 브로커용: [private_a, private_c])" + type = list(string) + default = [] +} + +variable "broker_count" { + description = "Kafka 브로커 수 (1 또는 3 권장)" + type = number + default = 1 +} + +variable "allowed_security_group_ids" { + description = "Kafka 접근 허용할 보안그룹 ID 목록" + type = list(string) +} + +variable "assign_public_ip" { + description = "Public IP 할당 여부" + type = bool + default = true +} + +# ============================================================================= +# EC2 Settings +# ============================================================================= +variable "instance_type" { + description = "EC2 인스턴스 타입" + type = string + default = "t3.small" +} + +variable "volume_size" { + description = "EBS 볼륨 크기 (GB)" + type = number + default = 20 +} + +variable "enable_ssh" { + description = "SSH 접근 허용 여부" + type = bool + default = false +} + +# ============================================================================= +# Kafka Settings +# ============================================================================= +variable "kafka_version" { + description = "Kafka 버전" + type = string + default = "3.7" +} + +variable "cluster_id" { + description = "KRaft 클러스터 ID (고정값 권장)" + type = string + default = "MkU3OEVBNTcwNTJENDM2Qk" +} + +variable "log_retention_hours" { + description = "메시지 보관 시간" + type = number + default = 168 # 7일 +} + +variable "log_retention_gb" { + description = "로그 최대 크기 (GB)" + type = number + default = 10 +} + +# ============================================================================= +# DNS Settings +# ============================================================================= +variable "create_private_dns" { + description = "Private DNS Zone 생성 여부" + type = bool + default = false +} diff --git a/infra/terraform/modules/network/main.tf b/infra/terraform/modules/network/main.tf index 3537fe90..0831a44f 100644 --- a/infra/terraform/modules/network/main.tf +++ b/infra/terraform/modules/network/main.tf @@ -23,6 +23,18 @@ resource "aws_subnet" "public_a" { }) } +resource "aws_subnet" "public_c" { + count = var.use_nat_gateway && !var.single_nat_gateway ? 1 : (contains(keys(var.public_subnet_cidrs), "c") ? 1 : 0) + vpc_id = aws_vpc.main.id + cidr_block = lookup(var.public_subnet_cidrs, "c", "10.1.2.0/24") + availability_zone = var.availability_zones["c"] + + tags = merge(var.common_tags, { + Name = "${var.name_prefix}-public-c" + Tier = "public" + }) +} + # ============================================================================= # Private Subnets # ============================================================================= @@ -55,9 +67,10 @@ resource "aws_internet_gateway" "igw" { } # ============================================================================= -# NAT Instance +# NAT Instance (Development - Cost Optimized) # ============================================================================= resource "aws_security_group" "nat_sg" { + count = var.use_nat_gateway ? 0 : 1 name = "${var.name_prefix}-nat-sg" vpc_id = aws_vpc.main.id @@ -79,6 +92,7 @@ resource "aws_security_group" "nat_sg" { } data "aws_ami" "al2023" { + count = var.use_nat_gateway ? 0 : 1 most_recent = true owners = ["amazon"] @@ -94,10 +108,11 @@ data "aws_ami" "al2023" { } resource "aws_instance" "nat_instance" { - ami = data.aws_ami.al2023.id + count = var.use_nat_gateway ? 0 : 1 + ami = data.aws_ami.al2023[0].id instance_type = var.nat_instance_type subnet_id = aws_subnet.public_a.id - vpc_security_group_ids = [aws_security_group.nat_sg.id] + vpc_security_group_ids = [aws_security_group.nat_sg[0].id] associate_public_ip_address = true source_dest_check = false @@ -112,6 +127,32 @@ resource "aws_instance" "nat_instance" { tags = merge(var.common_tags, { Name = "${var.name_prefix}-nat-instance" }) } +# ============================================================================= +# NAT Gateway (Production - High Availability) +# ============================================================================= +resource "aws_eip" "nat" { + count = var.use_nat_gateway ? (var.single_nat_gateway ? 1 : 2) : 0 + domain = "vpc" + + tags = merge(var.common_tags, { + Name = "${var.name_prefix}-nat-eip-${count.index == 0 ? "a" : "c"}" + }) + + depends_on = [aws_internet_gateway.igw] +} + +resource "aws_nat_gateway" "main" { + count = var.use_nat_gateway ? (var.single_nat_gateway ? 1 : 2) : 0 + allocation_id = aws_eip.nat[count.index].id + subnet_id = count.index == 0 ? aws_subnet.public_a.id : aws_subnet.public_c[0].id + + tags = merge(var.common_tags, { + Name = "${var.name_prefix}-nat-gw-${count.index == 0 ? "a" : "c"}" + }) + + depends_on = [aws_internet_gateway.igw] +} + # ============================================================================= # Route Tables # ============================================================================= @@ -131,23 +172,44 @@ resource "aws_route_table_association" "public_a" { route_table_id = aws_route_table.public.id } -resource "aws_route_table" "private" { +resource "aws_route_table_association" "public_c" { + count = length(aws_subnet.public_c) > 0 ? 1 : 0 + subnet_id = aws_subnet.public_c[0].id + route_table_id = aws_route_table.public.id +} + +# Private Route Table for AZ-a +resource "aws_route_table" "private_a" { vpc_id = aws_vpc.main.id route { cidr_block = "0.0.0.0/0" - network_interface_id = aws_instance.nat_instance.primary_network_interface_id + nat_gateway_id = var.use_nat_gateway ? aws_nat_gateway.main[0].id : null + network_interface_id = var.use_nat_gateway ? null : aws_instance.nat_instance[0].primary_network_interface_id + } + + tags = merge(var.common_tags, { Name = "${var.name_prefix}-private-rt-a" }) +} + +# Private Route Table for AZ-c (separate when using multi NAT Gateway) +resource "aws_route_table" "private_c" { + count = var.use_nat_gateway && !var.single_nat_gateway ? 1 : 0 + vpc_id = aws_vpc.main.id + + route { + cidr_block = "0.0.0.0/0" + nat_gateway_id = aws_nat_gateway.main[1].id } - tags = merge(var.common_tags, { Name = "${var.name_prefix}-private-rt" }) + tags = merge(var.common_tags, { Name = "${var.name_prefix}-private-rt-c" }) } resource "aws_route_table_association" "private_a" { subnet_id = aws_subnet.private_a.id - route_table_id = aws_route_table.private.id + route_table_id = aws_route_table.private_a.id } resource "aws_route_table_association" "private_c" { subnet_id = aws_subnet.private_c.id - route_table_id = aws_route_table.private.id + route_table_id = var.use_nat_gateway && !var.single_nat_gateway ? aws_route_table.private_c[0].id : aws_route_table.private_a.id } diff --git a/infra/terraform/modules/network/outputs.tf b/infra/terraform/modules/network/outputs.tf index 48ceef86..008c656d 100644 --- a/infra/terraform/modules/network/outputs.tf +++ b/infra/terraform/modules/network/outputs.tf @@ -13,6 +13,16 @@ output "public_subnet_a_id" { value = aws_subnet.public_a.id } +output "public_subnet_c_id" { + description = "Public Subnet C ID" + value = length(aws_subnet.public_c) > 0 ? aws_subnet.public_c[0].id : null +} + +output "public_subnet_ids" { + description = "Public Subnet IDs" + value = length(aws_subnet.public_c) > 0 ? [aws_subnet.public_a.id, aws_subnet.public_c[0].id] : [aws_subnet.public_a.id] +} + output "private_subnet_a_id" { description = "Private Subnet A ID" value = aws_subnet.private_a.id @@ -27,3 +37,21 @@ output "private_subnet_ids" { description = "Private Subnet IDs" value = [aws_subnet.private_a.id, aws_subnet.private_c.id] } + +# ============================================================================= +# NAT Gateway Outputs +# ============================================================================= +output "nat_gateway_ids" { + description = "NAT Gateway IDs" + value = aws_nat_gateway.main[*].id +} + +output "nat_elastic_ips" { + description = "NAT Gateway Elastic IPs" + value = aws_eip.nat[*].public_ip +} + +output "nat_type" { + description = "NAT 유형 (gateway 또는 instance)" + value = var.use_nat_gateway ? "gateway" : "instance" +} diff --git a/infra/terraform/modules/network/variables.tf b/infra/terraform/modules/network/variables.tf index 9a1257b1..fba795a0 100644 --- a/infra/terraform/modules/network/variables.tf +++ b/infra/terraform/modules/network/variables.tf @@ -34,3 +34,18 @@ variable "nat_instance_type" { type = string default = "t3.nano" } + +# ============================================================================= +# NAT Gateway 설정 (Production) +# ============================================================================= +variable "use_nat_gateway" { + description = "NAT Gateway 사용 여부 (false면 NAT Instance)" + type = bool + default = false +} + +variable "single_nat_gateway" { + description = "단일 NAT Gateway 사용 (비용 절감 vs HA)" + type = bool + default = true +} diff --git a/infra/terraform/modules/parameter-store/main.tf b/infra/terraform/modules/parameter-store/main.tf new file mode 100644 index 00000000..aff03602 --- /dev/null +++ b/infra/terraform/modules/parameter-store/main.tf @@ -0,0 +1,97 @@ +# ============================================================================= +# Parameter Store Module +# ============================================================================= + +locals { + prefix = "/${var.project}/${var.environment}" +} + +# ============================================================================= +# 민감 정보 Parameters (SecureString) +# ============================================================================= +resource "aws_ssm_parameter" "db_password" { + name = "${local.prefix}/database/password" + description = "Database password" + type = "SecureString" + value = var.db_password + + tags = merge(var.common_tags, { + Name = "${var.project}-${var.environment}-db-password" + Category = "database" + Type = "secret" + }) +} + +resource "aws_ssm_parameter" "jwt_secret" { + name = "${local.prefix}/secrets/jwt_secret" + description = "JWT secret key" + type = "SecureString" + value = var.jwt_secret + + tags = merge(var.common_tags, { + Name = "${var.project}-${var.environment}-jwt-secret" + Category = "secrets" + Type = "secret" + }) +} + +resource "aws_ssm_parameter" "mail_password" { + count = var.mail_password != "" ? 1 : 0 + + name = "${local.prefix}/secrets/mail_password" + description = "SMTP password" + type = "SecureString" + value = var.mail_password + + tags = merge(var.common_tags, { + Name = "${var.project}-${var.environment}-mail-password" + Category = "secrets" + Type = "secret" + }) +} + +resource "aws_ssm_parameter" "toss_secret_key" { + count = var.toss_secret_key != "" ? 1 : 0 + + name = "${local.prefix}/secrets/toss_secret_key" + description = "Toss Payments secret key" + type = "SecureString" + value = var.toss_secret_key + + tags = merge(var.common_tags, { + Name = "${var.project}-${var.environment}-toss-secret-key" + Category = "secrets" + Type = "secret" + }) +} + +# ============================================================================= +# 동적 인프라 값 Parameters (String) +# ============================================================================= +resource "aws_ssm_parameter" "db_endpoint" { + name = "${local.prefix}/database/endpoint" + description = "RDS endpoint (auto-populated by Terraform)" + type = "String" + value = var.db_endpoint + + tags = merge(var.common_tags, { + Name = "${var.project}-${var.environment}-db-endpoint" + Category = "database" + Type = "infrastructure" + }) +} + +resource "aws_ssm_parameter" "redis_endpoint" { + count = var.redis_endpoint != "" ? 1 : 0 + + name = "${local.prefix}/cache/redis_endpoint" + description = "Redis endpoint (auto-populated by Terraform)" + type = "String" + value = var.redis_endpoint + + tags = merge(var.common_tags, { + Name = "${var.project}-${var.environment}-redis-endpoint" + Category = "cache" + Type = "infrastructure" + }) +} diff --git a/infra/terraform/modules/parameter-store/outputs.tf b/infra/terraform/modules/parameter-store/outputs.tf new file mode 100644 index 00000000..a1d19c00 --- /dev/null +++ b/infra/terraform/modules/parameter-store/outputs.tf @@ -0,0 +1,59 @@ +# ============================================================================= +# Parameter Store Module Outputs +# ============================================================================= + +# ============================================================================= +# Parameter ARNs (ECS Task Definition secrets 블록에서 사용) +# ============================================================================= +output "db_password_arn" { + description = "DB Password Parameter ARN" + value = aws_ssm_parameter.db_password.arn +} + +output "jwt_secret_arn" { + description = "JWT Secret Parameter ARN" + value = aws_ssm_parameter.jwt_secret.arn +} + +output "mail_password_arn" { + description = "Mail Password Parameter ARN" + value = var.mail_password != "" ? aws_ssm_parameter.mail_password[0].arn : null +} + +output "toss_secret_key_arn" { + description = "Toss Secret Key Parameter ARN" + value = var.toss_secret_key != "" ? aws_ssm_parameter.toss_secret_key[0].arn : null +} + +output "db_endpoint_arn" { + description = "DB Endpoint Parameter ARN" + value = aws_ssm_parameter.db_endpoint.arn +} + +output "redis_endpoint_arn" { + description = "Redis Endpoint Parameter ARN" + value = var.redis_endpoint != "" ? aws_ssm_parameter.redis_endpoint[0].arn : null +} + +# ============================================================================= +# All Parameter ARNs (IAM Policy용) +# ============================================================================= +output "all_parameter_arns" { + description = "모든 Parameter ARN 목록 (IAM Policy용)" + value = compact([ + aws_ssm_parameter.db_password.arn, + aws_ssm_parameter.jwt_secret.arn, + var.mail_password != "" ? aws_ssm_parameter.mail_password[0].arn : null, + var.toss_secret_key != "" ? aws_ssm_parameter.toss_secret_key[0].arn : null, + aws_ssm_parameter.db_endpoint.arn, + var.redis_endpoint != "" ? aws_ssm_parameter.redis_endpoint[0].arn : null, + ]) +} + +# ============================================================================= +# Parameter Name Prefix (for wildcard IAM policies) +# ============================================================================= +output "parameter_prefix" { + description = "Parameter Store prefix for IAM policies" + value = "/${var.project}/${var.environment}" +} diff --git a/infra/terraform/modules/parameter-store/variables.tf b/infra/terraform/modules/parameter-store/variables.tf new file mode 100644 index 00000000..0e199bb2 --- /dev/null +++ b/infra/terraform/modules/parameter-store/variables.tf @@ -0,0 +1,62 @@ +# ============================================================================= +# Parameter Store Module Variables +# ============================================================================= + +variable "project" { + description = "프로젝트 이름" + type = string +} + +variable "environment" { + description = "환경 (dev, prod)" + type = string +} + +variable "common_tags" { + description = "공통 태그" + type = map(string) + default = {} +} + +# ============================================================================= +# 민감 정보 (SecureString으로 저장) +# ============================================================================= +variable "db_password" { + description = "데이터베이스 비밀번호" + type = string + sensitive = true +} + +variable "jwt_secret" { + description = "JWT 시크릿 키" + type = string + sensitive = true +} + +variable "mail_password" { + description = "SMTP 비밀번호" + type = string + sensitive = true + default = "" +} + +variable "toss_secret_key" { + description = "Toss Payments 시크릿 키" + type = string + sensitive = true + default = "" +} + +# ============================================================================= +# 동적 인프라 값 (String으로 저장) +# ============================================================================= +variable "db_endpoint" { + description = "RDS 엔드포인트 (Terraform이 생성 후 자동 저장)" + type = string +} + +variable "redis_endpoint" { + description = "Redis 엔드포인트 (Terraform이 생성 후 자동 저장)" + type = string + default = "" +} diff --git a/spot-gateway/build.gradle b/spot-gateway/build.gradle index 131eda53..41322b16 100644 --- a/spot-gateway/build.gradle +++ b/spot-gateway/build.gradle @@ -3,7 +3,11 @@ plugins { id 'org.springframework.boot' version '3.5.9' id 'io.spring.dependency-management' version '1.1.7' } - +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} repositories { mavenCentral() } dependencies { From 9ad4b9cbfef571ec8fac23e2691233f3a1a486ab Mon Sep 17 00:00:00 2001 From: dbswjd7 Date: Tue, 27 Jan 2026 13:52:34 +0900 Subject: [PATCH 72/77] feat(#221): paymentlistner conflict fix --- .../Spot/payments/application/service/PaymentService.java | 1 - .../Spot/payments/infrastructure/listener/PaymentListener.java | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/spot-payment/src/main/java/com/example/Spot/payments/application/service/PaymentService.java b/spot-payment/src/main/java/com/example/Spot/payments/application/service/PaymentService.java index a825b283..5ce649a5 100644 --- a/spot-payment/src/main/java/com/example/Spot/payments/application/service/PaymentService.java +++ b/spot-payment/src/main/java/com/example/Spot/payments/application/service/PaymentService.java @@ -14,7 +14,6 @@ import com.example.Spot.global.feign.StoreClient; import com.example.Spot.global.feign.UserClient; import com.example.Spot.global.feign.dto.OrderResponse; -import com.example.Spot.global.feign.dto.UserResponse; import com.example.Spot.global.presentation.advice.BillingKeyNotFoundException; import com.example.Spot.global.presentation.advice.ResourceNotFoundException; import com.example.Spot.payments.domain.entity.PaymentEntity; diff --git a/spot-payment/src/main/java/com/example/Spot/payments/infrastructure/listener/PaymentListener.java b/spot-payment/src/main/java/com/example/Spot/payments/infrastructure/listener/PaymentListener.java index 9a3c7f33..4986e22b 100644 --- a/spot-payment/src/main/java/com/example/Spot/payments/infrastructure/listener/PaymentListener.java +++ b/spot-payment/src/main/java/com/example/Spot/payments/infrastructure/listener/PaymentListener.java @@ -44,7 +44,7 @@ public void handleOrderCreated(String message) { .build(); // 2. 가공된 DTO를 서비스에 넘기기 - UUID paymentId = paymentService.ready(confirmRequest); + UUID paymentId = paymentService.ready(event.getUserId(), event.getOrderId(), confirmRequest); // 3. 결제 시도 및 결과에 따른 분기 처리 try { From ca5eae32e6f07b2ae023e4831b92f72f93a29031 Mon Sep 17 00:00:00 2001 From: dbswjd7 Date: Thu, 29 Jan 2026 13:42:31 +0900 Subject: [PATCH 73/77] =?UTF-8?q?feat(#261):=20CI=20=ED=97=AC=EC=8A=A4?= =?UTF-8?q?=EC=B2=B4=ED=81=AC=20-=20actuator=20security=20chain=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../config/security/SecurityConfig.java | 26 ++++++++++------ .../config/security/SecurityConfig.java | 31 +++++++++++++------ 2 files changed, 37 insertions(+), 20 deletions(-) diff --git a/spot-order/src/main/java/com/example/Spot/global/infrastructure/config/security/SecurityConfig.java b/spot-order/src/main/java/com/example/Spot/global/infrastructure/config/security/SecurityConfig.java index accca684..2abe5701 100644 --- a/spot-order/src/main/java/com/example/Spot/global/infrastructure/config/security/SecurityConfig.java +++ b/spot-order/src/main/java/com/example/Spot/global/infrastructure/config/security/SecurityConfig.java @@ -1,7 +1,9 @@ package com.example.Spot.global.infrastructure.config.security; +import org.springframework.boot.actuate.autoconfigure.security.servlet.EndpointRequest; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; import org.springframework.http.HttpMethod; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; @@ -22,19 +24,26 @@ public SecurityConfig(JWTUtil jwtUtil) { } @Bean - public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + @Order(0) + public SecurityFilterChain actuatorChain(HttpSecurity http) throws Exception { + http.securityMatcher(EndpointRequest.toAnyEndpoint()); + http.csrf(csrf -> csrf.disable()); + http.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); + http.authorizeHttpRequests(auth -> auth.anyRequest().permitAll()); + return http.build(); + } + @Bean + @Order(1) + public SecurityFilterChain apiChain(HttpSecurity http) throws Exception { http.csrf(csrf -> csrf.disable()); http.formLogin(form -> form.disable()); http.httpBasic(basic -> basic.disable()); http.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); http.authorizeHttpRequests(auth -> auth - .requestMatchers(HttpMethod.OPTIONS, "/**", "/actuator/**").permitAll() - .requestMatchers( - "/", "/swagger-ui/**", "/v3/api-docs/**", - "/api/internal/**" - ).permitAll() + .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() + .requestMatchers("/", "/swagger-ui/**", "/v3/api-docs/**", "/api/internal/**").permitAll() .anyRequest().authenticated() ); @@ -51,10 +60,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { }) ); - http.addFilterBefore( - new JWTFilter(jwtUtil), - UsernamePasswordAuthenticationFilter.class - ); + http.addFilterBefore(new JWTFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class); return http.build(); } } diff --git a/spot-payment/src/main/java/com/example/Spot/global/infrastructure/config/security/SecurityConfig.java b/spot-payment/src/main/java/com/example/Spot/global/infrastructure/config/security/SecurityConfig.java index efce0bb4..7bf7e4e5 100644 --- a/spot-payment/src/main/java/com/example/Spot/global/infrastructure/config/security/SecurityConfig.java +++ b/spot-payment/src/main/java/com/example/Spot/global/infrastructure/config/security/SecurityConfig.java @@ -1,7 +1,10 @@ package com.example.Spot.global.infrastructure.config.security; +import org.springframework.boot.actuate.autoconfigure.security.servlet.EndpointRequest; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; +import org.springframework.http.HttpMethod; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; @@ -20,20 +23,30 @@ public class SecurityConfig { public SecurityConfig(JWTUtil jwtUtil) { this.jwtUtil = jwtUtil; } + @Bean - public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + @Order(0) + public SecurityFilterChain actuatorChain(HttpSecurity http) throws Exception { + http.securityMatcher(EndpointRequest.toAnyEndpoint()); + http.csrf(csrf -> csrf.disable()); + http.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); + http.authorizeHttpRequests(auth -> auth.anyRequest().permitAll()); + return http.build(); + } + + + @Bean + @Order(1) + public SecurityFilterChain apiChain(HttpSecurity http) throws Exception { http.csrf(csrf -> csrf.disable()); http.formLogin(form -> form.disable()); http.httpBasic(basic -> basic.disable()); http.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); http.authorizeHttpRequests(auth -> auth - .requestMatchers("OPTIONS", "/**", "/actuator/**").permitAll() - .requestMatchers( - "/", "/swagger-ui/**", "/v3/api-docs/**", - "/api/stores/**", "/api/categories/**" - ).permitAll() + .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() + .requestMatchers("/", "/swagger-ui/**", "/v3/api-docs/**", "/api/internal/**").permitAll() .anyRequest().authenticated() ); @@ -50,10 +63,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { }) ); - http.addFilterBefore( - new JWTFilter(jwtUtil), - UsernamePasswordAuthenticationFilter.class - ); + http.addFilterBefore(new JWTFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class); return http.build(); } } + From 79613e9fc1c5f7e662c5ab57345c5b1de50e21fc Mon Sep 17 00:00:00 2001 From: dbswjd7 Date: Mon, 2 Feb 2026 13:58:25 +0900 Subject: [PATCH 74/77] =?UTF-8?q?feat(#239):=20admin=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=20user,=20store,=20order=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/spot-gateway.yml | 73 +- config/spot-user.yml | 4 +- node_modules/.package-lock.json | 34 + node_modules/@types/react-dom/LICENSE | 21 + node_modules/@types/react-dom/README.md | 16 + node_modules/@types/react-dom/canary.d.ts | 71 + node_modules/@types/react-dom/client.d.ts | 105 + .../@types/react-dom/experimental.d.ts | 54 + node_modules/@types/react-dom/index.d.ts | 133 + node_modules/@types/react-dom/package.json | 128 + .../@types/react-dom/server.browser.d.ts | 1 + node_modules/@types/react-dom/server.bun.d.ts | 1 + node_modules/@types/react-dom/server.d.ts | 183 + .../@types/react-dom/server.edge.d.ts | 1 + .../@types/react-dom/server.node.d.ts | 8 + .../@types/react-dom/static.browser.d.ts | 1 + node_modules/@types/react-dom/static.d.ts | 153 + .../@types/react-dom/static.edge.d.ts | 1 + .../@types/react-dom/static.node.d.ts | 7 + .../@types/react-dom/test-utils/index.d.ts | 7 + node_modules/@types/react/LICENSE | 21 + node_modules/@types/react/README.md | 15 + node_modules/@types/react/canary.d.ts | 120 + .../@types/react/compiler-runtime.d.ts | 4 + node_modules/@types/react/experimental.d.ts | 177 + node_modules/@types/react/global.d.ts | 166 + node_modules/@types/react/index.d.ts | 4362 +++ .../@types/react/jsx-dev-runtime.d.ts | 45 + node_modules/@types/react/jsx-runtime.d.ts | 36 + node_modules/@types/react/package.json | 210 + node_modules/@types/react/ts5.0/canary.d.ts | 120 + .../@types/react/ts5.0/experimental.d.ts | 177 + node_modules/@types/react/ts5.0/global.d.ts | 166 + node_modules/@types/react/ts5.0/index.d.ts | 4346 +++ .../@types/react/ts5.0/jsx-dev-runtime.d.ts | 44 + .../@types/react/ts5.0/jsx-runtime.d.ts | 35 + node_modules/csstype/LICENSE | 19 + node_modules/csstype/README.md | 291 + node_modules/csstype/index.d.ts | 22569 ++++++++++++++++ node_modules/csstype/index.js.flow | 6863 +++++ node_modules/csstype/package.json | 70 + package-lock.json | 40 + package.json | 6 + .../global/feign/dto/OrderPageResponse.java | 37 + .../Spot/global/feign/dto/OrderResponse.java | 23 + .../global/feign/dto/OrderStatsResponse.java | 39 + .../config/security/SecurityConfig.java | 1 + .../infra/auth/security/DevPrincipal.java | 3 - .../InternalOrderAdminController.java | 60 + .../service/InternalOrderAdminService.java | 98 + .../application/service/OrderServiceImpl.java | 2 - .../repository/OrderItemOptionRepository.java | 8 + .../repository/OrderItemRepository.java | 3 + .../domain/repository/OrderRepository.java | 14 + .../order/infrastructure/aop/OrderAspect.java | 3 - .../application/service/PaymentService.java | 1 - .../config/security/SecurityConfig.java | 5 +- .../controller/InternalStoreController.java | 1 + .../application/service/ReviewService.java | 2 - .../service/AdminStoreInternalService.java | 4 + .../application/service/UserCallService.java | 2 - .../InternalAdminStoreController.java | 10 +- spot-user/build.gradle | 1 + .../admin/AdminDashboardExecutorConfig.java | 4 + .../service/AdminDashboardStatsService.java | 0 .../service/AdminOrderService.java | 38 + .../service/AdminStatsService.java | 56 +- .../service/AdminStoreService.java | 21 +- .../controller/AdminOrderController.java | 58 + .../controller/AdminStatsController.java | 36 +- .../controller/AdminStoreController.java | 7 + .../Spot/global/feign/OrderClient.java | 6 +- .../Spot/global/feign/StoreAdminClient.java | 14 + .../Spot/global/feign/StoreClient.java | 1 + .../Spot/global/feign/dto/OrderResponse.java | 23 +- .../global/feign/dto/OrderStatsResponse.java | 8 + .../config/security/SecurityConfig.java | 6 +- 77 files changed, 41397 insertions(+), 102 deletions(-) create mode 100644 node_modules/.package-lock.json create mode 100644 node_modules/@types/react-dom/LICENSE create mode 100644 node_modules/@types/react-dom/README.md create mode 100644 node_modules/@types/react-dom/canary.d.ts create mode 100644 node_modules/@types/react-dom/client.d.ts create mode 100644 node_modules/@types/react-dom/experimental.d.ts create mode 100644 node_modules/@types/react-dom/index.d.ts create mode 100644 node_modules/@types/react-dom/package.json create mode 100644 node_modules/@types/react-dom/server.browser.d.ts create mode 100644 node_modules/@types/react-dom/server.bun.d.ts create mode 100644 node_modules/@types/react-dom/server.d.ts create mode 100644 node_modules/@types/react-dom/server.edge.d.ts create mode 100644 node_modules/@types/react-dom/server.node.d.ts create mode 100644 node_modules/@types/react-dom/static.browser.d.ts create mode 100644 node_modules/@types/react-dom/static.d.ts create mode 100644 node_modules/@types/react-dom/static.edge.d.ts create mode 100644 node_modules/@types/react-dom/static.node.d.ts create mode 100644 node_modules/@types/react-dom/test-utils/index.d.ts create mode 100644 node_modules/@types/react/LICENSE create mode 100644 node_modules/@types/react/README.md create mode 100644 node_modules/@types/react/canary.d.ts create mode 100644 node_modules/@types/react/compiler-runtime.d.ts create mode 100644 node_modules/@types/react/experimental.d.ts create mode 100644 node_modules/@types/react/global.d.ts create mode 100644 node_modules/@types/react/index.d.ts create mode 100644 node_modules/@types/react/jsx-dev-runtime.d.ts create mode 100644 node_modules/@types/react/jsx-runtime.d.ts create mode 100644 node_modules/@types/react/package.json create mode 100644 node_modules/@types/react/ts5.0/canary.d.ts create mode 100644 node_modules/@types/react/ts5.0/experimental.d.ts create mode 100644 node_modules/@types/react/ts5.0/global.d.ts create mode 100644 node_modules/@types/react/ts5.0/index.d.ts create mode 100644 node_modules/@types/react/ts5.0/jsx-dev-runtime.d.ts create mode 100644 node_modules/@types/react/ts5.0/jsx-runtime.d.ts create mode 100644 node_modules/csstype/LICENSE create mode 100644 node_modules/csstype/README.md create mode 100644 node_modules/csstype/index.d.ts create mode 100644 node_modules/csstype/index.js.flow create mode 100644 node_modules/csstype/package.json create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 spot-order/src/main/java/com/example/Spot/global/feign/dto/OrderPageResponse.java create mode 100644 spot-order/src/main/java/com/example/Spot/global/feign/dto/OrderResponse.java create mode 100644 spot-order/src/main/java/com/example/Spot/global/feign/dto/OrderStatsResponse.java delete mode 100644 spot-order/src/main/java/com/example/Spot/infra/auth/security/DevPrincipal.java create mode 100644 spot-order/src/main/java/com/example/Spot/internal/controller/InternalOrderAdminController.java create mode 100644 spot-order/src/main/java/com/example/Spot/order/application/service/InternalOrderAdminService.java create mode 100644 spot-user/src/main/java/com/example/Spot/admin/AdminDashboardExecutorConfig.java create mode 100644 spot-user/src/main/java/com/example/Spot/admin/application/service/AdminDashboardStatsService.java create mode 100644 spot-user/src/main/java/com/example/Spot/admin/application/service/AdminOrderService.java create mode 100644 spot-user/src/main/java/com/example/Spot/admin/presentation/controller/AdminOrderController.java diff --git a/config/spot-gateway.yml b/config/spot-gateway.yml index b46b6a1d..bdf7e2be 100644 --- a/config/spot-gateway.yml +++ b/config/spot-gateway.yml @@ -1,56 +1,73 @@ spring: application: name: spot-gateway + + # =========== 더미 데이터 Insert =========== + # sql: + # init: + # mode: always + # data-locations: classpath:dummy_data.sql + # continue-on-error: true + # + # mvc: + # cors: + # mappings: + # '[/**]': + # allowedOriginPatterns: + # - '*' + # allowedMethods: + # - GET + # - POST + # - PATCH + # - DELETE + # - OPTIONS + # allowedHeaders: + # - '*' + # exposedHeaders: + # - Authorization + # allowCredentials: true + + + cloud: gateway: server: webflux: - globalcors: - cors-configurations: - '[/**]': - allowedOriginPatterns: - - '*' - allowedMethods: - - GET - - POST - - PATCH - - DELETE - - OPTIONS - allowedHeaders: - - '*' - exposedHeaders: - - Authorization - allowCredentials: true routes: - id: user-auth - uri: http://spot-user:8081 + uri: http://localhost:8081 predicates: - Path=/api/login,/api/join,/api/auth/refresh - id: user-service - uri: http://spot-user:8081 + uri: http://localhost:8081 predicates: - Path=/api/users/** + - id: admin-to-user + uri: http://localhost:8081 + predicates: + - Path=/api/admin/** + - id: store-service - uri: http://spot-store:8083 + uri: http://localhost:8083 predicates: - - Path=/api/stores/**, /api/categories/**, /api/reviews/** + - Path=/api/stores/** - id: order-service - uri: http://spot-order:8082 + uri: http://localhost:8082 predicates: - Path=/api/orders/** - id: payment-service - uri: http://spot-payment:8084 + uri: http://localhost:8084 predicates: - Path=/api/payments/** - id: block-internal uri: http://localhost:9999 predicates: - - Path=/internal/** + - Path=/api/internal/** filters: - SetStatus=403 @@ -66,7 +83,9 @@ management: endpoints: web: exposure: - include: "*" - endpoint: - gateway: - enabled: true \ No newline at end of file + include: + - health + - info + - gateway + + diff --git a/config/spot-user.yml b/config/spot-user.yml index c14f1810..88c729c0 100644 --- a/config/spot-user.yml +++ b/config/spot-user.yml @@ -9,8 +9,8 @@ server: feign: order: - url: http://spot-order:8083 + url: http://spot-order:8082 store: - url: http://spot-store:8082 + url: http://spot-store:8083 payment: url: http://spot-payment:8084 diff --git a/node_modules/.package-lock.json b/node_modules/.package-lock.json new file mode 100644 index 00000000..876c871e --- /dev/null +++ b/node_modules/.package-lock.json @@ -0,0 +1,34 @@ +{ + "name": "Spot", + "lockfileVersion": 3, + "requires": true, + "packages": { + "node_modules/@types/react": { + "version": "19.2.10", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.10.tgz", + "integrity": "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/node_modules/@types/react-dom/LICENSE b/node_modules/@types/react-dom/LICENSE new file mode 100644 index 00000000..9e841e7a --- /dev/null +++ b/node_modules/@types/react-dom/LICENSE @@ -0,0 +1,21 @@ + MIT License + + Copyright (c) Microsoft Corporation. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE diff --git a/node_modules/@types/react-dom/README.md b/node_modules/@types/react-dom/README.md new file mode 100644 index 00000000..79ab708b --- /dev/null +++ b/node_modules/@types/react-dom/README.md @@ -0,0 +1,16 @@ +# Installation +> `npm install --save @types/react-dom` + +# Summary +This package contains type definitions for react-dom (https://react.dev/). + +# Details +Files were exported from https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/react-dom. + +### Additional Details + * Last updated: Wed, 12 Nov 2025 04:37:38 GMT + * Dependencies: none + * Peer dependencies: [@types/react](https://npmjs.com/package/@types/react) + +# Credits +These definitions were written by [Asana](https://asana.com), [AssureSign](http://www.assuresign.com), [Microsoft](https://microsoft.com), [MartynasZilinskas](https://github.com/MartynasZilinskas), [Josh Rutherford](https://github.com/theruther4d), [Jessica Franco](https://github.com/Jessidhia), and [Sebastian Silbermann](https://github.com/eps1lon). diff --git a/node_modules/@types/react-dom/canary.d.ts b/node_modules/@types/react-dom/canary.d.ts new file mode 100644 index 00000000..1d65161e --- /dev/null +++ b/node_modules/@types/react-dom/canary.d.ts @@ -0,0 +1,71 @@ +/* eslint-disable @definitelytyped/no-self-import -- self-imports in module augmentations aren't self-imports */ +/* eslint-disable @definitelytyped/no-declare-current-package -- The module augmentations are optional */ +/** + * These are types for things that are present in the upcoming React 18 release. + * + * Once React 18 is released they can just be moved to the main index file. + * + * To load the types declared here in an actual project, there are three ways. The easiest one, + * if your `tsconfig.json` already has a `"types"` array in the `"compilerOptions"` section, + * is to add `"react-dom/canary"` to the `"types"` array. + * + * Alternatively, a specific import syntax can to be used from a typescript file. + * This module does not exist in reality, which is why the {} is important: + * + * ```ts + * import {} from 'react-dom/canary' + * ``` + * + * It is also possible to include it through a triple-slash reference: + * + * ```ts + * /// + * ``` + * + * Either the import or the reference only needs to appear once, anywhere in the project. + */ + +// See https://github.com/facebook/react/blob/main/packages/react-dom/index.js to see how the exports are declared, +// but confirm with published source code (e.g. https://unpkg.com/react-dom@canary) that these exports end up in the published code + +import React = require("react"); +import ReactDOM = require("."); + +export {}; + +declare module "react" { + // @enableViewTransition + interface ViewTransitionPseudoElement extends Animatable { + getComputedStyle: () => CSSStyleDeclaration; + } + + interface ViewTransitionInstance { + group: ViewTransitionPseudoElement; + imagePair: ViewTransitionPseudoElement; + old: ViewTransitionPseudoElement; + new: ViewTransitionPseudoElement; + } + + // @enableFragmentRefs + interface FragmentInstance { + blur: () => void; + focus: (focusOptions?: FocusOptions | undefined) => void; + focusLast: (focusOptions?: FocusOptions | undefined) => void; + observeUsing(observer: IntersectionObserver | ResizeObserver): void; + unobserveUsing(observer: IntersectionObserver | ResizeObserver): void; + getClientRects(): Array; + getRootNode(getRootNodeOptions?: GetRootNodeOptions | undefined): Document | ShadowRoot | FragmentInstance; + addEventListener( + type: string, + listener: EventListener, + optionsOrUseCapture?: Parameters[2], + ): void; + removeEventListener( + type: string, + listener: EventListener, + optionsOrUseCapture?: Parameters[2], + ): void; + dispatchEvent(event: Event): boolean; + scrollIntoView(alignToTop?: boolean): void; + } +} diff --git a/node_modules/@types/react-dom/client.d.ts b/node_modules/@types/react-dom/client.d.ts new file mode 100644 index 00000000..2c7affa7 --- /dev/null +++ b/node_modules/@types/react-dom/client.d.ts @@ -0,0 +1,105 @@ +/** + * WARNING: This entrypoint is only available starting with `react-dom@18.0.0-rc.1` + */ + +// See https://github.com/facebook/react/blob/main/packages/react-dom/client.js to see how the exports are declared, + +import React = require("react"); + +export {}; + +declare const REACT_FORM_STATE_SIGIL: unique symbol; +export interface ReactFormState { + [REACT_FORM_STATE_SIGIL]: never; +} + +export interface HydrationOptions { + formState?: ReactFormState | null; + /** + * Prefix for `useId`. + */ + identifierPrefix?: string; + onUncaughtError?: + | ((error: unknown, errorInfo: { componentStack?: string | undefined }) => void) + | undefined; + onRecoverableError?: (error: unknown, errorInfo: ErrorInfo) => void; + onCaughtError?: + | (( + error: unknown, + errorInfo: { + componentStack?: string | undefined; + errorBoundary?: React.Component | undefined; + }, + ) => void) + | undefined; +} + +export interface RootOptions { + /** + * Prefix for `useId`. + */ + identifierPrefix?: string; + onUncaughtError?: + | ((error: unknown, errorInfo: { componentStack?: string | undefined }) => void) + | undefined; + onRecoverableError?: (error: unknown, errorInfo: ErrorInfo) => void; + onCaughtError?: + | (( + error: unknown, + errorInfo: { + componentStack?: string | undefined; + errorBoundary?: React.Component | undefined; + }, + ) => void) + | undefined; +} + +export interface ErrorInfo { + componentStack?: string; +} + +export interface Root { + render(children: React.ReactNode): void; + unmount(): void; +} + +/** + * Different release channels declare additional types of ReactNode this particular release channel accepts. + * App or library types should never augment this interface. + */ +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface DO_NOT_USE_OR_YOU_WILL_BE_FIRED_EXPERIMENTAL_CREATE_ROOT_CONTAINERS {} + +export type Container = + | Element + | DocumentFragment + | Document + | DO_NOT_USE_OR_YOU_WILL_BE_FIRED_EXPERIMENTAL_CREATE_ROOT_CONTAINERS[ + keyof DO_NOT_USE_OR_YOU_WILL_BE_FIRED_EXPERIMENTAL_CREATE_ROOT_CONTAINERS + ]; + +/** + * createRoot lets you create a root to display React components inside a browser DOM node. + * + * @see {@link https://react.dev/reference/react-dom/client/createRoot API Reference for `createRoot`} + */ +export function createRoot(container: Container, options?: RootOptions): Root; + +/** + * Same as `createRoot()`, but is used to hydrate a container whose HTML contents were rendered by ReactDOMServer. + * + * React will attempt to attach event listeners to the existing markup. + * + * **Example Usage** + * + * ```jsx + * hydrateRoot(document.querySelector('#root'), ) + * ``` + * + * @see https://react.dev/reference/react-dom/client/hydrateRoot + */ +export function hydrateRoot( + container: Element | Document, + initialChildren: React.ReactNode, + options?: HydrationOptions, +): Root; diff --git a/node_modules/@types/react-dom/experimental.d.ts b/node_modules/@types/react-dom/experimental.d.ts new file mode 100644 index 00000000..01999d35 --- /dev/null +++ b/node_modules/@types/react-dom/experimental.d.ts @@ -0,0 +1,54 @@ +/** + * These are types for things that are present in the `experimental` builds of React but not yet + * on a stable build. + * + * Once they are promoted to stable they can just be moved to the main index file. + * + * To load the types declared here in an actual project, there are three ways. The easiest one, + * if your `tsconfig.json` already has a `"types"` array in the `"compilerOptions"` section, + * is to add `"react-dom/experimental"` to the `"types"` array. + * + * Alternatively, a specific import syntax can to be used from a typescript file. + * This module does not exist in reality, which is why the {} is important: + * + * ```ts + * import {} from 'react-dom/experimental' + * ``` + * + * It is also possible to include it through a triple-slash reference: + * + * ```ts + * /// + * ``` + * + * Either the import or the reference only needs to appear once, anywhere in the project. + */ + +// See https://github.com/facebook/react/blob/main/packages/react-dom/index.experimental.js to see how the exports are declared, +// but confirm with published source code (e.g. https://unpkg.com/react-dom@experimental) that these exports end up in the published code + +import React = require("react"); +import ReactDOM = require("./canary"); + +export {}; + +declare const UNDEFINED_VOID_ONLY: unique symbol; +type VoidOrUndefinedOnly = void | { [UNDEFINED_VOID_ONLY]: never }; + +declare module "." { +} + +declare module "react" { + // eslint-disable-next-line @typescript-eslint/no-empty-interface + interface GestureProvider extends AnimationTimeline {} +} + +declare module "./client" { + type TransitionIndicatorCleanup = () => VoidOrUndefinedOnly; + interface RootOptions { + onDefaultTransitionIndicator?: (() => void | TransitionIndicatorCleanup) | undefined; + } + interface HydrationOptions { + onDefaultTransitionIndicator?: (() => void | TransitionIndicatorCleanup) | undefined; + } +} diff --git a/node_modules/@types/react-dom/index.d.ts b/node_modules/@types/react-dom/index.d.ts new file mode 100644 index 00000000..efb52b27 --- /dev/null +++ b/node_modules/@types/react-dom/index.d.ts @@ -0,0 +1,133 @@ +// NOTE: Users of the `experimental` builds of React should add a reference +// to 'react-dom/experimental' in their project. See experimental.d.ts's top comment +// for reference and documentation on how exactly to do it. + +export as namespace ReactDOM; + +import { Key, ReactNode, ReactPortal } from "react"; + +declare module "react" { + // eslint-disable-next-line @typescript-eslint/no-empty-interface + interface CacheSignal extends AbortSignal {} +} + +export function createPortal( + children: ReactNode, + container: Element | DocumentFragment, + key?: Key | null, +): ReactPortal; + +export const version: string; + +export function flushSync(fn: () => R): R; + +export function unstable_batchedUpdates(callback: (a: A) => R, a: A): R; +export function unstable_batchedUpdates(callback: () => R): R; + +export interface FormStatusNotPending { + pending: false; + data: null; + method: null; + action: null; +} + +export interface FormStatusPending { + pending: true; + data: FormData; + method: string; + action: string | ((formData: FormData) => void | Promise); +} + +export type FormStatus = FormStatusPending | FormStatusNotPending; + +export function useFormStatus(): FormStatus; + +export function useFormState( + action: (state: Awaited) => State | Promise, + initialState: Awaited, + permalink?: string, +): [state: Awaited, dispatch: () => void, isPending: boolean]; +export function useFormState( + action: (state: Awaited, payload: Payload) => State | Promise, + initialState: Awaited, + permalink?: string, +): [state: Awaited, dispatch: (payload: Payload) => void, isPending: boolean]; + +export function prefetchDNS(href: string): void; + +export interface PreconnectOptions { + // Don't create a helper type. + // It would have to be in module scope to be inlined in TS tooltips. + // But then it becomes part of the public API. + // TODO: Upstream to microsoft/TypeScript-DOM-lib-generator -> w3c/webref + // since the spec has a notion of a dedicated type: https://html.spec.whatwg.org/multipage/urls-and-fetching.html#cors-settings-attribute + crossOrigin?: "anonymous" | "use-credentials" | "" | undefined; +} +export function preconnect(href: string, options?: PreconnectOptions): void; + +export type PreloadAs = + | "audio" + | "document" + | "embed" + | "fetch" + | "font" + | "image" + | "object" + | "track" + | "script" + | "style" + | "video" + | "worker"; +export interface PreloadOptions { + as: PreloadAs; + crossOrigin?: "anonymous" | "use-credentials" | "" | undefined; + fetchPriority?: "high" | "low" | "auto" | undefined; + // TODO: These should only be allowed with `as: 'image'` but it's not trivial to write tests against the full TS support matrix. + imageSizes?: string | undefined; + imageSrcSet?: string | undefined; + integrity?: string | undefined; + type?: string | undefined; + nonce?: string | undefined; + referrerPolicy?: ReferrerPolicy | undefined; + media?: string | undefined; +} +export function preload(href: string, options?: PreloadOptions): void; + +// https://html.spec.whatwg.org/multipage/links.html#link-type-modulepreload +export type PreloadModuleAs = RequestDestination; +export interface PreloadModuleOptions { + /** + * @default "script" + */ + as: PreloadModuleAs; + crossOrigin?: "anonymous" | "use-credentials" | "" | undefined; + integrity?: string | undefined; + nonce?: string | undefined; +} +export function preloadModule(href: string, options?: PreloadModuleOptions): void; + +export type PreinitAs = "script" | "style"; +export interface PreinitOptions { + as: PreinitAs; + crossOrigin?: "anonymous" | "use-credentials" | "" | undefined; + fetchPriority?: "high" | "low" | "auto" | undefined; + precedence?: string | undefined; + integrity?: string | undefined; + nonce?: string | undefined; +} +export function preinit(href: string, options?: PreinitOptions): void; + +// Will be expanded to include all of https://github.com/tc39/proposal-import-attributes +export type PreinitModuleAs = "script"; +export interface PreinitModuleOptions { + /** + * @default "script" + */ + as?: PreinitModuleAs; + crossOrigin?: "anonymous" | "use-credentials" | "" | undefined; + integrity?: string | undefined; + nonce?: string | undefined; +} +export function preinitModule(href: string, options?: PreinitModuleOptions): void; + +export function requestFormReset(form: HTMLFormElement): void; diff --git a/node_modules/@types/react-dom/package.json b/node_modules/@types/react-dom/package.json new file mode 100644 index 00000000..62ca60ff --- /dev/null +++ b/node_modules/@types/react-dom/package.json @@ -0,0 +1,128 @@ +{ + "name": "@types/react-dom", + "version": "19.2.3", + "description": "TypeScript definitions for react-dom", + "homepage": "https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/react-dom", + "license": "MIT", + "contributors": [ + { + "name": "Asana", + "url": "https://asana.com" + }, + { + "name": "AssureSign", + "url": "http://www.assuresign.com" + }, + { + "name": "Microsoft", + "url": "https://microsoft.com" + }, + { + "name": "MartynasZilinskas", + "githubUsername": "MartynasZilinskas", + "url": "https://github.com/MartynasZilinskas" + }, + { + "name": "Josh Rutherford", + "githubUsername": "theruther4d", + "url": "https://github.com/theruther4d" + }, + { + "name": "Jessica Franco", + "githubUsername": "Jessidhia", + "url": "https://github.com/Jessidhia" + }, + { + "name": "Sebastian Silbermann", + "githubUsername": "eps1lon", + "url": "https://github.com/eps1lon" + } + ], + "main": "", + "types": "index.d.ts", + "exports": { + ".": { + "types": { + "default": "./index.d.ts" + } + }, + "./client": { + "types": { + "default": "./client.d.ts" + } + }, + "./canary": { + "types": { + "default": "./canary.d.ts" + } + }, + "./server": { + "types": { + "default": "./server.d.ts" + } + }, + "./server.browser": { + "types": { + "default": "./server.browser.d.ts" + } + }, + "./server.bun": { + "types": { + "default": "./server.bun.d.ts" + } + }, + "./server.edge": { + "types": { + "default": "./server.edge.d.ts" + } + }, + "./server.node": { + "types": { + "default": "./server.node.d.ts" + } + }, + "./static": { + "types": { + "default": "./static.d.ts" + } + }, + "./static.browser": { + "types": { + "default": "./static.browser.d.ts" + } + }, + "./static.edge": { + "types": { + "default": "./static.edge.d.ts" + } + }, + "./static.node": { + "types": { + "default": "./static.node.d.ts" + } + }, + "./experimental": { + "types": { + "default": "./experimental.d.ts" + } + }, + "./test-utils": { + "types": { + "default": "./test-utils/index.d.ts" + } + }, + "./package.json": "./package.json" + }, + "repository": { + "type": "git", + "url": "https://github.com/DefinitelyTyped/DefinitelyTyped.git", + "directory": "types/react-dom" + }, + "scripts": {}, + "dependencies": {}, + "peerDependencies": { + "@types/react": "^19.2.0" + }, + "typesPublisherContentHash": "6f16aac4f50b7ebe3201fdac53a58874d2899d6108894538ade2d61fbb99f8c5", + "typeScriptVersion": "5.2" +} \ No newline at end of file diff --git a/node_modules/@types/react-dom/server.browser.d.ts b/node_modules/@types/react-dom/server.browser.d.ts new file mode 100644 index 00000000..f2e3bc93 --- /dev/null +++ b/node_modules/@types/react-dom/server.browser.d.ts @@ -0,0 +1 @@ +export { renderToReadableStream, renderToStaticMarkup, renderToString } from "./server"; diff --git a/node_modules/@types/react-dom/server.bun.d.ts b/node_modules/@types/react-dom/server.bun.d.ts new file mode 100644 index 00000000..f2e3bc93 --- /dev/null +++ b/node_modules/@types/react-dom/server.bun.d.ts @@ -0,0 +1 @@ +export { renderToReadableStream, renderToStaticMarkup, renderToString } from "./server"; diff --git a/node_modules/@types/react-dom/server.d.ts b/node_modules/@types/react-dom/server.d.ts new file mode 100644 index 00000000..84312f9d --- /dev/null +++ b/node_modules/@types/react-dom/server.d.ts @@ -0,0 +1,183 @@ +// forward declarations +declare global { + namespace NodeJS { + // eslint-disable-next-line @typescript-eslint/no-empty-interface + interface ReadableStream {} + + // eslint-disable-next-line @typescript-eslint/no-empty-interface + interface WritableStream {} + } + + /** + * Stub for https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal + */ + // eslint-disable-next-line @typescript-eslint/no-empty-interface + interface AbortSignal {} + + /** + * Stub for https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream + */ + // eslint-disable-next-line @typescript-eslint/no-empty-interface + interface ReadableStream {} +} + +import { ReactNode } from "react"; +import { ErrorInfo, ReactFormState } from "./client"; +import { PostponedState, ResumeOptions } from "./static"; + +export interface BootstrapScriptDescriptor { + src: string; + integrity?: string | undefined; + crossOrigin?: string | undefined; +} + +/** + * @see {@link https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/script/type/importmap Import maps} + */ +// TODO: Ideally TypeScripts standard library would include this type. +// Until then we keep the prefixed one for future compatibility. +export interface ReactImportMap { + /** + * @see {@link https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/script/type/importmap#imports `imports` reference} + */ + imports?: { + [specifier: string]: string; + } | undefined; + /** + * @see {@link https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/script/type/importmap#integrity `integrity` reference} + */ + integrity?: { + [moduleURL: string]: string; + } | undefined; + /** + * @see {@link https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/script/type/importmap#scopes `scopes` reference} + */ + scopes?: { + [scope: string]: { + [specifier: string]: string; + }; + } | undefined; +} + +export interface RenderToPipeableStreamOptions { + identifierPrefix?: string; + namespaceURI?: string; + nonce?: string; + bootstrapScriptContent?: string; + bootstrapScripts?: Array; + bootstrapModules?: Array; + /** + * Maximum length of the header content in unicode code units i.e. string.length. + * Must be a positive integer if specified. + * @default 2000 + */ + headersLengthHint?: number | undefined; + importMap?: ReactImportMap | undefined; + progressiveChunkSize?: number; + onHeaders?: ((headers: Headers) => void) | undefined; + onShellReady?: () => void; + onShellError?: (error: unknown) => void; + onAllReady?: () => void; + onError?: (error: unknown, errorInfo: ErrorInfo) => string | void; + formState?: ReactFormState | null; +} + +export interface PipeableStream { + abort: (reason?: unknown) => void; + pipe: (destination: Writable) => Writable; +} + +export interface ServerOptions { + identifierPrefix?: string; +} + +/** + * Only available in the environments with [Node.js Streams](https://nodejs.dev/learn/nodejs-streams). + * + * @see [API](https://react.dev/reference/react-dom/server/renderToPipeableStream) + * + * @param children + * @param options + */ +export function renderToPipeableStream(children: ReactNode, options?: RenderToPipeableStreamOptions): PipeableStream; + +/** + * Render a React element to its initial HTML. This should only be used on the server. + * React will return an HTML string. You can use this method to generate HTML on the server + * and send the markup down on the initial request for faster page loads and to allow search + * engines to crawl your pages for SEO purposes. + * + * If you call `ReactDOMClient.hydrateRoot()` on a node that already has this server-rendered markup, + * React will preserve it and only attach event handlers, allowing you + * to have a very performant first-load experience. + */ +export function renderToString(element: ReactNode, options?: ServerOptions): string; + +/** + * Similar to `renderToString`, except this doesn't create extra DOM attributes + * such as `data-reactid`, that React uses internally. This is useful if you want + * to use React as a simple static page generator, as stripping away the extra + * attributes can save lots of bytes. + */ +export function renderToStaticMarkup(element: ReactNode, options?: ServerOptions): string; + +export interface RenderToReadableStreamOptions { + identifierPrefix?: string; + importMap?: ReactImportMap | undefined; + namespaceURI?: string; + nonce?: string; + bootstrapScriptContent?: string; + bootstrapScripts?: Array; + bootstrapModules?: Array; + /** + * Maximum length of the header content in unicode code units i.e. string.length. + * Must be a positive integer if specified. + * @default 2000 + */ + headersLengthHint?: number | undefined; + progressiveChunkSize?: number; + signal?: AbortSignal; + onError?: (error: unknown, errorInfo: ErrorInfo) => string | void; + onHeaders?: ((headers: Headers) => void) | undefined; + formState?: ReactFormState | null; +} + +export interface ReactDOMServerReadableStream extends ReadableStream { + allReady: Promise; +} + +/** + * Only available in the environments with [Web Streams](https://developer.mozilla.org/en-US/docs/Web/API/Streams_API) (this includes browsers, Deno, and some modern edge runtimes). + * + * @see [API](https://react.dev/reference/react-dom/server/renderToReadableStream) + */ +export function renderToReadableStream( + children: ReactNode, + options?: RenderToReadableStreamOptions, +): Promise; + +export { ResumeOptions }; + +/** + * @see {@link https://react.dev/reference/react-dom/server/resume `resume`` reference documentation} + * @version 19.2 + */ +export function resume( + children: React.ReactNode, + postponedState: PostponedState, + options?: ResumeOptions, +): Promise; + +/** + * @see {@link https://react.dev/reference/react-dom/server/resumeToPipeableStream `resumeToPipeableStream`` reference documentation} + * @version 19.2 + */ +export function resumeToPipeableStream( + children: React.ReactNode, + postponedState: PostponedState, + options?: ResumeOptions, +): Promise; + +export const version: string; + +export as namespace ReactDOMServer; diff --git a/node_modules/@types/react-dom/server.edge.d.ts b/node_modules/@types/react-dom/server.edge.d.ts new file mode 100644 index 00000000..f21d7d2b --- /dev/null +++ b/node_modules/@types/react-dom/server.edge.d.ts @@ -0,0 +1 @@ +export { renderToReadableStream, renderToStaticMarkup, renderToString, resume } from "./server"; diff --git a/node_modules/@types/react-dom/server.node.d.ts b/node_modules/@types/react-dom/server.node.d.ts new file mode 100644 index 00000000..eb777a99 --- /dev/null +++ b/node_modules/@types/react-dom/server.node.d.ts @@ -0,0 +1,8 @@ +export { + renderToPipeableStream, + renderToReadableStream, + renderToStaticMarkup, + renderToString, + resume, + resumeToPipeableStream, +} from "./server"; diff --git a/node_modules/@types/react-dom/static.browser.d.ts b/node_modules/@types/react-dom/static.browser.d.ts new file mode 100644 index 00000000..cd2d05ff --- /dev/null +++ b/node_modules/@types/react-dom/static.browser.d.ts @@ -0,0 +1 @@ +export { prerender, version } from "./static"; diff --git a/node_modules/@types/react-dom/static.d.ts b/node_modules/@types/react-dom/static.d.ts new file mode 100644 index 00000000..c86d5c10 --- /dev/null +++ b/node_modules/@types/react-dom/static.d.ts @@ -0,0 +1,153 @@ +// forward declarations +declare global { + namespace NodeJS { + // eslint-disable-next-line @typescript-eslint/no-empty-interface + interface ReadableStream {} + } + + /** + * Stub for https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal + */ + // eslint-disable-next-line @typescript-eslint/no-empty-interface + interface AbortSignal {} + + /** + * Stub for https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream + */ + // eslint-disable-next-line @typescript-eslint/no-empty-interface + interface ReadableStream {} + + /** + * Stub for https://developer.mozilla.org/en-US/docs/Web/API/Uint8Array + */ + // eslint-disable-next-line @typescript-eslint/no-empty-interface + interface Uint8Array {} +} + +import { ReactNode } from "react"; +import { ErrorInfo } from "./client"; +export {}; + +declare const POSTPONED_STATE_SIGIL: unique symbol; + +/** + * This is an opaque type i.e. users should not make any assumptions about its structure. + * It is JSON-serializeable to be a able to store it and retrvieve later for use with {@link https://react.dev/reference/react-dom/server/resume `resume`}. + */ +export interface PostponedState { + [POSTPONED_STATE_SIGIL]: never; +} + +/** + * @see {@link https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/script/type/importmap Import maps} + */ +// TODO: Ideally TypeScripts standard library would include this type. +// Until then we keep the prefixed one for future compatibility. +export interface ReactImportMap { + /** + * @see {@link https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/script/type/importmap#imports `imports` reference} + */ + imports?: { + [specifier: string]: string; + } | undefined; + /** + * @see {@link https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/script/type/importmap#integrity `integrity` reference} + */ + integrity?: { + [moduleURL: string]: string; + } | undefined; + /** + * @see {@link https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/script/type/importmap#scopes `scopes` reference} + */ + scopes?: { + [scope: string]: { + [specifier: string]: string; + }; + } | undefined; +} + +export interface BootstrapScriptDescriptor { + src: string; + integrity?: string | undefined; + crossOrigin?: string | undefined; +} + +export interface PrerenderOptions { + bootstrapScriptContent?: string; + bootstrapScripts?: Array; + bootstrapModules?: Array; + /** + * Maximum length of the header content in unicode code units i.e. string.length. + * Must be a positive integer if specified. + * @default 2000 + */ + headersLengthHint?: number | undefined; + identifierPrefix?: string; + importMap?: ReactImportMap | undefined; + namespaceURI?: string; + onError?: (error: unknown, errorInfo: ErrorInfo) => string | void; + onHeaders?: (headers: Headers) => void | undefined; + progressiveChunkSize?: number; + signal?: AbortSignal; +} + +export interface PrerenderResult { + postponed: null | PostponedState; + prelude: ReadableStream; +} + +/** + * Only available in the environments with [Web Streams](https://developer.mozilla.org/en-US/docs/Web/API/Streams_API) (this includes browsers, Deno, and some modern edge runtimes). + * + * @see [API](https://react.dev/reference/react-dom/static/prerender) + */ +export function prerender( + reactNode: ReactNode, + options?: PrerenderOptions, +): Promise; + +export interface PrerenderToNodeStreamResult { + prelude: NodeJS.ReadableStream; + postponed: null | PostponedState; +} + +/** + * Only available in the environments with [Node.js Streams](https://nodejs.dev/learn/nodejs-streams). + * + * @see [API](https://react.dev/reference/react-dom/static/prerenderToNodeStream) + * + * @param children + * @param options + */ +export function prerenderToNodeStream( + reactNode: ReactNode, + options?: PrerenderOptions, +): Promise; + +export interface ResumeOptions { + nonce?: string; + signal?: AbortSignal; + onError?: (error: unknown) => string | undefined | void; +} + +/** + * @see {@link https://react.dev/reference/react-dom/static/resumeAndPrerender `resumeAndPrerender` reference documentation} + * @version 19.2 + */ +export function resumeAndPrerender( + children: React.ReactNode, + postponedState: null | PostponedState, + options?: Omit, +): Promise; + +/** + * @see {@link https://react.dev/reference/react-dom/static/resumeAndPrerenderToNodeStream `resumeAndPrerenderToNodeStream`` reference documentation} + * @version 19.2 + */ +export function resumeAndPrerenderToNodeStream( + children: React.ReactNode, + postponedState: null | PostponedState, + options?: Omit, +): Promise; + +export const version: string; diff --git a/node_modules/@types/react-dom/static.edge.d.ts b/node_modules/@types/react-dom/static.edge.d.ts new file mode 100644 index 00000000..94c53937 --- /dev/null +++ b/node_modules/@types/react-dom/static.edge.d.ts @@ -0,0 +1 @@ +export { prerender, resumeAndPrerender, version } from "./static"; diff --git a/node_modules/@types/react-dom/static.node.d.ts b/node_modules/@types/react-dom/static.node.d.ts new file mode 100644 index 00000000..59dd5ddd --- /dev/null +++ b/node_modules/@types/react-dom/static.node.d.ts @@ -0,0 +1,7 @@ +export { + prerender, + prerenderToNodeStream, + resumeAndPrerender, + resumeAndPrerenderToNodeStream, + version, +} from "./static"; diff --git a/node_modules/@types/react-dom/test-utils/index.d.ts b/node_modules/@types/react-dom/test-utils/index.d.ts new file mode 100644 index 00000000..7e211ea0 --- /dev/null +++ b/node_modules/@types/react-dom/test-utils/index.d.ts @@ -0,0 +1,7 @@ +export {}; + +export { + /** + * @deprecated Import `act` from `react` instead. + */ act, +} from "react"; diff --git a/node_modules/@types/react/LICENSE b/node_modules/@types/react/LICENSE new file mode 100644 index 00000000..9e841e7a --- /dev/null +++ b/node_modules/@types/react/LICENSE @@ -0,0 +1,21 @@ + MIT License + + Copyright (c) Microsoft Corporation. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE diff --git a/node_modules/@types/react/README.md b/node_modules/@types/react/README.md new file mode 100644 index 00000000..5cbc0c29 --- /dev/null +++ b/node_modules/@types/react/README.md @@ -0,0 +1,15 @@ +# Installation +> `npm install --save @types/react` + +# Summary +This package contains type definitions for react (https://react.dev/). + +# Details +Files were exported from https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/react. + +### Additional Details + * Last updated: Tue, 27 Jan 2026 12:02:23 GMT + * Dependencies: [csstype](https://npmjs.com/package/csstype) + +# Credits +These definitions were written by [Asana](https://asana.com), [AssureSign](http://www.assuresign.com), [Microsoft](https://microsoft.com), [John Reilly](https://github.com/johnnyreilly), [Benoit Benezech](https://github.com/bbenezech), [Patricio Zavolinsky](https://github.com/pzavolinsky), [Eric Anderson](https://github.com/ericanderson), [Dovydas Navickas](https://github.com/DovydasNavickas), [Josh Rutherford](https://github.com/theruther4d), [Guilherme Hübner](https://github.com/guilhermehubner), [Ferdy Budhidharma](https://github.com/ferdaber), [Johann Rakotoharisoa](https://github.com/jrakotoharisoa), [Olivier Pascal](https://github.com/pascaloliv), [Martin Hochel](https://github.com/hotell), [Frank Li](https://github.com/franklixuefei), [Jessica Franco](https://github.com/Jessidhia), [Saransh Kataria](https://github.com/saranshkataria), [Kanitkorn Sujautra](https://github.com/lukyth), [Sebastian Silbermann](https://github.com/eps1lon), [Kyle Scully](https://github.com/zieka), [Cong Zhang](https://github.com/dancerphil), [Dimitri Mitropoulos](https://github.com/dimitropoulos), [JongChan Choi](https://github.com/disjukr), [Victor Magalhães](https://github.com/vhfmag), [Priyanshu Rav](https://github.com/priyanshurav), [Dmitry Semigradsky](https://github.com/Semigradsky), and [Matt Pocock](https://github.com/mattpocock). diff --git a/node_modules/@types/react/canary.d.ts b/node_modules/@types/react/canary.d.ts new file mode 100644 index 00000000..b1cac75e --- /dev/null +++ b/node_modules/@types/react/canary.d.ts @@ -0,0 +1,120 @@ +/** + * These are types for things that are present in the React `canary` release channel. + * + * To load the types declared here in an actual project, there are three ways. The easiest one, + * if your `tsconfig.json` already has a `"types"` array in the `"compilerOptions"` section, + * is to add `"react/canary"` to the `"types"` array. + * + * Alternatively, a specific import syntax can to be used from a typescript file. + * This module does not exist in reality, which is why the {} is important: + * + * ```ts + * import {} from 'react/canary' + * ``` + * + * It is also possible to include it through a triple-slash reference: + * + * ```ts + * /// + * ``` + * + * Either the import or the reference only needs to appear once, anywhere in the project. + */ + +// See https://github.com/facebook/react/blob/main/packages/react/src/React.js to see how the exports are declared, + +import React = require("."); + +export {}; + +declare const UNDEFINED_VOID_ONLY: unique symbol; +type VoidOrUndefinedOnly = void | { [UNDEFINED_VOID_ONLY]: never }; + +declare module "." { + export function unstable_useCacheRefresh(): () => void; + + // @enableViewTransition + export interface ViewTransitionInstance { + /** + * The {@link ViewTransitionProps name} that was used in the corresponding {@link ViewTransition} component or `"auto"` if the `name` prop was omitted. + */ + name: string; + } + + export type ViewTransitionClassPerType = Record<"default" | (string & {}), "none" | "auto" | (string & {})>; + export type ViewTransitionClass = ViewTransitionClassPerType | ViewTransitionClassPerType[string]; + + export interface ViewTransitionProps { + children?: ReactNode | undefined; + /** + * Assigns the {@link https://developer.chrome.com/blog/view-transitions-update-io24#view-transition-class `view-transition-class`} class to the underlying DOM node. + */ + default?: ViewTransitionClass | undefined; + /** + * Combined with {@link className} if this `` or its parent Component is mounted and there's no other with the same name being deleted. + * `"none"` is a special value that deactivates the view transition name under that condition. + */ + enter?: ViewTransitionClass | undefined; + /** + * Combined with {@link className} if this `` or its parent Component is unmounted and there's no other with the same name being deleted. + * `"none"` is a special value that deactivates the view transition name under that condition. + */ + exit?: ViewTransitionClass | undefined; + /** + * "auto" will automatically assign a view-transition-name to the inner DOM node. + * That way you can add a View Transition to a Component without controlling its DOM nodes styling otherwise. + * + * A difference between this and the browser's built-in view-transition-name: auto is that switching the DOM nodes within the `` component preserves the same name so this example cross-fades between the DOM nodes instead of causing an exit and enter. + * @default "auto" + */ + name?: "auto" | (string & {}) | undefined; + /** + * The `` or its parent Component is mounted and there's no other `` with the same name being deleted. + */ + onEnter?: (instance: ViewTransitionInstance, types: Array) => void | (() => void); + /** + * The `` or its parent Component is unmounted and there's no other `` with the same name being deleted. + */ + onExit?: (instance: ViewTransitionInstance, types: Array) => void | (() => void); + /** + * This `` is being mounted and another `` instance with the same name is being unmounted elsewhere. + */ + onShare?: (instance: ViewTransitionInstance, types: Array) => void | (() => void); + /** + * The content of `` has changed either due to DOM mutations or because an inner child `` has resized. + */ + onUpdate?: (instance: ViewTransitionInstance, types: Array) => void | (() => void); + ref?: Ref | undefined; + /** + * Combined with {@link className} if this `` is being mounted and another instance with the same name is being unmounted elsewhere. + * `"none"` is a special value that deactivates the view transition name under that condition. + */ + share?: ViewTransitionClass | undefined; + /** + * Combined with {@link className} if the content of this `` has changed either due to DOM mutations or because an inner child has resized. + * `"none"` is a special value that deactivates the view transition name under that condition. + */ + update?: ViewTransitionClass | undefined; + } + + /** + * Opt-in for using {@link https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API View Transitions} in React. + * View Transitions only trigger for async updates like {@link startTransition}, {@link useDeferredValue}, Actions or <{@link Suspense}> revealing from fallback to content. + * Synchronous updates provide an opt-out but also guarantee that they commit immediately which View Transitions can't. + * + * @see {@link https://react.dev/reference/react/ViewTransition `` reference documentation} + */ + export const ViewTransition: ExoticComponent; + + /** + * @see {@link https://react.dev/reference/react/addTransitionType `addTransitionType` reference documentation} + */ + export function addTransitionType(type: string): void; + + // @enableFragmentRefs + export interface FragmentInstance {} + + export interface FragmentProps { + ref?: Ref | undefined; + } +} diff --git a/node_modules/@types/react/compiler-runtime.d.ts b/node_modules/@types/react/compiler-runtime.d.ts new file mode 100644 index 00000000..a98a26e6 --- /dev/null +++ b/node_modules/@types/react/compiler-runtime.d.ts @@ -0,0 +1,4 @@ +// Not meant to be used directly +// Omitting all exports so that they don't appear in IDE autocomplete. + +export {}; diff --git a/node_modules/@types/react/experimental.d.ts b/node_modules/@types/react/experimental.d.ts new file mode 100644 index 00000000..49ff8620 --- /dev/null +++ b/node_modules/@types/react/experimental.d.ts @@ -0,0 +1,177 @@ +/** + * These are types for things that are present in the `experimental` builds of React but not yet + * on a stable build. + * + * Once they are promoted to stable they can just be moved to the main index file. + * + * To load the types declared here in an actual project, there are three ways. The easiest one, + * if your `tsconfig.json` already has a `"types"` array in the `"compilerOptions"` section, + * is to add `"react/experimental"` to the `"types"` array. + * + * Alternatively, a specific import syntax can to be used from a typescript file. + * This module does not exist in reality, which is why the {} is important: + * + * ```ts + * import {} from 'react/experimental' + * ``` + * + * It is also possible to include it through a triple-slash reference: + * + * ```ts + * /// + * ``` + * + * Either the import or the reference only needs to appear once, anywhere in the project. + */ + +// See https://github.com/facebook/react/blob/master/packages/react/src/React.js to see how the exports are declared, +// and https://github.com/facebook/react/blob/master/packages/shared/ReactFeatureFlags.js to verify which APIs are +// flagged experimental or not. Experimental APIs will be tagged with `__EXPERIMENTAL__`. +// +// For the inputs of types exported as simply a fiber tag, the `beginWork` function of ReactFiberBeginWork.js +// is a good place to start looking for details; it generally calls prop validation functions or delegates +// all tasks done as part of the render phase (the concurrent part of the React update cycle). +// +// Suspense-related handling can be found in ReactFiberThrow.js. + +import React = require("./canary"); + +export {}; + +declare const UNDEFINED_VOID_ONLY: unique symbol; +type VoidOrUndefinedOnly = void | { [UNDEFINED_VOID_ONLY]: never }; + +declare module "." { + export interface SuspenseProps { + // @enableCPUSuspense + /** + * The presence of this prop indicates that the content is computationally expensive to render. + * In other words, the tree is CPU bound and not I/O bound (e.g. due to fetching data). + * @see {@link https://github.com/facebook/react/pull/19936} + */ + defer?: boolean | undefined; + } + + export type SuspenseListRevealOrder = "forwards" | "backwards" | "together" | "independent"; + export type SuspenseListTailMode = "collapsed" | "hidden" | "visible"; + + export interface SuspenseListCommonProps { + } + + interface DirectionalSuspenseListProps extends SuspenseListCommonProps { + /** + * Note that SuspenseList require more than one child; + * it is a runtime warning to provide only a single child. + * + * It does, however, allow those children to be wrapped inside a single + * level of ``. + */ + children: Iterable | AsyncIterable; + /** + * Defines the order in which the `SuspenseList` children should be revealed. + * @default "forwards" + */ + revealOrder?: "forwards" | "backwards" | "unstable_legacy-backwards" | undefined; + /** + * Dictates how unloaded items in a SuspenseList is shown. + * + * - `collapsed` shows only the next fallback in the list. + * - `hidden` doesn't show any unloaded items. + * - `visible` shows all fallbacks in the list. + * + * @default "hidden" + */ + tail?: SuspenseListTailMode | undefined; + } + + interface NonDirectionalSuspenseListProps extends SuspenseListCommonProps { + children: ReactNode; + /** + * Defines the order in which the `SuspenseList` children should be revealed. + */ + revealOrder: Exclude; + /** + * The tail property is invalid when not using the `forwards` or `backwards` reveal orders. + */ + tail?: never; + } + + export type SuspenseListProps = DirectionalSuspenseListProps | NonDirectionalSuspenseListProps; + + /** + * `SuspenseList` helps coordinate many components that can suspend by orchestrating the order + * in which these components are revealed to the user. + * + * When multiple components need to fetch data, this data may arrive in an unpredictable order. + * However, if you wrap these items in a `SuspenseList`, React will not show an item in the list + * until previous items have been displayed (this behavior is adjustable). + * + * @see https://reactjs.org/docs/concurrent-mode-reference.html#suspenselist + * @see https://reactjs.org/docs/concurrent-mode-patterns.html#suspenselist + */ + export const unstable_SuspenseList: ExoticComponent; + + type Reference = object; + type TaintableUniqueValue = string | bigint | ArrayBufferView; + function experimental_taintUniqueValue( + message: string | undefined, + lifetime: Reference, + value: TaintableUniqueValue, + ): void; + function experimental_taintObjectReference(message: string | undefined, object: Reference): void; + + // @enableGestureTransition + // Implemented by the specific renderer e.g. `react-dom`. + // Keep in mind that augmented interfaces merge their JSDoc so if you put + // JSDoc here and in the renderer, the IDE will display both. + export interface GestureProvider {} + export interface GestureOptions { + rangeStart?: number | undefined; + rangeEnd?: number | undefined; + } + export type GestureOptionsRequired = { + [P in keyof GestureOptions]-?: NonNullable; + }; + /** */ + export function unstable_startGestureTransition( + provider: GestureProvider, + scope: () => void, + options?: GestureOptions, + ): () => void; + + interface ViewTransitionProps { + onGestureEnter?: ( + timeline: GestureProvider, + options: GestureOptionsRequired, + instance: ViewTransitionInstance, + types: Array, + ) => void | (() => void); + onGestureExit?: ( + timeline: GestureProvider, + options: GestureOptionsRequired, + instance: ViewTransitionInstance, + types: Array, + ) => void | (() => void); + onGestureShare?: ( + timeline: GestureProvider, + options: GestureOptionsRequired, + instance: ViewTransitionInstance, + types: Array, + ) => void | (() => void); + onGestureUpdate?: ( + timeline: GestureProvider, + options: GestureOptionsRequired, + instance: ViewTransitionInstance, + types: Array, + ) => void | (() => void); + } + + // @enableSrcObject + interface DO_NOT_USE_OR_YOU_WILL_BE_FIRED_EXPERIMENTAL_IMG_SRC_TYPES { + srcObject: Blob; + } + + interface DO_NOT_USE_OR_YOU_WILL_BE_FIRED_EXPERIMENTAL_MEDIA_SRC_TYPES { + srcObject: Blob | MediaSource | MediaStream; + } +} diff --git a/node_modules/@types/react/global.d.ts b/node_modules/@types/react/global.d.ts new file mode 100644 index 00000000..61862a3d --- /dev/null +++ b/node_modules/@types/react/global.d.ts @@ -0,0 +1,166 @@ +/* +React projects that don't include the DOM library need these interfaces to compile. +React Native applications use React, but there is no DOM available. The JavaScript runtime +is ES6/ES2015 only. These definitions allow such projects to compile with only `--lib ES6`. + +Warning: all of these interfaces are empty. If you want type definitions for various properties +(such as HTMLInputElement.prototype.value), you need to add `--lib DOM` (via command line or tsconfig.json). +*/ + +interface Event {} +interface AnimationEvent extends Event {} +interface ClipboardEvent extends Event {} +interface CompositionEvent extends Event {} +interface DragEvent extends Event {} +interface FocusEvent extends Event {} +interface InputEvent extends Event {} +interface KeyboardEvent extends Event {} +interface MouseEvent extends Event {} +interface TouchEvent extends Event {} +interface PointerEvent extends Event {} +interface SubmitEvent extends Event {} +interface ToggleEvent extends Event {} +interface TransitionEvent extends Event {} +interface UIEvent extends Event {} +interface WheelEvent extends Event {} + +interface EventTarget {} +interface Document {} +interface DataTransfer {} +interface StyleMedia {} + +interface Element {} +interface DocumentFragment {} + +interface HTMLElement extends Element {} +interface HTMLAnchorElement extends HTMLElement {} +interface HTMLAreaElement extends HTMLElement {} +interface HTMLAudioElement extends HTMLElement {} +interface HTMLBaseElement extends HTMLElement {} +interface HTMLBodyElement extends HTMLElement {} +interface HTMLBRElement extends HTMLElement {} +interface HTMLButtonElement extends HTMLElement {} +interface HTMLCanvasElement extends HTMLElement {} +interface HTMLDataElement extends HTMLElement {} +interface HTMLDataListElement extends HTMLElement {} +interface HTMLDetailsElement extends HTMLElement {} +interface HTMLDialogElement extends HTMLElement {} +interface HTMLDivElement extends HTMLElement {} +interface HTMLDListElement extends HTMLElement {} +interface HTMLEmbedElement extends HTMLElement {} +interface HTMLFieldSetElement extends HTMLElement {} +interface HTMLFormElement extends HTMLElement {} +interface HTMLHeadingElement extends HTMLElement {} +interface HTMLHeadElement extends HTMLElement {} +interface HTMLHRElement extends HTMLElement {} +interface HTMLHtmlElement extends HTMLElement {} +interface HTMLIFrameElement extends HTMLElement {} +interface HTMLImageElement extends HTMLElement {} +interface HTMLInputElement extends HTMLElement {} +interface HTMLModElement extends HTMLElement {} +interface HTMLLabelElement extends HTMLElement {} +interface HTMLLegendElement extends HTMLElement {} +interface HTMLLIElement extends HTMLElement {} +interface HTMLLinkElement extends HTMLElement {} +interface HTMLMapElement extends HTMLElement {} +interface HTMLMetaElement extends HTMLElement {} +interface HTMLMeterElement extends HTMLElement {} +interface HTMLObjectElement extends HTMLElement {} +interface HTMLOListElement extends HTMLElement {} +interface HTMLOptGroupElement extends HTMLElement {} +interface HTMLOptionElement extends HTMLElement {} +interface HTMLOutputElement extends HTMLElement {} +interface HTMLParagraphElement extends HTMLElement {} +interface HTMLParamElement extends HTMLElement {} +interface HTMLPreElement extends HTMLElement {} +interface HTMLProgressElement extends HTMLElement {} +interface HTMLQuoteElement extends HTMLElement {} +interface HTMLSlotElement extends HTMLElement {} +interface HTMLScriptElement extends HTMLElement {} +interface HTMLSelectElement extends HTMLElement {} +interface HTMLSourceElement extends HTMLElement {} +interface HTMLSpanElement extends HTMLElement {} +interface HTMLStyleElement extends HTMLElement {} +interface HTMLTableElement extends HTMLElement {} +interface HTMLTableColElement extends HTMLElement {} +interface HTMLTableDataCellElement extends HTMLElement {} +interface HTMLTableHeaderCellElement extends HTMLElement {} +interface HTMLTableRowElement extends HTMLElement {} +interface HTMLTableSectionElement extends HTMLElement {} +interface HTMLTemplateElement extends HTMLElement {} +interface HTMLTextAreaElement extends HTMLElement {} +interface HTMLTimeElement extends HTMLElement {} +interface HTMLTitleElement extends HTMLElement {} +interface HTMLTrackElement extends HTMLElement {} +interface HTMLUListElement extends HTMLElement {} +interface HTMLVideoElement extends HTMLElement {} +interface HTMLWebViewElement extends HTMLElement {} + +interface SVGElement extends Element {} +interface SVGSVGElement extends SVGElement {} +interface SVGCircleElement extends SVGElement {} +interface SVGClipPathElement extends SVGElement {} +interface SVGDefsElement extends SVGElement {} +interface SVGDescElement extends SVGElement {} +interface SVGEllipseElement extends SVGElement {} +interface SVGFEBlendElement extends SVGElement {} +interface SVGFEColorMatrixElement extends SVGElement {} +interface SVGFEComponentTransferElement extends SVGElement {} +interface SVGFECompositeElement extends SVGElement {} +interface SVGFEConvolveMatrixElement extends SVGElement {} +interface SVGFEDiffuseLightingElement extends SVGElement {} +interface SVGFEDisplacementMapElement extends SVGElement {} +interface SVGFEDistantLightElement extends SVGElement {} +interface SVGFEDropShadowElement extends SVGElement {} +interface SVGFEFloodElement extends SVGElement {} +interface SVGFEFuncAElement extends SVGElement {} +interface SVGFEFuncBElement extends SVGElement {} +interface SVGFEFuncGElement extends SVGElement {} +interface SVGFEFuncRElement extends SVGElement {} +interface SVGFEGaussianBlurElement extends SVGElement {} +interface SVGFEImageElement extends SVGElement {} +interface SVGFEMergeElement extends SVGElement {} +interface SVGFEMergeNodeElement extends SVGElement {} +interface SVGFEMorphologyElement extends SVGElement {} +interface SVGFEOffsetElement extends SVGElement {} +interface SVGFEPointLightElement extends SVGElement {} +interface SVGFESpecularLightingElement extends SVGElement {} +interface SVGFESpotLightElement extends SVGElement {} +interface SVGFETileElement extends SVGElement {} +interface SVGFETurbulenceElement extends SVGElement {} +interface SVGFilterElement extends SVGElement {} +interface SVGForeignObjectElement extends SVGElement {} +interface SVGGElement extends SVGElement {} +interface SVGImageElement extends SVGElement {} +interface SVGLineElement extends SVGElement {} +interface SVGLinearGradientElement extends SVGElement {} +interface SVGMarkerElement extends SVGElement {} +interface SVGMaskElement extends SVGElement {} +interface SVGMetadataElement extends SVGElement {} +interface SVGPathElement extends SVGElement {} +interface SVGPatternElement extends SVGElement {} +interface SVGPolygonElement extends SVGElement {} +interface SVGPolylineElement extends SVGElement {} +interface SVGRadialGradientElement extends SVGElement {} +interface SVGRectElement extends SVGElement {} +interface SVGSetElement extends SVGElement {} +interface SVGStopElement extends SVGElement {} +interface SVGSwitchElement extends SVGElement {} +interface SVGSymbolElement extends SVGElement {} +interface SVGTextElement extends SVGElement {} +interface SVGTextPathElement extends SVGElement {} +interface SVGTSpanElement extends SVGElement {} +interface SVGUseElement extends SVGElement {} +interface SVGViewElement extends SVGElement {} + +interface FormData {} +interface Text {} +interface TouchList {} +interface WebGLRenderingContext {} +interface WebGL2RenderingContext {} + +interface TrustedHTML {} + +interface Blob {} +interface MediaStream {} +interface MediaSource {} diff --git a/node_modules/@types/react/index.d.ts b/node_modules/@types/react/index.d.ts new file mode 100644 index 00000000..40620b45 --- /dev/null +++ b/node_modules/@types/react/index.d.ts @@ -0,0 +1,4362 @@ +// NOTE: Users of the `experimental` builds of React should add a reference +// to 'react/experimental' in their project. See experimental.d.ts's top comment +// for reference and documentation on how exactly to do it. + +/// + +import * as CSS from "csstype"; + +type NativeAnimationEvent = AnimationEvent; +type NativeClipboardEvent = ClipboardEvent; +type NativeCompositionEvent = CompositionEvent; +type NativeDragEvent = DragEvent; +type NativeFocusEvent = FocusEvent; +type NativeInputEvent = InputEvent; +type NativeKeyboardEvent = KeyboardEvent; +type NativeMouseEvent = MouseEvent; +type NativeTouchEvent = TouchEvent; +type NativePointerEvent = PointerEvent; +type NativeSubmitEvent = SubmitEvent; +type NativeToggleEvent = ToggleEvent; +type NativeTransitionEvent = TransitionEvent; +type NativeUIEvent = UIEvent; +type NativeWheelEvent = WheelEvent; + +/** + * Used to represent DOM API's where users can either pass + * true or false as a boolean or as its equivalent strings. + */ +type Booleanish = boolean | "true" | "false"; + +/** + * @see {@link https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/crossorigin MDN} + */ +type CrossOrigin = "anonymous" | "use-credentials" | "" | undefined; + +declare const UNDEFINED_VOID_ONLY: unique symbol; + +/** + * @internal Use `Awaited` instead + */ +// Helper type to enable `Awaited`. +// Must be a copy of the non-thenables of `ReactNode`. +type AwaitedReactNode = + | React.ReactElement + | string + | number + | bigint + | Iterable + | React.ReactPortal + | boolean + | null + | undefined + | React.DO_NOT_USE_OR_YOU_WILL_BE_FIRED_EXPERIMENTAL_REACT_NODES[ + keyof React.DO_NOT_USE_OR_YOU_WILL_BE_FIRED_EXPERIMENTAL_REACT_NODES + ]; + +/** + * The function returned from an effect passed to {@link React.useEffect useEffect}, + * which can be used to clean up the effect when the component unmounts. + * + * @see {@link https://react.dev/reference/react/useEffect React Docs} + */ +type Destructor = () => void | { [UNDEFINED_VOID_ONLY]: never }; +type VoidOrUndefinedOnly = void | { [UNDEFINED_VOID_ONLY]: never }; + +// eslint-disable-next-line @definitelytyped/export-just-namespace +export = React; +export as namespace React; + +declare namespace React { + // + // React Elements + // ---------------------------------------------------------------------- + + /** + * Used to retrieve the possible components which accept a given set of props. + * + * Can be passed no type parameters to get a union of all possible components + * and tags. + * + * Is a superset of {@link ComponentType}. + * + * @template P The props to match against. If not passed, defaults to any. + * @template Tag An optional tag to match against. If not passed, attempts to match against all possible tags. + * + * @example + * + * ```tsx + * // All components and tags (img, embed etc.) + * // which accept `src` + * type SrcComponents = ElementType<{ src: any }>; + * ``` + * + * @example + * + * ```tsx + * // All components + * type AllComponents = ElementType; + * ``` + * + * @example + * + * ```tsx + * // All custom components which match `src`, and tags which + * // match `src`, narrowed down to just `audio` and `embed` + * type SrcComponents = ElementType<{ src: any }, 'audio' | 'embed'>; + * ``` + */ + type ElementType

= + | { [K in Tag]: P extends JSX.IntrinsicElements[K] ? K : never }[Tag] + | ComponentType

; + + /** + * Represents any user-defined component, either as a function or a class. + * + * Similar to {@link JSXElementConstructor}, but with extra properties like + * {@link FunctionComponent.defaultProps defaultProps }. + * + * @template P The props the component accepts. + * + * @see {@link ComponentClass} + * @see {@link FunctionComponent} + */ + type ComponentType

= ComponentClass

| FunctionComponent

; + + /** + * Represents any user-defined component, either as a function or a class. + * + * Similar to {@link ComponentType}, but without extra properties like + * {@link FunctionComponent.defaultProps defaultProps }. + * + * @template P The props the component accepts. + */ + type JSXElementConstructor

= + | (( + props: P, + ) => ReactNode | Promise) + // constructor signature must match React.Component + | (new(props: P, context: any) => Component); + + /** + * Created by {@link createRef}, or {@link useRef} when passed `null`. + * + * @template T The type of the ref's value. + * + * @example + * + * ```tsx + * const ref = createRef(); + * + * ref.current = document.createElement('div'); // Error + * ``` + */ + interface RefObject { + /** + * The current value of the ref. + */ + current: T; + } + + interface DO_NOT_USE_OR_YOU_WILL_BE_FIRED_CALLBACK_REF_RETURN_VALUES { + } + /** + * A callback fired whenever the ref's value changes. + * + * @template T The type of the ref's value. + * + * @see {@link https://react.dev/reference/react-dom/components/common#ref-callback React Docs} + * + * @example + * + * ```tsx + *

console.log(node)} /> + * ``` + */ + type RefCallback = { + bivarianceHack( + instance: T | null, + ): + | void + | (() => VoidOrUndefinedOnly) + | DO_NOT_USE_OR_YOU_WILL_BE_FIRED_CALLBACK_REF_RETURN_VALUES[ + keyof DO_NOT_USE_OR_YOU_WILL_BE_FIRED_CALLBACK_REF_RETURN_VALUES + ]; + }["bivarianceHack"]; + + /** + * A union type of all possible shapes for React refs. + * + * @see {@link RefCallback} + * @see {@link RefObject} + */ + + type Ref = RefCallback | RefObject | null; + /** + * @deprecated Use `Ref` instead. String refs are no longer supported. + * If you're typing a library with support for React versions with string refs, use `RefAttributes['ref']` instead. + */ + type LegacyRef = Ref; + /** + * @deprecated Use `ComponentRef` instead + * + * Retrieves the type of the 'ref' prop for a given component type or tag name. + * + * @template C The component type. + * + * @example + * + * ```tsx + * type MyComponentRef = React.ElementRef; + * ``` + * + * @example + * + * ```tsx + * type DivRef = React.ElementRef<'div'>; + * ``` + */ + type ElementRef< + C extends + | ForwardRefExoticComponent + | { new(props: any, context: any): Component } + | ((props: any) => ReactNode) + | keyof JSX.IntrinsicElements, + > = ComponentRef; + + type ComponentState = any; + + /** + * A value which uniquely identifies a node among items in an array. + * + * @see {@link https://react.dev/learn/rendering-lists#keeping-list-items-in-order-with-key React Docs} + */ + type Key = string | number | bigint; + + /** + * @internal The props any component can receive. + * You don't have to add this type. All components automatically accept these props. + * ```tsx + * const Component = () =>
; + * + * ``` + * + * WARNING: The implementation of a component will never have access to these attributes. + * The following example would be incorrect usage because {@link Component} would never have access to `key`: + * ```tsx + * const Component = (props: React.Attributes) => props.key; + * ``` + */ + interface Attributes { + key?: Key | null | undefined; + } + /** + * The props any component accepting refs can receive. + * Class components, built-in browser components (e.g. `div`) and forwardRef components can receive refs and automatically accept these props. + * ```tsx + * const Component = forwardRef(() =>
); + * console.log(current)} /> + * ``` + * + * You only need this type if you manually author the types of props that need to be compatible with legacy refs. + * ```tsx + * interface Props extends React.RefAttributes {} + * declare const Component: React.FunctionComponent; + * ``` + * + * Otherwise it's simpler to directly use {@link Ref} since you can safely use the + * props type to describe to props that a consumer can pass to the component + * as well as describing the props the implementation of a component "sees". + * {@link RefAttributes} is generally not safe to describe both consumer and seen props. + * + * ```tsx + * interface Props extends { + * ref?: React.Ref | undefined; + * } + * declare const Component: React.FunctionComponent; + * ``` + * + * WARNING: The implementation of a component will not have access to the same type in versions of React supporting string refs. + * The following example would be incorrect usage because {@link Component} would never have access to a `ref` with type `string` + * ```tsx + * const Component = (props: React.RefAttributes) => props.ref; + * ``` + */ + interface RefAttributes extends Attributes { + /** + * Allows getting a ref to the component instance. + * Once the component unmounts, React will set `ref.current` to `null` + * (or call the ref with `null` if you passed a callback ref). + * + * @see {@link https://react.dev/learn/referencing-values-with-refs#refs-and-the-dom React Docs} + */ + ref?: Ref | undefined; + } + + /** + * Represents the built-in attributes available to class components. + */ + interface ClassAttributes extends RefAttributes { + } + + /** + * Represents a JSX element. + * + * Where {@link ReactNode} represents everything that can be rendered, `ReactElement` + * only represents JSX. + * + * @template P The type of the props object + * @template T The type of the component or tag + * + * @example + * + * ```tsx + * const element: ReactElement =
; + * ``` + */ + interface ReactElement< + P = unknown, + T extends string | JSXElementConstructor = string | JSXElementConstructor, + > { + type: T; + props: P; + key: string | null; + } + + /** + * @deprecated + */ + interface ReactComponentElement< + T extends keyof JSX.IntrinsicElements | JSXElementConstructor, + P = Pick, Exclude, "key" | "ref">>, + > extends ReactElement> {} + + /** + * @deprecated Use `ReactElement>` + */ + interface FunctionComponentElement

extends ReactElement> { + /** + * @deprecated Use `element.props.ref` instead. + */ + ref?: ("ref" extends keyof P ? P extends { ref?: infer R | undefined } ? R : never : never) | undefined; + } + + /** + * @deprecated Use `ReactElement>` + */ + type CElement> = ComponentElement; + /** + * @deprecated Use `ReactElement>` + */ + interface ComponentElement> extends ReactElement> { + /** + * @deprecated Use `element.props.ref` instead. + */ + ref?: Ref | undefined; + } + + /** + * @deprecated Use {@link ComponentElement} instead. + */ + type ClassicElement

= CElement>; + + // string fallback for custom web-components + /** + * @deprecated Use `ReactElement` + */ + interface DOMElement

| SVGAttributes, T extends Element> + extends ReactElement + { + /** + * @deprecated Use `element.props.ref` instead. + */ + ref: Ref; + } + + // ReactHTML for ReactHTMLElement + interface ReactHTMLElement extends DetailedReactHTMLElement, T> {} + + interface DetailedReactHTMLElement

, T extends HTMLElement> extends DOMElement { + type: HTMLElementType; + } + + // ReactSVG for ReactSVGElement + interface ReactSVGElement extends DOMElement, SVGElement> { + type: SVGElementType; + } + + interface ReactPortal extends ReactElement { + children: ReactNode; + } + + /** + * Different release channels declare additional types of ReactNode this particular release channel accepts. + * App or library types should never augment this interface. + */ + interface DO_NOT_USE_OR_YOU_WILL_BE_FIRED_EXPERIMENTAL_REACT_NODES {} + + /** + * Represents all of the things React can render. + * + * Where {@link ReactElement} only represents JSX, `ReactNode` represents everything that can be rendered. + * + * @see {@link https://react-typescript-cheatsheet.netlify.app/docs/react-types/reactnode/ React TypeScript Cheatsheet} + * + * @example + * + * ```tsx + * // Typing children + * type Props = { children: ReactNode } + * + * const Component = ({ children }: Props) =>

{children}
+ * + * hello + * ``` + * + * @example + * + * ```tsx + * // Typing a custom element + * type Props = { customElement: ReactNode } + * + * const Component = ({ customElement }: Props) =>
{customElement}
+ * + * hello
} /> + * ``` + */ + // non-thenables need to be kept in sync with AwaitedReactNode + type ReactNode = + | ReactElement + | string + | number + | bigint + | Iterable + | ReactPortal + | boolean + | null + | undefined + | DO_NOT_USE_OR_YOU_WILL_BE_FIRED_EXPERIMENTAL_REACT_NODES[ + keyof DO_NOT_USE_OR_YOU_WILL_BE_FIRED_EXPERIMENTAL_REACT_NODES + ] + | Promise; + + // + // Top Level API + // ---------------------------------------------------------------------- + + // DOM Elements + // TODO: generalize this to everything in `keyof ReactHTML`, not just "input" + function createElement( + type: "input", + props?: InputHTMLAttributes & ClassAttributes | null, + ...children: ReactNode[] + ): DetailedReactHTMLElement, HTMLInputElement>; + function createElement

, T extends HTMLElement>( + type: HTMLElementType, + props?: ClassAttributes & P | null, + ...children: ReactNode[] + ): DetailedReactHTMLElement; + function createElement

, T extends SVGElement>( + type: SVGElementType, + props?: ClassAttributes & P | null, + ...children: ReactNode[] + ): ReactSVGElement; + function createElement

, T extends Element>( + type: string, + props?: ClassAttributes & P | null, + ...children: ReactNode[] + ): DOMElement; + + // Custom components + + function createElement

( + type: FunctionComponent

, + props?: Attributes & P | null, + ...children: ReactNode[] + ): FunctionComponentElement

; + function createElement

, C extends ComponentClass

>( + type: ClassType, + props?: ClassAttributes & P | null, + ...children: ReactNode[] + ): CElement; + function createElement

( + type: FunctionComponent

| ComponentClass

| string, + props?: Attributes & P | null, + ...children: ReactNode[] + ): ReactElement

; + + // DOM Elements + // ReactHTMLElement + function cloneElement

, T extends HTMLElement>( + element: DetailedReactHTMLElement, + props?: P, + ...children: ReactNode[] + ): DetailedReactHTMLElement; + // ReactHTMLElement, less specific + function cloneElement

, T extends HTMLElement>( + element: ReactHTMLElement, + props?: P, + ...children: ReactNode[] + ): ReactHTMLElement; + // SVGElement + function cloneElement

, T extends SVGElement>( + element: ReactSVGElement, + props?: P, + ...children: ReactNode[] + ): ReactSVGElement; + // DOM Element (has to be the last, because type checking stops at first overload that fits) + function cloneElement

, T extends Element>( + element: DOMElement, + props?: DOMAttributes & P, + ...children: ReactNode[] + ): DOMElement; + + // Custom components + function cloneElement

( + element: FunctionComponentElement

, + props?: Partial

& Attributes, + ...children: ReactNode[] + ): FunctionComponentElement

; + function cloneElement>( + element: CElement, + props?: Partial

& ClassAttributes, + ...children: ReactNode[] + ): CElement; + function cloneElement

( + element: ReactElement

, + props?: Partial

& Attributes, + ...children: ReactNode[] + ): ReactElement

; + + /** + * Describes the props accepted by a Context {@link Provider}. + * + * @template T The type of the value the context provides. + */ + interface ProviderProps { + value: T; + children?: ReactNode | undefined; + } + + /** + * Describes the props accepted by a Context {@link Consumer}. + * + * @template T The type of the value the context provides. + */ + interface ConsumerProps { + children: (value: T) => ReactNode; + } + + /** + * An object masquerading as a component. These are created by functions + * like {@link forwardRef}, {@link memo}, and {@link createContext}. + * + * In order to make TypeScript work, we pretend that they are normal + * components. + * + * But they are, in fact, not callable - instead, they are objects which + * are treated specially by the renderer. + * + * @template P The props the component accepts. + */ + interface ExoticComponent

{ + (props: P): ReactNode; + readonly $$typeof: symbol; + } + + /** + * An {@link ExoticComponent} with a `displayName` property applied to it. + * + * @template P The props the component accepts. + */ + interface NamedExoticComponent

extends ExoticComponent

{ + /** + * Used in debugging messages. You might want to set it + * explicitly if you want to display a different name for + * debugging purposes. + * + * @see {@link https://legacy.reactjs.org/docs/react-component.html#displayname Legacy React Docs} + */ + displayName?: string | undefined; + } + + /** + * An {@link ExoticComponent} with a `propTypes` property applied to it. + * + * @template P The props the component accepts. + */ + interface ProviderExoticComponent

extends ExoticComponent

{ + } + + /** + * Used to retrieve the type of a context object from a {@link Context}. + * + * @template C The context object. + * + * @example + * + * ```tsx + * import { createContext } from 'react'; + * + * const MyContext = createContext({ foo: 'bar' }); + * + * type ContextType = ContextType; + * // ContextType = { foo: string } + * ``` + */ + type ContextType> = C extends Context ? T : never; + + /** + * Wraps your components to specify the value of this context for all components inside. + * + * @see {@link https://react.dev/reference/react/createContext#provider React Docs} + * + * @example + * + * ```tsx + * import { createContext } from 'react'; + * + * const ThemeContext = createContext('light'); + * + * function App() { + * return ( + * + * + * + * ); + * } + * ``` + */ + type Provider = ProviderExoticComponent>; + + /** + * The old way to read context, before {@link useContext} existed. + * + * @see {@link https://react.dev/reference/react/createContext#consumer React Docs} + * + * @example + * + * ```tsx + * import { UserContext } from './user-context'; + * + * function Avatar() { + * return ( + * + * {user => {user.name}} + * + * ); + * } + * ``` + */ + type Consumer = ExoticComponent>; + + /** + * Context lets components pass information deep down without explicitly + * passing props. + * + * Created from {@link createContext} + * + * @see {@link https://react.dev/learn/passing-data-deeply-with-context React Docs} + * @see {@link https://react-typescript-cheatsheet.netlify.app/docs/basic/getting-started/context/ React TypeScript Cheatsheet} + * + * @example + * + * ```tsx + * import { createContext } from 'react'; + * + * const ThemeContext = createContext('light'); + * ``` + */ + interface Context extends Provider { + Provider: Provider; + Consumer: Consumer; + /** + * Used in debugging messages. You might want to set it + * explicitly if you want to display a different name for + * debugging purposes. + * + * @see {@link https://legacy.reactjs.org/docs/react-component.html#displayname Legacy React Docs} + */ + displayName?: string | undefined; + } + + /** + * Lets you create a {@link Context} that components can provide or read. + * + * @param defaultValue The value you want the context to have when there is no matching + * {@link Provider} in the tree above the component reading the context. This is meant + * as a "last resort" fallback. + * + * @see {@link https://react.dev/reference/react/createContext#reference React Docs} + * @see {@link https://react-typescript-cheatsheet.netlify.app/docs/basic/getting-started/context/ React TypeScript Cheatsheet} + * + * @example + * + * ```tsx + * import { createContext } from 'react'; + * + * const ThemeContext = createContext('light'); + * function App() { + * return ( + * + * + * + * ); + * } + * ``` + */ + function createContext( + // If you thought this should be optional, see + // https://github.com/DefinitelyTyped/DefinitelyTyped/pull/24509#issuecomment-382213106 + defaultValue: T, + ): Context; + + function isValidElement

(object: {} | null | undefined): object is ReactElement

; + + const Children: { + map( + children: C | readonly C[], + fn: (child: C, index: number) => T, + ): C extends null | undefined ? C : Array>; + forEach(children: C | readonly C[], fn: (child: C, index: number) => void): void; + count(children: any): number; + only(children: C): C extends any[] ? never : C; + toArray(children: ReactNode | ReactNode[]): Array>; + }; + + export interface FragmentProps { + children?: React.ReactNode; + } + /** + * Lets you group elements without a wrapper node. + * + * @see {@link https://react.dev/reference/react/Fragment React Docs} + * + * @example + * + * ```tsx + * import { Fragment } from 'react'; + * + * + * Hello + * World + * + * ``` + * + * @example + * + * ```tsx + * // Using the <> shorthand syntax: + * + * <> + * Hello + * World + * + * ``` + */ + const Fragment: ExoticComponent; + + /** + * Lets you find common bugs in your components early during development. + * + * @see {@link https://react.dev/reference/react/StrictMode React Docs} + * + * @example + * + * ```tsx + * import { StrictMode } from 'react'; + * + * + * + * + * ``` + */ + const StrictMode: ExoticComponent<{ children?: ReactNode | undefined }>; + + /** + * The props accepted by {@link Suspense}. + * + * @see {@link https://react.dev/reference/react/Suspense React Docs} + */ + interface SuspenseProps { + children?: ReactNode | undefined; + + /** A fallback react tree to show when a Suspense child (like React.lazy) suspends */ + fallback?: ReactNode; + + /** + * A name for this Suspense boundary for instrumentation purposes. + * The name will help identify this boundary in React DevTools. + */ + name?: string | undefined; + } + + /** + * Lets you display a fallback until its children have finished loading. + * + * @see {@link https://react.dev/reference/react/Suspense React Docs} + * + * @example + * + * ```tsx + * import { Suspense } from 'react'; + * + * }> + * + * + * ``` + */ + const Suspense: ExoticComponent; + const version: string; + + /** + * The callback passed to {@link ProfilerProps.onRender}. + * + * @see {@link https://react.dev/reference/react/Profiler#onrender-callback React Docs} + */ + type ProfilerOnRenderCallback = ( + /** + * The string id prop of the {@link Profiler} tree that has just committed. This lets + * you identify which part of the tree was committed if you are using multiple + * profilers. + * + * @see {@link https://react.dev/reference/react/Profiler#onrender-callback React Docs} + */ + id: string, + /** + * This lets you know whether the tree has just been mounted for the first time + * or re-rendered due to a change in props, state, or hooks. + * + * @see {@link https://react.dev/reference/react/Profiler#onrender-callback React Docs} + */ + phase: "mount" | "update" | "nested-update", + /** + * The number of milliseconds spent rendering the {@link Profiler} and its descendants + * for the current update. This indicates how well the subtree makes use of + * memoization (e.g. {@link memo} and {@link useMemo}). Ideally this value should decrease + * significantly after the initial mount as many of the descendants will only need to + * re-render if their specific props change. + * + * @see {@link https://react.dev/reference/react/Profiler#onrender-callback React Docs} + */ + actualDuration: number, + /** + * The number of milliseconds estimating how much time it would take to re-render the entire + * {@link Profiler} subtree without any optimizations. It is calculated by summing up the most + * recent render durations of each component in the tree. This value estimates a worst-case + * cost of rendering (e.g. the initial mount or a tree with no memoization). Compare + * {@link actualDuration} against it to see if memoization is working. + * + * @see {@link https://react.dev/reference/react/Profiler#onrender-callback React Docs} + */ + baseDuration: number, + /** + * A numeric timestamp for when React began rendering the current update. + * + * @see {@link https://react.dev/reference/react/Profiler#onrender-callback React Docs} + */ + startTime: number, + /** + * A numeric timestamp for when React committed the current update. This value is shared + * between all profilers in a commit, enabling them to be grouped if desirable. + * + * @see {@link https://react.dev/reference/react/Profiler#onrender-callback React Docs} + */ + commitTime: number, + ) => void; + + /** + * The props accepted by {@link Profiler}. + * + * @see {@link https://react.dev/reference/react/Profiler React Docs} + */ + interface ProfilerProps { + children?: ReactNode | undefined; + id: string; + onRender: ProfilerOnRenderCallback; + } + + /** + * Lets you measure rendering performance of a React tree programmatically. + * + * @see {@link https://react.dev/reference/react/Profiler#onrender-callback React Docs} + * + * @example + * + * ```tsx + * + * + * + * ``` + */ + const Profiler: ExoticComponent; + + // + // Component API + // ---------------------------------------------------------------------- + + type ReactInstance = Component | Element; + + // Base component for plain JS classes + interface Component

extends ComponentLifecycle {} + class Component { + /** + * If set, `this.context` will be set at runtime to the current value of the given Context. + * + * @example + * + * ```ts + * type MyContext = number + * const Ctx = React.createContext(0) + * + * class Foo extends React.Component { + * static contextType = Ctx + * context!: React.ContextType + * render () { + * return <>My context's value: {this.context}; + * } + * } + * ``` + * + * @see {@link https://react.dev/reference/react/Component#static-contexttype} + */ + static contextType?: Context | undefined; + + /** + * Ignored by React. + * @deprecated Only kept in types for backwards compatibility. Will be removed in a future major release. + */ + static propTypes?: any; + + /** + * If using React Context, re-declare this in your class to be the + * `React.ContextType` of your `static contextType`. + * Should be used with type annotation or static contextType. + * + * @example + * ```ts + * static contextType = MyContext + * // For TS pre-3.7: + * context!: React.ContextType + * // For TS 3.7 and above: + * declare context: React.ContextType + * ``` + * + * @see {@link https://react.dev/reference/react/Component#context React Docs} + */ + context: unknown; + + // Keep in sync with constructor signature of JSXElementConstructor and ComponentClass. + constructor(props: P); + /** + * @param props + * @param context value of the parent {@link https://react.dev/reference/react/Component#context Context} specified + * in `contextType`. + */ + // TODO: Ideally we'd infer the constructor signatur from `contextType`. + // Might be hard to ship without breaking existing code. + constructor(props: P, context: any); + + // We MUST keep setState() as a unified signature because it allows proper checking of the method return type. + // See: https://github.com/DefinitelyTyped/DefinitelyTyped/issues/18365#issuecomment-351013257 + // Also, the ` | S` allows intellisense to not be dumbisense + setState( + state: ((prevState: Readonly, props: Readonly

) => Pick | S | null) | (Pick | S | null), + callback?: () => void, + ): void; + + forceUpdate(callback?: () => void): void; + render(): ReactNode; + + readonly props: Readonly

; + state: Readonly; + } + + class PureComponent

extends Component {} + + /** + * @deprecated Use `ClassicComponent` from `create-react-class` + * + * @see {@link https://legacy.reactjs.org/docs/react-without-es6.html Legacy React Docs} + * @see {@link https://www.npmjs.com/package/create-react-class `create-react-class` on npm} + */ + interface ClassicComponent

extends Component { + replaceState(nextState: S, callback?: () => void): void; + isMounted(): boolean; + getInitialState?(): S; + } + + // + // Class Interfaces + // ---------------------------------------------------------------------- + + /** + * Represents the type of a function component. Can optionally + * receive a type argument that represents the props the component + * receives. + * + * @template P The props the component accepts. + * @see {@link https://react-typescript-cheatsheet.netlify.app/docs/basic/getting-started/function_components React TypeScript Cheatsheet} + * @alias for {@link FunctionComponent} + * + * @example + * + * ```tsx + * // With props: + * type Props = { name: string } + * + * const MyComponent: FC = (props) => { + * return

{props.name}
+ * } + * ``` + * + * @example + * + * ```tsx + * // Without props: + * const MyComponentWithoutProps: FC = () => { + * return
MyComponentWithoutProps
+ * } + * ``` + */ + type FC

= FunctionComponent

; + + /** + * Represents the type of a function component. Can optionally + * receive a type argument that represents the props the component + * accepts. + * + * @template P The props the component accepts. + * @see {@link https://react-typescript-cheatsheet.netlify.app/docs/basic/getting-started/function_components React TypeScript Cheatsheet} + * + * @example + * + * ```tsx + * // With props: + * type Props = { name: string } + * + * const MyComponent: FunctionComponent = (props) => { + * return

{props.name}
+ * } + * ``` + * + * @example + * + * ```tsx + * // Without props: + * const MyComponentWithoutProps: FunctionComponent = () => { + * return
MyComponentWithoutProps
+ * } + * ``` + */ + interface FunctionComponent

{ + (props: P): ReactNode | Promise; + /** + * Ignored by React. + * @deprecated Only kept in types for backwards compatibility. Will be removed in a future major release. + */ + propTypes?: any; + /** + * Used in debugging messages. You might want to set it + * explicitly if you want to display a different name for + * debugging purposes. + * + * @see {@link https://legacy.reactjs.org/docs/react-component.html#displayname Legacy React Docs} + * + * @example + * + * ```tsx + * + * const MyComponent: FC = () => { + * return

Hello!
+ * } + * + * MyComponent.displayName = 'MyAwesomeComponent' + * ``` + */ + displayName?: string | undefined; + } + + /** + * The type of the ref received by a {@link ForwardRefRenderFunction}. + * + * @see {@link ForwardRefRenderFunction} + */ + // Making T nullable is assuming the refs will be managed by React or the component impl will write it somewhere else. + // But this isn't necessarily true. We haven't heard complains about it yet and hopefully `forwardRef` is removed from React before we do. + type ForwardedRef = ((instance: T | null) => void) | RefObject | null; + + /** + * The type of the function passed to {@link forwardRef}. This is considered different + * to a normal {@link FunctionComponent} because it receives an additional argument, + * + * @param props Props passed to the component, if any. + * @param ref A ref forwarded to the component of type {@link ForwardedRef}. + * + * @template T The type of the forwarded ref. + * @template P The type of the props the component accepts. + * + * @see {@link https://react-typescript-cheatsheet.netlify.app/docs/basic/getting-started/forward_and_create_ref/ React TypeScript Cheatsheet} + * @see {@link forwardRef} + */ + interface ForwardRefRenderFunction { + (props: P, ref: ForwardedRef): ReactNode; + /** + * Used in debugging messages. You might want to set it + * explicitly if you want to display a different name for + * debugging purposes. + * + * Will show `ForwardRef(${Component.displayName || Component.name})` + * in devtools by default, but can be given its own specific name. + * + * @see {@link https://legacy.reactjs.org/docs/react-component.html#displayname Legacy React Docs} + */ + displayName?: string | undefined; + /** + * Ignored by React. + * @deprecated Only kept in types for backwards compatibility. Will be removed in a future major release. + */ + propTypes?: any; + } + + /** + * Represents a component class in React. + * + * @template P The props the component accepts. + * @template S The internal state of the component. + */ + interface ComponentClass

extends StaticLifecycle { + // constructor signature must match React.Component + new( + props: P, + /** + * Value of the parent {@link https://react.dev/reference/react/Component#context Context} specified + * in `contextType`. + */ + context?: any, + ): Component; + /** + * Ignored by React. + * @deprecated Only kept in types for backwards compatibility. Will be removed in a future major release. + */ + propTypes?: any; + contextType?: Context | undefined; + defaultProps?: Partial

| undefined; + /** + * Used in debugging messages. You might want to set it + * explicitly if you want to display a different name for + * debugging purposes. + * + * @see {@link https://legacy.reactjs.org/docs/react-component.html#displayname Legacy React Docs} + */ + displayName?: string | undefined; + } + + /** + * @deprecated Use `ClassicComponentClass` from `create-react-class` + * + * @see {@link https://legacy.reactjs.org/docs/react-without-es6.html Legacy React Docs} + * @see {@link https://www.npmjs.com/package/create-react-class `create-react-class` on npm} + */ + interface ClassicComponentClass

extends ComponentClass

{ + new(props: P): ClassicComponent; + getDefaultProps?(): P; + } + + /** + * Used in {@link createElement} and {@link createFactory} to represent + * a class. + * + * An intersection type is used to infer multiple type parameters from + * a single argument, which is useful for many top-level API defs. + * See {@link https://github.com/Microsoft/TypeScript/issues/7234 this GitHub issue} + * for more info. + */ + type ClassType, C extends ComponentClass

> = + & C + & (new(props: P, context: any) => T); + + // + // Component Specs and Lifecycle + // ---------------------------------------------------------------------- + + // This should actually be something like `Lifecycle | DeprecatedLifecycle`, + // as React will _not_ call the deprecated lifecycle methods if any of the new lifecycle + // methods are present. + interface ComponentLifecycle extends NewLifecycle, DeprecatedLifecycle { + /** + * Called immediately after a component is mounted. Setting state here will trigger re-rendering. + */ + componentDidMount?(): void; + /** + * Called to determine whether the change in props and state should trigger a re-render. + * + * `Component` always returns true. + * `PureComponent` implements a shallow comparison on props and state and returns true if any + * props or states have changed. + * + * If false is returned, {@link Component.render}, `componentWillUpdate` + * and `componentDidUpdate` will not be called. + */ + shouldComponentUpdate?(nextProps: Readonly

, nextState: Readonly, nextContext: any): boolean; + /** + * Called immediately before a component is destroyed. Perform any necessary cleanup in this method, such as + * cancelled network requests, or cleaning up any DOM elements created in `componentDidMount`. + */ + componentWillUnmount?(): void; + /** + * Catches exceptions generated in descendant components. Unhandled exceptions will cause + * the entire component tree to unmount. + */ + componentDidCatch?(error: Error, errorInfo: ErrorInfo): void; + } + + // Unfortunately, we have no way of declaring that the component constructor must implement this + interface StaticLifecycle { + getDerivedStateFromProps?: GetDerivedStateFromProps | undefined; + getDerivedStateFromError?: GetDerivedStateFromError | undefined; + } + + type GetDerivedStateFromProps = + /** + * Returns an update to a component's state based on its new props and old state. + * + * Note: its presence prevents any of the deprecated lifecycle methods from being invoked + */ + (nextProps: Readonly

, prevState: S) => Partial | null; + + type GetDerivedStateFromError = + /** + * This lifecycle is invoked after an error has been thrown by a descendant component. + * It receives the error that was thrown as a parameter and should return a value to update state. + * + * Note: its presence prevents any of the deprecated lifecycle methods from being invoked + */ + (error: any) => Partial | null; + + // This should be "infer SS" but can't use it yet + interface NewLifecycle { + /** + * Runs before React applies the result of {@link Component.render render} to the document, and + * returns an object to be given to {@link componentDidUpdate}. Useful for saving + * things such as scroll position before {@link Component.render render} causes changes to it. + * + * Note: the presence of this method prevents any of the deprecated + * lifecycle events from running. + */ + getSnapshotBeforeUpdate?(prevProps: Readonly

, prevState: Readonly): SS | null; + /** + * Called immediately after updating occurs. Not called for the initial render. + * + * The snapshot is only present if {@link getSnapshotBeforeUpdate} is present and returns non-null. + */ + componentDidUpdate?(prevProps: Readonly

, prevState: Readonly, snapshot?: SS): void; + } + + interface DeprecatedLifecycle { + /** + * Called immediately before mounting occurs, and before {@link Component.render}. + * Avoid introducing any side-effects or subscriptions in this method. + * + * Note: the presence of {@link NewLifecycle.getSnapshotBeforeUpdate getSnapshotBeforeUpdate} + * or {@link StaticLifecycle.getDerivedStateFromProps getDerivedStateFromProps} prevents + * this from being invoked. + * + * @deprecated 16.3, use {@link ComponentLifecycle.componentDidMount componentDidMount} or the constructor instead; will stop working in React 17 + * @see {@link https://legacy.reactjs.org/blog/2018/03/27/update-on-async-rendering.html#initializing-state} + * @see {@link https://legacy.reactjs.org/blog/2018/03/27/update-on-async-rendering.html#gradual-migration-path} + */ + componentWillMount?(): void; + /** + * Called immediately before mounting occurs, and before {@link Component.render}. + * Avoid introducing any side-effects or subscriptions in this method. + * + * This method will not stop working in React 17. + * + * Note: the presence of {@link NewLifecycle.getSnapshotBeforeUpdate getSnapshotBeforeUpdate} + * or {@link StaticLifecycle.getDerivedStateFromProps getDerivedStateFromProps} prevents + * this from being invoked. + * + * @deprecated 16.3, use {@link ComponentLifecycle.componentDidMount componentDidMount} or the constructor instead + * @see {@link https://legacy.reactjs.org/blog/2018/03/27/update-on-async-rendering.html#initializing-state} + * @see {@link https://legacy.reactjs.org/blog/2018/03/27/update-on-async-rendering.html#gradual-migration-path} + */ + UNSAFE_componentWillMount?(): void; + /** + * Called when the component may be receiving new props. + * React may call this even if props have not changed, so be sure to compare new and existing + * props if you only want to handle changes. + * + * Calling {@link Component.setState} generally does not trigger this method. + * + * Note: the presence of {@link NewLifecycle.getSnapshotBeforeUpdate getSnapshotBeforeUpdate} + * or {@link StaticLifecycle.getDerivedStateFromProps getDerivedStateFromProps} prevents + * this from being invoked. + * + * @deprecated 16.3, use static {@link StaticLifecycle.getDerivedStateFromProps getDerivedStateFromProps} instead; will stop working in React 17 + * @see {@link https://legacy.reactjs.org/blog/2018/03/27/update-on-async-rendering.html#updating-state-based-on-props} + * @see {@link https://legacy.reactjs.org/blog/2018/03/27/update-on-async-rendering.html#gradual-migration-path} + */ + componentWillReceiveProps?(nextProps: Readonly

, nextContext: any): void; + /** + * Called when the component may be receiving new props. + * React may call this even if props have not changed, so be sure to compare new and existing + * props if you only want to handle changes. + * + * Calling {@link Component.setState} generally does not trigger this method. + * + * This method will not stop working in React 17. + * + * Note: the presence of {@link NewLifecycle.getSnapshotBeforeUpdate getSnapshotBeforeUpdate} + * or {@link StaticLifecycle.getDerivedStateFromProps getDerivedStateFromProps} prevents + * this from being invoked. + * + * @deprecated 16.3, use static {@link StaticLifecycle.getDerivedStateFromProps getDerivedStateFromProps} instead + * @see {@link https://legacy.reactjs.org/blog/2018/03/27/update-on-async-rendering.html#updating-state-based-on-props} + * @see {@link https://legacy.reactjs.org/blog/2018/03/27/update-on-async-rendering.html#gradual-migration-path} + */ + UNSAFE_componentWillReceiveProps?(nextProps: Readonly

, nextContext: any): void; + /** + * Called immediately before rendering when new props or state is received. Not called for the initial render. + * + * Note: You cannot call {@link Component.setState} here. + * + * Note: the presence of {@link NewLifecycle.getSnapshotBeforeUpdate getSnapshotBeforeUpdate} + * or {@link StaticLifecycle.getDerivedStateFromProps getDerivedStateFromProps} prevents + * this from being invoked. + * + * @deprecated 16.3, use getSnapshotBeforeUpdate instead; will stop working in React 17 + * @see {@link https://legacy.reactjs.org/blog/2018/03/27/update-on-async-rendering.html#reading-dom-properties-before-an-update} + * @see {@link https://legacy.reactjs.org/blog/2018/03/27/update-on-async-rendering.html#gradual-migration-path} + */ + componentWillUpdate?(nextProps: Readonly

, nextState: Readonly, nextContext: any): void; + /** + * Called immediately before rendering when new props or state is received. Not called for the initial render. + * + * Note: You cannot call {@link Component.setState} here. + * + * This method will not stop working in React 17. + * + * Note: the presence of {@link NewLifecycle.getSnapshotBeforeUpdate getSnapshotBeforeUpdate} + * or {@link StaticLifecycle.getDerivedStateFromProps getDerivedStateFromProps} prevents + * this from being invoked. + * + * @deprecated 16.3, use getSnapshotBeforeUpdate instead + * @see {@link https://legacy.reactjs.org/blog/2018/03/27/update-on-async-rendering.html#reading-dom-properties-before-an-update} + * @see {@link https://legacy.reactjs.org/blog/2018/03/27/update-on-async-rendering.html#gradual-migration-path} + */ + UNSAFE_componentWillUpdate?(nextProps: Readonly

, nextState: Readonly, nextContext: any): void; + } + + function createRef(): RefObject; + + /** + * The type of the component returned from {@link forwardRef}. + * + * @template P The props the component accepts, if any. + * + * @see {@link ExoticComponent} + */ + interface ForwardRefExoticComponent

extends NamedExoticComponent

{ + /** + * Ignored by React. + * @deprecated Only kept in types for backwards compatibility. Will be removed in a future major release. + */ + propTypes?: any; + } + + /** + * Lets your component expose a DOM node to a parent component + * using a ref. + * + * @see {@link https://react.dev/reference/react/forwardRef React Docs} + * @see {@link https://react-typescript-cheatsheet.netlify.app/docs/basic/getting-started/forward_and_create_ref/ React TypeScript Cheatsheet} + * + * @param render See the {@link ForwardRefRenderFunction}. + * + * @template T The type of the DOM node. + * @template P The props the component accepts, if any. + * + * @example + * + * ```tsx + * interface Props { + * children?: ReactNode; + * type: "submit" | "button"; + * } + * + * export const FancyButton = forwardRef((props, ref) => ( + * + * )); + * ``` + */ + function forwardRef( + render: ForwardRefRenderFunction>, + ): ForwardRefExoticComponent & RefAttributes>; + + /** + * Omits the 'ref' attribute from the given props object. + * + * @template Props The props object type. + */ + type PropsWithoutRef = + // Omit would not be sufficient for this. We'd like to avoid unnecessary mapping and need a distributive conditional to support unions. + // see: https://www.typescriptlang.org/docs/handbook/2/conditional-types.html#distributive-conditional-types + // https://github.com/Microsoft/TypeScript/issues/28339 + Props extends any ? ("ref" extends keyof Props ? Omit : Props) : Props; + /** + * Ensures that the props do not include string ref, which cannot be forwarded + * @deprecated Use `Props` directly. `PropsWithRef` is just an alias for `Props` + */ + type PropsWithRef = Props; + + type PropsWithChildren

= P & { children?: ReactNode | undefined }; + + /** + * Used to retrieve the props a component accepts. Can either be passed a string, + * indicating a DOM element (e.g. 'div', 'span', etc.) or the type of a React + * component. + * + * It's usually better to use {@link ComponentPropsWithRef} or {@link ComponentPropsWithoutRef} + * instead of this type, as they let you be explicit about whether or not to include + * the `ref` prop. + * + * @see {@link https://react-typescript-cheatsheet.netlify.app/docs/react-types/componentprops/ React TypeScript Cheatsheet} + * + * @example + * + * ```tsx + * // Retrieves the props an 'input' element accepts + * type InputProps = React.ComponentProps<'input'>; + * ``` + * + * @example + * + * ```tsx + * const MyComponent = (props: { foo: number, bar: string }) =>

; + * + * // Retrieves the props 'MyComponent' accepts + * type MyComponentProps = React.ComponentProps; + * ``` + */ + type ComponentProps> = T extends + JSXElementConstructor ? Props + : T extends keyof JSX.IntrinsicElements ? JSX.IntrinsicElements[T] + : {}; + + /** + * Used to retrieve the props a component accepts with its ref. Can either be + * passed a string, indicating a DOM element (e.g. 'div', 'span', etc.) or the + * type of a React component. + * + * @see {@link https://react-typescript-cheatsheet.netlify.app/docs/react-types/componentprops/ React TypeScript Cheatsheet} + * + * @example + * + * ```tsx + * // Retrieves the props an 'input' element accepts + * type InputProps = React.ComponentPropsWithRef<'input'>; + * ``` + * + * @example + * + * ```tsx + * const MyComponent = (props: { foo: number, bar: string }) =>
; + * + * // Retrieves the props 'MyComponent' accepts + * type MyComponentPropsWithRef = React.ComponentPropsWithRef; + * ``` + */ + type ComponentPropsWithRef = T extends JSXElementConstructor + // If it's a class i.e. newable we're dealing with a class component + ? T extends abstract new(args: any) => any ? PropsWithoutRef & RefAttributes> + : Props + : ComponentProps; + /** + * Used to retrieve the props a custom component accepts with its ref. + * + * Unlike {@link ComponentPropsWithRef}, this only works with custom + * components, i.e. components you define yourself. This is to improve + * type-checking performance. + * + * @example + * + * ```tsx + * const MyComponent = (props: { foo: number, bar: string }) =>
; + * + * // Retrieves the props 'MyComponent' accepts + * type MyComponentPropsWithRef = React.CustomComponentPropsWithRef; + * ``` + */ + type CustomComponentPropsWithRef = T extends JSXElementConstructor + // If it's a class i.e. newable we're dealing with a class component + ? T extends abstract new(args: any) => any ? PropsWithoutRef & RefAttributes> + : Props + : never; + + /** + * Used to retrieve the props a component accepts without its ref. Can either be + * passed a string, indicating a DOM element (e.g. 'div', 'span', etc.) or the + * type of a React component. + * + * @see {@link https://react-typescript-cheatsheet.netlify.app/docs/react-types/componentprops/ React TypeScript Cheatsheet} + * + * @example + * + * ```tsx + * // Retrieves the props an 'input' element accepts + * type InputProps = React.ComponentPropsWithoutRef<'input'>; + * ``` + * + * @example + * + * ```tsx + * const MyComponent = (props: { foo: number, bar: string }) =>
; + * + * // Retrieves the props 'MyComponent' accepts + * type MyComponentPropsWithoutRef = React.ComponentPropsWithoutRef; + * ``` + */ + type ComponentPropsWithoutRef = PropsWithoutRef>; + + /** + * Retrieves the type of the 'ref' prop for a given component type or tag name. + * + * @template C The component type. + * + * @example + * + * ```tsx + * type MyComponentRef = React.ComponentRef; + * ``` + * + * @example + * + * ```tsx + * type DivRef = React.ComponentRef<'div'>; + * ``` + */ + type ComponentRef = ComponentPropsWithRef extends RefAttributes ? Method + : never; + + // will show `Memo(${Component.displayName || Component.name})` in devtools by default, + // but can be given its own specific name + type MemoExoticComponent> = NamedExoticComponent> & { + readonly type: T; + }; + + /** + * Lets you skip re-rendering a component when its props are unchanged. + * + * @see {@link https://react.dev/reference/react/memo React Docs} + * + * @param Component The component to memoize. + * @param propsAreEqual A function that will be used to determine if the props have changed. + * + * @example + * + * ```tsx + * import { memo } from 'react'; + * + * const SomeComponent = memo(function SomeComponent(props: { foo: string }) { + * // ... + * }); + * ``` + */ + function memo

( + Component: FunctionComponent

, + propsAreEqual?: (prevProps: Readonly

, nextProps: Readonly

) => boolean, + ): NamedExoticComponent

; + function memo>( + Component: T, + propsAreEqual?: (prevProps: Readonly>, nextProps: Readonly>) => boolean, + ): MemoExoticComponent; + + interface LazyExoticComponent> + extends ExoticComponent> + { + readonly _result: T; + } + + /** + * Lets you defer loading a component’s code until it is rendered for the first time. + * + * @see {@link https://react.dev/reference/react/lazy React Docs} + * + * @param load A function that returns a `Promise` or another thenable (a `Promise`-like object with a + * then method). React will not call `load` until the first time you attempt to render the returned + * component. After React first calls load, it will wait for it to resolve, and then render the + * resolved value’s `.default` as a React component. Both the returned `Promise` and the `Promise`’s + * resolved value will be cached, so React will not call load more than once. If the `Promise` rejects, + * React will throw the rejection reason for the nearest Error Boundary to handle. + * + * @example + * + * ```tsx + * import { lazy } from 'react'; + * + * const MarkdownPreview = lazy(() => import('./MarkdownPreview.js')); + * ``` + */ + function lazy>( + load: () => Promise<{ default: T }>, + ): LazyExoticComponent; + + // + // React Hooks + // ---------------------------------------------------------------------- + + /** + * The instruction passed to a {@link Dispatch} function in {@link useState} + * to tell React what the next value of the {@link useState} should be. + * + * Often found wrapped in {@link Dispatch}. + * + * @template S The type of the state. + * + * @example + * + * ```tsx + * // This return type correctly represents the type of + * // `setCount` in the example below. + * const useCustomState = (): Dispatch> => { + * const [count, setCount] = useState(0); + * + * return setCount; + * } + * ``` + */ + type SetStateAction = S | ((prevState: S) => S); + + /** + * A function that can be used to update the state of a {@link useState} + * or {@link useReducer} hook. + */ + type Dispatch = (value: A) => void; + /** + * A {@link Dispatch} function can sometimes be called without any arguments. + */ + type DispatchWithoutAction = () => void; + // Limit the reducer to accept only 0 or 1 action arguments + // eslint-disable-next-line @definitelytyped/no-single-element-tuple-type + type AnyActionArg = [] | [any]; + // Get the dispatch type from the reducer arguments (captures optional action argument correctly) + type ActionDispatch = (...args: ActionArg) => void; + // Unlike redux, the actions _can_ be anything + type Reducer = (prevState: S, action: A) => S; + // If useReducer accepts a reducer without action, dispatch may be called without any parameters. + type ReducerWithoutAction = (prevState: S) => S; + // types used to try and prevent the compiler from reducing S + // to a supertype common with the second argument to useReducer() + type ReducerState> = R extends Reducer ? S : never; + type DependencyList = readonly unknown[]; + + // NOTE: callbacks are _only_ allowed to return either void, or a destructor. + type EffectCallback = () => void | Destructor; + + /** + * @deprecated Use `RefObject` instead. + */ + interface MutableRefObject { + current: T; + } + + // This will technically work if you give a Consumer or Provider but it's deprecated and warns + /** + * Accepts a context object (the value returned from `React.createContext`) and returns the current + * context value, as given by the nearest context provider for the given context. + * + * @version 16.8.0 + * @see {@link https://react.dev/reference/react/useContext} + */ + function useContext(context: Context /*, (not public API) observedBits?: number|boolean */): T; + /** + * Returns a stateful value, and a function to update it. + * + * @version 16.8.0 + * @see {@link https://react.dev/reference/react/useState} + */ + function useState(initialState: S | (() => S)): [S, Dispatch>]; + // convenience overload when first argument is omitted + /** + * Returns a stateful value, and a function to update it. + * + * @version 16.8.0 + * @see {@link https://react.dev/reference/react/useState} + */ + function useState(): [S | undefined, Dispatch>]; + /** + * An alternative to `useState`. + * + * `useReducer` is usually preferable to `useState` when you have complex state logic that involves + * multiple sub-values. It also lets you optimize performance for components that trigger deep + * updates because you can pass `dispatch` down instead of callbacks. + * + * @version 16.8.0 + * @see {@link https://react.dev/reference/react/useReducer} + */ + function useReducer( + reducer: (prevState: S, ...args: A) => S, + initialState: S, + ): [S, ActionDispatch]; + /** + * An alternative to `useState`. + * + * `useReducer` is usually preferable to `useState` when you have complex state logic that involves + * multiple sub-values. It also lets you optimize performance for components that trigger deep + * updates because you can pass `dispatch` down instead of callbacks. + * + * @version 16.8.0 + * @see {@link https://react.dev/reference/react/useReducer} + */ + function useReducer( + reducer: (prevState: S, ...args: A) => S, + initialArg: I, + init: (i: I) => S, + ): [S, ActionDispatch]; + /** + * `useRef` returns a mutable ref object whose `.current` property is initialized to the passed argument + * (`initialValue`). The returned object will persist for the full lifetime of the component. + * + * Note that `useRef()` is useful for more than the `ref` attribute. It’s handy for keeping any mutable + * value around similar to how you’d use instance fields in classes. + * + * @version 16.8.0 + * @see {@link https://react.dev/reference/react/useRef} + */ + function useRef(initialValue: T): RefObject; + // convenience overload for refs given as a ref prop as they typically start with a null value + /** + * `useRef` returns a mutable ref object whose `.current` property is initialized to the passed argument + * (`initialValue`). The returned object will persist for the full lifetime of the component. + * + * Note that `useRef()` is useful for more than the `ref` attribute. It’s handy for keeping any mutable + * value around similar to how you’d use instance fields in classes. + * + * @version 16.8.0 + * @see {@link https://react.dev/reference/react/useRef} + */ + function useRef(initialValue: T | null): RefObject; + // convenience overload for undefined initialValue + /** + * `useRef` returns a mutable ref object whose `.current` property is initialized to the passed argument + * (`initialValue`). The returned object will persist for the full lifetime of the component. + * + * Note that `useRef()` is useful for more than the `ref` attribute. It’s handy for keeping any mutable + * value around similar to how you’d use instance fields in classes. + * + * @version 16.8.0 + * @see {@link https://react.dev/reference/react/useRef} + */ + function useRef(initialValue: T | undefined): RefObject; + /** + * The signature is identical to `useEffect`, but it fires synchronously after all DOM mutations. + * Use this to read layout from the DOM and synchronously re-render. Updates scheduled inside + * `useLayoutEffect` will be flushed synchronously, before the browser has a chance to paint. + * + * Prefer the standard `useEffect` when possible to avoid blocking visual updates. + * + * If you’re migrating code from a class component, `useLayoutEffect` fires in the same phase as + * `componentDidMount` and `componentDidUpdate`. + * + * @version 16.8.0 + * @see {@link https://react.dev/reference/react/useLayoutEffect} + */ + function useLayoutEffect(effect: EffectCallback, deps?: DependencyList): void; + /** + * Accepts a function that contains imperative, possibly effectful code. + * + * @param effect Imperative function that can return a cleanup function + * @param deps If present, effect will only activate if the values in the list change. + * + * @version 16.8.0 + * @see {@link https://react.dev/reference/react/useEffect} + */ + function useEffect(effect: EffectCallback, deps?: DependencyList): void; + /** + * @see {@link https://react.dev/reference/react/useEffectEvent `useEffectEvent()` documentation} + * @version 19.2.0 + */ + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + export function useEffectEvent(callback: T): T; + // NOTE: this does not accept strings, but this will have to be fixed by removing strings from type Ref + /** + * `useImperativeHandle` customizes the instance value that is exposed to parent components when using + * `ref`. As always, imperative code using refs should be avoided in most cases. + * + * @version 16.8.0 + * @see {@link https://react.dev/reference/react/useImperativeHandle} + */ + function useImperativeHandle(ref: Ref | undefined, init: () => R, deps?: DependencyList): void; + // I made 'inputs' required here and in useMemo as there's no point to memoizing without the memoization key + // useCallback(X) is identical to just using X, useMemo(() => Y) is identical to just using Y. + /** + * `useCallback` will return a memoized version of the callback that only changes if one of the `inputs` + * has changed. + * + * @version 16.8.0 + * @see {@link https://react.dev/reference/react/useCallback} + */ + // A specific function type would not trigger implicit any. + // See https://github.com/DefinitelyTyped/DefinitelyTyped/issues/52873#issuecomment-845806435 for a comparison between `Function` and more specific types. + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + function useCallback(callback: T, deps: DependencyList): T; + /** + * `useMemo` will only recompute the memoized value when one of the `deps` has changed. + * + * @version 16.8.0 + * @see {@link https://react.dev/reference/react/useMemo} + */ + // allow undefined, but don't make it optional as that is very likely a mistake + function useMemo(factory: () => T, deps: DependencyList): T; + /** + * `useDebugValue` can be used to display a label for custom hooks in React DevTools. + * + * NOTE: We don’t recommend adding debug values to every custom hook. + * It’s most valuable for custom hooks that are part of shared libraries. + * + * @version 16.8.0 + * @see {@link https://react.dev/reference/react/useDebugValue} + */ + // the name of the custom hook is itself derived from the function name at runtime: + // it's just the function name without the "use" prefix. + function useDebugValue(value: T, format?: (value: T) => any): void; + + export type TransitionFunction = () => VoidOrUndefinedOnly | Promise; + // strange definition to allow vscode to show documentation on the invocation + export interface TransitionStartFunction { + /** + * State updates caused inside the callback are allowed to be deferred. + * + * **If some state update causes a component to suspend, that state update should be wrapped in a transition.** + * + * @param callback A function which causes state updates that can be deferred. + */ + (callback: TransitionFunction): void; + } + + /** + * Returns a deferred version of the value that may “lag behind” it. + * + * This is commonly used to keep the interface responsive when you have something that renders immediately + * based on user input and something that needs to wait for a data fetch. + * + * A good example of this is a text input. + * + * @param value The value that is going to be deferred + * @param initialValue A value to use during the initial render of a component. If this option is omitted, `useDeferredValue` will not defer during the initial render, because there’s no previous version of `value` that it can render instead. + * + * @see {@link https://react.dev/reference/react/useDeferredValue} + */ + export function useDeferredValue(value: T, initialValue?: T): T; + + /** + * Allows components to avoid undesirable loading states by waiting for content to load + * before transitioning to the next screen. It also allows components to defer slower, + * data fetching updates until subsequent renders so that more crucial updates can be + * rendered immediately. + * + * The `useTransition` hook returns two values in an array. + * + * The first is a boolean, React’s way of informing us whether we’re waiting for the transition to finish. + * The second is a function that takes a callback. We can use it to tell React which state we want to defer. + * + * **If some state update causes a component to suspend, that state update should be wrapped in a transition.** + * + * @see {@link https://react.dev/reference/react/useTransition} + */ + export function useTransition(): [boolean, TransitionStartFunction]; + + /** + * Similar to `useTransition` but allows uses where hooks are not available. + * + * @param callback A function which causes state updates that can be deferred. + */ + export function startTransition(scope: TransitionFunction): void; + + /** + * Wrap any code rendering and triggering updates to your components into `act()` calls. + * + * Ensures that the behavior in your tests matches what happens in the browser + * more closely by executing pending `useEffect`s before returning. This also + * reduces the amount of re-renders done. + * + * @param callback A synchronous, void callback that will execute as a single, complete React commit. + * + * @see https://reactjs.org/blog/2019/02/06/react-v16.8.0.html#testing-hooks + */ + // NOTES + // - the order of these signatures matters - typescript will check the signatures in source order. + // If the `() => VoidOrUndefinedOnly` signature is first, it'll erroneously match a Promise returning function for users with + // `strictNullChecks: false`. + // - VoidOrUndefinedOnly is there to forbid any non-void return values for users with `strictNullChecks: true` + // While act does always return Thenable, if a void function is passed, we pretend the return value is also void to not trigger dangling Promise lint rules. + export function act(callback: () => VoidOrUndefinedOnly): void; + export function act(callback: () => T | Promise): Promise; + + export function useId(): string; + + /** + * @param effect Imperative function that can return a cleanup function + * @param deps If present, effect will only activate if the values in the list change. + * + * @see {@link https://github.com/facebook/react/pull/21913} + */ + export function useInsertionEffect(effect: EffectCallback, deps?: DependencyList): void; + + /** + * @param subscribe + * @param getSnapshot + * + * @see {@link https://github.com/reactwg/react-18/discussions/86} + */ + // keep in sync with `useSyncExternalStore` from `use-sync-external-store` + export function useSyncExternalStore( + subscribe: (onStoreChange: () => void) => () => void, + getSnapshot: () => Snapshot, + getServerSnapshot?: () => Snapshot, + ): Snapshot; + + export function useOptimistic( + passthrough: State, + ): [State, (action: State | ((pendingState: State) => State)) => void]; + export function useOptimistic( + passthrough: State, + reducer: (state: State, action: Action) => State, + ): [State, (action: Action) => void]; + + interface UntrackedReactPromise extends PromiseLike { + status?: void; + } + + export interface PendingReactPromise extends PromiseLike { + status: "pending"; + } + + export interface FulfilledReactPromise extends PromiseLike { + status: "fulfilled"; + value: T; + } + + export interface RejectedReactPromise extends PromiseLike { + status: "rejected"; + reason: unknown; + } + + export type ReactPromise = + | UntrackedReactPromise + | PendingReactPromise + | FulfilledReactPromise + | RejectedReactPromise; + + export type Usable = ReactPromise | Context; + + export function use(usable: Usable): T; + + export function useActionState( + action: (state: Awaited) => State | Promise, + initialState: Awaited, + permalink?: string, + ): [state: Awaited, dispatch: () => void, isPending: boolean]; + export function useActionState( + action: (state: Awaited, payload: Payload) => State | Promise, + initialState: Awaited, + permalink?: string, + ): [state: Awaited, dispatch: (payload: Payload) => void, isPending: boolean]; + + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + export function cache(fn: CachedFunction): CachedFunction; + + export interface CacheSignal {} + /** + * @version 19.2.0 + */ + export function cacheSignal(): null | CacheSignal; + + export interface ActivityProps { + /** + * @default "visible" + */ + mode?: + | "hidden" + | "visible" + | undefined; + /** + * A name for this Activity boundary for instrumentation purposes. + * The name will help identify this boundary in React DevTools. + */ + name?: string | undefined; + children: ReactNode; + } + + /** + * @see {@link https://react.dev/reference/react/Activity `` documentation} + * @version 19.2.0 + */ + export const Activity: ExoticComponent; + + /** + * Warning: Only available in development builds. + * + * @see {@link https://react.dev/reference/react/captureOwnerStack Reference docs} + * @version 19.1.0 + */ + function captureOwnerStack(): string | null; + + // + // Event System + // ---------------------------------------------------------------------- + // TODO: change any to unknown when moving to TS v3 + interface BaseSyntheticEvent { + nativeEvent: E; + currentTarget: C; + target: T; + bubbles: boolean; + cancelable: boolean; + defaultPrevented: boolean; + eventPhase: number; + isTrusted: boolean; + preventDefault(): void; + isDefaultPrevented(): boolean; + stopPropagation(): void; + isPropagationStopped(): boolean; + persist(): void; + timeStamp: number; + type: string; + } + + /** + * currentTarget - a reference to the element on which the event listener is registered. + * + * target - a reference to the element from which the event was originally dispatched. + * This might be a child element to the element on which the event listener is registered. + * If you thought this should be `EventTarget & T`, see https://github.com/DefinitelyTyped/DefinitelyTyped/issues/11508#issuecomment-256045682 + */ + interface SyntheticEvent extends BaseSyntheticEvent {} + + interface ClipboardEvent extends SyntheticEvent { + clipboardData: DataTransfer; + } + + interface CompositionEvent extends SyntheticEvent { + data: string; + } + + interface DragEvent extends MouseEvent { + dataTransfer: DataTransfer; + } + + interface PointerEvent extends MouseEvent { + pointerId: number; + pressure: number; + tangentialPressure: number; + tiltX: number; + tiltY: number; + twist: number; + width: number; + height: number; + pointerType: "mouse" | "pen" | "touch"; + isPrimary: boolean; + } + + interface FocusEvent extends SyntheticEvent { + relatedTarget: (EventTarget & RelatedTarget) | null; + target: EventTarget & Target; + } + + /** + * @deprecated FormEvent doesn't actually exist. + * You probably meant to use {@link ChangeEvent}, {@link InputEvent}, {@link SubmitEvent}, or just {@link SyntheticEvent} instead + * depending on the event type. + */ + interface FormEvent extends SyntheticEvent { + } + + interface InvalidEvent extends SyntheticEvent { + } + + /** + * change events bubble in React so their target is generally unknown. + * Only for form elements we know their target type because form events can't + * be nested. + * This type exists purely to narrow `target` for form elements. It doesn't + * reflect a DOM event. Change events are just fired as standard {@link SyntheticEvent}. + */ + interface ChangeEvent extends SyntheticEvent { + // TODO: This is wrong for change event handlers on arbitrary. Should + // be EventTarget & Target, but kept for backward compatibility until React 20. + target: EventTarget & CurrentTarget; + } + + interface InputEvent extends SyntheticEvent { + data: string; + } + + export type ModifierKey = + | "Alt" + | "AltGraph" + | "CapsLock" + | "Control" + | "Fn" + | "FnLock" + | "Hyper" + | "Meta" + | "NumLock" + | "ScrollLock" + | "Shift" + | "Super" + | "Symbol" + | "SymbolLock"; + + interface KeyboardEvent extends UIEvent { + altKey: boolean; + /** @deprecated */ + charCode: number; + ctrlKey: boolean; + code: string; + /** + * See [DOM Level 3 Events spec](https://www.w3.org/TR/uievents-key/#keys-modifier). for a list of valid (case-sensitive) arguments to this method. + */ + getModifierState(key: ModifierKey): boolean; + /** + * See the [DOM Level 3 Events spec](https://www.w3.org/TR/uievents-key/#named-key-attribute-values). for possible values + */ + key: string; + /** @deprecated */ + keyCode: number; + locale: string; + location: number; + metaKey: boolean; + repeat: boolean; + shiftKey: boolean; + /** @deprecated */ + which: number; + } + + interface MouseEvent extends UIEvent { + altKey: boolean; + button: number; + buttons: number; + clientX: number; + clientY: number; + ctrlKey: boolean; + /** + * See [DOM Level 3 Events spec](https://www.w3.org/TR/uievents-key/#keys-modifier). for a list of valid (case-sensitive) arguments to this method. + */ + getModifierState(key: ModifierKey): boolean; + metaKey: boolean; + movementX: number; + movementY: number; + pageX: number; + pageY: number; + relatedTarget: EventTarget | null; + screenX: number; + screenY: number; + shiftKey: boolean; + } + + interface SubmitEvent extends SyntheticEvent { + // Currently not exposed by Reat + // submitter: HTMLElement | null; + // SubmitEvents are always targetted at HTMLFormElements. + target: EventTarget & HTMLFormElement; + } + + interface TouchEvent extends UIEvent { + altKey: boolean; + changedTouches: TouchList; + ctrlKey: boolean; + /** + * See [DOM Level 3 Events spec](https://www.w3.org/TR/uievents-key/#keys-modifier). for a list of valid (case-sensitive) arguments to this method. + */ + getModifierState(key: ModifierKey): boolean; + metaKey: boolean; + shiftKey: boolean; + targetTouches: TouchList; + touches: TouchList; + } + + interface UIEvent extends SyntheticEvent { + detail: number; + view: AbstractView; + } + + interface WheelEvent extends MouseEvent { + deltaMode: number; + deltaX: number; + deltaY: number; + deltaZ: number; + } + + interface AnimationEvent extends SyntheticEvent { + animationName: string; + elapsedTime: number; + pseudoElement: string; + } + + interface ToggleEvent extends SyntheticEvent { + oldState: "closed" | "open"; + newState: "closed" | "open"; + } + + interface TransitionEvent extends SyntheticEvent { + elapsedTime: number; + propertyName: string; + pseudoElement: string; + } + + // + // Event Handler Types + // ---------------------------------------------------------------------- + + type EventHandler> = { bivarianceHack(event: E): void }["bivarianceHack"]; + + type ReactEventHandler = EventHandler>; + + type ClipboardEventHandler = EventHandler>; + type CompositionEventHandler = EventHandler>; + type DragEventHandler = EventHandler>; + type FocusEventHandler = EventHandler>; + /** + * @deprecated FormEventHandler doesn't actually exist. + * You probably meant to use {@link ChangeEventHandler}, {@link InputEventHandler}, {@link SubmitEventHandler}, or just {@link EventHandler} instead + * depending on the event type. + */ + type FormEventHandler = EventHandler>; + type ChangeEventHandler = EventHandler< + ChangeEvent + >; + type InputEventHandler = EventHandler>; + type KeyboardEventHandler = EventHandler>; + type MouseEventHandler = EventHandler>; + type SubmitEventHandler = EventHandler>; + type TouchEventHandler = EventHandler>; + type PointerEventHandler = EventHandler>; + type UIEventHandler = EventHandler>; + type WheelEventHandler = EventHandler>; + type AnimationEventHandler = EventHandler>; + type ToggleEventHandler = EventHandler>; + type TransitionEventHandler = EventHandler>; + + // + // Props / DOM Attributes + // ---------------------------------------------------------------------- + + interface HTMLProps extends AllHTMLAttributes, ClassAttributes { + } + + type DetailedHTMLProps, T> = ClassAttributes & E; + + interface SVGProps extends SVGAttributes, ClassAttributes { + } + + interface SVGLineElementAttributes extends SVGProps {} + interface SVGTextElementAttributes extends SVGProps {} + + interface DOMAttributes { + children?: ReactNode | undefined; + dangerouslySetInnerHTML?: { + // Should be InnerHTML['innerHTML']. + // But unfortunately we're mixing renderer-specific type declarations. + __html: string | TrustedHTML; + } | undefined; + + // Clipboard Events + onCopy?: ClipboardEventHandler | undefined; + onCopyCapture?: ClipboardEventHandler | undefined; + onCut?: ClipboardEventHandler | undefined; + onCutCapture?: ClipboardEventHandler | undefined; + onPaste?: ClipboardEventHandler | undefined; + onPasteCapture?: ClipboardEventHandler | undefined; + + // Composition Events + onCompositionEnd?: CompositionEventHandler | undefined; + onCompositionEndCapture?: CompositionEventHandler | undefined; + onCompositionStart?: CompositionEventHandler | undefined; + onCompositionStartCapture?: CompositionEventHandler | undefined; + onCompositionUpdate?: CompositionEventHandler | undefined; + onCompositionUpdateCapture?: CompositionEventHandler | undefined; + + // Focus Events + onFocus?: FocusEventHandler | undefined; + onFocusCapture?: FocusEventHandler | undefined; + onBlur?: FocusEventHandler | undefined; + onBlurCapture?: FocusEventHandler | undefined; + + // form related Events + onChange?: ChangeEventHandler | undefined; + onChangeCapture?: ChangeEventHandler | undefined; + onBeforeInput?: InputEventHandler | undefined; + onBeforeInputCapture?: InputEventHandler | undefined; + onInput?: InputEventHandler | undefined; + onInputCapture?: InputEventHandler | undefined; + onReset?: ReactEventHandler | undefined; + onResetCapture?: ReactEventHandler | undefined; + onSubmit?: SubmitEventHandler | undefined; + onSubmitCapture?: SubmitEventHandler | undefined; + onInvalid?: ReactEventHandler | undefined; + onInvalidCapture?: ReactEventHandler | undefined; + + // Image Events + onLoad?: ReactEventHandler | undefined; + onLoadCapture?: ReactEventHandler | undefined; + onError?: ReactEventHandler | undefined; // also a Media Event + onErrorCapture?: ReactEventHandler | undefined; // also a Media Event + + // Keyboard Events + onKeyDown?: KeyboardEventHandler | undefined; + onKeyDownCapture?: KeyboardEventHandler | undefined; + /** @deprecated Use `onKeyUp` or `onKeyDown` instead */ + onKeyPress?: KeyboardEventHandler | undefined; + /** @deprecated Use `onKeyUpCapture` or `onKeyDownCapture` instead */ + onKeyPressCapture?: KeyboardEventHandler | undefined; + onKeyUp?: KeyboardEventHandler | undefined; + onKeyUpCapture?: KeyboardEventHandler | undefined; + + // Media Events + onAbort?: ReactEventHandler | undefined; + onAbortCapture?: ReactEventHandler | undefined; + onCanPlay?: ReactEventHandler | undefined; + onCanPlayCapture?: ReactEventHandler | undefined; + onCanPlayThrough?: ReactEventHandler | undefined; + onCanPlayThroughCapture?: ReactEventHandler | undefined; + onDurationChange?: ReactEventHandler | undefined; + onDurationChangeCapture?: ReactEventHandler | undefined; + onEmptied?: ReactEventHandler | undefined; + onEmptiedCapture?: ReactEventHandler | undefined; + onEncrypted?: ReactEventHandler | undefined; + onEncryptedCapture?: ReactEventHandler | undefined; + onEnded?: ReactEventHandler | undefined; + onEndedCapture?: ReactEventHandler | undefined; + onLoadedData?: ReactEventHandler | undefined; + onLoadedDataCapture?: ReactEventHandler | undefined; + onLoadedMetadata?: ReactEventHandler | undefined; + onLoadedMetadataCapture?: ReactEventHandler | undefined; + onLoadStart?: ReactEventHandler | undefined; + onLoadStartCapture?: ReactEventHandler | undefined; + onPause?: ReactEventHandler | undefined; + onPauseCapture?: ReactEventHandler | undefined; + onPlay?: ReactEventHandler | undefined; + onPlayCapture?: ReactEventHandler | undefined; + onPlaying?: ReactEventHandler | undefined; + onPlayingCapture?: ReactEventHandler | undefined; + onProgress?: ReactEventHandler | undefined; + onProgressCapture?: ReactEventHandler | undefined; + onRateChange?: ReactEventHandler | undefined; + onRateChangeCapture?: ReactEventHandler | undefined; + onSeeked?: ReactEventHandler | undefined; + onSeekedCapture?: ReactEventHandler | undefined; + onSeeking?: ReactEventHandler | undefined; + onSeekingCapture?: ReactEventHandler | undefined; + onStalled?: ReactEventHandler | undefined; + onStalledCapture?: ReactEventHandler | undefined; + onSuspend?: ReactEventHandler | undefined; + onSuspendCapture?: ReactEventHandler | undefined; + onTimeUpdate?: ReactEventHandler | undefined; + onTimeUpdateCapture?: ReactEventHandler | undefined; + onVolumeChange?: ReactEventHandler | undefined; + onVolumeChangeCapture?: ReactEventHandler | undefined; + onWaiting?: ReactEventHandler | undefined; + onWaitingCapture?: ReactEventHandler | undefined; + + // MouseEvents + onAuxClick?: MouseEventHandler | undefined; + onAuxClickCapture?: MouseEventHandler | undefined; + onClick?: MouseEventHandler | undefined; + onClickCapture?: MouseEventHandler | undefined; + onContextMenu?: MouseEventHandler | undefined; + onContextMenuCapture?: MouseEventHandler | undefined; + onDoubleClick?: MouseEventHandler | undefined; + onDoubleClickCapture?: MouseEventHandler | undefined; + onDrag?: DragEventHandler | undefined; + onDragCapture?: DragEventHandler | undefined; + onDragEnd?: DragEventHandler | undefined; + onDragEndCapture?: DragEventHandler | undefined; + onDragEnter?: DragEventHandler | undefined; + onDragEnterCapture?: DragEventHandler | undefined; + onDragExit?: DragEventHandler | undefined; + onDragExitCapture?: DragEventHandler | undefined; + onDragLeave?: DragEventHandler | undefined; + onDragLeaveCapture?: DragEventHandler | undefined; + onDragOver?: DragEventHandler | undefined; + onDragOverCapture?: DragEventHandler | undefined; + onDragStart?: DragEventHandler | undefined; + onDragStartCapture?: DragEventHandler | undefined; + onDrop?: DragEventHandler | undefined; + onDropCapture?: DragEventHandler | undefined; + onMouseDown?: MouseEventHandler | undefined; + onMouseDownCapture?: MouseEventHandler | undefined; + onMouseEnter?: MouseEventHandler | undefined; + onMouseLeave?: MouseEventHandler | undefined; + onMouseMove?: MouseEventHandler | undefined; + onMouseMoveCapture?: MouseEventHandler | undefined; + onMouseOut?: MouseEventHandler | undefined; + onMouseOutCapture?: MouseEventHandler | undefined; + onMouseOver?: MouseEventHandler | undefined; + onMouseOverCapture?: MouseEventHandler | undefined; + onMouseUp?: MouseEventHandler | undefined; + onMouseUpCapture?: MouseEventHandler | undefined; + + // Selection Events + onSelect?: ReactEventHandler | undefined; + onSelectCapture?: ReactEventHandler | undefined; + + // Touch Events + onTouchCancel?: TouchEventHandler | undefined; + onTouchCancelCapture?: TouchEventHandler | undefined; + onTouchEnd?: TouchEventHandler | undefined; + onTouchEndCapture?: TouchEventHandler | undefined; + onTouchMove?: TouchEventHandler | undefined; + onTouchMoveCapture?: TouchEventHandler | undefined; + onTouchStart?: TouchEventHandler | undefined; + onTouchStartCapture?: TouchEventHandler | undefined; + + // Pointer Events + onPointerDown?: PointerEventHandler | undefined; + onPointerDownCapture?: PointerEventHandler | undefined; + onPointerMove?: PointerEventHandler | undefined; + onPointerMoveCapture?: PointerEventHandler | undefined; + onPointerUp?: PointerEventHandler | undefined; + onPointerUpCapture?: PointerEventHandler | undefined; + onPointerCancel?: PointerEventHandler | undefined; + onPointerCancelCapture?: PointerEventHandler | undefined; + onPointerEnter?: PointerEventHandler | undefined; + onPointerLeave?: PointerEventHandler | undefined; + onPointerOver?: PointerEventHandler | undefined; + onPointerOverCapture?: PointerEventHandler | undefined; + onPointerOut?: PointerEventHandler | undefined; + onPointerOutCapture?: PointerEventHandler | undefined; + onGotPointerCapture?: PointerEventHandler | undefined; + onGotPointerCaptureCapture?: PointerEventHandler | undefined; + onLostPointerCapture?: PointerEventHandler | undefined; + onLostPointerCaptureCapture?: PointerEventHandler | undefined; + + // UI Events + onScroll?: UIEventHandler | undefined; + onScrollCapture?: UIEventHandler | undefined; + onScrollEnd?: UIEventHandler | undefined; + onScrollEndCapture?: UIEventHandler | undefined; + + // Wheel Events + onWheel?: WheelEventHandler | undefined; + onWheelCapture?: WheelEventHandler | undefined; + + // Animation Events + onAnimationStart?: AnimationEventHandler | undefined; + onAnimationStartCapture?: AnimationEventHandler | undefined; + onAnimationEnd?: AnimationEventHandler | undefined; + onAnimationEndCapture?: AnimationEventHandler | undefined; + onAnimationIteration?: AnimationEventHandler | undefined; + onAnimationIterationCapture?: AnimationEventHandler | undefined; + + // Toggle Events + onToggle?: ToggleEventHandler | undefined; + onBeforeToggle?: ToggleEventHandler | undefined; + + // Transition Events + onTransitionCancel?: TransitionEventHandler | undefined; + onTransitionCancelCapture?: TransitionEventHandler | undefined; + onTransitionEnd?: TransitionEventHandler | undefined; + onTransitionEndCapture?: TransitionEventHandler | undefined; + onTransitionRun?: TransitionEventHandler | undefined; + onTransitionRunCapture?: TransitionEventHandler | undefined; + onTransitionStart?: TransitionEventHandler | undefined; + onTransitionStartCapture?: TransitionEventHandler | undefined; + } + + export interface CSSProperties extends CSS.Properties { + /** + * The index signature was removed to enable closed typing for style + * using CSSType. You're able to use type assertion or module augmentation + * to add properties or an index signature of your own. + * + * For examples and more information, visit: + * https://github.com/frenic/csstype#what-should-i-do-when-i-get-type-errors + */ + } + + // All the WAI-ARIA 1.1 attributes from https://www.w3.org/TR/wai-aria-1.1/ + interface AriaAttributes { + /** Identifies the currently active element when DOM focus is on a composite widget, textbox, group, or application. */ + "aria-activedescendant"?: string | undefined; + /** Indicates whether assistive technologies will present all, or only parts of, the changed region based on the change notifications defined by the aria-relevant attribute. */ + "aria-atomic"?: Booleanish | undefined; + /** + * Indicates whether inputting text could trigger display of one or more predictions of the user's intended value for an input and specifies how predictions would be + * presented if they are made. + */ + "aria-autocomplete"?: "none" | "inline" | "list" | "both" | undefined; + /** Indicates an element is being modified and that assistive technologies MAY want to wait until the modifications are complete before exposing them to the user. */ + /** + * Defines a string value that labels the current element, which is intended to be converted into Braille. + * @see aria-label. + */ + "aria-braillelabel"?: string | undefined; + /** + * Defines a human-readable, author-localized abbreviated description for the role of an element, which is intended to be converted into Braille. + * @see aria-roledescription. + */ + "aria-brailleroledescription"?: string | undefined; + "aria-busy"?: Booleanish | undefined; + /** + * Indicates the current "checked" state of checkboxes, radio buttons, and other widgets. + * @see aria-pressed @see aria-selected. + */ + "aria-checked"?: boolean | "false" | "mixed" | "true" | undefined; + /** + * Defines the total number of columns in a table, grid, or treegrid. + * @see aria-colindex. + */ + "aria-colcount"?: number | undefined; + /** + * Defines an element's column index or position with respect to the total number of columns within a table, grid, or treegrid. + * @see aria-colcount @see aria-colspan. + */ + "aria-colindex"?: number | undefined; + /** + * Defines a human readable text alternative of aria-colindex. + * @see aria-rowindextext. + */ + "aria-colindextext"?: string | undefined; + /** + * Defines the number of columns spanned by a cell or gridcell within a table, grid, or treegrid. + * @see aria-colindex @see aria-rowspan. + */ + "aria-colspan"?: number | undefined; + /** + * Identifies the element (or elements) whose contents or presence are controlled by the current element. + * @see aria-owns. + */ + "aria-controls"?: string | undefined; + /** Indicates the element that represents the current item within a container or set of related elements. */ + "aria-current"?: boolean | "false" | "true" | "page" | "step" | "location" | "date" | "time" | undefined; + /** + * Identifies the element (or elements) that describes the object. + * @see aria-labelledby + */ + "aria-describedby"?: string | undefined; + /** + * Defines a string value that describes or annotates the current element. + * @see related aria-describedby. + */ + "aria-description"?: string | undefined; + /** + * Identifies the element that provides a detailed, extended description for the object. + * @see aria-describedby. + */ + "aria-details"?: string | undefined; + /** + * Indicates that the element is perceivable but disabled, so it is not editable or otherwise operable. + * @see aria-hidden @see aria-readonly. + */ + "aria-disabled"?: Booleanish | undefined; + /** + * Indicates what functions can be performed when a dragged object is released on the drop target. + * @deprecated in ARIA 1.1 + */ + "aria-dropeffect"?: "none" | "copy" | "execute" | "link" | "move" | "popup" | undefined; + /** + * Identifies the element that provides an error message for the object. + * @see aria-invalid @see aria-describedby. + */ + "aria-errormessage"?: string | undefined; + /** Indicates whether the element, or another grouping element it controls, is currently expanded or collapsed. */ + "aria-expanded"?: Booleanish | undefined; + /** + * Identifies the next element (or elements) in an alternate reading order of content which, at the user's discretion, + * allows assistive technology to override the general default of reading in document source order. + */ + "aria-flowto"?: string | undefined; + /** + * Indicates an element's "grabbed" state in a drag-and-drop operation. + * @deprecated in ARIA 1.1 + */ + "aria-grabbed"?: Booleanish | undefined; + /** Indicates the availability and type of interactive popup element, such as menu or dialog, that can be triggered by an element. */ + "aria-haspopup"?: boolean | "false" | "true" | "menu" | "listbox" | "tree" | "grid" | "dialog" | undefined; + /** + * Indicates whether the element is exposed to an accessibility API. + * @see aria-disabled. + */ + "aria-hidden"?: Booleanish | undefined; + /** + * Indicates the entered value does not conform to the format expected by the application. + * @see aria-errormessage. + */ + "aria-invalid"?: boolean | "false" | "true" | "grammar" | "spelling" | undefined; + /** Indicates keyboard shortcuts that an author has implemented to activate or give focus to an element. */ + "aria-keyshortcuts"?: string | undefined; + /** + * Defines a string value that labels the current element. + * @see aria-labelledby. + */ + "aria-label"?: string | undefined; + /** + * Identifies the element (or elements) that labels the current element. + * @see aria-describedby. + */ + "aria-labelledby"?: string | undefined; + /** Defines the hierarchical level of an element within a structure. */ + "aria-level"?: number | undefined; + /** Indicates that an element will be updated, and describes the types of updates the user agents, assistive technologies, and user can expect from the live region. */ + "aria-live"?: "off" | "assertive" | "polite" | undefined; + /** Indicates whether an element is modal when displayed. */ + "aria-modal"?: Booleanish | undefined; + /** Indicates whether a text box accepts multiple lines of input or only a single line. */ + "aria-multiline"?: Booleanish | undefined; + /** Indicates that the user may select more than one item from the current selectable descendants. */ + "aria-multiselectable"?: Booleanish | undefined; + /** Indicates whether the element's orientation is horizontal, vertical, or unknown/ambiguous. */ + "aria-orientation"?: "horizontal" | "vertical" | undefined; + /** + * Identifies an element (or elements) in order to define a visual, functional, or contextual parent/child relationship + * between DOM elements where the DOM hierarchy cannot be used to represent the relationship. + * @see aria-controls. + */ + "aria-owns"?: string | undefined; + /** + * Defines a short hint (a word or short phrase) intended to aid the user with data entry when the control has no value. + * A hint could be a sample value or a brief description of the expected format. + */ + "aria-placeholder"?: string | undefined; + /** + * Defines an element's number or position in the current set of listitems or treeitems. Not required if all elements in the set are present in the DOM. + * @see aria-setsize. + */ + "aria-posinset"?: number | undefined; + /** + * Indicates the current "pressed" state of toggle buttons. + * @see aria-checked @see aria-selected. + */ + "aria-pressed"?: boolean | "false" | "mixed" | "true" | undefined; + /** + * Indicates that the element is not editable, but is otherwise operable. + * @see aria-disabled. + */ + "aria-readonly"?: Booleanish | undefined; + /** + * Indicates what notifications the user agent will trigger when the accessibility tree within a live region is modified. + * @see aria-atomic. + */ + "aria-relevant"?: + | "additions" + | "additions removals" + | "additions text" + | "all" + | "removals" + | "removals additions" + | "removals text" + | "text" + | "text additions" + | "text removals" + | undefined; + /** Indicates that user input is required on the element before a form may be submitted. */ + "aria-required"?: Booleanish | undefined; + /** Defines a human-readable, author-localized description for the role of an element. */ + "aria-roledescription"?: string | undefined; + /** + * Defines the total number of rows in a table, grid, or treegrid. + * @see aria-rowindex. + */ + "aria-rowcount"?: number | undefined; + /** + * Defines an element's row index or position with respect to the total number of rows within a table, grid, or treegrid. + * @see aria-rowcount @see aria-rowspan. + */ + "aria-rowindex"?: number | undefined; + /** + * Defines a human readable text alternative of aria-rowindex. + * @see aria-colindextext. + */ + "aria-rowindextext"?: string | undefined; + /** + * Defines the number of rows spanned by a cell or gridcell within a table, grid, or treegrid. + * @see aria-rowindex @see aria-colspan. + */ + "aria-rowspan"?: number | undefined; + /** + * Indicates the current "selected" state of various widgets. + * @see aria-checked @see aria-pressed. + */ + "aria-selected"?: Booleanish | undefined; + /** + * Defines the number of items in the current set of listitems or treeitems. Not required if all elements in the set are present in the DOM. + * @see aria-posinset. + */ + "aria-setsize"?: number | undefined; + /** Indicates if items in a table or grid are sorted in ascending or descending order. */ + "aria-sort"?: "none" | "ascending" | "descending" | "other" | undefined; + /** Defines the maximum allowed value for a range widget. */ + "aria-valuemax"?: number | undefined; + /** Defines the minimum allowed value for a range widget. */ + "aria-valuemin"?: number | undefined; + /** + * Defines the current value for a range widget. + * @see aria-valuetext. + */ + "aria-valuenow"?: number | undefined; + /** Defines the human readable text alternative of aria-valuenow for a range widget. */ + "aria-valuetext"?: string | undefined; + } + + // All the WAI-ARIA 1.1 role attribute values from https://www.w3.org/TR/wai-aria-1.1/#role_definitions + type AriaRole = + | "alert" + | "alertdialog" + | "application" + | "article" + | "banner" + | "button" + | "cell" + | "checkbox" + | "columnheader" + | "combobox" + | "complementary" + | "contentinfo" + | "definition" + | "dialog" + | "directory" + | "document" + | "feed" + | "figure" + | "form" + | "grid" + | "gridcell" + | "group" + | "heading" + | "img" + | "link" + | "list" + | "listbox" + | "listitem" + | "log" + | "main" + | "marquee" + | "math" + | "menu" + | "menubar" + | "menuitem" + | "menuitemcheckbox" + | "menuitemradio" + | "navigation" + | "none" + | "note" + | "option" + | "presentation" + | "progressbar" + | "radio" + | "radiogroup" + | "region" + | "row" + | "rowgroup" + | "rowheader" + | "scrollbar" + | "search" + | "searchbox" + | "separator" + | "slider" + | "spinbutton" + | "status" + | "switch" + | "tab" + | "table" + | "tablist" + | "tabpanel" + | "term" + | "textbox" + | "timer" + | "toolbar" + | "tooltip" + | "tree" + | "treegrid" + | "treeitem" + | (string & {}); + + interface HTMLAttributes extends AriaAttributes, DOMAttributes { + // React-specific Attributes + defaultChecked?: boolean | undefined; + defaultValue?: string | number | readonly string[] | undefined; + suppressContentEditableWarning?: boolean | undefined; + suppressHydrationWarning?: boolean | undefined; + + // Standard HTML Attributes + accessKey?: string | undefined; + autoCapitalize?: "off" | "none" | "on" | "sentences" | "words" | "characters" | undefined | (string & {}); + autoFocus?: boolean | undefined; + className?: string | undefined; + contentEditable?: Booleanish | "inherit" | "plaintext-only" | undefined; + contextMenu?: string | undefined; + dir?: string | undefined; + draggable?: Booleanish | undefined; + enterKeyHint?: "enter" | "done" | "go" | "next" | "previous" | "search" | "send" | undefined; + hidden?: boolean | undefined; + id?: string | undefined; + lang?: string | undefined; + nonce?: string | undefined; + slot?: string | undefined; + spellCheck?: Booleanish | undefined; + style?: CSSProperties | undefined; + tabIndex?: number | undefined; + title?: string | undefined; + translate?: "yes" | "no" | undefined; + + // Unknown + radioGroup?: string | undefined; // , + + // WAI-ARIA + role?: AriaRole | undefined; + + // RDFa Attributes + about?: string | undefined; + content?: string | undefined; + datatype?: string | undefined; + inlist?: any; + prefix?: string | undefined; + property?: string | undefined; + rel?: string | undefined; + resource?: string | undefined; + rev?: string | undefined; + typeof?: string | undefined; + vocab?: string | undefined; + + // Non-standard Attributes + autoCorrect?: string | undefined; + autoSave?: string | undefined; + color?: string | undefined; + itemProp?: string | undefined; + itemScope?: boolean | undefined; + itemType?: string | undefined; + itemID?: string | undefined; + itemRef?: string | undefined; + results?: number | undefined; + security?: string | undefined; + unselectable?: "on" | "off" | undefined; + + // Popover API + popover?: "" | "auto" | "manual" | "hint" | undefined; + popoverTargetAction?: "toggle" | "show" | "hide" | undefined; + popoverTarget?: string | undefined; + + // Living Standard + /** + * @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/inert + */ + inert?: boolean | undefined; + /** + * Hints at the type of data that might be entered by the user while editing the element or its contents + * @see {@link https://html.spec.whatwg.org/multipage/interaction.html#input-modalities:-the-inputmode-attribute} + */ + inputMode?: "none" | "text" | "tel" | "url" | "email" | "numeric" | "decimal" | "search" | undefined; + /** + * Specify that a standard HTML element should behave like a defined custom built-in element + * @see {@link https://html.spec.whatwg.org/multipage/custom-elements.html#attr-is} + */ + is?: string | undefined; + /** + * @see {@link https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/exportparts} + */ + exportparts?: string | undefined; + /** + * @see {@link https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/part} + */ + part?: string | undefined; + } + + /** + * For internal usage only. + * Different release channels declare additional types of ReactNode this particular release channel accepts. + * App or library types should never augment this interface. + */ + interface DO_NOT_USE_OR_YOU_WILL_BE_FIRED_EXPERIMENTAL_FORM_ACTIONS {} + + interface AllHTMLAttributes extends HTMLAttributes { + // Standard HTML Attributes + accept?: string | undefined; + acceptCharset?: string | undefined; + action?: + | string + | undefined + | ((formData: FormData) => void | Promise) + | DO_NOT_USE_OR_YOU_WILL_BE_FIRED_EXPERIMENTAL_FORM_ACTIONS[ + keyof DO_NOT_USE_OR_YOU_WILL_BE_FIRED_EXPERIMENTAL_FORM_ACTIONS + ]; + allowFullScreen?: boolean | undefined; + allowTransparency?: boolean | undefined; + alt?: string | undefined; + as?: string | undefined; + async?: boolean | undefined; + autoComplete?: string | undefined; + autoPlay?: boolean | undefined; + capture?: boolean | "user" | "environment" | undefined; + cellPadding?: number | string | undefined; + cellSpacing?: number | string | undefined; + charSet?: string | undefined; + challenge?: string | undefined; + checked?: boolean | undefined; + cite?: string | undefined; + classID?: string | undefined; + cols?: number | undefined; + colSpan?: number | undefined; + controls?: boolean | undefined; + coords?: string | undefined; + crossOrigin?: CrossOrigin; + data?: string | undefined; + dateTime?: string | undefined; + default?: boolean | undefined; + defer?: boolean | undefined; + disabled?: boolean | undefined; + download?: any; + encType?: string | undefined; + form?: string | undefined; + formAction?: + | string + | undefined + | ((formData: FormData) => void | Promise) + | DO_NOT_USE_OR_YOU_WILL_BE_FIRED_EXPERIMENTAL_FORM_ACTIONS[ + keyof DO_NOT_USE_OR_YOU_WILL_BE_FIRED_EXPERIMENTAL_FORM_ACTIONS + ]; + formEncType?: string | undefined; + formMethod?: string | undefined; + formNoValidate?: boolean | undefined; + formTarget?: string | undefined; + frameBorder?: number | string | undefined; + headers?: string | undefined; + height?: number | string | undefined; + high?: number | undefined; + href?: string | undefined; + hrefLang?: string | undefined; + htmlFor?: string | undefined; + httpEquiv?: string | undefined; + integrity?: string | undefined; + keyParams?: string | undefined; + keyType?: string | undefined; + kind?: string | undefined; + label?: string | undefined; + list?: string | undefined; + loop?: boolean | undefined; + low?: number | undefined; + manifest?: string | undefined; + marginHeight?: number | undefined; + marginWidth?: number | undefined; + max?: number | string | undefined; + maxLength?: number | undefined; + media?: string | undefined; + mediaGroup?: string | undefined; + method?: string | undefined; + min?: number | string | undefined; + minLength?: number | undefined; + multiple?: boolean | undefined; + muted?: boolean | undefined; + name?: string | undefined; + noValidate?: boolean | undefined; + open?: boolean | undefined; + optimum?: number | undefined; + pattern?: string | undefined; + placeholder?: string | undefined; + playsInline?: boolean | undefined; + poster?: string | undefined; + preload?: string | undefined; + readOnly?: boolean | undefined; + required?: boolean | undefined; + reversed?: boolean | undefined; + rows?: number | undefined; + rowSpan?: number | undefined; + sandbox?: string | undefined; + scope?: string | undefined; + scoped?: boolean | undefined; + scrolling?: string | undefined; + seamless?: boolean | undefined; + selected?: boolean | undefined; + shape?: string | undefined; + size?: number | undefined; + sizes?: string | undefined; + span?: number | undefined; + src?: string | undefined; + srcDoc?: string | undefined; + srcLang?: string | undefined; + srcSet?: string | undefined; + start?: number | undefined; + step?: number | string | undefined; + summary?: string | undefined; + target?: string | undefined; + type?: string | undefined; + useMap?: string | undefined; + value?: string | readonly string[] | number | undefined; + width?: number | string | undefined; + wmode?: string | undefined; + wrap?: string | undefined; + } + + type HTMLAttributeReferrerPolicy = + | "" + | "no-referrer" + | "no-referrer-when-downgrade" + | "origin" + | "origin-when-cross-origin" + | "same-origin" + | "strict-origin" + | "strict-origin-when-cross-origin" + | "unsafe-url"; + + type HTMLAttributeAnchorTarget = + | "_self" + | "_blank" + | "_parent" + | "_top" + | (string & {}); + + interface AnchorHTMLAttributes extends HTMLAttributes { + download?: any; + href?: string | undefined; + hrefLang?: string | undefined; + media?: string | undefined; + ping?: string | undefined; + target?: HTMLAttributeAnchorTarget | undefined; + type?: string | undefined; + referrerPolicy?: HTMLAttributeReferrerPolicy | undefined; + } + + interface AudioHTMLAttributes extends MediaHTMLAttributes {} + + interface AreaHTMLAttributes extends HTMLAttributes { + alt?: string | undefined; + coords?: string | undefined; + download?: any; + href?: string | undefined; + hrefLang?: string | undefined; + media?: string | undefined; + referrerPolicy?: HTMLAttributeReferrerPolicy | undefined; + shape?: string | undefined; + target?: string | undefined; + } + + interface BaseHTMLAttributes extends HTMLAttributes { + href?: string | undefined; + target?: string | undefined; + } + + interface BlockquoteHTMLAttributes extends HTMLAttributes { + cite?: string | undefined; + } + + interface ButtonHTMLAttributes extends HTMLAttributes { + disabled?: boolean | undefined; + form?: string | undefined; + formAction?: + | string + | ((formData: FormData) => void | Promise) + | DO_NOT_USE_OR_YOU_WILL_BE_FIRED_EXPERIMENTAL_FORM_ACTIONS[ + keyof DO_NOT_USE_OR_YOU_WILL_BE_FIRED_EXPERIMENTAL_FORM_ACTIONS + ] + | undefined; + formEncType?: string | undefined; + formMethod?: string | undefined; + formNoValidate?: boolean | undefined; + formTarget?: string | undefined; + name?: string | undefined; + type?: "submit" | "reset" | "button" | undefined; + value?: string | readonly string[] | number | undefined; + } + + interface CanvasHTMLAttributes extends HTMLAttributes { + height?: number | string | undefined; + width?: number | string | undefined; + } + + interface ColHTMLAttributes extends HTMLAttributes { + span?: number | undefined; + width?: number | string | undefined; + } + + interface ColgroupHTMLAttributes extends HTMLAttributes { + span?: number | undefined; + } + + interface DataHTMLAttributes extends HTMLAttributes { + value?: string | readonly string[] | number | undefined; + } + + interface DetailsHTMLAttributes extends HTMLAttributes { + open?: boolean | undefined; + name?: string | undefined; + } + + interface DelHTMLAttributes extends HTMLAttributes { + cite?: string | undefined; + dateTime?: string | undefined; + } + + interface DialogHTMLAttributes extends HTMLAttributes { + closedby?: "any" | "closerequest" | "none" | undefined; + onCancel?: ReactEventHandler | undefined; + onClose?: ReactEventHandler | undefined; + open?: boolean | undefined; + } + + interface EmbedHTMLAttributes extends HTMLAttributes { + height?: number | string | undefined; + src?: string | undefined; + type?: string | undefined; + width?: number | string | undefined; + } + + interface FieldsetHTMLAttributes extends HTMLAttributes { + disabled?: boolean | undefined; + form?: string | undefined; + name?: string | undefined; + } + + interface FormHTMLAttributes extends HTMLAttributes { + acceptCharset?: string | undefined; + action?: + | string + | undefined + | ((formData: FormData) => void | Promise) + | DO_NOT_USE_OR_YOU_WILL_BE_FIRED_EXPERIMENTAL_FORM_ACTIONS[ + keyof DO_NOT_USE_OR_YOU_WILL_BE_FIRED_EXPERIMENTAL_FORM_ACTIONS + ]; + autoComplete?: string | undefined; + encType?: string | undefined; + method?: string | undefined; + name?: string | undefined; + noValidate?: boolean | undefined; + target?: string | undefined; + } + + interface HtmlHTMLAttributes extends HTMLAttributes { + manifest?: string | undefined; + } + + interface IframeHTMLAttributes extends HTMLAttributes { + allow?: string | undefined; + allowFullScreen?: boolean | undefined; + allowTransparency?: boolean | undefined; + /** @deprecated */ + frameBorder?: number | string | undefined; + height?: number | string | undefined; + loading?: "eager" | "lazy" | undefined; + /** @deprecated */ + marginHeight?: number | undefined; + /** @deprecated */ + marginWidth?: number | undefined; + name?: string | undefined; + referrerPolicy?: HTMLAttributeReferrerPolicy | undefined; + sandbox?: string | undefined; + /** @deprecated */ + scrolling?: string | undefined; + seamless?: boolean | undefined; + src?: string | undefined; + srcDoc?: string | undefined; + width?: number | string | undefined; + } + + interface DO_NOT_USE_OR_YOU_WILL_BE_FIRED_EXPERIMENTAL_IMG_SRC_TYPES {} + + interface ImgHTMLAttributes extends HTMLAttributes { + alt?: string | undefined; + crossOrigin?: CrossOrigin; + decoding?: "async" | "auto" | "sync" | undefined; + fetchPriority?: "high" | "low" | "auto"; + height?: number | string | undefined; + loading?: "eager" | "lazy" | undefined; + referrerPolicy?: HTMLAttributeReferrerPolicy | undefined; + sizes?: string | undefined; + src?: + | string + | DO_NOT_USE_OR_YOU_WILL_BE_FIRED_EXPERIMENTAL_IMG_SRC_TYPES[ + keyof DO_NOT_USE_OR_YOU_WILL_BE_FIRED_EXPERIMENTAL_IMG_SRC_TYPES + ] + | undefined; + srcSet?: string | undefined; + useMap?: string | undefined; + width?: number | string | undefined; + } + + interface InsHTMLAttributes extends HTMLAttributes { + cite?: string | undefined; + dateTime?: string | undefined; + } + + type HTMLInputTypeAttribute = + | "button" + | "checkbox" + | "color" + | "date" + | "datetime-local" + | "email" + | "file" + | "hidden" + | "image" + | "month" + | "number" + | "password" + | "radio" + | "range" + | "reset" + | "search" + | "submit" + | "tel" + | "text" + | "time" + | "url" + | "week" + | (string & {}); + + type AutoFillAddressKind = "billing" | "shipping"; + type AutoFillBase = "" | "off" | "on"; + type AutoFillContactField = + | "email" + | "tel" + | "tel-area-code" + | "tel-country-code" + | "tel-extension" + | "tel-local" + | "tel-local-prefix" + | "tel-local-suffix" + | "tel-national"; + type AutoFillContactKind = "home" | "mobile" | "work"; + type AutoFillCredentialField = "webauthn"; + type AutoFillNormalField = + | "additional-name" + | "address-level1" + | "address-level2" + | "address-level3" + | "address-level4" + | "address-line1" + | "address-line2" + | "address-line3" + | "bday-day" + | "bday-month" + | "bday-year" + | "cc-csc" + | "cc-exp" + | "cc-exp-month" + | "cc-exp-year" + | "cc-family-name" + | "cc-given-name" + | "cc-name" + | "cc-number" + | "cc-type" + | "country" + | "country-name" + | "current-password" + | "family-name" + | "given-name" + | "honorific-prefix" + | "honorific-suffix" + | "name" + | "new-password" + | "one-time-code" + | "organization" + | "postal-code" + | "street-address" + | "transaction-amount" + | "transaction-currency" + | "username"; + type OptionalPrefixToken = `${T} ` | ""; + type OptionalPostfixToken = ` ${T}` | ""; + type AutoFillField = AutoFillNormalField | `${OptionalPrefixToken}${AutoFillContactField}`; + type AutoFillSection = `section-${string}`; + type AutoFill = + | AutoFillBase + | `${OptionalPrefixToken}${OptionalPrefixToken< + AutoFillAddressKind + >}${AutoFillField}${OptionalPostfixToken}`; + type HTMLInputAutoCompleteAttribute = AutoFill | (string & {}); + + interface InputHTMLAttributes extends HTMLAttributes { + accept?: string | undefined; + alt?: string | undefined; + autoComplete?: HTMLInputAutoCompleteAttribute | undefined; + capture?: boolean | "user" | "environment" | undefined; // https://www.w3.org/TR/html-media-capture/#the-capture-attribute + checked?: boolean | undefined; + disabled?: boolean | undefined; + form?: string | undefined; + formAction?: + | string + | ((formData: FormData) => void | Promise) + | DO_NOT_USE_OR_YOU_WILL_BE_FIRED_EXPERIMENTAL_FORM_ACTIONS[ + keyof DO_NOT_USE_OR_YOU_WILL_BE_FIRED_EXPERIMENTAL_FORM_ACTIONS + ] + | undefined; + formEncType?: string | undefined; + formMethod?: string | undefined; + formNoValidate?: boolean | undefined; + formTarget?: string | undefined; + height?: number | string | undefined; + list?: string | undefined; + max?: number | string | undefined; + maxLength?: number | undefined; + min?: number | string | undefined; + minLength?: number | undefined; + multiple?: boolean | undefined; + name?: string | undefined; + pattern?: string | undefined; + placeholder?: string | undefined; + readOnly?: boolean | undefined; + required?: boolean | undefined; + size?: number | undefined; + src?: string | undefined; + step?: number | string | undefined; + type?: HTMLInputTypeAttribute | undefined; + value?: string | readonly string[] | number | undefined; + width?: number | string | undefined; + + // No other element dispatching change events can be nested in a + // so we know the target will be a HTMLInputElement. + onChange?: ChangeEventHandler | undefined; + } + + interface KeygenHTMLAttributes extends HTMLAttributes { + challenge?: string | undefined; + disabled?: boolean | undefined; + form?: string | undefined; + keyType?: string | undefined; + keyParams?: string | undefined; + name?: string | undefined; + } + + interface LabelHTMLAttributes extends HTMLAttributes { + form?: string | undefined; + htmlFor?: string | undefined; + } + + interface LiHTMLAttributes extends HTMLAttributes { + value?: string | readonly string[] | number | undefined; + } + + interface LinkHTMLAttributes extends HTMLAttributes { + as?: string | undefined; + blocking?: "render" | (string & {}) | undefined; + crossOrigin?: CrossOrigin; + fetchPriority?: "high" | "low" | "auto"; + href?: string | undefined; + hrefLang?: string | undefined; + integrity?: string | undefined; + media?: string | undefined; + imageSrcSet?: string | undefined; + imageSizes?: string | undefined; + referrerPolicy?: HTMLAttributeReferrerPolicy | undefined; + sizes?: string | undefined; + type?: string | undefined; + charSet?: string | undefined; + + // React props + precedence?: string | undefined; + } + + interface MapHTMLAttributes extends HTMLAttributes { + name?: string | undefined; + } + + interface MenuHTMLAttributes extends HTMLAttributes { + type?: string | undefined; + } + + interface DO_NOT_USE_OR_YOU_WILL_BE_FIRED_EXPERIMENTAL_MEDIA_SRC_TYPES {} + + interface MediaHTMLAttributes extends HTMLAttributes { + autoPlay?: boolean | undefined; + controls?: boolean | undefined; + controlsList?: string | undefined; + crossOrigin?: CrossOrigin; + loop?: boolean | undefined; + mediaGroup?: string | undefined; + muted?: boolean | undefined; + playsInline?: boolean | undefined; + preload?: string | undefined; + src?: + | string + | DO_NOT_USE_OR_YOU_WILL_BE_FIRED_EXPERIMENTAL_MEDIA_SRC_TYPES[ + keyof DO_NOT_USE_OR_YOU_WILL_BE_FIRED_EXPERIMENTAL_MEDIA_SRC_TYPES + ] + | undefined; + } + + interface MetaHTMLAttributes extends HTMLAttributes { + charSet?: string | undefined; + content?: string | undefined; + httpEquiv?: string | undefined; + media?: string | undefined; + name?: string | undefined; + } + + interface MeterHTMLAttributes extends HTMLAttributes { + form?: string | undefined; + high?: number | undefined; + low?: number | undefined; + max?: number | string | undefined; + min?: number | string | undefined; + optimum?: number | undefined; + value?: string | readonly string[] | number | undefined; + } + + interface QuoteHTMLAttributes extends HTMLAttributes { + cite?: string | undefined; + } + + interface ObjectHTMLAttributes extends HTMLAttributes { + classID?: string | undefined; + data?: string | undefined; + form?: string | undefined; + height?: number | string | undefined; + name?: string | undefined; + type?: string | undefined; + useMap?: string | undefined; + width?: number | string | undefined; + wmode?: string | undefined; + } + + interface OlHTMLAttributes extends HTMLAttributes { + reversed?: boolean | undefined; + start?: number | undefined; + type?: "1" | "a" | "A" | "i" | "I" | undefined; + } + + interface OptgroupHTMLAttributes extends HTMLAttributes { + disabled?: boolean | undefined; + label?: string | undefined; + } + + interface OptionHTMLAttributes extends HTMLAttributes { + disabled?: boolean | undefined; + label?: string | undefined; + selected?: boolean | undefined; + value?: string | readonly string[] | number | undefined; + } + + interface OutputHTMLAttributes extends HTMLAttributes { + form?: string | undefined; + htmlFor?: string | undefined; + name?: string | undefined; + } + + interface ParamHTMLAttributes extends HTMLAttributes { + name?: string | undefined; + value?: string | readonly string[] | number | undefined; + } + + interface ProgressHTMLAttributes extends HTMLAttributes { + max?: number | string | undefined; + value?: string | readonly string[] | number | undefined; + } + + interface SlotHTMLAttributes extends HTMLAttributes { + name?: string | undefined; + } + + interface ScriptHTMLAttributes extends HTMLAttributes { + async?: boolean | undefined; + blocking?: "render" | (string & {}) | undefined; + /** @deprecated */ + charSet?: string | undefined; + crossOrigin?: CrossOrigin; + defer?: boolean | undefined; + fetchPriority?: "high" | "low" | "auto" | undefined; + integrity?: string | undefined; + noModule?: boolean | undefined; + referrerPolicy?: HTMLAttributeReferrerPolicy | undefined; + src?: string | undefined; + type?: string | undefined; + } + + interface SelectHTMLAttributes extends HTMLAttributes { + autoComplete?: string | undefined; + disabled?: boolean | undefined; + form?: string | undefined; + multiple?: boolean | undefined; + name?: string | undefined; + required?: boolean | undefined; + size?: number | undefined; + value?: string | readonly string[] | number | undefined; + // No other element dispatching change events can be nested in a + // so we know the target will be a HTMLInputElement. + onChange?: ChangeEventHandler | undefined; + } + + interface KeygenHTMLAttributes extends HTMLAttributes { + challenge?: string | undefined; + disabled?: boolean | undefined; + form?: string | undefined; + keyType?: string | undefined; + keyParams?: string | undefined; + name?: string | undefined; + } + + interface LabelHTMLAttributes extends HTMLAttributes { + form?: string | undefined; + htmlFor?: string | undefined; + } + + interface LiHTMLAttributes extends HTMLAttributes { + value?: string | readonly string[] | number | undefined; + } + + interface LinkHTMLAttributes extends HTMLAttributes { + as?: string | undefined; + blocking?: "render" | (string & {}) | undefined; + crossOrigin?: CrossOrigin; + fetchPriority?: "high" | "low" | "auto"; + href?: string | undefined; + hrefLang?: string | undefined; + integrity?: string | undefined; + media?: string | undefined; + imageSrcSet?: string | undefined; + imageSizes?: string | undefined; + referrerPolicy?: HTMLAttributeReferrerPolicy | undefined; + sizes?: string | undefined; + type?: string | undefined; + charSet?: string | undefined; + + // React props + precedence?: string | undefined; + } + + interface MapHTMLAttributes extends HTMLAttributes { + name?: string | undefined; + } + + interface MenuHTMLAttributes extends HTMLAttributes { + type?: string | undefined; + } + + interface DO_NOT_USE_OR_YOU_WILL_BE_FIRED_EXPERIMENTAL_MEDIA_SRC_TYPES {} + + interface MediaHTMLAttributes extends HTMLAttributes { + autoPlay?: boolean | undefined; + controls?: boolean | undefined; + controlsList?: string | undefined; + crossOrigin?: CrossOrigin; + loop?: boolean | undefined; + mediaGroup?: string | undefined; + muted?: boolean | undefined; + playsInline?: boolean | undefined; + preload?: string | undefined; + src?: + | string + | DO_NOT_USE_OR_YOU_WILL_BE_FIRED_EXPERIMENTAL_MEDIA_SRC_TYPES[ + keyof DO_NOT_USE_OR_YOU_WILL_BE_FIRED_EXPERIMENTAL_MEDIA_SRC_TYPES + ] + | undefined; + } + + interface MetaHTMLAttributes extends HTMLAttributes { + charSet?: string | undefined; + content?: string | undefined; + httpEquiv?: string | undefined; + media?: string | undefined; + name?: string | undefined; + } + + interface MeterHTMLAttributes extends HTMLAttributes { + form?: string | undefined; + high?: number | undefined; + low?: number | undefined; + max?: number | string | undefined; + min?: number | string | undefined; + optimum?: number | undefined; + value?: string | readonly string[] | number | undefined; + } + + interface QuoteHTMLAttributes extends HTMLAttributes { + cite?: string | undefined; + } + + interface ObjectHTMLAttributes extends HTMLAttributes { + classID?: string | undefined; + data?: string | undefined; + form?: string | undefined; + height?: number | string | undefined; + name?: string | undefined; + type?: string | undefined; + useMap?: string | undefined; + width?: number | string | undefined; + wmode?: string | undefined; + } + + interface OlHTMLAttributes extends HTMLAttributes { + reversed?: boolean | undefined; + start?: number | undefined; + type?: "1" | "a" | "A" | "i" | "I" | undefined; + } + + interface OptgroupHTMLAttributes extends HTMLAttributes { + disabled?: boolean | undefined; + label?: string | undefined; + } + + interface OptionHTMLAttributes extends HTMLAttributes { + disabled?: boolean | undefined; + label?: string | undefined; + selected?: boolean | undefined; + value?: string | readonly string[] | number | undefined; + } + + interface OutputHTMLAttributes extends HTMLAttributes { + form?: string | undefined; + htmlFor?: string | undefined; + name?: string | undefined; + } + + interface ParamHTMLAttributes extends HTMLAttributes { + name?: string | undefined; + value?: string | readonly string[] | number | undefined; + } + + interface ProgressHTMLAttributes extends HTMLAttributes { + max?: number | string | undefined; + value?: string | readonly string[] | number | undefined; + } + + interface SlotHTMLAttributes extends HTMLAttributes { + name?: string | undefined; + } + + interface ScriptHTMLAttributes extends HTMLAttributes { + async?: boolean | undefined; + blocking?: "render" | (string & {}) | undefined; + /** @deprecated */ + charSet?: string | undefined; + crossOrigin?: CrossOrigin; + defer?: boolean | undefined; + fetchPriority?: "high" | "low" | "auto" | undefined; + integrity?: string | undefined; + noModule?: boolean | undefined; + referrerPolicy?: HTMLAttributeReferrerPolicy | undefined; + src?: string | undefined; + type?: string | undefined; + } + + interface SelectHTMLAttributes extends HTMLAttributes { + autoComplete?: string | undefined; + disabled?: boolean | undefined; + form?: string | undefined; + multiple?: boolean | undefined; + name?: string | undefined; + required?: boolean | undefined; + size?: number | undefined; + value?: string | readonly string[] | number | undefined; + // No other element dispatching change events can be nested in a

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