├── http ├── API GW.http ├── Order Service.http └── Item Service.http ├── api-gateway └── src │ ├── test │ ├── resources │ │ └── application.yml │ └── kotlin │ │ └── io │ │ └── philo │ │ └── shop │ │ ├── support │ │ └── GatewayTestConfig.kt │ │ └── integartion │ │ └── RoutingMappingTest.kt │ └── main │ ├── kotlin │ └── io │ │ └── philo │ │ └── shop │ │ ├── ApiGatewayApplication.kt │ │ ├── support │ │ ├── RouteInspector.kt │ │ ├── GwHttpMessageConfig.kt │ │ └── GlobalExceptionHandler.kt │ │ ├── filter │ │ ├── GlobalLoggingFilter.kt │ │ ├── AbstractAuthorizationFilter.kt │ │ ├── AuthorizationVerificationFilter.kt │ │ └── AuthorizationInceptionFilter.kt │ │ ├── config │ │ ├── RouteConfig_backup.kt │ │ └── GatewayRouteConfig.kt │ │ └── presentation │ │ └── ApiGatewayController.kt │ └── resources │ └── application.yml ├── micro-services ├── item │ └── src │ │ ├── main │ │ ├── resources │ │ │ ├── spy.properties │ │ │ ├── META-INF │ │ │ │ └── spring │ │ │ │ │ └── org.springframework.boot.autoconfigure.AutoConfiguration.imports │ │ │ ├── data.sql │ │ │ └── application.yml │ │ └── kotlin │ │ │ └── io │ │ │ └── philo │ │ │ └── shop │ │ │ ├── presentation │ │ │ ├── dto │ │ │ │ ├── ItemResponses.kt │ │ │ │ ├── ItemCreateRequest.kt │ │ │ │ └── ItemResponse.kt │ │ │ ├── ItemInternalController.kt │ │ │ └── ItemController.kt │ │ │ ├── repository │ │ │ ├── ItemRepositoryCustom.kt │ │ │ ├── ItemRepository.kt │ │ │ ├── ItemRepositoryCustomImpl.kt │ │ │ └── ItemOutBoxRepository.kt │ │ │ ├── ItemServiceApplication.kt │ │ │ ├── support │ │ │ ├── ItemHttpMessageConfig.kt │ │ │ └── ItemDataInitializer.kt │ │ │ ├── domain │ │ │ ├── outbox │ │ │ │ └── ItemOutboxEntity.kt │ │ │ ├── entity │ │ │ │ └── ItemEntity.kt │ │ │ └── service │ │ │ │ ├── ItemService.kt │ │ │ │ └── ItemEventService.kt │ │ │ ├── scheduler │ │ │ └── ItemEventLoader.kt │ │ │ └── messagequeue │ │ │ ├── consumer │ │ │ ├── ItemEventListenerDeprecated.kt │ │ │ └── ItemEventListener.kt │ │ │ ├── producer │ │ │ └── ItemEventPublisher.kt │ │ │ └── config │ │ │ └── ItemRabbitConfig.kt │ │ └── test │ │ ├── resources │ │ └── application.yml │ │ └── kotlin │ │ └── io │ │ └── philo │ │ └── shop │ │ └── item │ │ ├── domain │ │ └── ItemTest.kt │ │ └── integartion │ │ └── ItemIntegrationTest.kt ├── user │ └── src │ │ ├── main │ │ ├── resources │ │ │ ├── spy.properties │ │ │ ├── META-INF │ │ │ │ └── spring │ │ │ │ │ └── org.springframework.boot.autoconfigure.AutoConfiguration.imports │ │ │ ├── data.sql │ │ │ └── application.yml │ │ └── kotlin │ │ │ └── io │ │ │ └── philo │ │ │ └── shop │ │ │ ├── presentation │ │ │ ├── dto │ │ │ │ ├── login │ │ │ │ │ └── UserLoginRequest.kt │ │ │ │ └── create │ │ │ │ │ ├── UserCreateDto.kt │ │ │ │ │ └── UserListDto.kt │ │ │ ├── UserInternalController.kt │ │ │ └── UserController.kt │ │ │ ├── domain │ │ │ ├── repository │ │ │ │ └── UserRepository.kt │ │ │ ├── entity │ │ │ │ └── UserEntity.kt │ │ │ └── service │ │ │ │ └── UserService.kt │ │ │ ├── UserServiceApplication.kt │ │ │ └── support │ │ │ └── UserDataInitializer.kt │ │ └── test │ │ └── kotlin │ │ └── io │ │ └── philo │ │ ├── integration │ │ ├── not_success_case │ │ │ ├── README.MD │ │ │ ├── UserAcceptanceTest_Fail.kt │ │ │ └── AcceptanceTest_Fail.kt │ │ └── UserIntegrationTest.kt │ │ └── unit │ │ └── UserTest.kt ├── coupon │ └── src │ │ ├── main │ │ ├── resources │ │ │ ├── spy.properties │ │ │ ├── META-INF │ │ │ │ └── spring │ │ │ │ │ └── org.springframework.boot.autoconfigure.AutoConfiguration.imports │ │ │ ├── application.yml │ │ │ └── data.sql │ │ └── kotlin │ │ │ └── io │ │ │ └── philo │ │ │ └── shop │ │ │ ├── domain │ │ │ ├── replica │ │ │ │ ├── ItemReplicaRepository.kt │ │ │ │ └── ItemReplicaEntity.kt │ │ │ ├── core │ │ │ │ ├── CouponRepository.kt │ │ │ │ ├── UserCouponEntity.kt │ │ │ │ ├── CouponEntity.kt │ │ │ │ ├── RatioDiscountCouponEntity.kt │ │ │ │ ├── FixedDiscountCouponEntity.kt │ │ │ │ └── UserCouponRepository.kt │ │ │ └── outbox │ │ │ │ ├── CouponOutboxEntity.kt │ │ │ │ └── CouponOutBoxRepository.kt │ │ │ ├── presentation │ │ │ ├── dto │ │ │ │ ├── CouponAppliedAmountDto.kt │ │ │ │ ├── UserCouponListDto.kt │ │ │ │ └── CouponListDto.kt │ │ │ ├── CouponInternalController.kt │ │ │ └── CouponController.kt │ │ │ ├── CouponServiceApplication.kt │ │ │ ├── scheduler │ │ │ └── CouponEventLoader.kt │ │ │ ├── messagequeue │ │ │ ├── producer │ │ │ │ └── CouponEventPublisher.kt │ │ │ ├── CouponRabbitConfig.kt │ │ │ └── consumer │ │ │ │ └── CouponEventListener.kt │ │ │ ├── service │ │ │ ├── CouponService.kt │ │ │ ├── CouponDiscountCalculator.kt │ │ │ └── CouponEventService.kt │ │ │ └── query │ │ │ └── CouponQuery.kt │ │ └── test │ │ └── kotlin │ │ └── io │ │ └── philo │ │ └── shop │ │ ├── integration │ │ └── CouponIntegrationTest.kt │ │ └── unit │ │ └── CouponTest.kt ├── order │ └── src │ │ ├── main │ │ ├── resources │ │ │ ├── spy.properties │ │ │ ├── META-INF │ │ │ │ └── spring │ │ │ │ │ └── org.springframework.boot.autoconfigure.AutoConfiguration.imports │ │ │ └── application.yml │ │ └── kotlin │ │ │ └── io │ │ │ └── philo │ │ │ └── shop │ │ │ ├── dto │ │ │ └── web │ │ │ │ ├── OrderListResponses.kt │ │ │ │ ├── OrderDetailResponse.kt │ │ │ │ ├── ItemResponses.kt │ │ │ │ ├── OrderListResponse.kt │ │ │ │ ├── ItemResponse.kt │ │ │ │ ├── OrderCreateRequest.kt │ │ │ │ └── OrderLineRequestDto.kt │ │ │ ├── constant │ │ │ ├── OrderBusinessRuleMessage.kt │ │ │ └── OrderStatus.kt │ │ │ ├── repository │ │ │ ├── OrderRepository.kt │ │ │ ├── OrderCreatedOutboxRepository.kt │ │ │ └── OrderFailedOutboxRepository.kt │ │ │ ├── OrderServiceApplication.kt │ │ │ ├── domain │ │ │ ├── core │ │ │ │ ├── OrderHistoryEntity.kt │ │ │ │ ├── OrderCouponsEntity.kt │ │ │ │ ├── OrderLineItemEntity.kt │ │ │ │ └── OrderEntity.kt │ │ │ └── outbox │ │ │ │ ├── OrderCreatedOutboxEntity.kt │ │ │ │ └── OrderFailedOutboxEntity.kt │ │ │ ├── scheduler │ │ │ ├── OrderEventLoader.kt │ │ │ └── OrderEventCompleteProcessor.kt │ │ │ ├── query │ │ │ └── OrderQuery.kt │ │ │ ├── ui │ │ │ └── OrderController.kt │ │ │ ├── messagequeue │ │ │ ├── OrderEventListener.kt │ │ │ ├── OrderEventPublisher.kt │ │ │ └── config │ │ │ │ └── OrderRabbitConfig.kt │ │ │ └── application │ │ │ └── OrderService.kt │ │ └── test │ │ └── kotlin │ │ └── io │ │ └── philo │ │ └── shop │ │ └── integration │ │ └── OrderIntegrationTest.kt └── build.gradle ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── common ├── event │ └── src │ │ └── main │ │ ├── resources │ │ └── rabbitmq.yml │ │ └── kotlin │ │ └── io │ │ └── philo │ │ └── shop │ │ ├── item │ │ ├── ItemCreatedEvent.kt │ │ └── ItemRabbitProperty.kt │ │ ├── common │ │ ├── OrderChangedVerifiedEvent.kt │ │ ├── InAppEventListener.kt │ │ ├── InAppEventPublisher.kt │ │ ├── RabbitCommonConfig.kt │ │ └── VerificationStatus.kt │ │ ├── order │ │ ├── OrderLineCreatedEvent.kt │ │ ├── OrderChangedEvent.kt │ │ ├── OrderCreatedEventDeprecated.kt │ │ └── OrderRabbitProperty.kt │ │ └── CouponRabbitProperty.kt ├── general │ └── src │ │ ├── main │ │ ├── resources │ │ │ └── data-init-config.yml │ │ └── kotlin │ │ │ └── io │ │ │ └── philo │ │ │ └── shop │ │ │ ├── dto │ │ │ └── ResourceCreateResponse.kt │ │ │ ├── error │ │ │ ├── BadRequestException.kt │ │ │ ├── UnauthorizedException.kt │ │ │ ├── EntityNotFoundException.kt │ │ │ ├── NotFoundException.kt │ │ │ ├── ShouldNeverHappenException.kt │ │ │ └── InAppException.kt │ │ │ ├── constant │ │ │ └── SecurityConstant.kt │ │ │ ├── entity │ │ │ ├── OutboxBaseEntity.kt │ │ │ └── BaseEntity.kt │ │ │ └── logformatter │ │ │ └── P6SpySqlFormatter.kt │ │ └── testFixtures │ │ └── kotlin │ │ └── io │ │ └── philo │ │ └── shop │ │ └── AcceptanceTest.kt ├── rest-client │ └── src │ │ └── main │ │ └── kotlin │ │ └── io │ │ └── philo │ │ └── shop │ │ ├── item │ │ ├── dto │ │ │ └── ItemInternalResponseDto.kt │ │ ├── FeignConfig.kt │ │ ├── ItemFeignClient.kt │ │ └── ItemRestClientFacade.kt │ │ ├── coupon │ │ ├── CouponRestClientFacade.kt │ │ └── CouponFeignClient.kt │ │ └── user │ │ ├── UserRestClientFacade.kt │ │ ├── dto │ │ └── UserPassportResponse.kt │ │ └── UserFeignClient.kt └── security │ └── src │ ├── main │ └── kotlin │ │ └── io │ │ └── philo │ │ └── shop │ │ ├── PasswordEncoder.kt │ │ ├── JwtConfig.kt │ │ └── JwtManager.kt │ └── test │ └── kotlin │ └── io │ └── philo │ └── shop │ └── JwtManagerTest.kt ├── docker └── docker-compose.yml ├── settings.gradle ├── gradle.properties ├── eureka └── src │ └── main │ ├── resources │ └── application.yml │ └── kotlin │ └── io │ └── philo │ └── shop │ └── eureka │ └── EurekaApplication.kt ├── module-dependencies └── micro-service.gradle ├── .gitignore ├── gradlew.bat ├── README.md └── gradlew /http/API GW.http: -------------------------------------------------------------------------------- 1 | ### Config Refresh 2 | 3 | POST http://localhost:8000/actuator/refresh 4 | -------------------------------------------------------------------------------- /api-gateway/src/test/resources/application.yml: -------------------------------------------------------------------------------- 1 | eureka: 2 | client: 3 | enabled: false 4 | -------------------------------------------------------------------------------- /micro-services/item/src/main/resources/spy.properties: -------------------------------------------------------------------------------- 1 | appender=com.p6spy.engine.spy.appender.Slf4JLogger -------------------------------------------------------------------------------- /micro-services/user/src/main/resources/spy.properties: -------------------------------------------------------------------------------- 1 | appender=com.p6spy.engine.spy.appender.Slf4JLogger -------------------------------------------------------------------------------- /micro-services/coupon/src/main/resources/spy.properties: -------------------------------------------------------------------------------- 1 | appender=com.p6spy.engine.spy.appender.Slf4JLogger -------------------------------------------------------------------------------- /micro-services/order/src/main/resources/spy.properties: -------------------------------------------------------------------------------- 1 | appender=com.p6spy.engine.spy.appender.Slf4JLogger -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/progress0407/e-commerce-with-msa/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /common/event/src/main/resources/rabbitmq.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | rabbitmq: 3 | host: localhost 4 | port: 5672 5 | username: guest 6 | password: guest -------------------------------------------------------------------------------- /common/event/src/main/kotlin/io/philo/shop/item/ItemCreatedEvent.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.item 2 | 3 | data class ItemCreatedEvent(val id: Long, val amount: Int) 4 | -------------------------------------------------------------------------------- /micro-services/order/src/main/kotlin/io/philo/shop/dto/web/OrderListResponses.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.dto.web 2 | 3 | data class OrderListResponses(val orders: List) { 4 | } 5 | -------------------------------------------------------------------------------- /micro-services/coupon/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports: -------------------------------------------------------------------------------- 1 | com.github.gavlyukovskiy.boot.jdbc.decorator.DataSourceDecoratorAutoConfiguration -------------------------------------------------------------------------------- /micro-services/item/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports: -------------------------------------------------------------------------------- 1 | com.github.gavlyukovskiy.boot.jdbc.decorator.DataSourceDecoratorAutoConfiguration -------------------------------------------------------------------------------- /micro-services/order/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports: -------------------------------------------------------------------------------- 1 | com.github.gavlyukovskiy.boot.jdbc.decorator.DataSourceDecoratorAutoConfiguration -------------------------------------------------------------------------------- /micro-services/user/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports: -------------------------------------------------------------------------------- 1 | com.github.gavlyukovskiy.boot.jdbc.decorator.DataSourceDecoratorAutoConfiguration -------------------------------------------------------------------------------- /micro-services/user/src/test/kotlin/io/philo/integration/not_success_case/README.MD: -------------------------------------------------------------------------------- 1 | # 해당 패키지 설명 2 | 3 | Kotest와 Rest Assured 를 연결해서 사용하려고 했으나 잘 되지 않았다 4 | 5 | - Green으로 동작으로 하나 디버깅도 되지 않고, 6 | 7 | 추후 살펴보자 -------------------------------------------------------------------------------- /common/general/src/main/resources/data-init-config.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | sql.init.mode : always # execute data.sql 3 | jpa.defer-datasource-initialization: true # control priority (hibernate ddl -> data.sql) 4 | -------------------------------------------------------------------------------- /micro-services/item/src/main/kotlin/io/philo/shop/presentation/dto/ItemResponses.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.presentation.dto 2 | 3 | data class ItemResponses( 4 | val items: List = emptyList() 5 | ) -------------------------------------------------------------------------------- /micro-services/coupon/src/test/kotlin/io/philo/shop/integration/CouponIntegrationTest.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.integration 2 | 3 | import io.philo.shop.AcceptanceTest 4 | 5 | class CouponIntegrationTest: AcceptanceTest() { 6 | } -------------------------------------------------------------------------------- /micro-services/order/src/main/kotlin/io/philo/shop/dto/web/OrderDetailResponse.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.dto.web 2 | 3 | import io.philo.shop.domain.core.OrderEntity 4 | 5 | class OrderDetailResponse(val entitiy: OrderEntity) { 6 | } -------------------------------------------------------------------------------- /micro-services/item/src/test/resources/application.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | rabbitmq: 3 | host: localhost 4 | port: 5672 5 | username: guest 6 | password: guest 7 | 8 | eureka: 9 | client: 10 | enabled: false 11 | -------------------------------------------------------------------------------- /common/general/src/main/kotlin/io/philo/shop/dto/ResourceCreateResponse.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.dto 2 | 3 | /** 4 | * 자원 생성에 대한 DTO 5 | * 6 | * 단일 자원에 대한 응답 포맷이 모두 같기에 공통 클래스로 추출함 7 | */ 8 | data class ResourceCreateResponse(val id:Long) -------------------------------------------------------------------------------- /micro-services/order/src/main/kotlin/io/philo/shop/dto/web/ItemResponses.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.dto.web 2 | 3 | import com.fasterxml.jackson.annotation.JsonCreator 4 | 5 | data class ItemResponses @JsonCreator constructor(val items: List) -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.1-bin.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /micro-services/order/src/main/kotlin/io/philo/shop/constant/OrderBusinessRuleMessage.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.constant 2 | 3 | class OrderBusinessRuleMessage 4 | 5 | const val ITEM_COUPON_SIZE_APPLY_VALIDATION_MESSAGE = "한 상품에 적용할 수 있는 쿠폰의 갯수는 2개를 초과할 수 없습니다." -------------------------------------------------------------------------------- /micro-services/user/src/main/resources/data.sql: -------------------------------------------------------------------------------- 1 | INSERT INTO users (id, email, name, address, encoded_password, created_at, updated_at) 2 | VALUES (1, 'swcho@naver.com', '성우', '노량진', '$2a$10$HBONLhmRWT44cpQBhDMOweRpvjAJwMAlLs1uF.d0xjjsGdn/Yta8C', now(), now()); 3 | -------------------------------------------------------------------------------- /common/rest-client/src/main/kotlin/io/philo/shop/item/dto/ItemInternalResponseDto.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.item.dto 2 | 3 | data class ItemInternalResponseDto( 4 | val id: Long, 5 | val name: String, 6 | val size: String, 7 | val amount: Int, 8 | ) 9 | 10 | -------------------------------------------------------------------------------- /micro-services/coupon/src/main/kotlin/io/philo/shop/domain/replica/ItemReplicaRepository.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.domain.replica 2 | 3 | import org.springframework.data.jpa.repository.JpaRepository 4 | 5 | interface ItemReplicaRepository : JpaRepository { 6 | } -------------------------------------------------------------------------------- /micro-services/user/src/main/kotlin/io/philo/shop/presentation/dto/login/UserLoginRequest.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.presentation.dto.login 2 | 3 | data class UserLoginRequest( 4 | val email: String, 5 | val password: String 6 | ) { 7 | companion object 8 | } 9 | 10 | -------------------------------------------------------------------------------- /micro-services/item/src/main/kotlin/io/philo/shop/repository/ItemRepositoryCustom.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.repository 2 | 3 | import io.philo.shop.domain.entity.ItemEntity 4 | 5 | @Deprecated("순환 의존성으로 인해 적용 보류") 6 | interface ItemRepositoryCustom { 7 | 8 | fun saveAndPublish(entity: ItemEntity) 9 | } -------------------------------------------------------------------------------- /docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.1' 2 | 3 | services: 4 | rabbitmq: 5 | image: "rabbitmq:management" 6 | ports: 7 | - "15672:15672" # Management UI 8 | - "5672:5672" # AMQP port 9 | environment: 10 | RABBITMQ_DEFAULT_USER: user 11 | RABBITMQ_DEFAULT_PASS: password 12 | -------------------------------------------------------------------------------- /common/general/src/main/kotlin/io/philo/shop/error/BadRequestException.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.error 2 | 3 | import org.springframework.http.HttpStatus.BAD_REQUEST 4 | 5 | /** 6 | * 400 예외 7 | */ 8 | open class BadRequestException(message: String, cause: Throwable? = null) : 9 | InAppException(BAD_REQUEST, message, cause) -------------------------------------------------------------------------------- /common/general/src/main/kotlin/io/philo/shop/error/UnauthorizedException.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.error 2 | 3 | import org.springframework.http.HttpStatus.UNAUTHORIZED 4 | 5 | /** 6 | * 401 예외 7 | */ 8 | open class UnauthorizedException(message: String, cause: Throwable? = null) : 9 | InAppException(UNAUTHORIZED, message, cause) -------------------------------------------------------------------------------- /common/rest-client/src/main/kotlin/io/philo/shop/item/FeignConfig.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.item 2 | 3 | //@Configuration 4 | class FeignConfig { 5 | 6 | // @Bean 7 | fun itemRestClientFacade(itemHttpClient: ItemFeignClient): ItemRestClientFacade { 8 | return ItemRestClientFacade(itemHttpClient) 9 | } 10 | } -------------------------------------------------------------------------------- /micro-services/coupon/src/main/kotlin/io/philo/shop/domain/core/CouponRepository.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.domain.core 2 | 3 | import org.springframework.data.jpa.repository.JpaRepository 4 | 5 | interface CouponRepository : JpaRepository { 6 | 7 | fun findAllByIdIn(ids: List): List 8 | } -------------------------------------------------------------------------------- /micro-services/item/src/main/kotlin/io/philo/shop/presentation/dto/ItemCreateRequest.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.presentation.dto 2 | 3 | data class ItemCreateRequest( 4 | val name: String = "", 5 | val size: String = "", 6 | val price: Int = 0, 7 | val stockQuantity: Int = 0 8 | ) { 9 | companion object 10 | } 11 | -------------------------------------------------------------------------------- /micro-services/order/src/main/kotlin/io/philo/shop/dto/web/OrderListResponse.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.dto.web 2 | 3 | import io.philo.shop.domain.core.OrderEntity 4 | 5 | data class OrderListResponse(val id:Long, val totalOrderAmount: Int) { 6 | 7 | constructor(entity: OrderEntity) : this(entity.id!!, entity.totalOrderAmount) 8 | } 9 | -------------------------------------------------------------------------------- /common/general/src/main/kotlin/io/philo/shop/error/EntityNotFoundException.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.error 2 | 3 | class EntityNotFoundException( 4 | entityInfo: Any, 5 | cause: Throwable? 6 | ) : 7 | NotFoundException("엔티티를 찾을 수 없습니다: $entityInfo", cause) { 8 | 9 | constructor(condition: Any) : this(condition, null) 10 | } -------------------------------------------------------------------------------- /micro-services/coupon/src/main/kotlin/io/philo/shop/presentation/dto/CouponAppliedAmountDto.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.presentation.dto 2 | 3 | class CouponAppliedAmountDto 4 | 5 | data class CouponAppliedAmountRequestDto(val itemId: Long, val userCouponIds: List) 6 | 7 | data class CouponAppliedAmountResponseDto(val itemDiscountedAmount:Int) -------------------------------------------------------------------------------- /common/event/src/main/kotlin/io/philo/shop/common/OrderChangedVerifiedEvent.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.common 2 | 3 | /** 4 | * 주문 생성|실패 후 협력 컴포넌트의 검증 결과값을 반환 5 | * 6 | * @param orderId 주문 번호 (trace id의 의미로 사용) 7 | * @param verification 검증 결과 8 | */ 9 | data class OrderChangedVerifiedEvent(val orderId: Long, val verification: Boolean) 10 | -------------------------------------------------------------------------------- /micro-services/item/src/main/resources/data.sql: -------------------------------------------------------------------------------- 1 | INSERT INTO item (id, name, size, price, stock_quantity) 2 | VALUES (1, '초신사 스탠다드 블랙 스웨트 셔츠 오버 핏', '90-S', 49800, 1000) 3 | , (2, '초신사 스탠다드 블랙 스웨트 셔츠 오버 핏', '100-M', 49800, 1000) 4 | , (3, '초신사 스탠다드 블랙 스웨트 셔츠 오버 핏', '110-L', 49800, 1000) 5 | , (4, '드로우핏 네이비 발마칸 코트 세미 오버 핏', '-', 245000, 200); 6 | -------------------------------------------------------------------------------- /common/event/src/main/kotlin/io/philo/shop/order/OrderLineCreatedEvent.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.order 2 | 3 | data class OrderLineCreatedEvent( 4 | val itemId: Long, 5 | val itemAmount: Int, 6 | val itemDiscountedAmount: Int, 7 | val itemQuantity: Int, 8 | val userCouponIds: List? = null, 9 | ) { 10 | companion object 11 | } 12 | -------------------------------------------------------------------------------- /micro-services/order/src/main/kotlin/io/philo/shop/dto/web/ItemResponse.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.dto.web 2 | 3 | import com.fasterxml.jackson.annotation.JsonCreator 4 | 5 | data class ItemResponse @JsonCreator constructor( 6 | val id: Long, 7 | val name: String, 8 | val size: String, 9 | val price: Int, 10 | val availableQuantity: Int 11 | ) -------------------------------------------------------------------------------- /api-gateway/src/test/kotlin/io/philo/shop/support/GatewayTestConfig.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.support 2 | 3 | import org.springframework.http.codec.ServerCodecConfigurer 4 | 5 | //@TestConfiguration 6 | class GatewayTestConfig { 7 | // @Bean 8 | fun serverCodecConfigurer(): ServerCodecConfigurer { 9 | return ServerCodecConfigurer.create() 10 | } 11 | } -------------------------------------------------------------------------------- /micro-services/order/src/main/kotlin/io/philo/shop/constant/OrderStatus.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.constant 2 | 3 | enum class OrderStatus(val koreanName: String, val description: String) { 4 | 5 | PENDING("주문 대기", "검증 이벤트 대기 중"), 6 | SUCCESS("주문 성공", "모든 검증 완료"), 7 | FAIL("주문 실패", "하나 이상의 이벤트 실패"), 8 | CANCEL("주문 취소", "보상 트랜잭션 처리 이후 주문 취소") // todo 구현할 지 고민 9 | } -------------------------------------------------------------------------------- /micro-services/order/src/main/kotlin/io/philo/shop/dto/web/OrderCreateRequest.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.dto.web 2 | 3 | import com.fasterxml.jackson.annotation.JsonCreator 4 | 5 | 6 | data class OrderCreateRequest @JsonCreator constructor( 7 | val userId: Long, // 이 부분은 Redis with Netflix Passport로 없어질 수 있다 8 | val orderLineRequestDtos: List, 9 | ) -------------------------------------------------------------------------------- /micro-services/order/src/main/kotlin/io/philo/shop/repository/OrderRepository.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.repository 2 | 3 | import io.philo.shop.domain.core.OrderEntity 4 | import org.springframework.data.jpa.repository.JpaRepository 5 | 6 | interface OrderRepository : JpaRepository { 7 | 8 | fun findAllByIdIn(orderIds: List): List 9 | } -------------------------------------------------------------------------------- /micro-services/user/src/main/kotlin/io/philo/shop/domain/repository/UserRepository.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.domain.repository 2 | 3 | import io.philo.shop.domain.entity.UserEntity 4 | import org.springframework.data.jpa.repository.JpaRepository 5 | 6 | interface UserRepository : JpaRepository { 7 | fun findByEmail(email: String): UserEntity? 8 | } 9 | -------------------------------------------------------------------------------- /micro-services/item/src/main/kotlin/io/philo/shop/repository/ItemRepository.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.repository 2 | 3 | import io.philo.shop.domain.entity.ItemEntity 4 | import org.springframework.data.jpa.repository.JpaRepository 5 | 6 | interface ItemRepository : JpaRepository { 7 | 8 | fun findAllByIdIn(itemIds: Collection): List 9 | } -------------------------------------------------------------------------------- /common/event/src/main/kotlin/io/philo/shop/order/OrderChangedEvent.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.order 2 | 3 | /** 4 | * 주문 생성 | 실패에 대한 이벤트 5 | * 6 | * 현재 상품과 쿠폰에서 사용된다 7 | */ 8 | data class OrderChangedEvent( 9 | val orderId : Long, 10 | val requesterId: Long, 11 | val orderLineCreatedEvents: List 12 | ) { 13 | companion object 14 | } 15 | -------------------------------------------------------------------------------- /http/Order Service.http: -------------------------------------------------------------------------------- 1 | 2 | ### 주문 리스트 조회 3 | GET http://localhost:8000/orders 4 | 5 | 6 | ### 주문하기 7 | POST http://localhost:8000/orders 8 | Content-Type: application/json 9 | 10 | { 11 | "orderLineRequests": [ 12 | { 13 | "itemId": 1, 14 | "quantity": 10 15 | }, 16 | { 17 | "itemId": 2, 18 | "quantity": 1 19 | } 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /common/general/src/main/kotlin/io/philo/shop/error/NotFoundException.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.error 2 | 3 | import org.springframework.http.HttpStatus 4 | 5 | open class NotFoundException(message: String?, cause: Throwable?) : RuntimeException(message, cause) { 6 | 7 | private val httpStatus = HttpStatus.NOT_FOUND 8 | 9 | constructor(message: String): this(message, null) 10 | } -------------------------------------------------------------------------------- /common/event/src/main/kotlin/io/philo/shop/common/InAppEventListener.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.common 2 | 3 | import org.springframework.stereotype.Component 4 | 5 | /** 6 | * 현재 애플리케이션에서 사용하는 이벤트 리스너 7 | * 8 | * 마커 어노테이션 - 소스 코드 추적용으로 사용 9 | */ 10 | @Target(AnnotationTarget.CLASS) 11 | @Retention(AnnotationRetention.RUNTIME) 12 | @Component 13 | annotation class InAppEventListener() 14 | -------------------------------------------------------------------------------- /common/event/src/main/kotlin/io/philo/shop/common/InAppEventPublisher.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.common 2 | 3 | import org.springframework.stereotype.Component 4 | 5 | /** 6 | * 현재 애플리케이션에서 사용하는 이벤트 발행기 7 | * 8 | * 마커 어노테이션 - 소스 코드 추적용으로 사용 9 | */ 10 | @Target(AnnotationTarget.CLASS) 11 | @Retention(AnnotationRetention.RUNTIME) 12 | @Component 13 | annotation class InAppEventPublisher() 14 | -------------------------------------------------------------------------------- /common/general/src/main/kotlin/io/philo/shop/error/ShouldNeverHappenException.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.error 2 | 3 | import org.springframework.http.HttpStatus 4 | 5 | /** 6 | * 시스템 설계상 일어날 수 없는 예외 발생 7 | * 8 | * 코틀린 문법으로 보완할 수 없는 상황에서 사용하기 위한 예외 클래스 9 | */ 10 | class ShouldNeverHappenException(cause: Throwable? = null) : 11 | InAppException(HttpStatus.INTERNAL_SERVER_ERROR, "존재할 수 없는 예외입니다.", cause) -------------------------------------------------------------------------------- /micro-services/coupon/src/main/kotlin/io/philo/shop/presentation/dto/UserCouponListDto.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.presentation.dto 2 | 3 | import io.philo.shop.domain.core.UserCouponEntity 4 | 5 | data class UserCouponListDto(val id: Long, val userId: Long, val couponId:Long, val isUse: Boolean) { 6 | 7 | constructor(entity: UserCouponEntity) : this(entity.id!!, entity.userId, entity.coupon.id!!, entity.isUse) 8 | } -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | rootProject.name = 'e-commerce' 2 | 3 | include 'eureka', 4 | 'api-gateway', 5 | 6 | 'micro-services', 7 | 'micro-services:user', 8 | 'micro-services:item', 9 | 'micro-services:order', 10 | 'micro-services:coupon', 11 | 12 | 'common:general', 13 | 'common:event', 14 | 'common:rest-client', 15 | 'common:security' 16 | -------------------------------------------------------------------------------- /micro-services/user/src/main/kotlin/io/philo/shop/presentation/dto/create/UserCreateDto.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.presentation.dto.create 2 | 3 | class UserCreateDto 4 | 5 | data class UserCreateRequestDto( 6 | val email: String, 7 | val name: String, 8 | val address: String, 9 | val password: String 10 | ) { 11 | companion object 12 | } 13 | 14 | data class UserCreateResponseDto(val id: Long) 15 | -------------------------------------------------------------------------------- /common/general/src/main/kotlin/io/philo/shop/constant/SecurityConstant.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.constant 2 | 3 | class SecurityConstant { 4 | 5 | companion object { 6 | const val TOKEN_PREFIX = "Bearer " 7 | 8 | 9 | const val USER_PASSPORT = "user-passport" 10 | 11 | /** 12 | * 헤더 속성 명으로 사용하기 위함 13 | */ 14 | const val LOGIN_USER_ID = "login-user-id" 15 | } 16 | } -------------------------------------------------------------------------------- /common/rest-client/src/main/kotlin/io/philo/shop/coupon/CouponRestClientFacade.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.coupon 2 | 3 | import org.springframework.stereotype.Component 4 | 5 | @Component 6 | class CouponRestClientFacade(private val couponFeignClient: CouponFeignClient) { 7 | 8 | fun requestItemCostsByIds(ids: List): Map { 9 | return couponFeignClient.requestItemCostsByIds(ids) 10 | } 11 | } -------------------------------------------------------------------------------- /micro-services/user/src/main/kotlin/io/philo/shop/presentation/dto/create/UserListDto.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.presentation.dto.create 2 | 3 | import io.philo.shop.domain.entity.UserEntity 4 | 5 | class UserListDto 6 | 7 | data class UserListResponseDto(val id: Long, val email: String, val name: String, val address: String) { 8 | 9 | constructor(entity: UserEntity) : this(entity.id!!, entity.email, entity.name, entity.address) 10 | } -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # use gradle build caching 2 | org.gradle.caching=true 3 | 4 | org.gradle.configureondemand=true 5 | 6 | # use gradle parllel build 7 | org.gradle.parallel=true 8 | 9 | # use gradle daemon 10 | org.gradle.daemon=true 11 | 12 | # set gradle java heap 13 | org.gradle.jvmargs=-Xmx4096m 14 | 15 | ### Versions ### 16 | springBootVersion=3.2.2 17 | springDependencyVersion=1.1.4 18 | kotlinVersion=1.9.22 19 | junitVersion=5.8.0 -------------------------------------------------------------------------------- /eureka/src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | server.port: 8761 2 | 3 | spring: 4 | application:.name: eureka 5 | 6 | eureka: 7 | instance: 8 | instance-id: ${spring.application.name}:${spring.application.instance_id:${random.value}} 9 | client: 10 | register-with-eureka: false 11 | fetch-registry: false 12 | 13 | # Spring Actuator 14 | management: 15 | endpoints: 16 | web: 17 | exposure: 18 | include: refresh, beans, health, info -------------------------------------------------------------------------------- /micro-services/coupon/src/main/kotlin/io/philo/shop/presentation/dto/CouponListDto.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.presentation.dto 2 | 3 | import io.philo.shop.domain.core.CouponEntity 4 | import java.time.LocalDate 5 | 6 | data class CouponListDto(val id: Long, val name: String, val expirationStartAt: LocalDate, val expirationEndAt: LocalDate) { 7 | 8 | constructor(entity: CouponEntity) : this(entity.id!!, entity.name, entity.expirationStartAt, entity.expirationEndAt) 9 | } -------------------------------------------------------------------------------- /api-gateway/src/main/kotlin/io/philo/shop/ApiGatewayApplication.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop 2 | 3 | import org.springframework.boot.autoconfigure.SpringBootApplication 4 | import org.springframework.boot.runApplication 5 | import org.springframework.cloud.openfeign.EnableFeignClients 6 | 7 | @SpringBootApplication 8 | @EnableFeignClients 9 | class ApiGatewayApplication 10 | 11 | fun main(args: Array) { 12 | runApplication(*args) 13 | } 14 | -------------------------------------------------------------------------------- /micro-services/user/src/main/kotlin/io/philo/shop/UserServiceApplication.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop 2 | 3 | import org.springframework.boot.autoconfigure.SpringBootApplication 4 | import org.springframework.boot.runApplication 5 | import org.springframework.cloud.openfeign.EnableFeignClients 6 | 7 | @SpringBootApplication 8 | @EnableFeignClients 9 | class UserServiceApplication 10 | 11 | fun main(args: Array) { 12 | runApplication(*args) 13 | } 14 | -------------------------------------------------------------------------------- /common/event/src/main/kotlin/io/philo/shop/order/OrderCreatedEventDeprecated.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.order 2 | 3 | @Deprecated("OutBox 패턴으로 사용중단") 4 | data class OrderCreatedEventDeprecated(val itemIdToOrderedQuantity: Map) { 5 | 6 | fun values(): Map { 7 | return itemIdToOrderedQuantity 8 | } 9 | 10 | /** 11 | * this constructor for json se/deserialize 12 | */ 13 | constructor(): this(emptyMap()) 14 | 15 | companion object 16 | } -------------------------------------------------------------------------------- /common/event/src/main/kotlin/io/philo/shop/common/RabbitCommonConfig.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.common 2 | 3 | import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter 4 | import org.springframework.context.annotation.Bean 5 | import org.springframework.context.annotation.Configuration 6 | 7 | @Configuration 8 | class RabbitCommonConfig { 9 | 10 | @Bean 11 | fun messageConverter(): Jackson2JsonMessageConverter { 12 | return Jackson2JsonMessageConverter() 13 | } 14 | } -------------------------------------------------------------------------------- /eureka/src/main/kotlin/io/philo/shop/eureka/EurekaApplication.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.eureka 2 | 3 | import org.springframework.boot.SpringApplication 4 | import org.springframework.boot.autoconfigure.SpringBootApplication 5 | import org.springframework.cloud.netflix.eureka.server.EnableEurekaServer 6 | 7 | @SpringBootApplication 8 | @EnableEurekaServer 9 | class EurekaApplication 10 | 11 | fun main(args: Array) { 12 | SpringApplication.run(EurekaApplication::class.java, *args) 13 | } 14 | -------------------------------------------------------------------------------- /micro-services/item/src/main/kotlin/io/philo/shop/presentation/dto/ItemResponse.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.presentation.dto 2 | 3 | import io.philo.shop.domain.entity.ItemEntity 4 | 5 | data class ItemResponse( 6 | val id: Long, 7 | val name: String, 8 | val size: String, 9 | val price: Int, 10 | val availableQuantity: Int 11 | ) { 12 | 13 | constructor(itemEntity: ItemEntity) : this(itemEntity.id!!, itemEntity.name, itemEntity.size, itemEntity.price, itemEntity.stockQuantity) 14 | } -------------------------------------------------------------------------------- /common/general/src/main/kotlin/io/philo/shop/error/InAppException.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.error 2 | 3 | import org.springframework.http.HttpStatus 4 | import org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR 5 | 6 | /** 7 | * 현재 프로젝트에서 사용하는 비즈니스 최상위 예외 클래스 8 | */ 9 | open class InAppException(val httpStatus: HttpStatus = INTERNAL_SERVER_ERROR, 10 | message: String = "", 11 | cause: Throwable? = null) : 12 | RuntimeException(message, cause) { 13 | } -------------------------------------------------------------------------------- /common/rest-client/src/main/kotlin/io/philo/shop/item/ItemFeignClient.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.item 2 | 3 | import io.philo.shop.item.dto.ItemInternalResponseDto 4 | import org.springframework.cloud.openfeign.FeignClient 5 | import org.springframework.web.bind.annotation.GetMapping 6 | import org.springframework.web.bind.annotation.RequestParam 7 | 8 | @FeignClient(name = "item-service") 9 | interface ItemFeignClient { 10 | 11 | @GetMapping("/items/internal") 12 | fun requestItems(@RequestParam("ids") ids: List): List 13 | } -------------------------------------------------------------------------------- /micro-services/item/src/main/kotlin/io/philo/shop/ItemServiceApplication.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop 2 | 3 | import org.springframework.boot.autoconfigure.SpringBootApplication 4 | import org.springframework.boot.runApplication 5 | import org.springframework.cloud.openfeign.EnableFeignClients 6 | import org.springframework.scheduling.annotation.EnableScheduling 7 | 8 | @SpringBootApplication 9 | @EnableFeignClients 10 | @EnableScheduling 11 | class ItemServiceApplication 12 | 13 | fun main(args: Array) { 14 | runApplication(*args) 15 | } -------------------------------------------------------------------------------- /micro-services/coupon/src/main/kotlin/io/philo/shop/domain/replica/ItemReplicaEntity.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.domain.replica 2 | 3 | import io.philo.shop.entity.BaseEntity 4 | import jakarta.persistence.Column 5 | import jakarta.persistence.Entity 6 | import jakarta.persistence.Table 7 | 8 | @Entity 9 | @Table(name = "item_replica") 10 | class ItemReplicaEntity( 11 | 12 | @Column(nullable = false) 13 | val itemId: Long, 14 | 15 | @Column(nullable = false) 16 | val itemAmount: Int 17 | 18 | ) : BaseEntity() { 19 | 20 | constructor() : this(0L, 0) 21 | } -------------------------------------------------------------------------------- /micro-services/order/src/main/kotlin/io/philo/shop/OrderServiceApplication.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop 2 | 3 | import org.springframework.boot.autoconfigure.SpringBootApplication 4 | import org.springframework.boot.runApplication 5 | import org.springframework.cloud.openfeign.EnableFeignClients 6 | import org.springframework.scheduling.annotation.EnableScheduling 7 | 8 | @SpringBootApplication 9 | @EnableFeignClients 10 | @EnableScheduling 11 | class OrderServiceApplication 12 | 13 | fun main(args: Array) { 14 | runApplication(*args) 15 | } -------------------------------------------------------------------------------- /micro-services/coupon/src/main/kotlin/io/philo/shop/CouponServiceApplication.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop 2 | 3 | import org.springframework.boot.autoconfigure.SpringBootApplication 4 | import org.springframework.boot.runApplication 5 | import org.springframework.cloud.openfeign.EnableFeignClients 6 | import org.springframework.scheduling.annotation.EnableScheduling 7 | 8 | @SpringBootApplication 9 | @EnableFeignClients 10 | @EnableScheduling 11 | class CouponServiceApplication 12 | 13 | fun main(args: Array) { 14 | runApplication(*args) 15 | } -------------------------------------------------------------------------------- /micro-services/order/src/main/kotlin/io/philo/shop/dto/web/OrderLineRequestDto.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.dto.web 2 | 3 | import io.philo.shop.constant.ITEM_COUPON_SIZE_APPLY_VALIDATION_MESSAGE 4 | import jakarta.validation.constraints.Size 5 | 6 | data class OrderLineRequestDto( 7 | val itemId: Long, 8 | val itemQuantity: Int = 0, 9 | val itemAmount: Int = 0, 10 | val itemDiscountedAmount: Int = 0, 11 | 12 | @Size(max = 3, message = ITEM_COUPON_SIZE_APPLY_VALIDATION_MESSAGE) 13 | val userCouponIds: List? = null, // Coupon Id가 존재할 경우, 상품은 Discount되었다고 가정한다 14 | ) -------------------------------------------------------------------------------- /common/security/src/main/kotlin/io/philo/shop/PasswordEncoder.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop 2 | 3 | import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder 4 | 5 | /** 6 | * Facade Of Password Encoding 7 | */ 8 | object PasswordEncoder { 9 | private val encoder = BCryptPasswordEncoder() 10 | fun encodePassword(rawPassword: String?): String { 11 | return encoder.encode(rawPassword) 12 | } 13 | 14 | fun isSamePassword(rawPassword: String?, encodedPassword: String?): Boolean { 15 | return encoder.matches(rawPassword, encodedPassword) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /micro-services/user/src/test/kotlin/io/philo/unit/UserTest.kt: -------------------------------------------------------------------------------- 1 | package io.philo.unit 2 | 3 | import io.kotest.core.spec.style.StringSpec 4 | import io.kotest.matchers.shouldBe 5 | import io.philo.shop.domain.entity.UserEntity 6 | 7 | class UserTest : StringSpec({ 8 | 9 | "사용자 생성 및 암호화 검증" { 10 | val userEntity = UserEntity(email = "philo@gmail.com", name = "cho", address = "korea", rawPassword = "1234") 11 | 12 | println("user = ${userEntity}") 13 | 14 | userEntity.isSamePassword("1234") shouldBe true 15 | userEntity.isSamePassword("9999") shouldBe false 16 | } 17 | }) -------------------------------------------------------------------------------- /common/rest-client/src/main/kotlin/io/philo/shop/item/ItemRestClientFacade.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.item 2 | 3 | import io.philo.shop.item.dto.ItemInternalResponseDto 4 | import org.springframework.stereotype.Component 5 | 6 | @Component 7 | class ItemRestClientFacade(private val itemHttpClient: ItemFeignClient) { 8 | 9 | fun requestItems(ids: List): List { 10 | return itemHttpClient.requestItems(ids) 11 | } 12 | 13 | fun getItemAmount(itemId: Long): ItemResponse { 14 | 15 | return ItemResponse() 16 | } 17 | } 18 | 19 | class ItemResponse { 20 | var amount: Int = 0 21 | } -------------------------------------------------------------------------------- /micro-services/coupon/src/main/kotlin/io/philo/shop/presentation/CouponInternalController.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.presentation 2 | 3 | import io.philo.shop.service.CouponService 4 | import org.springframework.web.bind.annotation.* 5 | 6 | @RequestMapping("/coupon/internal") 7 | @RestController 8 | class CouponInternalController(private val couponService: CouponService) { 9 | 10 | @GetMapping("/discount-amounts") 11 | fun calculateAmount(@RequestHeader("loginUserId") userId: Long, 12 | @RequestBody ids: List): Int { 13 | 14 | return couponService.calculateAmountForInternal(userId, ids) 15 | } 16 | } -------------------------------------------------------------------------------- /common/rest-client/src/main/kotlin/io/philo/shop/user/UserRestClientFacade.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.user 2 | 3 | import io.philo.shop.user.dto.UserPassportResponse 4 | import mu.KotlinLogging 5 | import org.springframework.stereotype.Component 6 | 7 | @Component 8 | class UserRestClientFacade(private val userFeignClient: UserFeignClient) { 9 | 10 | val log = KotlinLogging.logger { } 11 | 12 | fun isValidToken(): Boolean { 13 | return userFeignClient.isValidToken() 14 | } 15 | 16 | fun getUserPassport(authHeader: String): UserPassportResponse { 17 | 18 | return userFeignClient.getUserPassport(authHeader) 19 | } 20 | } -------------------------------------------------------------------------------- /micro-services/build.gradle: -------------------------------------------------------------------------------- 1 | allprojects { 2 | jar.enabled = false 3 | } 4 | 5 | subprojects { 6 | apply from: rootProject.file('module-dependencies/micro-service.gradle') 7 | 8 | dependencies { 9 | implementation project(':common:general') 10 | implementation project(':common:rest-client') 11 | implementation project(':common:event') 12 | 13 | testImplementation testFixtures(project(':common:general')) 14 | testImplementation testFixtures(project(':common:general')) 15 | } 16 | } 17 | 18 | project(':micro-services:user') { 19 | dependencies { 20 | implementation project(':common:security') 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /micro-services/user/src/main/kotlin/io/philo/shop/support/UserDataInitializer.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.support 2 | 3 | import io.philo.shop.domain.entity.UserEntity 4 | import io.philo.shop.domain.repository.UserRepository 5 | import jakarta.annotation.PostConstruct 6 | 7 | //@Component 8 | class UserDataInitializer(private val userRepository: UserRepository) { 9 | 10 | @PostConstruct 11 | fun init() { 12 | 13 | val userEntity = UserEntity.of( 14 | email = "swcho@naver.com", 15 | name = "성우", 16 | address = "노량진", 17 | rawPassword = "12345678" 18 | ) 19 | 20 | userRepository.save(userEntity) 21 | } 22 | } -------------------------------------------------------------------------------- /common/rest-client/src/main/kotlin/io/philo/shop/coupon/CouponFeignClient.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.coupon 2 | 3 | import io.philo.shop.item.dto.ItemInternalResponseDto 4 | import org.springframework.cloud.openfeign.FeignClient 5 | import org.springframework.web.bind.annotation.GetMapping 6 | import org.springframework.web.bind.annotation.RequestParam 7 | 8 | @FeignClient(name = "COUPON-SERVICE") 9 | interface CouponFeignClient { 10 | 11 | @GetMapping("/coupon/internal") 12 | fun requestItems(@RequestParam("ids") ids: List): List 13 | 14 | @GetMapping("/coupon/internal/discount-amounts") 15 | fun requestItemCostsByIds(ids: List): Map 16 | } -------------------------------------------------------------------------------- /http/Item Service.http: -------------------------------------------------------------------------------- 1 | 2 | ### 상품 리스트 조회 3 | GET http://localhost:8000/items 4 | 5 | 6 | ### 상품 리스트 조회 7 | GET http://localhost:8000/item-service 8 | 9 | 10 | ### 상품 리스트 조회 11 | GET http://localhost:8000/item-service/items 12 | 13 | 14 | ### 상품 등록: 블랙 스웨터 15 | POST http://localhost:8000/item-service/items 16 | Content-Type: application/json 17 | 18 | { 19 | "name": "블랙 스웨터", 20 | "size": "100L", 21 | "price": 55000, 22 | "availableQuantity": 10 23 | } 24 | 25 | 26 | ### 상품 등록: 스니키 청바지 27 | POST http://localhost:8000/item-service/items 28 | Content-Type: application/json 29 | 30 | { 31 | "name": "스니키 청바지", 32 | "size": "95M", 33 | "price": 35000, 34 | "availableQuantity": 10 35 | } 36 | 37 | -------------------------------------------------------------------------------- /micro-services/item/src/main/kotlin/io/philo/shop/repository/ItemRepositoryCustomImpl.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.repository 2 | 3 | import io.philo.shop.domain.entity.ItemEntity 4 | import io.philo.shop.messagequeue.producer.ItemEventPublisher 5 | import io.philo.shop.messagequeue.producer.toEvent 6 | 7 | @Deprecated("순환 의존성으로 인해 적용 보류") 8 | class ItemRepositoryCustomImpl( 9 | private val itemRepository: ItemRepository, 10 | private val eventPublisher: ItemEventPublisher 11 | ): ItemRepositoryCustom { 12 | override fun saveAndPublish(item: ItemEntity) { 13 | 14 | itemRepository.save(item) 15 | val event = item.toEvent() 16 | eventPublisher.publishEvent(event) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /common/rest-client/src/main/kotlin/io/philo/shop/user/dto/UserPassportResponse.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.user.dto 2 | 3 | class UserPassportResponse private constructor( 4 | val id: Long = -1, 5 | val name: String = "", 6 | val email: String = "", 7 | val isValid: Boolean = true 8 | ) { 9 | 10 | companion object { 11 | fun OfInvalid() = UserPassportResponse(isValid = false) 12 | 13 | fun OfValid(id: Long, name: String, email: String) = 14 | UserPassportResponse(id, name, email, isValid = true) 15 | } 16 | 17 | override fun toString(): String { 18 | return "UserPassportResponse(id=$id, name='$name', email='$email', isValid=$isValid)" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /api-gateway/src/main/kotlin/io/philo/shop/support/RouteInspector.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.support 2 | 3 | import mu.KotlinLogging 4 | import org.springframework.beans.factory.annotation.Autowired 5 | import org.springframework.cloud.gateway.route.Route 6 | import org.springframework.cloud.gateway.route.RouteLocator 7 | import org.springframework.context.annotation.Configuration 8 | 9 | 10 | @Configuration 11 | class RouteInspector @Autowired constructor(routeLocator: RouteLocator) { 12 | 13 | private val log = KotlinLogging.logger { } 14 | 15 | init { 16 | routeLocator.routes.subscribe { route: Route -> 17 | log.info { "Route ID: ${route.id} , URI: ${route.uri}" } 18 | } 19 | } 20 | } -------------------------------------------------------------------------------- /common/rest-client/src/main/kotlin/io/philo/shop/user/UserFeignClient.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.user 2 | 3 | import io.philo.shop.user.dto.UserPassportResponse 4 | import org.springframework.cloud.openfeign.FeignClient 5 | import org.springframework.http.HttpHeaders 6 | import org.springframework.web.bind.annotation.GetMapping 7 | import org.springframework.web.bind.annotation.RequestHeader 8 | 9 | @FeignClient(name = "USER-SERVICE") 10 | interface UserFeignClient { 11 | 12 | @GetMapping("/user/internal/valid-token") 13 | fun isValidToken(): Boolean 14 | 15 | @GetMapping("/user/internal/passport") 16 | fun getUserPassport(@RequestHeader(HttpHeaders.AUTHORIZATION) authHeader: String): UserPassportResponse 17 | 18 | } -------------------------------------------------------------------------------- /micro-services/item/src/main/kotlin/io/philo/shop/support/ItemHttpMessageConfig.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.support 2 | 3 | import jakarta.annotation.PostConstruct 4 | import org.springframework.beans.factory.annotation.Autowired 5 | import org.springframework.boot.autoconfigure.http.HttpMessageConverters 6 | import org.springframework.context.ApplicationContext 7 | import org.springframework.context.annotation.Configuration 8 | 9 | @Configuration 10 | class ItemHttpMessageConfig { 11 | 12 | @Autowired 13 | lateinit var applicationContext: ApplicationContext 14 | 15 | @PostConstruct 16 | fun init() { 17 | val bean = applicationContext.getBean(HttpMessageConverters::class.java) 18 | println("bean = ${bean}") 19 | } 20 | } -------------------------------------------------------------------------------- /micro-services/item/src/main/kotlin/io/philo/shop/domain/outbox/ItemOutboxEntity.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.domain.outbox 2 | 3 | import io.philo.shop.entity.OutboxBaseEntity 4 | import jakarta.persistence.Column 5 | import jakarta.persistence.Entity 6 | import jakarta.persistence.Table 7 | 8 | @Entity 9 | @Table(name = "item_outbox") 10 | class ItemOutboxEntity( 11 | 12 | traceId: Long, 13 | 14 | requesterId: Long, 15 | 16 | @Column(nullable = false) 17 | val verification: Boolean, 18 | 19 | @Column(nullable = false) 20 | val isCompensatingTx: Boolean = false, // 보상 트랜잭션 여부 21 | 22 | ) : OutboxBaseEntity(traceId, requesterId) { 23 | 24 | protected constructor() : this(traceId = 0L, requesterId = 0L, verification = false) 25 | } -------------------------------------------------------------------------------- /micro-services/coupon/src/main/kotlin/io/philo/shop/domain/outbox/CouponOutboxEntity.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.domain.outbox 2 | 3 | import io.philo.shop.entity.OutboxBaseEntity 4 | import jakarta.persistence.Column 5 | import jakarta.persistence.Entity 6 | import jakarta.persistence.Table 7 | 8 | @Entity 9 | @Table(name = "coupon_outbox") 10 | class CouponOutboxEntity( 11 | 12 | traceId: Long, 13 | 14 | requesterId: Long, 15 | 16 | @Column(nullable = false) 17 | val isCompensatingTx: Boolean = false, // 보상 트랜잭션 여부 18 | 19 | @Column(nullable = false) 20 | val verification: Boolean, 21 | 22 | ) : OutboxBaseEntity(traceId, requesterId) { 23 | 24 | protected constructor() : this(traceId = 0L, requesterId = 0L, verification = false) 25 | } -------------------------------------------------------------------------------- /micro-services/item/src/main/kotlin/io/philo/shop/scheduler/ItemEventLoader.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.scheduler 2 | 3 | import io.philo.shop.domain.service.ItemEventService 4 | import mu.KotlinLogging 5 | import org.springframework.scheduling.annotation.Scheduled 6 | import org.springframework.stereotype.Component 7 | 8 | @Component 9 | class ItemEventLoader(private val itemEventService: ItemEventService) { 10 | 11 | private val log = KotlinLogging.logger { } 12 | 13 | @Scheduled(fixedDelay = 1_000) 14 | fun loadEventToBroker() { 15 | itemEventService.loadEventToBroker() 16 | } 17 | 18 | @Scheduled(fixedDelay = 1_000) 19 | fun loadCompensatingEventToBroker() { 20 | itemEventService.loadCompensatingEventToBroker() 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /micro-services/order/src/main/kotlin/io/philo/shop/domain/core/OrderHistoryEntity.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.domain.core 2 | 3 | import io.philo.shop.constant.OrderStatus 4 | import io.philo.shop.entity.BaseEntity 5 | import jakarta.persistence.* 6 | import jakarta.persistence.EnumType.STRING 7 | import jakarta.persistence.FetchType.LAZY 8 | 9 | @Entity 10 | @Table(name = "order_history") 11 | class OrderHistoryEntity( 12 | 13 | @JoinColumn(name = "order_id", nullable = false) 14 | @ManyToOne(fetch = LAZY) 15 | val orderEntity: OrderEntity, 16 | 17 | @Enumerated(STRING) 18 | @Column(nullable = false) 19 | val orderStatus: OrderStatus = OrderStatus.PENDING 20 | 21 | ) : BaseEntity() { 22 | protected constructor() : this(OrderEntity.empty()) 23 | } -------------------------------------------------------------------------------- /common/event/src/main/kotlin/io/philo/shop/common/VerificationStatus.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.common 2 | 3 | import io.philo.shop.error.InAppException 4 | 5 | /** 6 | * 마이크로 서비스 간 유효성 검증 상태 7 | */ 8 | enum class VerificationStatus(private val description: String) { 9 | 10 | PENDING("대기 중"), 11 | SUCCESS("검증 결과 정상"), 12 | FAIL("검증 실패"); 13 | 14 | val toBool: Boolean 15 | get() = when (this) { 16 | PENDING -> throw InAppException(message = "PENDING 상태는 참/거짓 값으로 반환되지 않습니다.") 17 | SUCCESS -> true 18 | FAIL -> false 19 | } 20 | 21 | companion object { 22 | 23 | @JvmStatic 24 | fun of(verification: Boolean) = 25 | if (verification) SUCCESS 26 | else FAIL 27 | } 28 | } -------------------------------------------------------------------------------- /common/security/src/main/kotlin/io/philo/shop/JwtConfig.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop 2 | 3 | import io.jsonwebtoken.security.Keys 4 | import org.springframework.context.annotation.Bean 5 | import org.springframework.context.annotation.Configuration 6 | import java.nio.charset.StandardCharsets 7 | 8 | @Configuration 9 | class JwtConfig { 10 | 11 | // todo! 1. yaml 분리 후 2. repository 외부로 값을 추출할 것 12 | private val SECRET_KEY_STRING = "abc 123 abc 123 abc 123 abc 123 abc 123 abc 123 abc 123 abc 123 abc 123 abc 123" 13 | private val secretKey = Keys.hmacShaKeyFor(SECRET_KEY_STRING.toByteArray(StandardCharsets.UTF_8)) 14 | private val expirationDurationTime: Long = 60 * 60 * 1000 15 | 16 | @Bean 17 | fun jwtManager() = JwtManager(secretKey, expirationDurationTime) 18 | } -------------------------------------------------------------------------------- /micro-services/coupon/src/main/kotlin/io/philo/shop/scheduler/CouponEventLoader.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.scheduler 2 | 3 | import io.philo.shop.service.CouponEventService 4 | import mu.KotlinLogging 5 | import org.springframework.scheduling.annotation.Scheduled 6 | import org.springframework.stereotype.Component 7 | 8 | @Component 9 | class CouponEventLoader(private val couponEventService: CouponEventService) { 10 | 11 | private val log = KotlinLogging.logger { } 12 | 13 | @Scheduled(fixedDelay = 1_000) 14 | fun loadEventToBroker() { 15 | couponEventService.loadEventToBroker() 16 | } 17 | 18 | @Scheduled(fixedDelay = 1_000) 19 | fun loadCompensatingEventToBroker() { 20 | couponEventService.loadCompensatingEventToBroker() 21 | } 22 | 23 | } -------------------------------------------------------------------------------- /api-gateway/src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | server.port: 8000 2 | 3 | 4 | spring: 5 | application.name: api-gateway 6 | main.web-application-type: reactive # 내장 서버를 tomcat이 아닌 netty로 7 | 8 | eureka: 9 | instance: 10 | instance-id: ${spring.application.name}:${spring.application.instance_id:${random.value}} 11 | # lease-expiration-duration-in-seconds: 1 12 | 13 | client: 14 | enabled: true 15 | service-url: 16 | defaultZone: http://localhost:8761/eureka 17 | register-with-eureka: true 18 | fetch-registry: true 19 | registry-fetch-interval-seconds: 3 20 | 21 | # Spring Actuator 22 | management: 23 | endpoints: 24 | web: 25 | exposure: 26 | include: refresh, beans, health, info 27 | 28 | constant: 29 | test.value: something-001 30 | -------------------------------------------------------------------------------- /common/general/src/main/kotlin/io/philo/shop/entity/OutboxBaseEntity.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.entity 2 | 3 | import jakarta.persistence.Column 4 | import jakarta.persistence.MappedSuperclass 5 | 6 | @MappedSuperclass 7 | abstract class OutboxBaseEntity( 8 | 9 | @Column(nullable = false) 10 | val traceId: Long, // 분산 트랜잭션에서의 고유 ID (이 프로젝트에서는 주로 orderId이다) 11 | 12 | @Column(nullable = false) 13 | val requesterId: Long, 14 | 15 | ) : BaseEntity() { 16 | 17 | @Column(nullable = false) 18 | private var loaded: Boolean = false // 발송 여부 19 | 20 | fun load() { 21 | this.loaded = true 22 | } 23 | } 24 | 25 | /** 26 | * ID -> Entity 에 대응하는 Map을 만든다 27 | */ 28 | fun List.toMap(): Map = 29 | this.associateBy { it.traceId } -------------------------------------------------------------------------------- /micro-services/item/src/main/kotlin/io/philo/shop/messagequeue/consumer/ItemEventListenerDeprecated.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.messagequeue.consumer 2 | 3 | import io.philo.shop.domain.service.ItemService 4 | import io.philo.shop.order.OrderCreatedEventDeprecated 5 | import mu.KotlinLogging 6 | 7 | //@Component 8 | @Deprecated("out box 패턴을 사용하면서 불필요하게 된 이벤트 리스너") 9 | class ItemEventListenerDeprecated(private val itemService: ItemService) { 10 | 11 | private val log = KotlinLogging.logger { } 12 | 13 | // @RabbitListener(queues = [RabbitConfig.QUEUE_NAME]) 14 | fun listenEvent(event: OrderCreatedEventDeprecated) { 15 | 16 | log.info { "$event" } 17 | 18 | val itemIdToDecreaseQuantity = event.values() 19 | 20 | itemService.decreaseItemsDeprecated(itemIdToDecreaseQuantity) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /common/general/src/main/kotlin/io/philo/shop/entity/BaseEntity.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.entity 2 | 3 | import jakarta.persistence.* 4 | import org.springframework.data.annotation.CreatedDate 5 | import org.springframework.data.annotation.LastModifiedDate 6 | import java.time.LocalDateTime 7 | import java.time.LocalDateTime.now 8 | 9 | @MappedSuperclass 10 | abstract class BaseEntity { 11 | 12 | @Id 13 | @GeneratedValue(strategy = GenerationType.IDENTITY) 14 | @Column(name = "id") 15 | private var _id: Long? = null 16 | val id: Long 17 | get() = _id!! 18 | 19 | 20 | @field:CreatedDate 21 | @field:Column(nullable = false) 22 | val createdAt: LocalDateTime = now() 23 | 24 | @field:LastModifiedDate 25 | @field:Column(nullable = false) 26 | var updatedAt: LocalDateTime = now() 27 | } -------------------------------------------------------------------------------- /micro-services/coupon/src/main/kotlin/io/philo/shop/domain/core/UserCouponEntity.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.domain.core 2 | 3 | import io.philo.shop.entity.BaseEntity 4 | import jakarta.persistence.* 5 | 6 | @Entity 7 | @Table(name = "user_coupon") 8 | class UserCouponEntity( 9 | 10 | @field:Column(nullable = false) 11 | val userId: Long, 12 | 13 | @field:Column(nullable = false) 14 | var isUse: Boolean = false // 사용 여부 15 | 16 | ):BaseEntity() { 17 | 18 | @JoinColumn(name = "coupon_id", nullable = false) 19 | @ManyToOne(fetch = FetchType.LAZY) 20 | lateinit var coupon: CouponEntity 21 | 22 | protected constructor() : this(0L) 23 | 24 | fun useCoupon() { 25 | this.isUse = true 26 | } 27 | 28 | fun changeToUsable() { 29 | this.isUse = false 30 | } 31 | } 32 | 33 | -------------------------------------------------------------------------------- /micro-services/coupon/src/main/kotlin/io/philo/shop/domain/core/CouponEntity.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.domain.core 2 | 3 | import io.philo.shop.entity.BaseEntity 4 | import jakarta.persistence.* 5 | import java.time.LocalDate 6 | 7 | @Entity 8 | @Table(name = "coupon") 9 | @Inheritance(strategy = InheritanceType.SINGLE_TABLE) 10 | abstract class CouponEntity( 11 | 12 | @field:Column(nullable = false) 13 | val name: String = "", 14 | 15 | @field:Column(nullable = false) 16 | val expirationStartAt: LocalDate = LocalDate.now(), 17 | 18 | @field:Column(nullable = false) 19 | val expirationEndAt: LocalDate = LocalDate.now().plusDays(30) 20 | 21 | ): BaseEntity() { 22 | 23 | val order : Int 24 | get() = order() 25 | 26 | abstract fun order(): Int 27 | 28 | abstract fun discount(itemAmount: Int): Int 29 | } -------------------------------------------------------------------------------- /micro-services/order/src/main/kotlin/io/philo/shop/scheduler/OrderEventLoader.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.scheduler 2 | 3 | import io.philo.shop.application.OrderEventService 4 | import mu.KotlinLogging 5 | import org.springframework.scheduling.annotation.Scheduled 6 | import org.springframework.stereotype.Component 7 | 8 | /** 9 | * OutBox에 저장한 이벤트를 브로커에 적재하는 역할 10 | */ 11 | @Component 12 | class OrderEventLoader( 13 | private val orderEventService: OrderEventService 14 | ) { 15 | 16 | private val log = KotlinLogging.logger { } 17 | 18 | @Scheduled(fixedDelay = 1_000) 19 | fun loadEventToBroker() { 20 | orderEventService.loadEventToBroker() 21 | } 22 | 23 | @Scheduled(fixedDelay = 1_000) 24 | fun loadCompensatingEventToBroker() { 25 | orderEventService.loadCompensatingEventToBroker() 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /micro-services/user/src/test/kotlin/io/philo/integration/not_success_case/UserAcceptanceTest_Fail.kt: -------------------------------------------------------------------------------- 1 | package io.philo.integration.not_success_case 2 | 3 | import io.kotest.matchers.shouldBe 4 | import io.philo.shop.presentation.dto.create.UserCreateRequestDto 5 | import io.philo.shop.presentation.dto.create.UserCreateResponseDto 6 | 7 | class UserAcceptanceTest_Fail() : AcceptanceTest_Fail({ 8 | 9 | "사용자 생성 및 암호화 검증" { 10 | val requestBody = UserCreateRequestDto( 11 | email = "jason0101@example.com", 12 | name = "jason", 13 | address = "seoul yongsangu", 14 | password = "1234" 15 | ) 16 | 17 | val response = post(uri = "/users", body = requestBody) 18 | 19 | val userId = response.`as`(UserCreateResponseDto::class.java).id 20 | 21 | (userId > 0) shouldBe true 22 | } 23 | }) -------------------------------------------------------------------------------- /micro-services/coupon/src/main/kotlin/io/philo/shop/domain/core/RatioDiscountCouponEntity.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.domain.core 2 | 3 | import jakarta.persistence.Entity 4 | 5 | /** 6 | * 비율 할인 7 | */ 8 | @Entity 9 | class RatioDiscountCouponEntity(name: String, val discountPercent: Int) : CouponEntity(name = name) { 10 | 11 | protected constructor() : this(name = "", discountPercent = 50) 12 | 13 | init { 14 | require(discountPercent in 5..95) { 15 | "쿠폰의 할인율은 5에서 95 (%) 사이여야 합니다." 16 | } 17 | } 18 | 19 | override fun order() = 2 20 | 21 | /** 22 | * 할인된 금액을 계산 23 | * 24 | * 상품가 10,000에 10%일 경우 25 | * 26 | * 10,000 * 90% = 9,000 27 | */ 28 | override fun discount(itemAmount: Int): Int { 29 | return (itemAmount * ((100 - discountPercent).toDouble() / 100)).toInt() 30 | } 31 | } -------------------------------------------------------------------------------- /micro-services/order/src/main/kotlin/io/philo/shop/scheduler/OrderEventCompleteProcessor.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.scheduler 2 | 3 | import io.philo.shop.application.OrderEventService 4 | import mu.KotlinLogging 5 | import org.springframework.scheduling.annotation.Scheduled 6 | import org.springframework.stereotype.Component 7 | 8 | /** 9 | * 협력 마이크로 서비스가 검증 결과들을 모두 수신 후 이벤트 완료처리하는 역할 10 | */ 11 | @Component 12 | class OrderEventCompleteProcessor(private val orderEventService: OrderEventService) { 13 | 14 | private val log = KotlinLogging.logger { } 15 | 16 | @Scheduled(fixedDelay = 1_000) 17 | fun processOrderCreatedEvent() { 18 | 19 | orderEventService.processOrderCreatedVerified() 20 | } 21 | 22 | @Scheduled(fixedDelay = 1_000) 23 | fun processOrderFailedEvent() { 24 | 25 | orderEventService.processOrderFailedEvent() 26 | } 27 | 28 | } -------------------------------------------------------------------------------- /micro-services/coupon/src/main/kotlin/io/philo/shop/domain/outbox/CouponOutBoxRepository.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.domain.outbox 2 | 3 | import org.springframework.data.jpa.repository.JpaRepository 4 | import org.springframework.data.jpa.repository.Query 5 | 6 | interface CouponOutBoxRepository: JpaRepository { 7 | 8 | fun findAllByLoadedIsFalse(): List 9 | 10 | @Query(""" 11 | select ob 12 | from CouponOutboxEntity ob 13 | where ob.loaded = false 14 | and ob.isCompensatingTx = false 15 | """) 16 | fun findAllToNormalTx(): List 17 | 18 | @Query(""" 19 | select ob 20 | from CouponOutboxEntity ob 21 | where ob.loaded = false 22 | and ob.isCompensatingTx = true 23 | """) 24 | fun findAllToCompensatingTx(): List 25 | } -------------------------------------------------------------------------------- /micro-services/coupon/src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | server.port: 0 2 | 3 | spring: 4 | application: 5 | name: COUPON-SERVICE 6 | 7 | jpa: 8 | hibernate.ddl-auto: create-drop 9 | 10 | config.import: # this is highlighted as red, but working normally 11 | - classpath:/data-init-config.yml 12 | - classpath:/rabbitmq.yml 13 | 14 | datasource: 15 | url: jdbc:mysql://localhost:3306/coupon-db?useSSL=false 16 | driver-class-name: com.mysql.cj.jdbc.Driver 17 | username: coupon-user 18 | password: 1234 19 | 20 | eureka: 21 | instance: 22 | instance-id: ${spring.application.name}:${spring.application.instance_id:${random.value}} 23 | client: 24 | enabled: true 25 | service-url: 26 | defaultZone: http://localhost:8761/eureka 27 | register-with-eureka: true 28 | fetch-registry: true 29 | registry-fetch-interval-seconds: 5 30 | -------------------------------------------------------------------------------- /micro-services/coupon/src/test/kotlin/io/philo/shop/unit/CouponTest.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.unit 2 | 3 | import io.kotest.core.spec.style.StringSpec 4 | import io.kotest.matchers.shouldBe 5 | import io.philo.shop.domain.core.FixedDiscountCouponEntity 6 | import io.philo.shop.domain.core.RatioDiscountCouponEntity 7 | import io.philo.shop.service.CouponDiscountCalculator.Companion.calculateDiscountAmount 8 | 9 | class CouponTest : StringSpec({ 10 | 11 | 12 | "할인된 금액을 계산한다" { 13 | 14 | // given 15 | val coupon_1 = RatioDiscountCouponEntity(discountPercent = 10) 16 | val coupon_2 = FixedDiscountCouponEntity(discountAmount = 1_000) 17 | 18 | // when 19 | // (10,000 - 1,000) * 90% = 8,100 20 | val discountedAmount = calculateDiscountAmount(10_000, coupon_1, coupon_2) 21 | 22 | // then 23 | discountedAmount shouldBe 8100 24 | } 25 | }) 26 | -------------------------------------------------------------------------------- /micro-services/coupon/src/main/resources/data.sql: -------------------------------------------------------------------------------- 1 | INSERT INTO coupon (id, dtype, name, discount_amount, discount_percent, expiration_start_at, expiration_end_at, created_at, updated_at) 2 | VALUES (1, 'FixedDiscountCouponEntity', '첫 가입 3,000원 할인 쿠폰', 3000, null, now(), ADDDATE(NOW(), 30), now(), now()), 3 | (2, 'RatioDiscountCouponEntity', '초신사 생일 15% 할인 쿠폰', null, 15, now(), ADDDATE(NOW(), 30), now(), now()); 4 | 5 | INSERT INTO user_coupon (id, user_id, coupon_id, is_use, created_at, updated_at) 6 | VALUES (1, 1, 1, false, now(), now()), 7 | (2, 1, 2, false, now(), now()), 8 | (3, 2, 1, false, now(), now()), 9 | (4, 2, 2, false, now(), now()); 10 | 11 | insert into item_replica (id, item_id, item_amount, created_at, updated_at) 12 | values (1, 1, 49800, now(), now()), 13 | (2, 2, 49800, now(), now()), 14 | (3, 3, 49800, now(), now()), 15 | (4, 4, 245000, now(), now()) -------------------------------------------------------------------------------- /micro-services/order/src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | server.port: 0 2 | 3 | spring: 4 | application: 5 | name: ORDER-SERVICE 6 | 7 | jpa.hibernate.ddl-auto: create-drop 8 | 9 | config.import: # this is highlighted as red, but working normally 10 | - classpath:/data-init-config.yml 11 | - classpath:/rabbitmq.yml 12 | 13 | datasource: 14 | url: jdbc:mysql://localhost:3306/order-db?useSSL=false 15 | driver-class-name: com.mysql.cj.jdbc.Driver 16 | username: order-user 17 | password: 1234 18 | 19 | eureka: 20 | instance: 21 | instance-id: ${spring.application.name}:${spring.application.instance_id:${random.value}} 22 | leaseRenewalIntervalInSeconds: 1 23 | client: 24 | enabled: true 25 | service-url: 26 | defaultZone: http://127.0.0.1:8761/eureka 27 | fetch-registry: true 28 | register-with-eureka: true 29 | registry-fetch-interval-seconds: 1 30 | -------------------------------------------------------------------------------- /micro-services/item/src/main/kotlin/io/philo/shop/repository/ItemOutBoxRepository.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.repository 2 | 3 | import io.philo.shop.domain.outbox.ItemOutboxEntity 4 | import org.springframework.data.jpa.repository.JpaRepository 5 | import org.springframework.data.jpa.repository.Query 6 | 7 | interface ItemOutBoxRepository : JpaRepository { 8 | 9 | // fun findAllByLoadedIsFalse(): List 10 | 11 | @Query(""" 12 | select ob 13 | from ItemOutboxEntity ob 14 | where ob.loaded = false 15 | and ob.isCompensatingTx = false 16 | """) 17 | fun findAllToNormalTx(): List 18 | 19 | @Query(""" 20 | select ob 21 | from ItemOutboxEntity ob 22 | where ob.loaded = false 23 | and ob.isCompensatingTx = true 24 | """) 25 | fun findAllToCompensatingTx(): List 26 | } -------------------------------------------------------------------------------- /micro-services/item/src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | server.port: 8081 2 | 3 | spring: 4 | application: 5 | name: ITEM-SERVICE 6 | 7 | jpa: 8 | hibernate.ddl-auto: create-drop 9 | 10 | config.import: # this is highlighted as red, but working normally 11 | - classpath:/data-init-config.yml 12 | - classpath:/rabbitmq.yml 13 | 14 | datasource: 15 | url: jdbc:mysql://localhost:3306/item-db?useSSL=false 16 | driver-class-name: com.mysql.cj.jdbc.Driver 17 | username: item-user 18 | password: 1234 19 | 20 | eureka: 21 | instance: 22 | instance-id: ${spring.application.name}:${spring.application.instance_id:${random.value}} 23 | # lease-renewal-interval-in-seconds: 1 24 | client: 25 | enabled: true 26 | service-url: 27 | defaultZone: http://localhost:8761/eureka 28 | fetch-registry: true 29 | register-with-eureka: true 30 | registry-fetch-interval-seconds: 3 31 | -------------------------------------------------------------------------------- /micro-services/coupon/src/main/kotlin/io/philo/shop/domain/core/FixedDiscountCouponEntity.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.domain.core 2 | 3 | import jakarta.persistence.Entity 4 | 5 | /** 6 | * 고정 할인 7 | */ 8 | @Entity 9 | class FixedDiscountCouponEntity(name: String, val discountAmount: Int) : CouponEntity(name = name) { 10 | 11 | protected constructor() : this(name = "", discountAmount = 0) 12 | 13 | init { 14 | require(discountAmount >= 0) { 15 | "할인액은 음수가 될 수 없습니다" 16 | } 17 | 18 | } 19 | 20 | override fun order() = 1 21 | 22 | override fun discount(itemAmount: Int): Int { 23 | validateDiscount(itemAmount) 24 | return itemAmount - discountAmount 25 | } 26 | 27 | private fun validateDiscount(itemAmount: Int) { 28 | check(itemAmount / 2 >= discountAmount) { 29 | "할인액이 너무 많습니다. (고정 금액 쿠폰 하나로 상품가의 50% 이상의 할인은 불가합니다.)" 30 | } 31 | } 32 | } -------------------------------------------------------------------------------- /micro-services/order/src/main/kotlin/io/philo/shop/repository/OrderCreatedOutboxRepository.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.repository 2 | 3 | import io.philo.shop.domain.outbox.OrderCreatedOutboxEntity 4 | import org.springframework.data.jpa.repository.JpaRepository 5 | import org.springframework.data.jpa.repository.Query 6 | 7 | interface OrderCreatedOutboxRepository : JpaRepository { 8 | 9 | fun findAllByLoadedIsFalse(): List 10 | 11 | fun findByTraceId(orderId: Long): OrderCreatedOutboxEntity? 12 | 13 | @Query( 14 | """ 15 | select o 16 | from OrderCreatedOutboxEntity o 17 | where o.loaded = true 18 | and o.itemValidated <> io.philo.shop.common.VerificationStatus.PENDING 19 | and o.couponValidated <> io.philo.shop.common.VerificationStatus.PENDING 20 | """ 21 | ) 22 | fun findAllToCompleteEvent(): List 23 | } -------------------------------------------------------------------------------- /micro-services/user/src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | server.port: 9090 2 | 3 | spring: 4 | application: 5 | name: USER-SERVICE 6 | 7 | jpa: 8 | hibernate.ddl-auto: create-drop 9 | 10 | config.import: # this is highlighted as red, but working normally 11 | - classpath:/data-init-config.yml 12 | - classpath:/rabbitmq.yml 13 | 14 | datasource: 15 | url: jdbc:mysql://localhost:3306/user-db?useSSL=false 16 | driver-class-name: com.mysql.cj.jdbc.Driver 17 | username: user-user 18 | password: 1234 19 | 20 | eureka: 21 | instance: 22 | instance-id: ${spring.application.name}:${spring.application.instance_id:${random.value}} 23 | client: 24 | service-url: 25 | defaultZone: http://localhost:8761/eureka 26 | enabled: true 27 | fetch-registry: true 28 | register-with-eureka: true 29 | 30 | # Spring Actuator 31 | management: 32 | endpoints: 33 | web: 34 | exposure: 35 | include: refresh, beans, health, info -------------------------------------------------------------------------------- /module-dependencies/micro-service.gradle: -------------------------------------------------------------------------------- 1 | dependencies { 2 | // Spring 3 | implementation 'org.springframework.boot:spring-boot-starter-web' 4 | testImplementation 'org.springframework.boot:spring-boot-starter-test' 5 | 6 | // JPA 7 | implementation 'org.springframework.boot:spring-boot-starter-data-jpa' 8 | 9 | // Service Registry Client 10 | implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client' 11 | 12 | // AMQP 13 | implementation 'org.springframework.boot:spring-boot-starter-amqp' 14 | 15 | // Feign Client 16 | implementation 'org.springframework.cloud:spring-cloud-starter-openfeign:4.0.0' 17 | 18 | // H2 19 | runtimeOnly 'com.h2database:h2' 20 | 21 | // GSON 22 | implementation 'com.google.code.gson:gson:2.10' 23 | 24 | // E2E test 25 | testImplementation 'io.rest-assured:rest-assured' 26 | 27 | // p6spy, prettier binding parameter 28 | implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.8.1' 29 | } 30 | -------------------------------------------------------------------------------- /micro-services/item/src/main/kotlin/io/philo/shop/presentation/ItemInternalController.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.presentation 2 | 3 | import io.philo.shop.domain.service.ItemService 4 | import io.philo.shop.item.dto.ItemInternalResponseDto 5 | import mu.KotlinLogging 6 | import org.springframework.web.bind.annotation.GetMapping 7 | import org.springframework.web.bind.annotation.RequestMapping 8 | import org.springframework.web.bind.annotation.RequestParam 9 | import org.springframework.web.bind.annotation.RestController 10 | 11 | @RequestMapping("/items/internal") 12 | @RestController 13 | class ItemInternalController(private val itemService: ItemService) { 14 | 15 | private val log = KotlinLogging.logger { } 16 | 17 | /** 18 | * @param itemIds null일 경우 모두 조회 19 | */ 20 | @GetMapping 21 | fun list(@RequestParam(value = "ids") itemIds: List): List { 22 | 23 | val dtos: List = itemService.findItemsForInternal(itemIds) 24 | 25 | return dtos 26 | } 27 | } -------------------------------------------------------------------------------- /micro-services/coupon/src/main/kotlin/io/philo/shop/domain/core/UserCouponRepository.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.domain.core 2 | 3 | import org.springframework.data.jpa.repository.EntityGraph 4 | import org.springframework.data.jpa.repository.JpaRepository 5 | import org.springframework.data.jpa.repository.Query 6 | import org.springframework.data.repository.query.Param 7 | 8 | interface UserCouponRepository : JpaRepository { 9 | 10 | fun findByUserId(userId: Long) 11 | 12 | fun findAllByUserIdAndCouponIdIn(userId: Long, couponIds:List): List 13 | 14 | /** 15 | * 활성화된 모든 쿠폰 찾기 16 | * 17 | * todo 일자까지 고려하기 18 | */ 19 | @EntityGraph(attributePaths = ["coupon"]) 20 | @Query(""" 21 | select uc 22 | from UserCouponEntity as uc 23 | join fetch uc.coupon c 24 | where uc.userId = :userId 25 | and uc.id in :userCouponIds 26 | and uc.isUse = false 27 | """) 28 | fun findAllUsable(@Param("userId") userId: Long, @Param("userCouponIds") userCouponIds:List): List 29 | } 30 | 31 | -------------------------------------------------------------------------------- /micro-services/order/src/main/kotlin/io/philo/shop/query/OrderQuery.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.query 2 | 3 | import io.philo.shop.domain.core.OrderEntity 4 | import io.philo.shop.dto.web.OrderDetailResponse 5 | import io.philo.shop.dto.web.OrderListResponse 6 | import io.philo.shop.dto.web.OrderListResponses 7 | import io.philo.shop.repository.OrderRepository 8 | import org.springframework.stereotype.Component 9 | 10 | @Component 11 | class OrderQuery(private val orderRepository: OrderRepository) { 12 | 13 | fun list(): OrderListResponses { 14 | val savedItems = orderRepository.findAll() 15 | return convertListDtos(savedItems); 16 | } 17 | 18 | fun detail(id: Long): OrderDetailResponse { 19 | val entitiy = orderRepository.findById(id).orElseThrow { IllegalArgumentException("존재하지 않는 주문입니다.") } 20 | return OrderDetailResponse(entitiy) 21 | } 22 | 23 | private fun convertListDtos(savedItems: List): OrderListResponses { 24 | val dtos = savedItems 25 | .map(::OrderListResponse) 26 | .toList() 27 | return OrderListResponses(dtos) 28 | } 29 | } -------------------------------------------------------------------------------- /micro-services/order/src/main/kotlin/io/philo/shop/repository/OrderFailedOutboxRepository.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.repository 2 | 3 | import io.philo.shop.domain.outbox.OrderCreatedOutboxEntity 4 | import io.philo.shop.domain.outbox.OrderFailedOutboxEntity 5 | import org.springframework.data.jpa.repository.JpaRepository 6 | import org.springframework.data.jpa.repository.Query 7 | 8 | interface OrderFailedOutboxRepository : JpaRepository { 9 | fun findAllByLoadedIsFalse(): List 10 | fun findByTraceId(orderId: Long): OrderFailedOutboxEntity? 11 | 12 | @Query( 13 | """ 14 | select o 15 | from OrderFailedOutboxEntity o 16 | where o.loaded = true 17 | and ( o.isCompensatingItem = false or ( o.isCompensatingItem = true and o.itemValidated <> io.philo.shop.common.VerificationStatus.SUCCESS )) 18 | and ( o.isCompensatingCoupon = false or ( o.isCompensatingCoupon = true and o.couponValidated <> io.philo.shop.common.VerificationStatus.SUCCESS )) 19 | """ 20 | ) 21 | fun findAllToCompleteEvent(): List 22 | } -------------------------------------------------------------------------------- /micro-services/item/src/test/kotlin/io/philo/shop/item/domain/ItemTest.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.item.domain 2 | 3 | import io.kotest.assertions.throwables.shouldThrow 4 | import io.kotest.core.spec.style.StringSpec 5 | import io.kotest.matchers.shouldBe 6 | import io.philo.shop.domain.entity.ItemEntity 7 | 8 | class ItemTest : StringSpec({ 9 | 10 | "구매한 수량만큼 재고가 감소한다" { 11 | 12 | // given 13 | val itemEntity = ItemEntity.fixture 14 | 15 | // when 16 | itemEntity.decreaseStockQuantity(2) 17 | 18 | // then 19 | itemEntity.stockQuantity shouldBe 2 20 | } 21 | 22 | "재고 수량보다 많은 수량을 구매할 수 없다" { 23 | 24 | // given 25 | val itemEntity = ItemEntity.fixture 26 | 27 | // when 28 | val exception = shouldThrow { 29 | itemEntity.decreaseStockQuantity(5) 30 | } 31 | 32 | // then 33 | exception.message shouldBe "주문수량에 비해 상품의 재고수량이 충분하지 않습니다." 34 | } 35 | }) 36 | 37 | val ItemEntity.Companion.fixture: ItemEntity 38 | get() = ItemEntity(name = "척 70 클래식 블랙 컨버스", size = "270", price = 86_000, stockQuantity = 4) 39 | -------------------------------------------------------------------------------- /common/event/src/main/kotlin/io/philo/shop/CouponRabbitProperty.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop 2 | 3 | import io.philo.shop.item.ItemRabbitProperty 4 | 5 | class CouponRabbitProperty { 6 | 7 | companion object { 8 | /** 9 | * 주문 생성 검증에 대한 응답 10 | */ 11 | private const val COUPON_VERIFY_RES_TOPIC_PREFIX = "coupon.verification.for.created.order" 12 | const val COUPON_VERIFY_RES_QUEUE_NAME = "$COUPON_VERIFY_RES_TOPIC_PREFIX.queue" 13 | const val COUPON_VERIFY_RES_EXCHANGE_NAME = "$COUPON_VERIFY_RES_TOPIC_PREFIX.exchange" 14 | const val COUPON_VERIFY_RES_ROUTING_KEY = "$COUPON_VERIFY_RES_TOPIC_PREFIX.routing.#" 15 | 16 | /** 17 | * 주문 실패 검증에 대한 응답 18 | */ 19 | private const val COUPON_VERIFY_FAIL_RES_TOPIC_PREFIX = "item.verification.for.failed.order" 20 | const val COUPON_VERIFY_FAIL_RES_QUEUE_NAME = "$COUPON_VERIFY_FAIL_RES_TOPIC_PREFIX.queue" 21 | const val COUPON_VERIFY_FAIL_RES_EXCHANGE_NAME = "$COUPON_VERIFY_FAIL_RES_TOPIC_PREFIX.exchange" 22 | const val COUPON_VERIFY_FAIL_RES_ROUTING_KEY = "$COUPON_VERIFY_FAIL_RES_TOPIC_PREFIX.routing.#" 23 | } 24 | } -------------------------------------------------------------------------------- /api-gateway/src/test/kotlin/io/philo/shop/integartion/RoutingMappingTest.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.integartion 2 | 3 | import io.restassured.RestAssured 4 | import org.junit.jupiter.api.BeforeEach 5 | import org.junit.jupiter.api.Test 6 | import org.springframework.boot.test.context.SpringBootTest 7 | import org.springframework.boot.test.web.server.LocalServerPort 8 | 9 | /** 10 | * 라우팅이 잘 이루어졌는지 테스트 11 | */ 12 | @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) 13 | class RoutingMappingTest { 14 | 15 | @LocalServerPort 16 | var port = 0 17 | 18 | // @Autowired 19 | // lateinit var routeDefinitionLocator: RouteDefinitionLocator 20 | 21 | // @Autowired 22 | // lateinit var routeLocator: RouteLocator 23 | 24 | @BeforeEach 25 | protected fun setUp() { 26 | RestAssured.port = port 27 | } 28 | 29 | @Test 30 | fun `라우트 목록을 테스트한다`() { 31 | println("") 32 | // val routeDefinitions = routeDefinitionLocator.getRouteDefinitions() 33 | // println("routeDefinitionLocator = ${routeDefinitionLocator}") 34 | 35 | // val routes: Flux = routeLocator.routes 36 | // println("routes = ${routes}") 37 | } 38 | } -------------------------------------------------------------------------------- /api-gateway/src/main/kotlin/io/philo/shop/support/GwHttpMessageConfig.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.support 2 | 3 | import org.springframework.beans.factory.annotation.Autowired 4 | import org.springframework.boot.autoconfigure.http.HttpMessageConverters 5 | import org.springframework.context.ApplicationContext 6 | import org.springframework.context.annotation.Bean 7 | import org.springframework.context.annotation.Configuration 8 | import org.springframework.http.converter.StringHttpMessageConverter 9 | import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter 10 | 11 | /** 12 | * API Gateway 13 | */ 14 | @Configuration 15 | class GwHttpMessageConfig { 16 | 17 | @Autowired 18 | lateinit var applicationContext: ApplicationContext 19 | 20 | // @PostConstruct 21 | // fun init() { 22 | // val bean = applicationContext.getBean(HttpMessageConverters::class.java) 23 | // println("bean = ${bean}") 24 | // println("") 25 | // } 26 | 27 | @Bean 28 | fun httpMessageConverters() : HttpMessageConverters { 29 | 30 | return HttpMessageConverters( 31 | MappingJackson2HttpMessageConverter(), 32 | StringHttpMessageConverter() 33 | ) 34 | } 35 | } -------------------------------------------------------------------------------- /micro-services/user/src/main/kotlin/io/philo/shop/presentation/UserInternalController.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.presentation 2 | 3 | import io.philo.shop.domain.service.UserService 4 | import io.philo.shop.user.dto.UserPassportResponse 5 | import mu.KotlinLogging 6 | import org.springframework.http.HttpHeaders.AUTHORIZATION 7 | import org.springframework.web.bind.annotation.GetMapping 8 | import org.springframework.web.bind.annotation.RequestHeader 9 | import org.springframework.web.bind.annotation.RequestMapping 10 | import org.springframework.web.bind.annotation.RestController 11 | 12 | @RestController 13 | @RequestMapping("/user/internal") 14 | class UserInternalController(private val userService: UserService) { 15 | 16 | val log = KotlinLogging.logger { } 17 | 18 | @GetMapping("/valid-token") 19 | fun isValidToken(@RequestHeader(AUTHORIZATION) accessToken: String): Boolean { 20 | 21 | return userService.isValidToken(accessToken) 22 | } 23 | 24 | @GetMapping("/passport") 25 | fun passport(@RequestHeader(AUTHORIZATION) accessToken: String): UserPassportResponse { 26 | 27 | log.info { "accessToken=$accessToken" } 28 | 29 | return userService.passport(accessToken) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /micro-services/coupon/src/main/kotlin/io/philo/shop/messagequeue/producer/CouponEventPublisher.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.messagequeue.producer 2 | 3 | import io.philo.shop.CouponRabbitProperty.Companion.COUPON_VERIFY_FAIL_RES_EXCHANGE_NAME 4 | import io.philo.shop.CouponRabbitProperty.Companion.COUPON_VERIFY_FAIL_RES_ROUTING_KEY 5 | import io.philo.shop.CouponRabbitProperty.Companion.COUPON_VERIFY_RES_EXCHANGE_NAME 6 | import io.philo.shop.CouponRabbitProperty.Companion.COUPON_VERIFY_RES_ROUTING_KEY 7 | import io.philo.shop.common.InAppEventPublisher 8 | import io.philo.shop.common.OrderChangedVerifiedEvent 9 | import org.springframework.amqp.rabbit.core.RabbitTemplate 10 | 11 | @InAppEventPublisher 12 | class CouponEventPublisher(private val rabbitTemplate: RabbitTemplate) { 13 | 14 | /** 15 | * 주문생성시 검증 요청한 값을 전송 16 | */ 17 | fun publishEvent(event: OrderChangedVerifiedEvent) = 18 | rabbitTemplate.convertAndSend(COUPON_VERIFY_RES_EXCHANGE_NAME, COUPON_VERIFY_RES_ROUTING_KEY, event) 19 | 20 | /** 21 | * 주문 실패시 검증 요청한 값 전송 22 | */ 23 | fun publishEventForFail(event: OrderChangedVerifiedEvent) = 24 | rabbitTemplate.convertAndSend(COUPON_VERIFY_FAIL_RES_EXCHANGE_NAME, COUPON_VERIFY_FAIL_RES_ROUTING_KEY, event) 25 | 26 | } -------------------------------------------------------------------------------- /micro-services/coupon/src/main/kotlin/io/philo/shop/service/CouponService.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.service 2 | 3 | import io.philo.shop.domain.core.CouponRepository 4 | import io.philo.shop.domain.core.UserCouponRepository 5 | import io.philo.shop.error.BadRequestException 6 | import io.philo.shop.item.ItemRestClientFacade 7 | import mu.KotlinLogging 8 | import org.springframework.stereotype.Service 9 | import org.springframework.transaction.annotation.Transactional 10 | 11 | @Service 12 | @Transactional(readOnly = false) 13 | class CouponService( 14 | private val itemClient: ItemRestClientFacade, 15 | private val couponRepository: CouponRepository, 16 | private val userCouponRepository: UserCouponRepository 17 | ) { 18 | 19 | private val log = KotlinLogging.logger { } 20 | 21 | @Transactional 22 | fun createCoupon(): Unit { 23 | 24 | // couponRepository.save() 25 | } 26 | 27 | fun calculateAmountForInternal(userId: Long, reqUserCouponIds: List): Int { 28 | 29 | val itemDtos = itemClient.requestItems(reqUserCouponIds) 30 | 31 | val userCoupons = userCouponRepository.findAllUsable(userId, reqUserCouponIds) 32 | if(reqUserCouponIds.size != userCoupons.size) 33 | throw BadRequestException("사용자의 쿠폰에 대해 검증이 실패하였습니다.") 34 | 35 | return -1 36 | } 37 | } -------------------------------------------------------------------------------- /micro-services/item/src/main/kotlin/io/philo/shop/messagequeue/consumer/ItemEventListener.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.messagequeue.consumer 2 | 3 | import io.philo.shop.common.InAppEventListener 4 | import io.philo.shop.domain.service.ItemEventService 5 | import io.philo.shop.order.OrderChangedEvent 6 | import io.philo.shop.order.OrderRabbitProperty.Companion.ORDER_CREATED_TO_ITEM_QUEUE_NAME 7 | import io.philo.shop.order.OrderRabbitProperty.Companion.ORDER_FAILED_TO_ITEM_QUEUE_NAME 8 | import mu.KotlinLogging 9 | import org.springframework.amqp.rabbit.annotation.RabbitListener 10 | 11 | @InAppEventListener 12 | class ItemEventListener(private val itemEventService: ItemEventService) { 13 | 14 | private val log = KotlinLogging.logger { } 15 | 16 | /** 17 | * 주문 생성 이벤트 수신처 18 | */ 19 | @RabbitListener(queues = [ORDER_CREATED_TO_ITEM_QUEUE_NAME]) 20 | fun listenOrderCreatedEvent(event: OrderChangedEvent) { 21 | 22 | log.info { "$event" } 23 | 24 | itemEventService.listenOrderCreatedEvent(event) 25 | } 26 | 27 | /** 28 | * 주문 실패 이벤트 수신처 29 | */ 30 | @RabbitListener(queues = [ORDER_FAILED_TO_ITEM_QUEUE_NAME]) 31 | fun listenOrderFailedEvent(event: OrderChangedEvent) { 32 | 33 | log.info { "$event" } 34 | 35 | itemEventService.listenOrderFailedEvent(event) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /micro-services/order/src/main/kotlin/io/philo/shop/domain/core/OrderCouponsEntity.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.domain.core 2 | 3 | import io.philo.shop.constant.ITEM_COUPON_SIZE_APPLY_VALIDATION_MESSAGE 4 | import io.philo.shop.entity.BaseEntity 5 | import jakarta.persistence.Column 6 | import jakarta.persistence.Entity 7 | import jakarta.persistence.OneToOne 8 | import jakarta.persistence.Table 9 | 10 | @Entity 11 | @Table(name = "order_coupons") 12 | class OrderCouponsEntity( 13 | 14 | @Column 15 | val userCouponId1: Long? = null, 16 | 17 | @Column 18 | val userCouponId2: Long? = null, 19 | 20 | @OneToOne(mappedBy = "coupons") 21 | var orderLineItemEntity: OrderLineItemEntity, 22 | 23 | ) : BaseEntity() { 24 | 25 | protected constructor() : this(null, null, OrderLineItemEntity.empty) 26 | 27 | 28 | companion object { 29 | 30 | /** 31 | * ids가 없는 경우(null)도 null-safe 처리한다. 32 | */ 33 | fun of(orderLineEntity: OrderLineItemEntity, userCouponIds: List?): OrderCouponsEntity? { 34 | 35 | if (userCouponIds.isNullOrEmpty()) return null 36 | require(userCouponIds.size in 1..2) { ITEM_COUPON_SIZE_APPLY_VALIDATION_MESSAGE } 37 | 38 | val couponId1 = userCouponIds.getOrNull(0) 39 | val couponId2 = userCouponIds.getOrNull(1) 40 | 41 | return OrderCouponsEntity(couponId1, couponId2, orderLineEntity) 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /micro-services/order/src/main/kotlin/io/philo/shop/ui/OrderController.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.ui 2 | 3 | import io.philo.shop.application.OrderService 4 | import io.philo.shop.constant.SecurityConstant.Companion.LOGIN_USER_ID 5 | import io.philo.shop.dto.ResourceCreateResponse 6 | import io.philo.shop.dto.web.OrderCreateRequest 7 | import io.philo.shop.dto.web.OrderDetailResponse 8 | import io.philo.shop.dto.web.OrderListResponses 9 | import io.philo.shop.query.OrderQuery 10 | import lombok.RequiredArgsConstructor 11 | import org.springframework.http.HttpStatus.CREATED 12 | import org.springframework.web.bind.annotation.* 13 | 14 | @RestController 15 | @RequestMapping("/orders") 16 | @RequiredArgsConstructor 17 | class OrderController(private val orderService: OrderService, 18 | private val orderQuery: OrderQuery 19 | ) { 20 | 21 | @PostMapping 22 | @ResponseStatus(CREATED) 23 | fun order(@RequestBody request: OrderCreateRequest, 24 | @RequestHeader(LOGIN_USER_ID) requesterId: Long): ResourceCreateResponse { 25 | 26 | val orderLineRequests = request.orderLineRequestDtos 27 | val orderId = orderService.order(orderLineRequests, requesterId) 28 | 29 | return ResourceCreateResponse(orderId) 30 | } 31 | 32 | @GetMapping 33 | fun list(): OrderListResponses { 34 | 35 | return orderQuery.list() 36 | } 37 | 38 | @GetMapping("/id") 39 | fun detail(@PathVariable("id") id: Long): OrderDetailResponse { 40 | 41 | return orderQuery.detail(id) 42 | } 43 | } -------------------------------------------------------------------------------- /micro-services/coupon/src/main/kotlin/io/philo/shop/query/CouponQuery.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.query 2 | 3 | import io.philo.shop.domain.core.CouponRepository 4 | import io.philo.shop.domain.core.UserCouponRepository 5 | import io.philo.shop.domain.replica.ItemReplicaRepository 6 | import io.philo.shop.error.EntityNotFoundException 7 | import io.philo.shop.presentation.dto.CouponAppliedAmountResponseDto 8 | import io.philo.shop.service.CouponDiscountCalculator 9 | import org.springframework.stereotype.Component 10 | import org.springframework.transaction.annotation.Transactional 11 | 12 | @Component 13 | class CouponQuery( 14 | private val couponRepository: CouponRepository, 15 | private val userCouponRepository: UserCouponRepository, 16 | private val itemReplicaRepository: ItemReplicaRepository, 17 | ) { 18 | 19 | @Transactional(readOnly = false) 20 | fun calculateAmount( 21 | userId: Long, 22 | itemId: Long, 23 | userCouponIds: List, 24 | ): CouponAppliedAmountResponseDto { 25 | 26 | val item = itemReplicaRepository.findById(itemId).orElseThrow { EntityNotFoundException(itemId) } 27 | val userCoupons = userCouponRepository.findAllByUserIdAndCouponIdIn(userId, userCouponIds) 28 | val couponIds = userCoupons.map { it.coupon.id!! }.toList() 29 | val coupons = couponRepository.findAllByIdIn(couponIds) 30 | 31 | val discountAmount = CouponDiscountCalculator.calculateDiscountAmount(item.itemAmount, coupons) 32 | 33 | return CouponAppliedAmountResponseDto(discountAmount) 34 | } 35 | } -------------------------------------------------------------------------------- /common/event/src/main/kotlin/io/philo/shop/item/ItemRabbitProperty.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.item 2 | 3 | class ItemRabbitProperty { 4 | 5 | companion object { 6 | 7 | /** 8 | * 주문 생성 검증에 대한 응답 9 | */ 10 | private const val ITEM_VERIFY_RES_TOPIC_PREFIX = "item.verification.for.created.order" 11 | const val ITEM_VERIFY_RES_QUEUE_NAME = "$ITEM_VERIFY_RES_TOPIC_PREFIX.queue" 12 | const val ITEM_VERIFY_RES_EXCHANGE_NAME = "$ITEM_VERIFY_RES_TOPIC_PREFIX.exchange" 13 | const val ITEM_VERIFY_RES_ROUTING_KEY = "$ITEM_VERIFY_RES_TOPIC_PREFIX.routing.#" 14 | 15 | /** 16 | * 주문 실패 검증에 대한 응답 17 | */ 18 | private const val ITEM_VERIFY_FAIL_RES_TOPIC_PREFIX = "item.verification.for.failed.order" 19 | const val ITEM_VERIFY_FAIL_RES_QUEUE_NAME = "$ITEM_VERIFY_FAIL_RES_TOPIC_PREFIX.queue" 20 | const val ITEM_VERIFY_FAIL_RES_EXCHANGE_NAME = "$ITEM_VERIFY_FAIL_RES_TOPIC_PREFIX.exchange" 21 | const val ITEM_VERIFY_FAIL_RES_ROUTING_KEY = "$ITEM_VERIFY_FAIL_RES_TOPIC_PREFIX.routing.#" 22 | 23 | /** 24 | * 쿠폰과 데이터 동기화를 하기 위한 상품 이벤트 25 | */ 26 | private const val ITEM_REPLICA_FOR_COUPON_TOPIC_PREFIX = "item.replica.for.coupon" 27 | const val ITEM_REPLICA_FOR_COUPON_QUEUE_NAME = "$ITEM_REPLICA_FOR_COUPON_TOPIC_PREFIX.queue" 28 | const val ITEM_REPLICA_FOR_COUPON_RES_EXCHANGE_NAME = "$ITEM_REPLICA_FOR_COUPON_TOPIC_PREFIX.exchange" 29 | const val ITEM_REPLICA_FOR_COUPON_RES_ROUTING_KEY = "$ITEM_REPLICA_FOR_COUPON_TOPIC_PREFIX.routing.#" 30 | } 31 | } -------------------------------------------------------------------------------- /micro-services/coupon/src/main/kotlin/io/philo/shop/service/CouponDiscountCalculator.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.service 2 | 3 | import io.philo.shop.domain.core.CouponEntity 4 | 5 | class CouponDiscountCalculator { 6 | 7 | companion object { 8 | 9 | /** 10 | * 상품 할인액을 계산한다. 11 | * 이때, 비율할인보다 고정할인을 먼저 계산한다. 12 | * 이렇게 하는 이유는 쇼핑몰 입장에서 감액되는 금액을 적게 하기 위함이다. 13 | * 14 | * 수학적 직관적으로 해석하면 15 | * 고정할인은 상품금액과 무관하게 할인액이 항상 일정하지만 16 | * 비율할인은 상품금액에 따라 할인액이 비례한다. 17 | * 18 | * 예를 들어, 19 | * 가격이 10,000원인 상품에 대해 20 | * 20% 할인 후 2,000원 할인하면 6,000원이지만 21 | * 역순으로 하면 6,400원으로 더 크다. (이는 어떤 숫자가 오더라도 항상 참이다.) 22 | */ 23 | fun calculateDiscountAmount(itemAmount: Int, vararg couponEntities: CouponEntity): Int { 24 | 25 | return calculateDiscountAmount(itemAmount, couponEntities.toList()) 26 | } 27 | 28 | /** 29 | * couponEntities가 List 자료형으로 올 것을 고려하여 생성한 API 30 | * @see calculateDiscountAmount 31 | */ 32 | fun calculateDiscountAmount(itemAmount: Int, couponEntities: List): Int { 33 | 34 | val discountedAmount = 35 | couponEntities 36 | .toList() 37 | .sortedBy { it.order } 38 | .fold(itemAmount) { nextAmount, coupon -> coupon.discount(nextAmount) } 39 | 40 | require(discountedAmount >= 0) { 41 | "상품 가격은 음수가 될 수 없습니다 (${discountedAmount})" 42 | } 43 | 44 | return discountedAmount 45 | } 46 | 47 | } 48 | } -------------------------------------------------------------------------------- /micro-services/order/src/main/kotlin/io/philo/shop/domain/outbox/OrderCreatedOutboxEntity.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.domain.outbox 2 | 3 | import io.philo.shop.common.VerificationStatus 4 | import io.philo.shop.common.VerificationStatus.* 5 | import io.philo.shop.entity.OutboxBaseEntity 6 | import jakarta.persistence.Column 7 | import jakarta.persistence.Entity 8 | import jakarta.persistence.EnumType.STRING 9 | import jakarta.persistence.Enumerated 10 | import jakarta.persistence.Table 11 | 12 | /** 13 | * 주문 생성 후 이벤트 발행을 하기 위한 이벤트 저장 테이블 14 | */ 15 | @Entity 16 | @Table(name = "order_created_outbox") 17 | class OrderCreatedOutboxEntity(traceId:Long, requesterId:Long) : OutboxBaseEntity(traceId, requesterId) { 18 | 19 | @Enumerated(STRING) 20 | @Column(nullable = false) 21 | var itemValidated: VerificationStatus = PENDING // 상품 서비스 유효성 체크 22 | 23 | @Enumerated(STRING) 24 | @Column(nullable = false) 25 | var couponValidated: VerificationStatus = PENDING // 쿠폰 서비스 유효성 체크 26 | 27 | 28 | protected constructor () : this(0L, 0L) 29 | 30 | fun changeItemValidated(verification: Boolean) { 31 | this.itemValidated = VerificationStatus.of(verification) 32 | } 33 | 34 | fun changeCouponValidated(verification: Boolean) { 35 | this.couponValidated = VerificationStatus.of(verification) 36 | } 37 | 38 | val isSuccess: Boolean 39 | get() = itemValidated == SUCCESS && couponValidated == SUCCESS 40 | 41 | val isCanceled: Boolean 42 | get() = itemValidated == FAIL && couponValidated == FAIL 43 | 44 | val isDone: Boolean 45 | get() = itemValidated != PENDING && couponValidated != PENDING 46 | } 47 | -------------------------------------------------------------------------------- /micro-services/user/src/main/kotlin/io/philo/shop/domain/entity/UserEntity.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.domain.entity 2 | 3 | import io.philo.shop.PasswordEncoder 4 | import io.philo.shop.entity.BaseEntity 5 | import jakarta.persistence.Column 6 | import jakarta.persistence.Entity 7 | import jakarta.persistence.Table 8 | import jakarta.persistence.UniqueConstraint 9 | 10 | @Entity 11 | @Table( 12 | name = "users", 13 | uniqueConstraints = [ 14 | UniqueConstraint(name = "unique__users__email", columnNames = ["email"]) 15 | ] 16 | ) 17 | class UserEntity private constructor( 18 | 19 | @Column(nullable = false) 20 | var email: String = "", 21 | 22 | @Column(nullable = false) 23 | var name: String = "", 24 | 25 | @Column(nullable = false) 26 | var address: String = "", 27 | 28 | @Column(nullable = false) 29 | var encodedPassword: String = "", 30 | 31 | ) : BaseEntity() { 32 | 33 | protected constructor() : this("", ", ", "") 34 | 35 | fun isSamePassword(rawPassword: String?): Boolean { 36 | return PasswordEncoder.isSamePassword(rawPassword, encodedPassword) 37 | } 38 | 39 | override fun toString(): String { 40 | return "UserEntity(id=$id, email='$email', name='$name', address='$address')" 41 | } 42 | 43 | companion object { 44 | 45 | @JvmStatic 46 | fun of(email: String, name: String, address: String, rawPassword: String) = 47 | UserEntity( 48 | email = email, 49 | name = name, 50 | address = address, 51 | encodedPassword = PasswordEncoder.encodePassword(rawPassword) 52 | ) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | 3 | ### Java template 4 | # Compiled class file 5 | *.class 6 | 7 | # Log file 8 | *.log 9 | 10 | # BlueJ files 11 | *.ctxt 12 | 13 | # Mobile Tools for Java (J2ME) 14 | .mtj.tmp/ 15 | 16 | # Package Files # 17 | *.jar 18 | *.war 19 | *.nar 20 | *.ear 21 | *.zip 22 | *.tar.gz 23 | *.rar 24 | 25 | # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml 26 | hs_err_pid* 27 | replay_pid* 28 | 29 | 30 | 31 | # CMake 32 | cmake-build-*/ 33 | 34 | # Mongo Explorer plugin 35 | .idea/**/mongoSettings.xml 36 | 37 | # File-based project format 38 | *.iws 39 | 40 | # IntelliJ 41 | out/ 42 | 43 | # mpeltonen/sbt-idea plugin 44 | .idea_modules/ 45 | 46 | # JIRA plugin 47 | atlassian-ide-plugin.xml 48 | 49 | # Cursive Clojure plugin 50 | .idea/replstate.xml 51 | 52 | # SonarLint plugin 53 | .idea/sonarlint/ 54 | 55 | # Crashlytics plugin (for Android Studio and IntelliJ) 56 | com_crashlytics_export_strings.xml 57 | crashlytics.properties 58 | crashlytics-build.properties 59 | fabric.properties 60 | 61 | # Editor-based Rest Client 62 | .idea/httpRequests 63 | 64 | # Android studio 3.1+ serialized cache file 65 | .idea/caches/build_file_checksums.ser 66 | 67 | ### Gradle template 68 | .gradle 69 | **/build/ 70 | !src/**/build/ 71 | 72 | # Ignore Gradle GUI config 73 | gradle-app.setting 74 | 75 | # Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) 76 | !gradle-wrapper.jar 77 | 78 | # Avoid ignore Gradle wrappper properties 79 | !gradle-wrapper.properties 80 | 81 | # Cache of project 82 | .gradletasknamecache 83 | 84 | # Eclipse Gradle plugin generated files 85 | # Eclipse Core 86 | .project 87 | # JDT-specific (Eclipse Java Development Tools) 88 | .classpath 89 | -------------------------------------------------------------------------------- /api-gateway/src/main/kotlin/io/philo/shop/filter/GlobalLoggingFilter.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.filter 2 | 3 | import mu.KotlinLogging 4 | import org.springframework.cloud.gateway.filter.GatewayFilterChain 5 | import org.springframework.cloud.gateway.filter.GlobalFilter 6 | import org.springframework.core.Ordered 7 | import org.springframework.stereotype.Component 8 | import org.springframework.web.server.ServerWebExchange 9 | import reactor.core.publisher.Mono 10 | 11 | @Component 12 | //@Order(-1) 13 | class GlobalLoggingFilter : GlobalFilter, Ordered { 14 | 15 | private val log = KotlinLogging.logger { } 16 | 17 | override fun filter(exchange: ServerWebExchange, chain: GatewayFilterChain): Mono { 18 | 19 | val request = exchange.request 20 | 21 | log.info { 22 | """ 23 | [ Start ] 24 | id: ${request.id} 25 | path: ${request.path} 26 | uri: ${request.uri} 27 | method: ${request.method} 28 | localAddress: ${request.localAddress} 29 | remoteAddress: ${request.remoteAddress} 30 | """.trimIndent() 31 | } 32 | 33 | return chain.filter(exchange).then( 34 | Mono.fromRunnable { 35 | val response = exchange.response 36 | log.info { """ 37 | [ End ] 38 | id: ${request.id} 39 | response.status: ${response.statusCode} 40 | response.isCommitted: ${response.isCommitted} 41 | rawStatusCode: ${response.rawStatusCode} 42 | """.trimIndent() } 43 | }) 44 | } 45 | 46 | override fun getOrder(): Int { 47 | return 1 48 | } 49 | } -------------------------------------------------------------------------------- /micro-services/item/src/main/kotlin/io/philo/shop/presentation/ItemController.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.presentation 2 | 3 | import com.google.gson.Gson 4 | import io.philo.shop.domain.service.ItemService 5 | import io.philo.shop.dto.ResourceCreateResponse 6 | import io.philo.shop.presentation.dto.ItemCreateRequest 7 | import io.philo.shop.presentation.dto.ItemResponse 8 | import io.philo.shop.presentation.dto.ItemResponses 9 | import io.philo.shop.user.dto.UserPassportResponse 10 | import mu.KotlinLogging 11 | import org.springframework.http.HttpStatus.CREATED 12 | import org.springframework.web.bind.annotation.* 13 | 14 | @RequestMapping("/items") 15 | @RestController 16 | class ItemController(private val itemService: ItemService) { 17 | 18 | private val log = KotlinLogging.logger { } 19 | 20 | /** 21 | * 상품 등록 22 | */ 23 | @PostMapping 24 | @ResponseStatus(CREATED) 25 | fun add(@RequestBody request: ItemCreateRequest): ResourceCreateResponse { 26 | 27 | val (name, size, price, stockQuantity) = request 28 | 29 | val itemId = itemService.registerItem(name, size, price, stockQuantity) 30 | 31 | return ResourceCreateResponse(itemId) 32 | } 33 | 34 | /** 35 | * @param itemIds null일 경우 모두 조회 36 | */ 37 | @GetMapping 38 | fun list(@RequestParam(name = "ids", required = false) itemIds: List?, 39 | @RequestHeader(name = "user-passport", required = false) userHeader: String? 40 | ): ItemResponses { 41 | 42 | log.info { "# user header: ${userHeader}" } 43 | 44 | val gson = Gson() 45 | val json = gson.fromJson(userHeader, UserPassportResponse::class.java) 46 | 47 | val items: List = itemService.findItems(itemIds) 48 | 49 | return ItemResponses(items) 50 | } 51 | } -------------------------------------------------------------------------------- /micro-services/coupon/src/main/kotlin/io/philo/shop/messagequeue/CouponRabbitConfig.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.messagequeue 2 | 3 | import io.philo.shop.CouponRabbitProperty.Companion.COUPON_VERIFY_RES_EXCHANGE_NAME 4 | import io.philo.shop.CouponRabbitProperty.Companion.COUPON_VERIFY_RES_QUEUE_NAME 5 | import io.philo.shop.CouponRabbitProperty.Companion.COUPON_VERIFY_RES_ROUTING_KEY 6 | import org.springframework.amqp.core.Binding 7 | import org.springframework.amqp.core.BindingBuilder 8 | import org.springframework.amqp.core.DirectExchange 9 | import org.springframework.amqp.core.Queue 10 | import org.springframework.amqp.rabbit.connection.ConnectionFactory 11 | import org.springframework.amqp.rabbit.core.RabbitTemplate 12 | import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter 13 | import org.springframework.context.annotation.Bean 14 | import org.springframework.context.annotation.Configuration 15 | 16 | @Configuration 17 | class CouponRabbitConfig { 18 | 19 | @Bean 20 | fun couponVerifyResQueue() = Queue(COUPON_VERIFY_RES_QUEUE_NAME) 21 | 22 | @Bean 23 | fun couponVerifyResExchange() = DirectExchange(COUPON_VERIFY_RES_EXCHANGE_NAME) 24 | 25 | @Bean 26 | fun couponVerifyResBinding( 27 | queue: Queue, 28 | exchange: DirectExchange, 29 | ): Binding = 30 | BindingBuilder 31 | .bind(queue) 32 | .to(exchange) 33 | .with(COUPON_VERIFY_RES_ROUTING_KEY) 34 | 35 | @Bean 36 | fun rabbitTemplate( 37 | connectionFactory: ConnectionFactory, // 위에서 다 주입된 Caching 구현체로, port, username등 다 포함됨 38 | messageConverter: Jackson2JsonMessageConverter, 39 | ): RabbitTemplate { 40 | 41 | val rabbitTemplate = RabbitTemplate(connectionFactory) 42 | rabbitTemplate.messageConverter = messageConverter 43 | 44 | return rabbitTemplate 45 | } 46 | } -------------------------------------------------------------------------------- /micro-services/order/src/main/kotlin/io/philo/shop/domain/core/OrderLineItemEntity.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.domain.core 2 | 3 | import io.philo.shop.entity.BaseEntity 4 | import jakarta.persistence.* 5 | import lombok.ToString 6 | 7 | @Entity 8 | @Table(name = "order_line_item") 9 | class OrderLineItemEntity( 10 | 11 | @field:Column(nullable = false, length = 100) 12 | val itemId: Long, 13 | 14 | @field:Column(nullable = false) 15 | val itemRawAmount: Int, // 주문 당시의 상품 가격 16 | 17 | @field:Column(nullable = false) 18 | val itemDiscountedAmount: Int, // 주문 당시의 할인된 상품 가격 19 | 20 | @field:Column(nullable = false) 21 | val orderedQuantity: Int, // 주문 수량 22 | 23 | ): BaseEntity() { 24 | 25 | @JoinColumn(name = "order_id", nullable = false) 26 | @ManyToOne(fetch = FetchType.LAZY) 27 | lateinit var orderEntity: OrderEntity 28 | 29 | @JoinColumn(name = "coupon_id", nullable = true) 30 | @OneToOne(cascade = [CascadeType.ALL], orphanRemoval = true) 31 | var coupons: OrderCouponsEntity? = null 32 | 33 | protected constructor() : this(0L, 0, 0, 0) 34 | 35 | fun mapOrder(orderEntity: OrderEntity) { 36 | this.orderEntity = orderEntity 37 | } 38 | 39 | val useCoupon: Boolean 40 | get() = this.coupons != null 41 | 42 | /** 43 | * null인 경우도 고려되었다. (null-safe) 44 | */ 45 | fun initUserCoupon(userCouponIds: List?) { 46 | this.coupons = OrderCouponsEntity.of(this, userCouponIds) 47 | } 48 | 49 | override fun toString(): String { 50 | return "OrderLineItemEntity(itemId=$itemId, itemRawAmount=$itemRawAmount, itemDiscountedAmount=$itemDiscountedAmount, orderedQuantity=$orderedQuantity, id=$id, useCoupon=$useCoupon)" 51 | } 52 | 53 | companion object { 54 | val empty: OrderLineItemEntity 55 | get() = OrderLineItemEntity() 56 | } 57 | } -------------------------------------------------------------------------------- /common/security/src/test/kotlin/io/philo/shop/JwtManagerTest.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop 2 | 3 | import io.jsonwebtoken.security.Keys 4 | import io.kotest.core.spec.style.StringSpec 5 | import io.kotest.matchers.shouldBe 6 | import io.philo.shop.JwtManager.Companion.ENCODING_ALGORITHM 7 | import java.nio.charset.StandardCharsets 8 | 9 | class JwtManagerTest:StringSpec( { 10 | 11 | val secretKey = Keys.hmacShaKeyFor(SECRET_KEY_STRING.toByteArray(StandardCharsets.UTF_8)) 12 | val jwtManager = JwtManager(secretKey, ONE_DAY_IN_SECONDS) 13 | 14 | 15 | "토큰을_만들고_검증한다" { 16 | // given 17 | val 원본_문자열 = "유일한 어떤 것" 18 | 19 | // when 20 | val accessToken = jwtManager.createAccessToken(원본_문자열) 21 | val 복호한_값 = jwtManager.parse(accessToken) 22 | 23 | // then 24 | 복호한_값 shouldBe 원본_문자열 25 | } 26 | 27 | "토큰의_유효성을_검증한다" { 28 | // given 29 | val 원본_문자열 = "유일한 어떤 것" 30 | val 정상_토큰 = jwtManager.createAccessToken(원본_문자열) 31 | val 변조된_토큰 = 정상_토큰 + "a" 32 | 33 | jwtManager.isValidToken(정상_토큰) shouldBe true 34 | jwtManager.isValidToken(변조된_토큰) shouldBe false 35 | } 36 | 37 | "만일 HS512로 만든 두 개의 SecretKey를 사용해서 암/복화하면 정상적으로 수행되지 않는다" { 38 | // given 39 | val jwtManager1 = JwtManager(Keys.secretKeyFor(ENCODING_ALGORITHM), ONE_DAY_IN_SECONDS) 40 | val jwtManager2 = JwtManager(Keys.secretKeyFor(ENCODING_ALGORITHM), ONE_DAY_IN_SECONDS) 41 | 42 | val 원본_문자열 = "유일한 어떤 것" 43 | val 정상_토큰 = jwtManager1.createAccessToken(원본_문자열) 44 | 45 | jwtManager2.isValidToken(정상_토큰) shouldBe false 46 | } 47 | }) { 48 | companion object { 49 | private val SECRET_KEY_STRING = "abc 123 abc 123 abc 123 abc 123 abc 123 abc 123 abc 123 abc 123 abc 123 abc 123" 50 | private val ONE_DAY_IN_SECONDS = 86400000L // 하루를 초로 표현한 것 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /api-gateway/src/main/kotlin/io/philo/shop/config/RouteConfig_backup.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.config 2 | 3 | import io.philo.shop.filter.GlobalLoggingFilter 4 | import org.springframework.cloud.gateway.route.Route 5 | import org.springframework.cloud.gateway.route.RouteLocator 6 | import org.springframework.cloud.gateway.route.builder.Buildable 7 | import org.springframework.cloud.gateway.route.builder.GatewayFilterSpec 8 | import org.springframework.cloud.gateway.route.builder.PredicateSpec 9 | import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder 10 | 11 | //@Configuration 12 | class RouteConfig_backup(private val globalLoggingFilter: GlobalLoggingFilter) { 13 | 14 | // @Bean 15 | fun routes(builder: RouteLocatorBuilder, globalLoggingFilter: GlobalLoggingFilter): RouteLocator { 16 | return builder.routes() 17 | // .route { it.route("ITEM-SERVICE", "/items") } 18 | .route { it.route("ITEM-SERVICE", "/items") } 19 | .route { it.route("ORDER-SERVICE", "/orders") } 20 | .build() 21 | } 22 | 23 | private fun PredicateSpec.route(serviceName: String, path: String): Buildable = 24 | this.path("$path/**") 25 | .filters { it.buildFilter(path) } 26 | .uri("lb://${serviceName}") 27 | 28 | private fun GatewayFilterSpec.buildFilter(path: String): GatewayFilterSpec = 29 | this.removeRequestHeader("Cookie") 30 | // .filter(loggingFilter) 31 | /** 32 | * success ex. /item-service/items/1 -> /items/1 33 | * fail ex. /items -> / 34 | */ 35 | // .rewritePath("$path/(?.*)", "/\${segment}") 36 | /** 37 | * success ex. /items/1 -> /items/1 38 | * fail ex. /item-service/items/1 -> /items/1 39 | */ 40 | // .rewritePath(path, "/") 41 | } 42 | -------------------------------------------------------------------------------- /micro-services/user/src/main/kotlin/io/philo/shop/presentation/UserController.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.presentation 2 | 3 | import io.philo.shop.domain.repository.UserRepository 4 | import io.philo.shop.domain.service.UserService 5 | import io.philo.shop.presentation.dto.create.UserCreateRequestDto 6 | import io.philo.shop.presentation.dto.create.UserCreateResponseDto 7 | import io.philo.shop.presentation.dto.create.UserListResponseDto 8 | import io.philo.shop.presentation.dto.login.UserLoginRequest 9 | import org.springframework.http.HttpHeaders.AUTHORIZATION 10 | import org.springframework.http.ResponseEntity 11 | import org.springframework.web.bind.annotation.* 12 | 13 | @RestController 14 | @RequestMapping("/users") 15 | class UserController( 16 | private val userService: UserService, 17 | private val userRepository: UserRepository, 18 | ) { 19 | 20 | @PostMapping("/login") 21 | fun login(@RequestBody request: UserLoginRequest): ResponseEntity<*> { 22 | 23 | val accessToken = userService.login(request.email, request.password) 24 | 25 | return ResponseEntity.ok() 26 | .header(AUTHORIZATION, accessToken) 27 | .body("User logged in successfully. See response header") 28 | } 29 | 30 | @PostMapping 31 | fun create(@RequestBody request: UserCreateRequestDto): UserCreateResponseDto { 32 | 33 | val userId = userService.createUser( 34 | request.email, 35 | request.name, 36 | request.address, 37 | request.password 38 | ) 39 | 40 | return UserCreateResponseDto(userId) 41 | } 42 | 43 | /** 44 | * todo! 페이징 처리하기 45 | */ 46 | @GetMapping 47 | fun list(): List { 48 | val entities = userRepository.findAll() 49 | 50 | return entities 51 | .map { UserListResponseDto(it!!) } 52 | .toList() 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /api-gateway/src/main/kotlin/io/philo/shop/presentation/ApiGatewayController.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.presentation 2 | 3 | import com.netflix.discovery.EurekaClient 4 | import com.netflix.discovery.shared.Application 5 | import org.springframework.beans.factory.annotation.Value 6 | import org.springframework.cloud.client.ServiceInstance 7 | import org.springframework.cloud.client.discovery.DiscoveryClient 8 | import org.springframework.cloud.context.config.annotation.RefreshScope 9 | import org.springframework.cloud.gateway.route.RouteLocator 10 | import org.springframework.web.bind.annotation.GetMapping 11 | import org.springframework.web.bind.annotation.RequestMapping 12 | import org.springframework.web.bind.annotation.RestController 13 | 14 | @RestController 15 | @RequestMapping("/api-gateway") 16 | @RefreshScope 17 | class ApiGatewayController( 18 | @Value("\${constant.test.value}") 19 | private val testConstantValue: String, 20 | private val routeLocator: RouteLocator, 21 | private val discoveryClient: DiscoveryClient, 22 | private val eurekaClient: EurekaClient 23 | ) { 24 | 25 | @GetMapping("/env-test") 26 | fun testConstantValue(): String { 27 | return "check value for dynamical refresh test: $testConstantValue" 28 | } 29 | 30 | @GetMapping("/route-info") 31 | fun routeLocator(): RouteLocator { 32 | return routeLocator 33 | } 34 | 35 | @GetMapping("/routes/service-instances") 36 | fun instances3(): Map> { 37 | return discoveryClient.services.associateWith { serviceId -> 38 | discoveryClient.getInstances(serviceId) 39 | } 40 | } 41 | 42 | @GetMapping("/routes/applications") 43 | fun instances4(): Map { 44 | return discoveryClient.services.associateWith { serviceId -> 45 | eurekaClient.getApplication(serviceId) 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /api-gateway/src/main/kotlin/io/philo/shop/filter/AbstractAuthorizationFilter.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.filter 2 | 3 | import io.philo.shop.constant.SecurityConstant.Companion.TOKEN_PREFIX 4 | import io.philo.shop.error.UnauthorizedException 5 | import mu.KotlinLogging 6 | import org.springframework.cloud.gateway.filter.GatewayFilterChain 7 | import org.springframework.http.HttpHeaders 8 | import org.springframework.web.server.ServerWebExchange 9 | import reactor.core.publisher.Mono 10 | 11 | abstract class AbstractAuthorizationFilter { 12 | 13 | 14 | private val log = KotlinLogging.logger { } 15 | 16 | /** 17 | * JWT 에 대해 유효성 검증을 하고 토큰을 추출합니다 18 | */ 19 | protected open fun validateAndExtractAccessToken(exchange: ServerWebExchange): String { 20 | 21 | val request = exchange.request 22 | val headerValues = request.headers[HttpHeaders.AUTHORIZATION] 23 | 24 | if (headerValues.isNullOrEmpty()) 25 | throw UnauthorizedException("Bearer prefix가 존재하지 않습니다.") 26 | 27 | val tokenWithPrefix = findOrThrow(headerValues) 28 | 29 | return formatToken(tokenWithPrefix) 30 | } 31 | 32 | private fun formatToken(tokenWithPrefix: String) = tokenWithPrefix 33 | .replace(TOKEN_PREFIX, "") 34 | .removeAllWhiteSpaces() 35 | .trim() 36 | 37 | private fun findOrThrow(headerValues: List) = (headerValues 38 | .find { it.contains(TOKEN_PREFIX) } 39 | ?: throw UnauthorizedException("Bearer prefix가 존재하지 않습니다.")) 40 | 41 | protected fun proceedNextFilter(chain: GatewayFilterChain, exchange: ServerWebExchange): Mono { 42 | // return chain.filter(exchange) 43 | return chain.filter(exchange).then(Mono.fromRunnable { 44 | exchange.response 45 | 46 | log.info { "this is Auth Post Process" } 47 | }) 48 | } 49 | 50 | private fun String.removeAllWhiteSpaces() = this.replace("\\s+", "") 51 | } -------------------------------------------------------------------------------- /micro-services/item/src/test/kotlin/io/philo/shop/item/integartion/ItemIntegrationTest.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.item.integartion 2 | 3 | import io.kotest.matchers.shouldBe 4 | import io.kotest.matchers.shouldNotBe 5 | import io.philo.shop.AcceptanceTest 6 | import io.philo.shop.domain.entity.ItemEntity 7 | import io.philo.shop.dto.ResourceCreateResponse 8 | import io.philo.shop.presentation.dto.ItemCreateRequest 9 | import io.philo.shop.presentation.dto.ItemResponse 10 | import io.philo.shop.presentation.dto.ItemResponses 11 | import org.junit.jupiter.api.Test 12 | 13 | class ItemIntegrationTest : AcceptanceTest() { 14 | 15 | 16 | @Test 17 | fun `재고 등록 및 조회`() { 18 | 19 | // given 20 | val requestBody = ItemCreateRequest.fixture 21 | 22 | // when 23 | val itemId = postAndGetBody("/items", requestBody).id 24 | val responseBody: ItemResponses = getAndGetBody("/items?ids=$itemId") 25 | 26 | // then 27 | val foundItems: List = responseBody.items 28 | 29 | itemId shouldNotBe null 30 | foundItems shouldNotBe null 31 | 32 | val foundItem = foundItems[0] 33 | 34 | foundItem.size shouldBe ItemCreateRequest.fixture.size 35 | foundItem.name shouldBe ItemCreateRequest.fixture.name 36 | } 37 | 38 | val ItemCreateRequest.Companion.fixture: ItemCreateRequest 39 | get() { 40 | 41 | val entity = ItemEntity.fixture 42 | 43 | return ItemCreateRequest( 44 | name = entity.name, 45 | size = entity.size, 46 | price = entity.price, 47 | stockQuantity = entity.stockQuantity 48 | ) 49 | } 50 | 51 | val ItemEntity.Companion.fixture 52 | get() = ItemEntity(name = "컨셉원 슬랙스 BLACK 30", 53 | size = "30", 54 | price = 70_000, 55 | stockQuantity = 500) 56 | } -------------------------------------------------------------------------------- /micro-services/item/src/main/kotlin/io/philo/shop/messagequeue/producer/ItemEventPublisher.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.messagequeue.producer 2 | 3 | import io.philo.shop.common.InAppEventPublisher 4 | import io.philo.shop.common.OrderChangedVerifiedEvent 5 | import io.philo.shop.domain.entity.ItemEntity 6 | import io.philo.shop.item.ItemCreatedEvent 7 | import io.philo.shop.item.ItemRabbitProperty.Companion.ITEM_REPLICA_FOR_COUPON_RES_EXCHANGE_NAME 8 | import io.philo.shop.item.ItemRabbitProperty.Companion.ITEM_REPLICA_FOR_COUPON_RES_ROUTING_KEY 9 | import io.philo.shop.item.ItemRabbitProperty.Companion.ITEM_VERIFY_FAIL_RES_EXCHANGE_NAME 10 | import io.philo.shop.item.ItemRabbitProperty.Companion.ITEM_VERIFY_FAIL_RES_ROUTING_KEY 11 | import io.philo.shop.item.ItemRabbitProperty.Companion.ITEM_VERIFY_RES_EXCHANGE_NAME 12 | import io.philo.shop.item.ItemRabbitProperty.Companion.ITEM_VERIFY_RES_ROUTING_KEY 13 | import org.springframework.amqp.rabbit.core.RabbitTemplate 14 | 15 | @InAppEventPublisher 16 | class ItemEventPublisher(private val rabbitTemplate: RabbitTemplate) { 17 | 18 | /** 19 | * 주문 성공시 검증 요청한 값 전송 20 | */ 21 | fun publishEvent(event: OrderChangedVerifiedEvent) = 22 | rabbitTemplate.convertAndSend(ITEM_VERIFY_RES_EXCHANGE_NAME, ITEM_VERIFY_RES_ROUTING_KEY, event) 23 | 24 | /** 25 | * 주문 실패시 검증 요청한 값 전송 26 | */ 27 | fun publishEventForFail(event: OrderChangedVerifiedEvent) = 28 | rabbitTemplate.convertAndSend(ITEM_VERIFY_FAIL_RES_EXCHANGE_NAME, ITEM_VERIFY_FAIL_RES_ROUTING_KEY, event) 29 | 30 | fun publishEvent(event: ItemCreatedEvent) = 31 | rabbitTemplate.convertAndSend(ITEM_REPLICA_FOR_COUPON_RES_EXCHANGE_NAME, ITEM_REPLICA_FOR_COUPON_RES_ROUTING_KEY, event) 32 | 33 | 34 | private fun publishEventToBroker(message: ItemCreatedEvent, routingKey: String) = 35 | rabbitTemplate.convertAndSend(routingKey, message) 36 | } 37 | 38 | fun ItemEntity.toEvent() = ItemCreatedEvent(this.id!!, this.price) 39 | -------------------------------------------------------------------------------- /api-gateway/src/main/kotlin/io/philo/shop/filter/AuthorizationVerificationFilter.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.filter 2 | 3 | import io.philo.shop.JwtManager 4 | import io.philo.shop.constant.SecurityConstant.Companion.LOGIN_USER_ID 5 | import io.philo.shop.error.UnauthorizedException 6 | import org.springframework.cloud.gateway.filter.GatewayFilter 7 | import org.springframework.cloud.gateway.filter.GatewayFilterChain 8 | import org.springframework.core.Ordered 9 | import org.springframework.http.server.reactive.ServerHttpRequest 10 | import org.springframework.stereotype.Component 11 | import org.springframework.web.server.ServerWebExchange 12 | import reactor.core.publisher.Mono 13 | 14 | /** 15 | * API Gateway 기본 Edge 기능인 인증/인가 필터 16 | */ 17 | @Component 18 | class AuthorizationVerificationFilter(private val jwtManager: JwtManager) : AbstractAuthorizationFilter(), GatewayFilter, Ordered { 19 | 20 | 21 | override fun filter(exchange: ServerWebExchange, chain: GatewayFilterChain): Mono { 22 | 23 | val accessToken = validateAndExtractAccessToken(exchange) 24 | 25 | val userId = accessToken.validateAndParse() 26 | val modifiedExchange = exchange setLoginUserId userId 27 | 28 | return proceedNextFilter(chain, modifiedExchange) 29 | } 30 | 31 | override fun getOrder(): Int { 32 | return 2 33 | } 34 | 35 | private infix fun ServerWebExchange.setLoginUserId(userId: String): ServerWebExchange { 36 | 37 | val request = this.request.mutate().header(LOGIN_USER_ID, userId).build() 38 | return this.mutate().request(request).build() 39 | } 40 | 41 | private fun setLoginUserId(request: ServerHttpRequest, userId: String) { 42 | request.mutate().header(LOGIN_USER_ID, userId).build() 43 | } 44 | 45 | private fun String.validateAndParse(): String = 46 | try { 47 | jwtManager.parse(this) 48 | } catch (e: Exception) { 49 | throw UnauthorizedException("유효하지 않은 토큰입니다.", e) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /micro-services/item/src/main/kotlin/io/philo/shop/domain/entity/ItemEntity.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.domain.entity 2 | 3 | import jakarta.persistence.* 4 | import jakarta.persistence.GenerationType.IDENTITY 5 | 6 | @Entity 7 | @Table(name = "item", 8 | uniqueConstraints = [ 9 | UniqueConstraint(name = "unique__item__name_size", columnNames = ["name", "size"]) 10 | ] 11 | ) 12 | class ItemEntity protected constructor( 13 | 14 | @Id 15 | @GeneratedValue(strategy = IDENTITY) 16 | @Column 17 | val id: Long? = null, 18 | 19 | @Column(nullable = false, length = 100) 20 | var name: String = "", 21 | 22 | @Column(nullable = false) 23 | var size: String = "-", 24 | 25 | @Column(nullable = false) 26 | var price: Int = 0, 27 | 28 | @Column(nullable = false) 29 | var stockQuantity: Int, 30 | ) { 31 | 32 | 33 | // default constructor for using JPA 34 | protected constructor() : this(id = null, name = "", size = "-", price = -1, stockQuantity = -1) 35 | 36 | // Size가 존재하지 않는 경우 37 | constructor( 38 | name: String, 39 | price: Int, 40 | stockQuantity: Int, 41 | ) : this(id = null, name, size = "-", price, stockQuantity) 42 | 43 | constructor( 44 | name: String, 45 | size: String, 46 | price: Int, 47 | stockQuantity: Int, 48 | ) : this(id = null, name, size, price, stockQuantity) 49 | 50 | companion object {} 51 | 52 | fun decreaseStockQuantity(orderQuantity: Int) { 53 | validateCanDecrease(orderQuantity) 54 | stockQuantity -= orderQuantity 55 | } 56 | 57 | fun increaseStockQuantity(orderQuantity: Int) { 58 | stockQuantity += orderQuantity 59 | } 60 | 61 | private fun validateCanDecrease(orderQuantity: Int) { 62 | check(stockQuantity - orderQuantity >= 0) { 63 | "주문수량에 비해 상품의 재고수량이 충분하지 않습니다." 64 | } 65 | } 66 | 67 | override fun toString(): String { 68 | return "Item(id=$id, name='$name', size='$size', price=$price, stockQuantity=$stockQuantity)" 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /common/event/src/main/kotlin/io/philo/shop/order/OrderRabbitProperty.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.order 2 | 3 | class OrderRabbitProperty { 4 | 5 | 6 | companion object { 7 | 8 | /** 9 | * 주문 생성시 상품 서비스에 발행하는 이벤트 10 | */ 11 | private const val ORDER_CREATED_TO_ITEM_TOPIC_PREFIX = "order.created.to.item" 12 | const val ORDER_CREATED_TO_ITEM_QUEUE_NAME = "$ORDER_CREATED_TO_ITEM_TOPIC_PREFIX.queue" 13 | const val ORDER_CREATED_TO_ITEM_EXCHANGE_NAME = "$ORDER_CREATED_TO_ITEM_TOPIC_PREFIX.exchange" 14 | const val ORDER_CREATED_TO_ITEM_ROUTING_KEY = "$ORDER_CREATED_TO_ITEM_TOPIC_PREFIX.routing.#" 15 | 16 | /** 17 | * 주문 생성시 쿠폰 서비스에 발행하는 이벤트 18 | */ 19 | private const val ORDER_CREATED_TO_COUPON_TOPIC_PREFIX = "order.created.to.coupon" 20 | const val ORDER_CREATED_TO_COUPON_QUEUE_NAME = "$ORDER_CREATED_TO_COUPON_TOPIC_PREFIX.queue" 21 | const val ORDER_CREATED_TO_COUPON_EXCHANGE_NAME = "$ORDER_CREATED_TO_COUPON_TOPIC_PREFIX.exchange" 22 | const val ORDER_CREATED_TO_COUPON_ROUTING_KEY = "$ORDER_CREATED_TO_COUPON_TOPIC_PREFIX.routing.#" 23 | 24 | /** 25 | * 주문 실패시 상품 서비스에 발행하는 보상 이벤트 26 | */ 27 | private const val ORDER_FAILED_TO_ITEM_TOPIC_PREFIX = "order.failed.to.item" 28 | const val ORDER_FAILED_TO_ITEM_QUEUE_NAME = "$ORDER_FAILED_TO_ITEM_TOPIC_PREFIX.queue" 29 | const val ORDER_FAILED_TO_ITEM_EXCHANGE_NAME = "$ORDER_FAILED_TO_ITEM_TOPIC_PREFIX.exchange" 30 | const val ORDER_FAILED_TO_ITEM_ROUTING_KEY = "$ORDER_FAILED_TO_ITEM_TOPIC_PREFIX.routing.#" 31 | 32 | /** 33 | * 주문 실패시 쿠폰 서비스에 발행하는 보상 이벤트 34 | */ 35 | private const val ORDER_FAILED_TO_COUPON_TOPIC_PREFIX = "order.failed.to.coupon" 36 | const val ORDER_FAILED_TO_COUPON_QUEUE_NAME = "$ORDER_FAILED_TO_COUPON_TOPIC_PREFIX.queue" 37 | const val ORDER_FAILED_TO_COUPON_EXCHANGE_NAME = "$ORDER_FAILED_TO_COUPON_TOPIC_PREFIX.exchange" 38 | const val ORDER_FAILED_TO_COUPON_ROUTING_KEY = "$ORDER_FAILED_TO_COUPON_TOPIC_PREFIX.routing.#" 39 | } 40 | } -------------------------------------------------------------------------------- /micro-services/coupon/src/main/kotlin/io/philo/shop/messagequeue/consumer/CouponEventListener.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.messagequeue.consumer 2 | 3 | import io.philo.shop.common.InAppEventListener 4 | import io.philo.shop.domain.outbox.CouponOutBoxRepository 5 | import io.philo.shop.domain.replica.ItemReplicaRepository 6 | import io.philo.shop.item.ItemCreatedEvent 7 | import io.philo.shop.item.ItemRabbitProperty.Companion.ITEM_REPLICA_FOR_COUPON_QUEUE_NAME 8 | import io.philo.shop.order.OrderChangedEvent 9 | import io.philo.shop.order.OrderRabbitProperty.Companion.ORDER_CREATED_TO_COUPON_QUEUE_NAME 10 | import io.philo.shop.order.OrderRabbitProperty.Companion.ORDER_FAILED_TO_COUPON_QUEUE_NAME 11 | import io.philo.shop.service.CouponEventService 12 | import io.philo.shop.service.CouponService 13 | import io.philo.shop.service.toEntity 14 | import mu.KotlinLogging 15 | import org.springframework.amqp.rabbit.annotation.RabbitListener 16 | 17 | @InAppEventListener 18 | class CouponEventListener( 19 | private val couponService: CouponService, 20 | private val itemReplicaRepository: ItemReplicaRepository, 21 | private val couponOutBoxRepository: CouponOutBoxRepository, 22 | private val couponEventService: CouponEventService 23 | ) { 24 | 25 | private val log = KotlinLogging.logger { } 26 | 27 | /** 28 | * 주문 생성 이벤트 수신처 29 | */ 30 | @RabbitListener(queues = [ORDER_CREATED_TO_COUPON_QUEUE_NAME]) 31 | fun listenOrderCreatedEvent(event: OrderChangedEvent) { 32 | 33 | couponEventService.validateAndProcessOrderCreatedEvent(event) 34 | } 35 | 36 | /** 37 | * 주문 실패 이벤트 수신처 38 | */ 39 | @RabbitListener(queues = [ORDER_FAILED_TO_COUPON_QUEUE_NAME]) 40 | fun listenOrderFailedEvent(event: OrderChangedEvent) { 41 | 42 | log.info { "$event" } 43 | 44 | couponEventService.processOrderFailedEvent(event) 45 | } 46 | 47 | 48 | /** 49 | * 상품의 복제본 동기화 50 | */ 51 | @RabbitListener(queues = [ITEM_REPLICA_FOR_COUPON_QUEUE_NAME]) 52 | fun listenItemReplicaForCoupon(event: ItemCreatedEvent) { 53 | 54 | val entity = event.toEntity() 55 | itemReplicaRepository.save(entity) 56 | } 57 | } 58 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /micro-services/order/src/main/kotlin/io/philo/shop/domain/outbox/OrderFailedOutboxEntity.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.domain.outbox 2 | 3 | import io.philo.shop.common.VerificationStatus 4 | import io.philo.shop.common.VerificationStatus.* 5 | import io.philo.shop.entity.OutboxBaseEntity 6 | import jakarta.persistence.* 7 | import jakarta.persistence.EnumType.STRING 8 | 9 | /** 10 | * 주문 실패 후 이벤트 발행을 하기 위한 이벤트 저장 테이블 11 | */ 12 | @Entity 13 | @Table(name = "order_failed_outbox") 14 | class OrderFailedOutboxEntity( 15 | 16 | traceId: Long, 17 | 18 | requesterId: Long, 19 | 20 | @Column(nullable = false) 21 | val isCompensatingItem: Boolean = false, 22 | 23 | @Column(nullable = false) 24 | val isCompensatingCoupon: Boolean = false, 25 | 26 | ) : OutboxBaseEntity(traceId, requesterId) { 27 | 28 | @Enumerated(STRING) 29 | @Column(nullable = false) 30 | var itemValidated: VerificationStatus = PENDING // 상품 서비스 유효성 체크 31 | 32 | @Enumerated(STRING) 33 | @Column(nullable = false) 34 | var couponValidated: VerificationStatus = PENDING // 쿠폰 서비스 유효성 체크 35 | 36 | protected constructor () : this(traceId = 0L, requesterId = 0L, isCompensatingItem = false, isCompensatingCoupon = false) 37 | 38 | fun changeItemValidated(verification: Boolean) { 39 | this.itemValidated = VerificationStatus.of(verification) 40 | } 41 | 42 | fun changeCouponValidated(verification: Boolean) { 43 | this.couponValidated = VerificationStatus.of(verification) 44 | } 45 | 46 | val isSuccess: Boolean 47 | get() = itemValidated == SUCCESS && couponValidated == SUCCESS 48 | 49 | val isCanceled: Boolean 50 | get() { 51 | return if (isCompensatingItem && isCompensatingCoupon) 52 | itemValidated == FAIL && couponValidated == FAIL 53 | else if(isCompensatingItem.not() && isCompensatingCoupon) 54 | couponValidated == FAIL 55 | else if(isCompensatingItem && isCompensatingCoupon.not()) 56 | itemValidated == FAIL 57 | else false 58 | } 59 | 60 | val isDone: Boolean 61 | get() = itemValidated != PENDING && couponValidated != PENDING 62 | 63 | companion object {} 64 | } 65 | -------------------------------------------------------------------------------- /micro-services/coupon/src/main/kotlin/io/philo/shop/presentation/CouponController.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.presentation 2 | 3 | import io.philo.shop.constant.SecurityConstant.Companion.LOGIN_USER_ID 4 | import io.philo.shop.domain.core.CouponRepository 5 | import io.philo.shop.domain.core.UserCouponRepository 6 | import io.philo.shop.presentation.dto.CouponAppliedAmountRequestDto 7 | import io.philo.shop.presentation.dto.CouponAppliedAmountResponseDto 8 | import io.philo.shop.presentation.dto.CouponListDto 9 | import io.philo.shop.presentation.dto.UserCouponListDto 10 | import io.philo.shop.query.CouponQuery 11 | import io.philo.shop.service.CouponService 12 | import org.springframework.web.bind.annotation.* 13 | 14 | @RequestMapping("/coupons") 15 | @RestController 16 | class CouponController( 17 | private val couponService: CouponService, 18 | private val couponRepository: CouponRepository, 19 | private val userCouponRepository: UserCouponRepository, 20 | private val couponQuery: CouponQuery, 21 | ) { 22 | 23 | /** 24 | * Coupon 생성 25 | * 26 | * 고민: 27 | * 28 | * 오직 시스템으로 생성할 것인지 아니면, 29 | * 30 | * 유저도 생성 가능하게 할 것인지 31 | */ 32 | @PostMapping 33 | fun createCoupon() { 34 | 35 | return couponService.createCoupon() 36 | } 37 | 38 | /** 39 | * 쇼핑몰에 존재하는 쿠폰 목록 조회 40 | */ 41 | @GetMapping 42 | fun list(): List { 43 | 44 | return couponRepository 45 | .findAll() 46 | .map { CouponListDto(it) } 47 | } 48 | 49 | /** 50 | * 유저가 가지고 있는 쿠폰 목록 조회 51 | */ 52 | @GetMapping("/users/{userId}") 53 | fun listOfUser(@PathVariable userId: Long): List { 54 | 55 | return userCouponRepository 56 | .findAll() 57 | .map { UserCouponListDto(it) } 58 | } 59 | 60 | /** 61 | * 할인된 상품 가격 정보를 보여준다 62 | */ 63 | @GetMapping("/coupon-applied-amount") 64 | fun calculateAmount( 65 | @RequestHeader(name = LOGIN_USER_ID) userId: String, 66 | @RequestBody requestDto: CouponAppliedAmountRequestDto 67 | ): CouponAppliedAmountResponseDto { 68 | 69 | return couponQuery.calculateAmount(2L, requestDto.itemId, requestDto.userCouponIds) 70 | } 71 | } -------------------------------------------------------------------------------- /common/general/src/main/kotlin/io/philo/shop/logformatter/P6SpySqlFormatter.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.logformatter 2 | 3 | import com.p6spy.engine.logging.Category 4 | import com.p6spy.engine.spy.P6SpyOptions 5 | import com.p6spy.engine.spy.appender.MessageFormattingStrategy 6 | import jakarta.annotation.PostConstruct 7 | import org.hibernate.engine.jdbc.internal.FormatStyle 8 | import org.springframework.context.annotation.Configuration 9 | import org.springframework.util.StringUtils.hasText 10 | 11 | @Configuration 12 | class P6SpySqlFormatter : MessageFormattingStrategy { 13 | @PostConstruct 14 | fun setLogMessageFormat() { 15 | P6SpyOptions.getActiveInstance().logMessageFormat = this.javaClass.getName() 16 | } 17 | 18 | override fun formatMessage( 19 | connectionId: Int, 20 | now: String, 21 | elapsed: Long, 22 | category: String, 23 | prepared: String, 24 | sql: String, 25 | url: String, 26 | ): String { 27 | val formattedSql = formatSql(category, sql) 28 | return formatLog(elapsed, category, formattedSql) 29 | } 30 | 31 | private fun formatSql(category: String, sql: String): String { 32 | if (hasText(sql) && isStatement(category)) { 33 | val trimmedSQL = trim(sql) 34 | return if (isDdl(trimmedSQL)) { 35 | FormatStyle.DDL.formatter.format(sql) 36 | } else FormatStyle.BASIC.formatter.format(sql) // maybe this line is DML 37 | } 38 | return sql 39 | } 40 | 41 | private fun formatLog(elapsed: Long, category: String, formattedSql: String): String { 42 | return String.format("[%s] | %d ms | %s", category, elapsed, formatSql(category, formattedSql)) 43 | } 44 | 45 | companion object { 46 | private fun isDdl(trimmedSQL: String): Boolean { 47 | return trimmedSQL.startsWith("create") || trimmedSQL.startsWith("alter") || trimmedSQL.startsWith("comment") 48 | } 49 | 50 | private fun trim(sql: String): String { 51 | return sql.trim { it <= ' ' }.lowercase() 52 | } 53 | 54 | private fun isStatement(category: String): Boolean { 55 | return Category.STATEMENT.name == category 56 | } 57 | } 58 | } 59 | 60 | -------------------------------------------------------------------------------- /micro-services/order/src/test/kotlin/io/philo/shop/integration/OrderIntegrationTest.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.integration 2 | 3 | import com.ninjasquad.springmockk.MockkBean 4 | import io.kotest.matchers.shouldBe 5 | import io.mockk.every 6 | import io.mockk.just 7 | import io.mockk.runs 8 | import io.philo.shop.AcceptanceTest 9 | import io.philo.shop.dto.ResourceCreateResponse 10 | import io.philo.shop.dto.web.OrderCreateRequest 11 | import io.philo.shop.dto.web.OrderLineRequestDto 12 | import io.philo.shop.item.ItemRestClientFacade 13 | import io.philo.shop.item.dto.ItemInternalResponseDto 14 | import io.philo.shop.messagequeue.OrderEventPublisher 15 | import io.philo.shop.repository.OrderRepository 16 | import org.junit.jupiter.api.Test 17 | import org.springframework.beans.factory.annotation.Autowired 18 | import org.springframework.transaction.annotation.Transactional 19 | 20 | class OrderIntegrationTest : AcceptanceTest() { 21 | 22 | @Autowired 23 | lateinit var orderRepository: OrderRepository 24 | 25 | @MockkBean 26 | lateinit var itemClient: ItemRestClientFacade 27 | 28 | @MockkBean 29 | lateinit var orderEventPublisher: OrderEventPublisher 30 | 31 | /** 32 | * todo! 리펙터링하기 33 | * 34 | * - @mockkBean 35 | * - do nothing 모킹법 알기 36 | * - 테스트 픽스쳐 생각하기 37 | */ 38 | @Test 39 | @Transactional(readOnly = true) 40 | fun `주문을 한다`() { 41 | 42 | // given 43 | // val itemClient = mockk() 44 | every { itemClient.requestItems(any()) } returns 45 | listOf(ItemInternalResponseDto(1L, "드로우핏 캐시미어 발마칸 코트 D.NABY", "M", 190_000)) 46 | every { orderEventPublisher.publishEvent(any()) } just runs 47 | 48 | val requestBody = OrderCreateRequest(orderLineRequestDtos = listOf(OrderLineRequestDto(itemId = 1L, itemQuantity = 5))) 49 | 50 | // when 51 | val createdEntityId = postAndGetBody("/orders", requestBody).id 52 | 53 | // then 54 | val foundEntity = orderRepository.findById(createdEntityId).get() 55 | val orderItems = foundEntity.orderLineItemEntities 56 | 57 | (createdEntityId > 0L) shouldBe true 58 | orderItems.size shouldBe 1 59 | val orderItem = orderItems[0] 60 | orderItem.itemId shouldBe 1L 61 | orderItem.orderedQuantity shouldBe 5 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /micro-services/user/src/test/kotlin/io/philo/integration/UserIntegrationTest.kt: -------------------------------------------------------------------------------- 1 | package io.philo.integration 2 | 3 | import io.kotest.matchers.shouldBe 4 | import io.kotest.matchers.shouldNotBe 5 | import io.philo.shop.AcceptanceTest 6 | import io.philo.shop.domain.entity.UserEntity 7 | import io.philo.shop.presentation.dto.create.UserCreateRequestDto 8 | import io.philo.shop.presentation.dto.create.UserCreateResponseDto 9 | import io.philo.shop.presentation.dto.login.UserLoginRequest 10 | import io.restassured.response.ExtractableResponse 11 | import io.restassured.response.Response 12 | import org.junit.jupiter.api.Test 13 | import org.springframework.http.HttpHeaders.AUTHORIZATION 14 | 15 | class UserIntegrationTest : AcceptanceTest() { 16 | 17 | @Test 18 | fun `회원가입 후 로그인 한다`() { 19 | 20 | // given 21 | val requestBody = UserCreateRequestDto.fixture 22 | 23 | // when 24 | val userId = 회원가입(requestBody).id 25 | val loginResponse = 로그인() 26 | 27 | // then 28 | val accessToken = loginResponse.authHeader 29 | 30 | (userId > 0) shouldBe true // id 생성 검증 31 | accessToken shouldNotBe null // token 존재 검증 32 | } 33 | 34 | private fun 회원가입(requestBody: UserCreateRequestDto) = 35 | postAndGetBody("/users", requestBody) 36 | 37 | private fun 로그인() = post("/users/login", UserLoginRequest.fixture) 38 | 39 | val ExtractableResponse.authHeader 40 | get() = this.header(AUTHORIZATION) 41 | 42 | val UserCreateRequestDto.Companion.fixture: UserCreateRequestDto 43 | get() = UserCreateRequestDto( 44 | email = UserEntity.fixture.email, 45 | name = UserEntity.fixture.name, 46 | address = UserEntity.fixture.address, 47 | password = UserEntity.fixture_rawPassword 48 | ) 49 | 50 | val UserEntity.Companion.fixture: UserEntity 51 | get() = UserEntity( 52 | email = "jason0101@example.com", 53 | name = "jason", 54 | address = "seoul yongsangu", 55 | rawPassword = UserEntity.fixture_rawPassword 56 | ) 57 | 58 | val UserEntity.Companion.fixture_rawPassword 59 | get() = "1234" 60 | 61 | val UserLoginRequest.Companion.fixture: Any 62 | get() = UserLoginRequest(email = UserEntity.fixture.email, password = UserEntity.fixture_rawPassword) 63 | } 64 | -------------------------------------------------------------------------------- /api-gateway/src/main/kotlin/io/philo/shop/support/GlobalExceptionHandler.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.support 2 | 3 | import com.google.gson.Gson 4 | import io.philo.shop.error.InAppException 5 | import mu.KotlinLogging 6 | import org.springframework.boot.web.reactive.error.ErrorWebExceptionHandler 7 | import org.springframework.core.io.buffer.DataBuffer 8 | import org.springframework.http.HttpStatus 9 | import org.springframework.http.MediaType 10 | import org.springframework.http.server.reactive.ServerHttpResponse 11 | import org.springframework.stereotype.Component 12 | import org.springframework.web.server.ServerWebExchange 13 | import reactor.core.publisher.Mono 14 | 15 | @Component 16 | class GlobalExceptionHandler : ErrorWebExceptionHandler { 17 | 18 | private val log = KotlinLogging.logger { } 19 | 20 | private val gson = Gson() 21 | 22 | override fun handle(exchange: ServerWebExchange, ex: Throwable): Mono { 23 | 24 | log.info { ex } 25 | ex.printStackTrace() 26 | 27 | val response = exchange.response 28 | setJsonContentType(response) 29 | setHttpStatusCode(ex, response) 30 | val responseBody = createResponseBody(ex, response) 31 | 32 | return response.writeWith(responseBody) 33 | } 34 | 35 | private fun setJsonContentType(response: ServerHttpResponse) { 36 | 37 | response.headers.contentType = MediaType.APPLICATION_JSON 38 | } 39 | 40 | private fun setHttpStatusCode(ex: Throwable, response: ServerHttpResponse) { 41 | 42 | if (ex is InAppException) { 43 | response.statusCode = ex.httpStatus 44 | } else { 45 | response.statusCode = HttpStatus.INTERNAL_SERVER_ERROR 46 | } 47 | } 48 | 49 | private fun createResponseBody( 50 | ex: Throwable, 51 | response: ServerHttpResponse, 52 | ): Mono { 53 | 54 | val responseBody = ErrorResponse("${ex.message}") 55 | val responseJsonString = gson.toJson(responseBody) 56 | val buffer = convertDataBuffer(response, responseJsonString) 57 | val mono = Mono.just(buffer) 58 | return mono 59 | } 60 | 61 | private fun convertDataBuffer(response: ServerHttpResponse, string: String): DataBuffer { 62 | 63 | return response.bufferFactory().wrap(string.toByteArray(Charsets.UTF_8)) 64 | } 65 | } 66 | 67 | data class ErrorResponse(private val errorMessage: String) 68 | -------------------------------------------------------------------------------- /api-gateway/src/main/kotlin/io/philo/shop/filter/AuthorizationInceptionFilter.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.filter 2 | 3 | import com.google.gson.Gson 4 | import io.philo.shop.constant.SecurityConstant.Companion.USER_PASSPORT 5 | import io.philo.shop.error.UnauthorizedException 6 | import io.philo.shop.user.UserRestClientFacade 7 | import io.philo.shop.user.dto.UserPassportResponse 8 | import mu.KotlinLogging 9 | import org.springframework.cloud.gateway.filter.GatewayFilter 10 | import org.springframework.cloud.gateway.filter.GatewayFilterChain 11 | import org.springframework.context.annotation.Lazy 12 | import org.springframework.core.Ordered 13 | import org.springframework.http.server.reactive.ServerHttpRequest 14 | import org.springframework.web.server.ServerWebExchange 15 | import reactor.core.publisher.Mono 16 | 17 | 18 | /** 19 | * 인증 토큰 정보가 있을 경우 20 | * 21 | * User Passport 정보를 삽입하는 역할 22 | * 23 | * Netflix Passport에서 착안한 아이디어 24 | */ 25 | //@Component 26 | @Deprecated("동기 호출로 인한 Blocking RuntTime 예외로 잠정 중단") 27 | class AuthorizationInceptionFilter(@Lazy private val userRestClient: UserRestClientFacade) : 28 | AbstractAuthorizationFilter(), GatewayFilter, Ordered { 29 | 30 | private val log = KotlinLogging.logger { } 31 | 32 | private val gson = Gson() 33 | 34 | override fun filter( 35 | exchange: ServerWebExchange, 36 | chain: GatewayFilterChain, 37 | ): Mono { 38 | 39 | val request = exchange.request 40 | // if ("/users/login" == request.path.toString()) 41 | // return proceedNextFilter(chain, exchange) 42 | 43 | val accessToken = validateAndExtractAccessToken(exchange) 44 | val userPassport = userRestClient.getUserPassport(accessToken) 45 | 46 | validatePassport(userPassport) 47 | 48 | request.setUserPassport(userPassport) 49 | 50 | return proceedNextFilter(chain, exchange) 51 | } 52 | 53 | private fun validatePassport(userPassport: UserPassportResponse) { 54 | if (userPassport.isValid.not()) 55 | throw UnauthorizedException("올바르지 못한 인증 헤더입니다.") 56 | } 57 | 58 | /** 59 | * Request 객체에 유저 패스포트 저장 60 | */ 61 | private fun ServerHttpRequest.setUserPassport(userPassport: UserPassportResponse) { 62 | val jsonString = gson.toJson(userPassport) 63 | this.mutate().header(USER_PASSPORT, jsonString).build() 64 | } 65 | 66 | override fun getOrder(): Int { 67 | return Ordered.LOWEST_PRECEDENCE 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /micro-services/order/src/main/kotlin/io/philo/shop/messagequeue/OrderEventListener.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.messagequeue 2 | 3 | import io.philo.shop.CouponRabbitProperty.Companion.COUPON_VERIFY_FAIL_RES_QUEUE_NAME 4 | import io.philo.shop.CouponRabbitProperty.Companion.COUPON_VERIFY_RES_QUEUE_NAME 5 | import io.philo.shop.application.OrderEventService 6 | import io.philo.shop.common.OrderChangedVerifiedEvent 7 | import io.philo.shop.item.ItemRabbitProperty.Companion.ITEM_VERIFY_FAIL_RES_QUEUE_NAME 8 | import io.philo.shop.item.ItemRabbitProperty.Companion.ITEM_VERIFY_RES_QUEUE_NAME 9 | import mu.KotlinLogging 10 | import org.springframework.amqp.rabbit.annotation.RabbitListener 11 | import org.springframework.stereotype.Component 12 | 13 | @Component 14 | class OrderEventListener(private val orderEventService: OrderEventService) { 15 | 16 | private val log = KotlinLogging.logger { } 17 | 18 | /** 19 | * 상품 검증 이벤트 수신처 20 | */ 21 | @RabbitListener(queues = [ITEM_VERIFY_RES_QUEUE_NAME]) 22 | fun listenItemVerification(event: OrderChangedVerifiedEvent) { 23 | 24 | try { 25 | orderEventService.processAfterItemVerification(event.orderId, event.verification) 26 | } catch (e: Exception) { 27 | log.error { e } 28 | } 29 | } 30 | 31 | /** 32 | * 쿠폰 검증 이벤트 수신처 33 | */ 34 | @RabbitListener(queues = [COUPON_VERIFY_RES_QUEUE_NAME]) 35 | fun listenCouponVerification(event: OrderChangedVerifiedEvent) { 36 | 37 | try { 38 | orderEventService.processAfterCouponVerification(event.orderId, event.verification) 39 | } catch (e: Exception) { 40 | log.error { e } 41 | } 42 | } 43 | 44 | /** 45 | * 상품 검증 이벤트 수신처 (보상 트랜잭션) 46 | */ 47 | @RabbitListener(queues = [ITEM_VERIFY_FAIL_RES_QUEUE_NAME]) 48 | fun listenItemVerificationForFail(event: OrderChangedVerifiedEvent) { 49 | 50 | try { 51 | orderEventService.processAfterItemVerificationForFail(event.orderId) 52 | } catch (e: Exception) { 53 | log.error { e } 54 | } 55 | } 56 | 57 | /** 58 | * 쿠폰 검증 이벤트 수신처 (보상 트랜잭션) 59 | */ 60 | @RabbitListener(queues = [COUPON_VERIFY_FAIL_RES_QUEUE_NAME]) 61 | fun listenCouponVerificationForFail(event: OrderChangedVerifiedEvent) { 62 | 63 | try { 64 | orderEventService.processAfterCouponVerificationForFail(event.orderId) 65 | } catch (e: Exception) { 66 | log.error { e } 67 | } 68 | } 69 | 70 | } -------------------------------------------------------------------------------- /common/security/src/main/kotlin/io/philo/shop/JwtManager.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop 2 | 3 | import io.jsonwebtoken.* 4 | import io.jsonwebtoken.io.DecodingException 5 | import io.jsonwebtoken.security.SignatureException 6 | import mu.KotlinLogging 7 | import java.util.* 8 | import javax.crypto.SecretKey 9 | 10 | 11 | class JwtManager( 12 | private val secretKey: SecretKey, 13 | private val expirationDurationTime: Long 14 | ) { 15 | 16 | companion object { 17 | @JvmStatic 18 | val ENCODING_ALGORITHM = SignatureAlgorithm.HS512 19 | } 20 | 21 | private val log = KotlinLogging.logger { } 22 | 23 | 24 | /** 25 | * JWT 생성 26 | */ 27 | fun createAccessToken(tokenSubject: String): String { 28 | 29 | return Jwts.builder() 30 | .signWith(secretKey, ENCODING_ALGORITHM) 31 | .setSubject(tokenSubject) 32 | .setIssuedAt(Date()) 33 | .setExpiration(createExpirationDateTime()) 34 | .compact() 35 | } 36 | 37 | /** 38 | * 유효한 토큰인지 검증 39 | */ 40 | fun isValidToken(accessToken: String): Boolean { 41 | 42 | return try { 43 | tryParseJwt(accessToken) 44 | true 45 | } catch (e: Exception) { 46 | when (e) { 47 | is IllegalArgumentException , 48 | is SignatureException, 49 | is MalformedJwtException, 50 | is ExpiredJwtException, 51 | is UnsupportedJwtException, 52 | is DecodingException -> { 53 | false 54 | } 55 | else -> { 56 | log.error { e } // 예측하지 못한 예외 57 | throw e 58 | } 59 | } 60 | } 61 | } 62 | 63 | /** 64 | * JWT의 Subject 추출 65 | */ 66 | fun parse(accessToken: String?): String { 67 | return Jwts.parserBuilder() 68 | .setSigningKey(secretKey) 69 | .build() 70 | .parseClaimsJws(accessToken) 71 | .body 72 | .subject 73 | } 74 | 75 | /** 76 | * 유요한 토큰인지 확인하는데 사용 77 | */ 78 | private fun tryParseJwt(accessToken: String) { 79 | Jwts.parserBuilder() 80 | .setSigningKey(secretKey) 81 | .build() 82 | .parseClaimsJws(accessToken) 83 | } 84 | 85 | /** 86 | * 만료 일시 생성 87 | */ 88 | private fun createExpirationDateTime() = 89 | Date(System.currentTimeMillis() + expirationDurationTime) 90 | } 91 | -------------------------------------------------------------------------------- /micro-services/user/src/main/kotlin/io/philo/shop/domain/service/UserService.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.domain.service 2 | 3 | import io.philo.shop.JwtManager 4 | import io.philo.shop.domain.entity.UserEntity 5 | import io.philo.shop.domain.repository.UserRepository 6 | import io.philo.shop.error.EntityNotFoundException 7 | import io.philo.shop.error.UnauthorizedException 8 | import io.philo.shop.user.dto.UserPassportResponse 9 | import org.springframework.stereotype.Service 10 | 11 | @Service 12 | class UserService(private val jwtManager: JwtManager, private val repository: UserRepository) { 13 | 14 | fun createUser( 15 | email: String, 16 | name: String, 17 | address: String, 18 | password: String 19 | ): Long { 20 | 21 | val userEntity = UserEntity.of(email, name, address, password) 22 | 23 | repository.save(userEntity) 24 | 25 | return userEntity.id 26 | } 27 | 28 | fun login(inputEmail: String, inputPassword: String): String { 29 | 30 | val user = repository.findByEmail(inputEmail) ?: throw EntityNotFoundException(inputEmail) 31 | 32 | validateCredential(inputPassword, user) 33 | 34 | // todo 더 암호화하기... 이렇게 되면 보안에 취약하다 35 | val subject = user.id.toString() 36 | val newAccessToken = jwtManager.createAccessToken(subject) 37 | 38 | return newAccessToken 39 | } 40 | 41 | fun isValidToken(accessToken: String): Boolean { 42 | return jwtManager.isValidToken(accessToken) 43 | } 44 | 45 | fun passport(accessToken: String): UserPassportResponse { 46 | 47 | return if (jwtManager.isValidToken(accessToken)) { 48 | val userInfo = jwtManager.parse(accessToken) 49 | val userEntity: UserEntity? = repository.findById(userInfo.toLong()).orElseGet(null) 50 | if (userEntity == null) { 51 | UserPassportResponse.OfInvalid() 52 | } else { 53 | UserPassportResponse.OfValid(userEntity.id, userEntity.name, userEntity.email) 54 | } 55 | } else { 56 | UserPassportResponse.OfInvalid() 57 | } 58 | } 59 | 60 | private fun validateCredential(inputPassword: String, userEntity: UserEntity) { 61 | if (isCorrectCredential(inputPassword, userEntity).not()) { 62 | throw UnauthorizedException("유효한 로그인 정보가 아닙니다.") 63 | } 64 | } 65 | 66 | private fun isCorrectCredential(inputPassword: String, userEntity: UserEntity): Boolean { 67 | return userEntity.isSamePassword(inputPassword) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /micro-services/order/src/main/kotlin/io/philo/shop/domain/core/OrderEntity.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.domain.core 2 | 3 | import io.philo.shop.constant.OrderStatus 4 | import io.philo.shop.constant.OrderStatus.* 5 | import io.philo.shop.entity.BaseEntity 6 | import jakarta.persistence.* 7 | import jakarta.persistence.CascadeType.ALL 8 | import jakarta.persistence.EnumType.STRING 9 | import jakarta.persistence.FetchType.EAGER 10 | import jakarta.persistence.FetchType.LAZY 11 | import lombok.ToString 12 | 13 | @Entity 14 | @Table(name = "orders") 15 | class OrderEntity ( 16 | 17 | val requesterId: Long, 18 | 19 | @OneToMany(mappedBy = "orderEntity", cascade = [ALL], orphanRemoval = true, fetch = LAZY) 20 | val orderLineItemEntities: MutableList = mutableListOf() 21 | 22 | ) : BaseEntity() { 23 | 24 | @Column(nullable = false) 25 | var totalOrderAmount: Int = 0 26 | 27 | @Enumerated(STRING) 28 | @Column(nullable = false) 29 | var orderStatus: OrderStatus = PENDING 30 | 31 | @OneToMany(mappedBy = "orderEntity", cascade = [ALL], orphanRemoval = true, fetch = LAZY) 32 | val orderHistories: MutableList = mutableListOf(OrderHistoryEntity(orderEntity = this)) 33 | 34 | protected constructor() : this(0L, mutableListOf()) 35 | 36 | init { 37 | mapOrder(orderLineItemEntities) 38 | } 39 | 40 | private fun mapOrder(orderLineItemEntities: List) { 41 | 42 | for (orderItem in orderLineItemEntities) { 43 | orderItem.mapOrder(this) 44 | } 45 | } 46 | 47 | fun completeToSuccess() { 48 | this.orderStatus = SUCCESS 49 | orderHistories.add(OrderHistoryEntity(this, SUCCESS)) 50 | } 51 | 52 | fun completeToFail() { 53 | this.orderStatus = FAIL 54 | orderHistories.add(OrderHistoryEntity(this, FAIL)) 55 | } 56 | 57 | fun completeToCanceled() { 58 | this.orderStatus = CANCEL 59 | orderHistories.add(OrderHistoryEntity(this, CANCEL)) 60 | } 61 | 62 | 63 | val isSuccess 64 | get() = orderStatus == SUCCESS 65 | 66 | val isFail 67 | get() = orderStatus == FAIL 68 | 69 | override fun toString(): String { 70 | return "OrderEntity(requesterId=$requesterId, totalOrderAmount=$totalOrderAmount, orderStatus=$orderStatus, isSuccess=$isSuccess, isFail=$isFail)" 71 | } 72 | 73 | companion object { 74 | 75 | @JvmStatic 76 | fun empty() = OrderEntity() 77 | 78 | /* 79 | @JvmStatic 80 | fun of(requesterId: Long, orderLineItemEntities: MutableList): OrderEntity { 81 | return OrderEntity(requesterId, orderLineItemEntities) 82 | } 83 | */ 84 | } 85 | } -------------------------------------------------------------------------------- /micro-services/item/src/main/kotlin/io/philo/shop/domain/service/ItemService.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.domain.service 2 | 3 | import io.philo.shop.domain.entity.ItemEntity 4 | import io.philo.shop.item.dto.ItemInternalResponseDto 5 | import io.philo.shop.messagequeue.producer.ItemEventPublisher 6 | import io.philo.shop.messagequeue.producer.toEvent 7 | import io.philo.shop.presentation.dto.ItemResponse 8 | import io.philo.shop.repository.ItemRepository 9 | import org.springframework.stereotype.Service 10 | import org.springframework.transaction.annotation.Transactional 11 | 12 | @Service 13 | @Transactional(readOnly = true) 14 | class ItemService( 15 | private val itemRepository: ItemRepository, 16 | private val itemEventPublisher: ItemEventPublisher, 17 | ) { 18 | 19 | @Transactional 20 | fun registerItem( 21 | name: String, 22 | size: String, 23 | price: Int, 24 | availableQuantity: Int, 25 | ): Long { 26 | 27 | val entity = ItemEntity(name, size, price, availableQuantity) 28 | itemRepository.save(entity) 29 | itemEventPublisher.publishEvent(entity.toEvent()) 30 | 31 | return entity.id!! 32 | } 33 | 34 | @Transactional(readOnly = true) 35 | fun findItems(itemIds: List?): List { 36 | 37 | return if (itemIds.isNullOrEmpty()) { 38 | val entities = itemRepository.findAll() 39 | val dtos = entities 40 | .map { item -> ItemResponse(item) } 41 | .toList() 42 | dtos 43 | } else { 44 | val entities = itemRepository.findAllByIdIn(itemIds) 45 | val dtos = entities 46 | .map { item -> ItemResponse(item) } 47 | .toList() 48 | dtos 49 | } 50 | } 51 | 52 | @Transactional(readOnly = true) 53 | fun findItemsForInternal(itemIds: List): List { 54 | 55 | val entities = itemRepository.findAllByIdIn(itemIds) 56 | val dtos = entities 57 | .map { item -> ItemInternalResponseDto(item.id!!, item.name, item.size, item.price) } 58 | .toList() 59 | return dtos 60 | } 61 | 62 | @Transactional 63 | @Deprecated("미 사용") 64 | fun decreaseItemsDeprecated(itemIdToDecreaseQuantity: Map) { 65 | 66 | val itemIds = itemIdToDecreaseQuantity.keys 67 | val findItems = itemRepository.findAllByIdIn(itemIds) // problem ! 68 | validateAndDecreaseItemQuantity(itemIdToDecreaseQuantity, findItems) 69 | } 70 | 71 | private fun validateAndDecreaseItemQuantity(itemIdToDecreaseQuantity: Map, findItemEntities: List) { 72 | for (item in findItemEntities) { 73 | val decreaseQuantity = itemIdToDecreaseQuantity[item.id]!! 74 | item.decreaseStockQuantity(decreaseQuantity) 75 | } 76 | } 77 | } -------------------------------------------------------------------------------- /micro-services/order/src/main/kotlin/io/philo/shop/messagequeue/OrderEventPublisher.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.messagequeue 2 | 3 | import io.philo.shop.common.InAppEventPublisher 4 | import io.philo.shop.domain.core.OrderEntity 5 | import io.philo.shop.domain.core.OrderLineItemEntity 6 | import io.philo.shop.order.OrderChangedEvent 7 | import io.philo.shop.order.OrderCreatedEventDeprecated 8 | import io.philo.shop.order.OrderRabbitProperty.Companion.ORDER_CREATED_TO_COUPON_EXCHANGE_NAME 9 | import io.philo.shop.order.OrderRabbitProperty.Companion.ORDER_CREATED_TO_COUPON_ROUTING_KEY 10 | import io.philo.shop.order.OrderRabbitProperty.Companion.ORDER_CREATED_TO_ITEM_EXCHANGE_NAME 11 | import io.philo.shop.order.OrderRabbitProperty.Companion.ORDER_CREATED_TO_ITEM_ROUTING_KEY 12 | import io.philo.shop.order.OrderRabbitProperty.Companion.ORDER_FAILED_TO_COUPON_EXCHANGE_NAME 13 | import io.philo.shop.order.OrderRabbitProperty.Companion.ORDER_FAILED_TO_COUPON_ROUTING_KEY 14 | import io.philo.shop.order.OrderRabbitProperty.Companion.ORDER_FAILED_TO_ITEM_EXCHANGE_NAME 15 | import io.philo.shop.order.OrderRabbitProperty.Companion.ORDER_FAILED_TO_ITEM_ROUTING_KEY 16 | import org.springframework.amqp.rabbit.core.RabbitTemplate 17 | 18 | /** 19 | * RabbitMQ 브로커에 이벤트를 적재하는 역할 20 | */ 21 | @InAppEventPublisher 22 | class OrderEventPublisher(private val rabbitTemplate: RabbitTemplate) { 23 | 24 | fun publishEventToItemServer(event: OrderChangedEvent) { 25 | 26 | rabbitTemplate.convertAndSend(ORDER_CREATED_TO_ITEM_EXCHANGE_NAME, ORDER_CREATED_TO_ITEM_ROUTING_KEY, event) 27 | } 28 | 29 | fun publishEventToCouponServer(event: OrderChangedEvent) { 30 | 31 | rabbitTemplate.convertAndSend(ORDER_CREATED_TO_COUPON_EXCHANGE_NAME, ORDER_CREATED_TO_COUPON_ROUTING_KEY, event) 32 | } 33 | 34 | fun publishReverseEventToItemServer(event: OrderChangedEvent) { 35 | 36 | rabbitTemplate.convertAndSend(ORDER_FAILED_TO_ITEM_EXCHANGE_NAME, ORDER_FAILED_TO_ITEM_ROUTING_KEY, event) 37 | } 38 | 39 | fun publishReverseEventToCouponServer(event: OrderChangedEvent) { 40 | 41 | rabbitTemplate.convertAndSend(ORDER_FAILED_TO_COUPON_EXCHANGE_NAME, ORDER_FAILED_TO_COUPON_ROUTING_KEY, event) 42 | } 43 | 44 | @Deprecated("OutBox 패턴 사용으로 인한 사용 중단") 45 | fun publishEvent(aggregateRoot: OrderEntity) { 46 | 47 | val event = OrderCreatedEventDeprecated.from(aggregateRoot) 48 | 49 | rabbitTemplate.convertAndSend(ORDER_CREATED_TO_ITEM_EXCHANGE_NAME, ORDER_CREATED_TO_ITEM_ROUTING_KEY, event) 50 | } 51 | 52 | private fun OrderCreatedEventDeprecated.Companion.from(orderEntity: OrderEntity): OrderCreatedEventDeprecated { 53 | 54 | val orderItems = orderEntity.orderLineItemEntities 55 | 56 | return OrderCreatedEventDeprecated(orderItems.toMap()) 57 | } 58 | 59 | /** 60 | * 주문 ID -> 주문 수량 61 | */ 62 | private fun List.toMap(): Map { 63 | 64 | return this.associateBy({ it.itemId }, { it.orderedQuantity }) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /micro-services/item/src/main/kotlin/io/philo/shop/support/ItemDataInitializer.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.support 2 | 3 | import io.philo.shop.domain.entity.ItemEntity 4 | import io.philo.shop.messagequeue.producer.ItemEventPublisher 5 | import io.philo.shop.messagequeue.producer.toEvent 6 | import io.philo.shop.repository.ItemRepository 7 | import lombok.extern.slf4j.Slf4j 8 | import mu.KotlinLogging 9 | import org.springframework.boot.context.event.ApplicationStartedEvent 10 | import org.springframework.context.ApplicationListener 11 | import java.sql.Connection 12 | import java.sql.SQLException 13 | import java.sql.SQLTimeoutException 14 | import javax.sql.DataSource 15 | 16 | /** 17 | * 상품 샘플 데이터 초기화 18 | */ 19 | //@Component 20 | @Slf4j 21 | class ItemDataInitializer( 22 | private val dataSource: DataSource, 23 | private val itemRepository: ItemRepository, 24 | private val itemEventPublisher: ItemEventPublisher 25 | ) : ApplicationListener { 26 | 27 | private val log = KotlinLogging.logger { } 28 | 29 | override fun onApplicationEvent(event: ApplicationStartedEvent) { 30 | checkConnection() 31 | initItems() 32 | } 33 | 34 | private fun checkConnection() { 35 | val connection: Connection? = getConnection() 36 | if (connection === null) { 37 | log.error { "[DB Connection Fail] ${this.javaClass.simpleName}" } 38 | } 39 | log.info("[DB Connection Success] ${this.javaClass.simpleName}") 40 | } 41 | 42 | private fun getConnection(): Connection? { 43 | return try { 44 | dataSource.connection 45 | } catch (e: SQLException) { 46 | log.error { "[DB Connection Fail] ${this.javaClass.simpleName}" } 47 | throw RuntimeException(e) 48 | } catch (e: SQLTimeoutException) { 49 | log.error { "[DB Connection Fail] ${this.javaClass.simpleName}" } 50 | throw RuntimeException(e) 51 | } 52 | } 53 | 54 | private fun initItems() { 55 | 56 | try { 57 | val item1 = ItemEntity(name = "초신사 스탠다드 블랙 스웨트 셔츠 오버 핏", size = "90-S", price = 49_800, stockQuantity = 1_000) 58 | val item2 = ItemEntity(name = "초신사 스탠다드 블랙 스웨트 셔츠 오버 핏", size = "100-M", price = 49_800, stockQuantity = 1_000) 59 | val item3 = ItemEntity(name = "초신사 스탠다드 블랙 스웨트 셔츠 오버 핏", size = "110-L", price = 49_800, stockQuantity = 1_000) 60 | val item4 = ItemEntity(name = "드로우핏 네이비 발마칸 코트 세미 오버 핏", price = 245_000, stockQuantity = 200) 61 | 62 | for (entity in listOf(item1, item2, item3, item4)) { 63 | itemRepository.save(entity) 64 | itemEventPublisher.publishEvent(entity.toEvent()) 65 | } 66 | } catch (e: Exception) { 67 | // 데이터 초기화 에러 68 | log.error { "[Data Initialization error]" } 69 | e.printStackTrace() 70 | val all = itemRepository.findAll() 71 | log.info { "item list = $all" } 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /micro-services/item/src/main/kotlin/io/philo/shop/messagequeue/config/ItemRabbitConfig.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.messagequeue.config 2 | 3 | import io.philo.shop.item.ItemRabbitProperty.Companion.ITEM_REPLICA_FOR_COUPON_QUEUE_NAME 4 | import io.philo.shop.item.ItemRabbitProperty.Companion.ITEM_REPLICA_FOR_COUPON_RES_EXCHANGE_NAME 5 | import io.philo.shop.item.ItemRabbitProperty.Companion.ITEM_REPLICA_FOR_COUPON_RES_ROUTING_KEY 6 | import io.philo.shop.item.ItemRabbitProperty.Companion.ITEM_VERIFY_FAIL_RES_EXCHANGE_NAME 7 | import io.philo.shop.item.ItemRabbitProperty.Companion.ITEM_VERIFY_FAIL_RES_QUEUE_NAME 8 | import io.philo.shop.item.ItemRabbitProperty.Companion.ITEM_VERIFY_FAIL_RES_ROUTING_KEY 9 | import io.philo.shop.item.ItemRabbitProperty.Companion.ITEM_VERIFY_RES_EXCHANGE_NAME 10 | import io.philo.shop.item.ItemRabbitProperty.Companion.ITEM_VERIFY_RES_QUEUE_NAME 11 | import io.philo.shop.item.ItemRabbitProperty.Companion.ITEM_VERIFY_RES_ROUTING_KEY 12 | import org.springframework.amqp.core.Binding 13 | import org.springframework.amqp.core.BindingBuilder 14 | import org.springframework.amqp.core.DirectExchange 15 | import org.springframework.amqp.core.Queue 16 | import org.springframework.context.annotation.Bean 17 | import org.springframework.context.annotation.Configuration 18 | 19 | @Configuration 20 | class ItemRabbitConfig { 21 | 22 | /** 23 | * 상품 검증 24 | */ 25 | @Bean 26 | fun itemVerifyResQueue() = Queue(ITEM_VERIFY_RES_QUEUE_NAME) 27 | 28 | @Bean 29 | fun itemVerifyResExchange() = DirectExchange(ITEM_VERIFY_RES_EXCHANGE_NAME) 30 | 31 | @Bean 32 | fun itemVerifyResBinding(itemVerifyResQueue: Queue, itemVerifyResExchange: DirectExchange): Binding = 33 | BindingBuilder 34 | .bind(itemVerifyResQueue) 35 | .to(itemVerifyResExchange) 36 | .with(ITEM_VERIFY_RES_ROUTING_KEY) 37 | 38 | /** 39 | * 쿠폰 복제본 40 | */ 41 | @Bean 42 | fun itemReplicaForCouponQueue() = Queue(ITEM_REPLICA_FOR_COUPON_QUEUE_NAME) 43 | 44 | @Bean 45 | fun itemReplicaForCouponExchange() = DirectExchange(ITEM_REPLICA_FOR_COUPON_RES_EXCHANGE_NAME) 46 | 47 | @Bean 48 | fun itemReplicaForCouponBinding(itemReplicaForCouponQueue: Queue, itemReplicaForCouponExchange: DirectExchange): Binding = 49 | BindingBuilder 50 | .bind(itemReplicaForCouponQueue) 51 | .to(itemReplicaForCouponExchange) 52 | .with(ITEM_REPLICA_FOR_COUPON_RES_ROUTING_KEY) 53 | 54 | /** 55 | * 주문 실패시 상품 검증 56 | */ 57 | @Bean 58 | fun itemVerifyFailResQueue() = Queue(ITEM_VERIFY_FAIL_RES_QUEUE_NAME) 59 | 60 | @Bean 61 | fun itemVerifyFailResExchange() = DirectExchange(ITEM_VERIFY_FAIL_RES_EXCHANGE_NAME) 62 | 63 | @Bean 64 | fun itemVerifyFailResBinding(itemVerifyFailResQueue: Queue, itemVerifyFailResExchange: DirectExchange): Binding = 65 | BindingBuilder 66 | .bind(itemVerifyFailResQueue) 67 | .to(itemVerifyFailResExchange) 68 | .with(ITEM_VERIFY_FAIL_RES_ROUTING_KEY) 69 | } -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%"=="" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%"=="" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if %ERRORLEVEL% equ 0 goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if %ERRORLEVEL% equ 0 goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | set EXIT_CODE=%ERRORLEVEL% 84 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 85 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 86 | exit /b %EXIT_CODE% 87 | 88 | :mainEnd 89 | if "%OS%"=="Windows_NT" endlocal 90 | 91 | :omega 92 | -------------------------------------------------------------------------------- /api-gateway/src/main/kotlin/io/philo/shop/config/GatewayRouteConfig.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.config 2 | 3 | import io.philo.shop.filter.AuthorizationVerificationFilter 4 | import org.springframework.cloud.gateway.route.Route 5 | import org.springframework.cloud.gateway.route.RouteLocator 6 | import org.springframework.cloud.gateway.route.builder.* 7 | import org.springframework.context.annotation.Bean 8 | import org.springframework.context.annotation.Configuration 9 | import org.springframework.http.HttpMethod 10 | import org.springframework.http.HttpMethod.GET 11 | import org.springframework.http.HttpMethod.POST 12 | 13 | @Configuration 14 | class GatewayRouteConfig( 15 | private val routeLocatorBuilder: RouteLocatorBuilder, 16 | private val authFilter: AuthorizationVerificationFilter, 17 | ) { 18 | @Bean 19 | fun routes(): RouteLocator = 20 | routeLocatorBuilder.routes() 21 | .route { it.route(serviceName = "USER-SERVICE", path = "/users") } 22 | 23 | .route { it.route(serviceName = "ITEM-SERVICE", path = "/items", httpMethods = arrayOf(GET)) } 24 | .route { it.route(serviceName = "ITEM-SERVICE", path = "/items", httpMethods = arrayOf(POST), authRequired = true) } 25 | 26 | .route { it.route(serviceName = "ORDER-SERVICE", path = "/orders", httpMethods = arrayOf(POST, GET), authRequired = true) } 27 | 28 | .route { it.route(serviceName = "COUPON-SERVICE", path = "/coupons/users", httpMethods = arrayOf(GET), authRequired = true) } 29 | .route { it.route(serviceName = "COUPON-SERVICE", path = "/coupons/coupon-applied-amount", httpMethods = arrayOf(GET), authRequired = true) } 30 | .route { it.route(serviceName = "COUPON-SERVICE", path = "/coupons", httpMethods = arrayOf(GET)) } 31 | .route { it.route(serviceName = "COUPON-SERVICE", path = "/coupons", httpMethods = arrayOf(POST), authRequired = true) } 32 | 33 | .build() 34 | 35 | 36 | private fun PredicateSpec.route( 37 | serviceName: String, 38 | path: String, 39 | vararg httpMethods: HttpMethod, 40 | authRequired: Boolean = false, 41 | ): Buildable { 42 | 43 | val pathSpec = pathSpec(path, httpMethods) 44 | 45 | return pathSpec 46 | .filters { it.buildFilter(path, authRequired) } 47 | .uri("lb://${serviceName}") 48 | } 49 | 50 | private fun PredicateSpec.pathSpec( 51 | path: String, 52 | httpMethods: Array, 53 | ): BooleanSpec { 54 | val pathSpec: BooleanSpec = this.path("$path/**") 55 | 56 | if (httpMethods.isEmpty()) 57 | return pathSpec 58 | 59 | return pathSpec.and().method(*httpMethods) 60 | } 61 | 62 | 63 | private fun GatewayFilterSpec.buildFilter(path: String, authRequired: Boolean = false): GatewayFilterSpec { 64 | 65 | // val filterSpec = this.removeRequestHeader("Cookie") 66 | val filterSpec = this 67 | // .rewritePath("/$path/(?.*)", "/\${segment}") // ex. /users/test -> /test 68 | 69 | if (authRequired.not()) 70 | return filterSpec 71 | 72 | return filterSpec.filter(authFilter) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /micro-services/order/src/main/kotlin/io/philo/shop/application/OrderService.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.application 2 | 3 | import io.philo.shop.coupon.CouponRestClientFacade 4 | import io.philo.shop.domain.core.OrderEntity 5 | import io.philo.shop.domain.core.OrderLineItemEntity 6 | import io.philo.shop.domain.outbox.OrderCreatedOutboxEntity 7 | import io.philo.shop.dto.web.OrderLineRequestDto 8 | import io.philo.shop.error.BadRequestException 9 | import io.philo.shop.item.ItemRestClientFacade 10 | import io.philo.shop.messagequeue.OrderEventPublisher 11 | import io.philo.shop.repository.OrderCreatedOutboxRepository 12 | import io.philo.shop.repository.OrderRepository 13 | import lombok.RequiredArgsConstructor 14 | import mu.KotlinLogging 15 | import org.springframework.stereotype.Service 16 | import org.springframework.transaction.annotation.Transactional 17 | 18 | @Service 19 | @Transactional(readOnly = true) 20 | @RequiredArgsConstructor 21 | class OrderService( 22 | private val orderRepository: OrderRepository, 23 | private val orderOutBoxRepository: OrderCreatedOutboxRepository, 24 | private val itemClient: ItemRestClientFacade, 25 | private val couponClient: CouponRestClientFacade, 26 | private val orderEventPublisher: OrderEventPublisher, 27 | ) { 28 | private val log = KotlinLogging.logger { } 29 | 30 | /** 31 | * 비동기 호출로 만들 것: order 생성 -> PENDING 32 | * 33 | * item, coupon, payment 서비스에 validate and decrease 요청 34 | * 35 | * out box pattern 으로 요청 36 | */ 37 | @Transactional 38 | fun order(orderLineDtos: List, requesterId: Long): Long { 39 | 40 | validateCouponUsable(orderLineDtos) 41 | 42 | val orderEntity = OrderEntity.createOrder(orderLineDtos, requesterId) 43 | orderRepository.save(orderEntity) 44 | 45 | val outbox = OrderCreatedOutboxEntity(orderEntity.id!!, requesterId) 46 | orderOutBoxRepository.save(outbox) 47 | 48 | return orderEntity.id!! 49 | } 50 | 51 | /** 52 | * 쿠폰 사용 가능 여부 검증 53 | * 54 | * 쿠폰은 하나의 상품에 대해서만 사용할 수 있습니다. 55 | */ 56 | private fun validateCouponUsable(orderLineDtos: List) { 57 | 58 | for (orderLineDto in orderLineDtos) { 59 | if (orderLineDto.userCouponIds != null && orderLineDto.itemQuantity >= 2) { 60 | throw BadRequestException("한 쿠폰을 둘 이상의 상품에 동시 적용할 수 없습니다.") 61 | } 62 | } 63 | } 64 | 65 | private fun OrderEntity.Companion.createOrder(orderLineDtos: List, requesterId: Long): OrderEntity { 66 | 67 | val orderItems = orderLineDtos.toEntities() 68 | return OrderEntity(requesterId, orderItems) 69 | } 70 | 71 | private fun List.toEntities(): MutableList = 72 | this 73 | .map { dto -> createOrderLine(dto) } 74 | .toMutableList() 75 | 76 | private fun createOrderLine(dto: OrderLineRequestDto): OrderLineItemEntity { 77 | 78 | val orderLineItemEntity = OrderLineItemEntity( 79 | itemId = dto.itemId, 80 | itemRawAmount = dto.itemAmount, 81 | itemDiscountedAmount = dto.itemDiscountedAmount, 82 | orderedQuantity = dto.itemQuantity, 83 | ) 84 | 85 | orderLineItemEntity.initUserCoupon(dto.userCouponIds) 86 | 87 | return orderLineItemEntity 88 | } 89 | } -------------------------------------------------------------------------------- /micro-services/order/src/main/kotlin/io/philo/shop/messagequeue/config/OrderRabbitConfig.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.messagequeue.config 2 | 3 | import io.philo.shop.order.OrderRabbitProperty.Companion.ORDER_CREATED_TO_COUPON_EXCHANGE_NAME 4 | import io.philo.shop.order.OrderRabbitProperty.Companion.ORDER_CREATED_TO_COUPON_QUEUE_NAME 5 | import io.philo.shop.order.OrderRabbitProperty.Companion.ORDER_CREATED_TO_COUPON_ROUTING_KEY 6 | import io.philo.shop.order.OrderRabbitProperty.Companion.ORDER_CREATED_TO_ITEM_EXCHANGE_NAME 7 | import io.philo.shop.order.OrderRabbitProperty.Companion.ORDER_CREATED_TO_ITEM_QUEUE_NAME 8 | import io.philo.shop.order.OrderRabbitProperty.Companion.ORDER_CREATED_TO_ITEM_ROUTING_KEY 9 | import io.philo.shop.order.OrderRabbitProperty.Companion.ORDER_FAILED_TO_COUPON_EXCHANGE_NAME 10 | import io.philo.shop.order.OrderRabbitProperty.Companion.ORDER_FAILED_TO_COUPON_QUEUE_NAME 11 | import io.philo.shop.order.OrderRabbitProperty.Companion.ORDER_FAILED_TO_COUPON_ROUTING_KEY 12 | import io.philo.shop.order.OrderRabbitProperty.Companion.ORDER_FAILED_TO_ITEM_EXCHANGE_NAME 13 | import io.philo.shop.order.OrderRabbitProperty.Companion.ORDER_FAILED_TO_ITEM_QUEUE_NAME 14 | import io.philo.shop.order.OrderRabbitProperty.Companion.ORDER_FAILED_TO_ITEM_ROUTING_KEY 15 | import org.springframework.amqp.core.Binding 16 | import org.springframework.amqp.core.BindingBuilder 17 | import org.springframework.amqp.core.DirectExchange 18 | import org.springframework.amqp.core.Queue 19 | import org.springframework.amqp.rabbit.connection.ConnectionFactory 20 | import org.springframework.amqp.rabbit.core.RabbitTemplate 21 | import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter 22 | import org.springframework.context.annotation.Bean 23 | import org.springframework.context.annotation.Configuration 24 | 25 | @Configuration 26 | class OrderRabbitConfig { 27 | 28 | /** 29 | * 주문 생성시 상품 서비스에 발행하는 이벤트 30 | */ 31 | @Bean 32 | fun orderCreatedToItemQueue() = Queue(ORDER_CREATED_TO_ITEM_QUEUE_NAME) 33 | 34 | @Bean 35 | fun orderCreatedToItemExchange() = DirectExchange(ORDER_CREATED_TO_ITEM_EXCHANGE_NAME) 36 | 37 | @Bean 38 | fun orderCreatedToItemBinding(orderCreatedToItemQueue: Queue, orderCreatedToItemExchange: DirectExchange): Binding = 39 | BindingBuilder.bind(orderCreatedToItemQueue).to(orderCreatedToItemExchange).with(ORDER_CREATED_TO_ITEM_ROUTING_KEY) 40 | 41 | 42 | /** 43 | * 주문 생성시 쿠폰 서비스에 발행하는 이벤트 44 | */ 45 | @Bean 46 | fun orderCreatedToCouponQueue() = Queue(ORDER_CREATED_TO_COUPON_QUEUE_NAME) 47 | 48 | @Bean 49 | fun orderCreatedToCouponExchange() = DirectExchange(ORDER_CREATED_TO_COUPON_EXCHANGE_NAME) 50 | 51 | @Bean 52 | fun orderCreatedToCouponBinding(orderCreatedToCouponQueue: Queue, orderCreatedToCouponExchange: DirectExchange): Binding = 53 | BindingBuilder.bind(orderCreatedToCouponQueue).to(orderCreatedToCouponExchange).with(ORDER_CREATED_TO_COUPON_ROUTING_KEY) 54 | 55 | /** 56 | * 주문 실패시 상품 서비스에 발행하는 보상 이벤트 57 | */ 58 | @Bean 59 | fun orderFailedToItemQueue() = Queue(ORDER_FAILED_TO_ITEM_QUEUE_NAME) 60 | 61 | @Bean 62 | fun orderFailedToItemExchange() = DirectExchange(ORDER_FAILED_TO_ITEM_EXCHANGE_NAME) 63 | 64 | @Bean 65 | fun orderFailedToItemBinding(orderFailedToItemQueue: Queue, orderFailedToItemExchange: DirectExchange): Binding = 66 | BindingBuilder.bind(orderFailedToItemQueue).to(orderFailedToItemExchange).with(ORDER_FAILED_TO_ITEM_ROUTING_KEY) 67 | 68 | /** 69 | * 주문 실패시 쿠폰 서비스에 발행하는 보상 이벤트 70 | */ 71 | @Bean 72 | fun orderFailedToCouponQueue() = Queue(ORDER_FAILED_TO_COUPON_QUEUE_NAME) 73 | 74 | @Bean 75 | fun orderFailedToCouponExchange() = DirectExchange(ORDER_FAILED_TO_COUPON_EXCHANGE_NAME) 76 | 77 | @Bean 78 | fun orderFailedToCouponBinding(orderFailedToCouponQueue: Queue, orderFailedToCouponExchange: DirectExchange): Binding = 79 | BindingBuilder.bind(orderFailedToCouponQueue).to(orderFailedToCouponExchange).with(ORDER_FAILED_TO_COUPON_ROUTING_KEY) 80 | 81 | 82 | @Bean 83 | fun rabbitTemplate(connectionFactory: ConnectionFactory, messageConverter: Jackson2JsonMessageConverter): RabbitTemplate { 84 | 85 | val rabbitTemplate = RabbitTemplate(connectionFactory) 86 | rabbitTemplate.messageConverter = messageConverter 87 | 88 | return rabbitTemplate 89 | } 90 | } -------------------------------------------------------------------------------- /common/general/src/testFixtures/kotlin/io/philo/shop/AcceptanceTest.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop 2 | 3 | import io.restassured.RestAssured 4 | import io.restassured.response.ExtractableResponse 5 | import io.restassured.response.Response 6 | import jakarta.persistence.EntityManager 7 | import jakarta.persistence.PersistenceContext 8 | import org.junit.jupiter.api.BeforeEach 9 | import org.springframework.boot.test.context.SpringBootTest 10 | import org.springframework.boot.test.web.server.LocalServerPort 11 | import org.springframework.http.MediaType.APPLICATION_JSON_VALUE 12 | 13 | @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) 14 | abstract class AcceptanceTest { 15 | 16 | @LocalServerPort 17 | var port = 0 18 | 19 | @PersistenceContext 20 | lateinit var entityManager: EntityManager 21 | 22 | // @Autowired 23 | // lateinit var dataSource: DataSource 24 | 25 | @BeforeEach 26 | protected fun setUp() { 27 | RestAssured.port = port 28 | } 29 | 30 | fun post(uri: String, body: Any): ExtractableResponse { 31 | 32 | return RestAssured.given().log().all() 33 | .body(body) 34 | .contentType(APPLICATION_JSON_VALUE) 35 | .accept(APPLICATION_JSON_VALUE) 36 | .`when`().post(uri) 37 | .then().log().all() 38 | .extract() 39 | } 40 | 41 | fun post(uri: String, body: Any, token: String): ExtractableResponse { 42 | 43 | return RestAssured.given().log().all() 44 | .auth().oauth2(token) 45 | .body(body) 46 | .contentType(APPLICATION_JSON_VALUE) 47 | .accept(APPLICATION_JSON_VALUE) 48 | .`when`().post(uri) 49 | .then().log().all() 50 | .extract() 51 | } 52 | 53 | 54 | final inline fun postAndGetBody(uri: String, body: Any): T { 55 | 56 | val response = RestAssured.given().log().all() 57 | .body(body) 58 | .contentType(APPLICATION_JSON_VALUE) 59 | .accept(APPLICATION_JSON_VALUE) 60 | .`when`().post(uri) 61 | .then().log().all() 62 | .extract() 63 | .`as`(T::class.java) 64 | 65 | return response 66 | } 67 | 68 | protected fun get(uri: String): ExtractableResponse { 69 | 70 | return RestAssured.given().log().all() 71 | .accept(APPLICATION_JSON_VALUE) 72 | .`when`()[uri] 73 | .then().log().all() 74 | .extract() 75 | } 76 | 77 | final inline fun getAndGetBody(uri: String): T { 78 | 79 | return RestAssured.given().log().all() 80 | .accept(APPLICATION_JSON_VALUE) 81 | .`when`()[uri] 82 | .then().log().all() 83 | .extract() 84 | .`as`(T::class.java) 85 | } 86 | 87 | protected fun get(uri: String, token: String): ExtractableResponse { 88 | 89 | return RestAssured.given().log().all() 90 | .auth().oauth2(token) 91 | .accept(APPLICATION_JSON_VALUE) 92 | .`when`()[uri] 93 | .then().log().all() 94 | .extract() 95 | } 96 | 97 | protected fun put(uri: String, requestBody: Any, token: String): ExtractableResponse { 98 | 99 | return RestAssured.given().log().all() 100 | .auth().oauth2(token) 101 | .body(requestBody) 102 | .contentType(APPLICATION_JSON_VALUE) 103 | .accept(APPLICATION_JSON_VALUE) 104 | .`when`().put(uri) 105 | .then().log().all() 106 | .extract() 107 | } 108 | 109 | protected fun delete(uri: String, token: String): ExtractableResponse { 110 | 111 | return RestAssured.given().log().all() 112 | .auth().oauth2(token) 113 | .contentType(APPLICATION_JSON_VALUE) 114 | .accept(APPLICATION_JSON_VALUE) 115 | .`when`().delete(uri) 116 | .then().log().all() 117 | .extract() 118 | } 119 | 120 | protected fun delete(uri: String, requestBody: Any, token: String): ExtractableResponse { 121 | 122 | return RestAssured.given().log().all() 123 | .auth().oauth2(token) 124 | .body(requestBody) 125 | .contentType(APPLICATION_JSON_VALUE) 126 | .accept(APPLICATION_JSON_VALUE) 127 | .`when`().delete(uri) 128 | .then().log().all() 129 | .extract() 130 | } 131 | } -------------------------------------------------------------------------------- /micro-services/user/src/test/kotlin/io/philo/integration/not_success_case/AcceptanceTest_Fail.kt: -------------------------------------------------------------------------------- 1 | package io.philo.integration.not_success_case 2 | 3 | import io.kotest.core.spec.style.StringSpec 4 | import io.kotest.core.spec.style.StringSpecTestFactoryConfiguration 5 | import io.restassured.RestAssured 6 | import io.restassured.response.ExtractableResponse 7 | import io.restassured.response.Response 8 | import jakarta.persistence.EntityManager 9 | import jakarta.persistence.PersistenceContext 10 | import org.junit.jupiter.api.BeforeEach 11 | import org.springframework.beans.factory.annotation.Autowired 12 | import org.springframework.boot.test.context.SpringBootTest 13 | import org.springframework.boot.test.web.server.LocalServerPort 14 | import org.springframework.http.MediaType 15 | import javax.sql.DataSource 16 | 17 | @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) 18 | open class AcceptanceTest_Fail(block: StringSpecTestFactoryConfiguration.() -> Unit):StringSpec( { 19 | }) { 20 | @LocalServerPort 21 | var port = 0 22 | 23 | @PersistenceContext 24 | var entityManager: EntityManager? = null 25 | 26 | @Autowired 27 | var dataSource: DataSource? = null 28 | 29 | @BeforeEach 30 | protected fun setUp() { 31 | RestAssured.port = port 32 | } 33 | 34 | companion object { 35 | fun post(uri: String?, body: Any?): ExtractableResponse { 36 | return RestAssured.given().log().all() 37 | .body(body) 38 | .contentType(MediaType.APPLICATION_JSON_VALUE) 39 | .accept(MediaType.APPLICATION_JSON_VALUE) 40 | .`when`().post(uri) 41 | .then().log().all() 42 | .extract() 43 | } 44 | } 45 | 46 | protected fun post(uri: String?, body: Any?): ExtractableResponse { 47 | return RestAssured.given().log().all() 48 | .body(body) 49 | .contentType(MediaType.APPLICATION_JSON_VALUE) 50 | .accept(MediaType.APPLICATION_JSON_VALUE) 51 | .`when`().post(uri) 52 | .then().log().all() 53 | .extract() 54 | } 55 | 56 | protected fun post(uri: String?, body: Any?, token: String?): ExtractableResponse { 57 | return RestAssured.given().log().all() 58 | .auth().oauth2(token) 59 | .body(body) 60 | .contentType(MediaType.APPLICATION_JSON_VALUE) 61 | .accept(MediaType.APPLICATION_JSON_VALUE) 62 | .`when`().post(uri) 63 | .then().log().all() 64 | .extract() 65 | } 66 | 67 | protected fun get(uri: String?): ExtractableResponse { 68 | return RestAssured.given().log().all() 69 | .accept(MediaType.APPLICATION_JSON_VALUE) 70 | .`when`()[uri] 71 | .then().log().all() 72 | .extract() 73 | } 74 | 75 | protected fun get(uri: String?, token: String?): ExtractableResponse { 76 | return RestAssured.given().log().all() 77 | .auth().oauth2(token) 78 | .accept(MediaType.APPLICATION_JSON_VALUE) 79 | .`when`()[uri] 80 | .then().log().all() 81 | .extract() 82 | } 83 | 84 | protected fun put(uri: String?, requestBody: Any?, token: String?): ExtractableResponse { 85 | return RestAssured.given().log().all() 86 | .auth().oauth2(token) 87 | .body(requestBody) 88 | .contentType(MediaType.APPLICATION_JSON_VALUE) 89 | .accept(MediaType.APPLICATION_JSON_VALUE) 90 | .`when`().put(uri) 91 | .then().log().all() 92 | .extract() 93 | } 94 | 95 | protected fun delete(uri: String?, token: String?): ExtractableResponse { 96 | return RestAssured.given().log().all() 97 | .auth().oauth2(token) 98 | .contentType(MediaType.APPLICATION_JSON_VALUE) 99 | .accept(MediaType.APPLICATION_JSON_VALUE) 100 | .`when`().delete(uri) 101 | .then().log().all() 102 | .extract() 103 | } 104 | 105 | protected fun delete(uri: String?, requestBody: Any?, token: String?): ExtractableResponse { 106 | return RestAssured.given().log().all() 107 | .auth().oauth2(token) 108 | .body(requestBody) 109 | .contentType(MediaType.APPLICATION_JSON_VALUE) 110 | .accept(MediaType.APPLICATION_JSON_VALUE) 111 | .`when`().delete(uri) 112 | .then().log().all() 113 | .extract() 114 | } 115 | } -------------------------------------------------------------------------------- /micro-services/coupon/src/main/kotlin/io/philo/shop/service/CouponEventService.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.service 2 | 3 | import io.philo.shop.common.OrderChangedVerifiedEvent 4 | import io.philo.shop.domain.core.CouponRepository 5 | import io.philo.shop.domain.core.UserCouponEntity 6 | import io.philo.shop.domain.core.UserCouponRepository 7 | import io.philo.shop.domain.outbox.CouponOutBoxRepository 8 | import io.philo.shop.domain.outbox.CouponOutboxEntity 9 | import io.philo.shop.domain.replica.ItemReplicaEntity 10 | import io.philo.shop.domain.replica.ItemReplicaRepository 11 | import io.philo.shop.entity.toMap 12 | import io.philo.shop.item.ItemCreatedEvent 13 | import io.philo.shop.messagequeue.producer.CouponEventPublisher 14 | import io.philo.shop.order.OrderChangedEvent 15 | import io.philo.shop.order.OrderLineCreatedEvent 16 | import mu.KotlinLogging 17 | import org.springframework.stereotype.Service 18 | import org.springframework.transaction.annotation.Transactional 19 | 20 | @Service 21 | class CouponEventService( 22 | private val couponOutBoxRepository: CouponOutBoxRepository, 23 | private val couponRepository: CouponRepository, 24 | private val itemReplRepository: ItemReplicaRepository, 25 | private val userCouponRepository: UserCouponRepository, 26 | private val couponEventPublisher: CouponEventPublisher, 27 | ) { 28 | 29 | 30 | private val log = KotlinLogging.logger { } 31 | 32 | 33 | /** 34 | * 쿠폰에 대한 유효성 검증을 하고 이상이 없을 경우 쿠폰을 사용합니다. 35 | */ 36 | @Transactional 37 | fun validateAndProcessOrderCreatedEvent(event: OrderChangedEvent) { 38 | 39 | val orderLineEvents = event.orderLineCreatedEvents 40 | val requesterId = event.requesterId 41 | 42 | val couponVerification = checkCouponForOrder(requesterId, orderLineEvents) 43 | if (couponVerification) { 44 | changeUserCoupons(requesterId, orderLineEvents) { userCoupon -> userCoupon.useCoupon() } 45 | } 46 | 47 | val outbox = CouponOutboxEntity(event.orderId, event.requesterId, isCompensatingTx = false, couponVerification) 48 | couponOutBoxRepository.save(outbox) 49 | } 50 | 51 | /** 52 | * 실패할 이벤트에 대해 쿠폰을 다시 사용 가능 상태로 되돌립니다. 53 | */ 54 | @Transactional 55 | fun processOrderFailedEvent(event: OrderChangedEvent) { 56 | 57 | val orderLineEvents = event.orderLineCreatedEvents 58 | val requesterId = event.requesterId 59 | 60 | changeUserCoupons(requesterId, orderLineEvents) { userCoupon -> userCoupon.changeToUsable() } 61 | val outbox = CouponOutboxEntity(event.orderId, event.requesterId, isCompensatingTx = true, verification = true) 62 | couponOutBoxRepository.save(outbox) 63 | } 64 | 65 | @Transactional 66 | fun loadEventToBroker() { 67 | 68 | val outboxes = couponOutBoxRepository.findAllToNormalTx() 69 | loadEventToBrokerInternal(outboxes) { event -> couponEventPublisher.publishEvent(event) } 70 | } 71 | 72 | @Transactional 73 | fun loadCompensatingEventToBroker() { 74 | 75 | val outboxes = couponOutBoxRepository.findAllToCompensatingTx() 76 | loadEventToBrokerInternal(outboxes) { event -> couponEventPublisher.publishEventForFail(event) } 77 | } 78 | 79 | private fun loadEventToBrokerInternal(outboxes: List, publishEventLambda: (OrderChangedVerifiedEvent) -> Unit) { 80 | 81 | if (outboxes.isNullOrEmpty()) 82 | return 83 | 84 | log.info { "브로커에 적재할 이벤트가 존재합니다." } 85 | 86 | val outBoxMap = outboxes.toMap() 87 | val events = outboxes.map { OrderChangedVerifiedEvent(it.traceId, it.verification) }.toList() 88 | 89 | for (event in events) { 90 | publishEventLambda.invoke(event) 91 | 92 | val matchedOutBox = outBoxMap[event.orderId]!! 93 | matchedOutBox.load() 94 | } 95 | } 96 | 97 | /** 98 | * 다음의 사항들을 검증합니다. 99 | * 100 | * - 유저가 보유한 쿠폰 중에서 유효하지 않은 쿠폰이 있는지 101 | * - 할인한 금액이 맞는지 유저가 입력한 금액과 일치하는 지 102 | */ 103 | private fun checkCouponForOrder(requesterId: Long, orderLineDtos: List): Boolean { 104 | 105 | for (orderLineDto in orderLineDtos) { 106 | 107 | val midVerification = iterCheckCouponForOrder(orderLineDto, requesterId) 108 | if (midVerification.not()) return false 109 | } 110 | 111 | return true 112 | } 113 | 114 | private fun iterCheckCouponForOrder(orderLineDto: OrderLineCreatedEvent, requesterId: Long): Boolean { 115 | 116 | val userCouponIds: List? = orderLineDto.userCouponIds 117 | if (userCouponIds.isNullOrEmpty()) { 118 | log.info { "쿠폰이 존재하지 않습니다." } 119 | return false 120 | } 121 | 122 | val foundUserCoupons = userCouponRepository.findAllUsable(requesterId, userCouponIds) 123 | if (userCouponIds.size != foundUserCoupons.size) { 124 | log.info { "유효하지 않은 쿠폰이 포함되어 있습니다." } 125 | return false 126 | } 127 | 128 | // todo item_replica와 비교하도록 설정하기 129 | val requestItemAmount = orderLineDto.itemAmount 130 | val requestDiscountedAmount = orderLineDto.itemDiscountedAmount 131 | val foundCoupons = foundUserCoupons.map { it.coupon }.toList() 132 | val actualDiscountedAmount = CouponDiscountCalculator.calculateDiscountAmount(requestItemAmount, foundCoupons) 133 | if (requestDiscountedAmount != actualDiscountedAmount) { 134 | log.info { "사용자의 요청 할인액이 실제 할인한 가격과 일치하지 않습니다." } 135 | return false 136 | } 137 | 138 | return true 139 | } 140 | 141 | private fun changeUserCoupons( 142 | requesterId: Long, 143 | orderLineEvents: List, 144 | couponStatusChanger: (UserCouponEntity) -> Unit, 145 | ) { 146 | 147 | val userCouponIds = orderLineEvents.flatMap { it.userCouponIds!! }.toList() 148 | val userCoupons = userCouponRepository.findAllUsable(requesterId, userCouponIds) 149 | for (userCoupon in userCoupons) { 150 | 151 | couponStatusChanger.invoke(userCoupon) 152 | } 153 | } 154 | 155 | } 156 | 157 | fun ItemCreatedEvent.toEntity() = ItemReplicaEntity(this.id, this.amount) 158 | -------------------------------------------------------------------------------- /micro-services/item/src/main/kotlin/io/philo/shop/domain/service/ItemEventService.kt: -------------------------------------------------------------------------------- 1 | package io.philo.shop.domain.service 2 | 3 | import io.philo.shop.common.OrderChangedVerifiedEvent 4 | import io.philo.shop.domain.entity.ItemEntity 5 | import io.philo.shop.domain.outbox.ItemOutboxEntity 6 | import io.philo.shop.error.InAppException 7 | import io.philo.shop.messagequeue.producer.ItemEventPublisher 8 | import io.philo.shop.order.OrderChangedEvent 9 | import io.philo.shop.order.OrderLineCreatedEvent 10 | import io.philo.shop.repository.ItemOutBoxRepository 11 | import io.philo.shop.repository.ItemRepository 12 | import mu.KotlinLogging 13 | import org.springframework.data.repository.findByIdOrNull 14 | import org.springframework.http.HttpStatus 15 | import org.springframework.stereotype.Service 16 | import org.springframework.transaction.annotation.Transactional 17 | 18 | @Service 19 | @Transactional(readOnly = true) 20 | class ItemEventService( 21 | private val itemOutBoxRepository: ItemOutBoxRepository, 22 | private val itemRepository: ItemRepository, 23 | private val itemEventPublisher: ItemEventPublisher, 24 | ) { 25 | 26 | 27 | private val log = KotlinLogging.logger { } 28 | 29 | 30 | /** 31 | * 상품에 대한 유효성 검증을 하고 Outbox 데이터를 넣습니다. 32 | */ 33 | @Transactional 34 | fun listenOrderCreatedEvent(event: OrderChangedEvent) { 35 | 36 | val orderLineEvents = event.orderLineCreatedEvents 37 | val itemVerification = checkItemBeforeOrder(orderLineEvents) 38 | if (itemVerification) { 39 | val itemMap = orderLineEvents.associateBy({ it.itemId }, { it.itemQuantity }) 40 | decreaseItems(itemMap) 41 | } 42 | 43 | val outbox = ItemOutboxEntity(event.orderId, event.requesterId, itemVerification) 44 | itemOutBoxRepository.save(outbox) 45 | } 46 | 47 | @Transactional 48 | fun listenOrderFailedEvent(event: OrderChangedEvent) { 49 | 50 | val itemMap = event.orderLineCreatedEvents.associateBy({ it.itemId }, { it.itemQuantity }) 51 | increaseItems(itemMap) 52 | 53 | val outbox = ItemOutboxEntity(event.orderId, event.requesterId, verification = true, isCompensatingTx = true) 54 | itemOutBoxRepository.save(outbox) 55 | } 56 | 57 | @Transactional 58 | fun loadEventToBroker() { 59 | 60 | val outboxes = itemOutBoxRepository.findAllToNormalTx() 61 | loadEventToBrokerInternal(outboxes) { event -> itemEventPublisher.publishEvent(event) } 62 | } 63 | 64 | @Transactional 65 | fun loadCompensatingEventToBroker() { 66 | 67 | val outboxes = itemOutBoxRepository.findAllToCompensatingTx() 68 | loadEventToBrokerInternal(outboxes) { event -> itemEventPublisher.publishEventForFail(event) } 69 | } 70 | 71 | private fun loadEventToBrokerInternal(outboxes: List, publishEventLambda: (OrderChangedVerifiedEvent) -> Unit) { 72 | 73 | if (outboxes.isNullOrEmpty()) 74 | return 75 | 76 | log.info { "브로커에 적재할 이벤트가 존재합니다." } 77 | 78 | val events = outboxes.convertToEvents() 79 | val outboxMap = outboxes.associateBy { it.traceId } 80 | 81 | for (event in events) { 82 | publishEventLambda.invoke(event) 83 | changeOutBoxStatusToLoad(outboxMap, event) 84 | } 85 | } 86 | 87 | /** 88 | * 주문 전에 상품 가격이 맞는지, 현재 재고가 충분하지를 검증 89 | */ 90 | private fun checkItemBeforeOrder(events: List): Boolean { 91 | 92 | val itemIds = events.map { it.itemId }.toList() 93 | val items = itemRepository.findAllByIdIn(itemIds) 94 | 95 | if (events.size != items.size) // DB에 존재하지 않는 상품 존재 96 | return false 97 | 98 | val eventMap = events.associateBy { it.itemId } 99 | for (item in items) { 100 | val request = eventMap[item.id!!] 101 | 102 | // 존재하지 않는 이벤트일 경우 103 | if (request == null) 104 | return false 105 | 106 | // 상품 가격이 일치해야한다 107 | if (request.itemAmount != item.price) 108 | return false 109 | 110 | // 재고 수량이 0 이하로 내려갈 수 없다 111 | if (item.stockQuantity - request.itemQuantity < 0) 112 | return false 113 | } 114 | 115 | return true 116 | } 117 | 118 | /** 119 | * 수신 받은 이벤트 정보를 기준으로 상품들의 재고 수량을 감소시킨다 120 | * 121 | * @param itemMap 122 | */ 123 | private fun decreaseItems(itemMap: Map) { 124 | 125 | changeItemQuantity(itemMap) { item, quantity -> item.decreaseStockQuantity(quantity) } 126 | } 127 | 128 | /** 129 | * 다른 서비스의 실패로 인해 재고 수량을 다시 증가시킨다 130 | * 131 | * @see decreaseItems 132 | */ 133 | private fun increaseItems(itemMap: Map) { 134 | 135 | changeItemQuantity(itemMap) { item, quantity -> item.increaseStockQuantity(quantity) } 136 | } 137 | 138 | private fun changeItemQuantity(itemMap: Map, changeQuantity: (ItemEntity, Int) -> Unit) { 139 | 140 | val itemIds = itemMap.keys 141 | val findItems = itemRepository.findAllByIdIn(itemIds) 142 | for (findItem in findItems) { 143 | val decreaseQuantity = itemMap[findItem.id!!]!! 144 | changeQuantity.invoke(findItem, decreaseQuantity) 145 | } 146 | } 147 | 148 | /** 149 | * 주문 전에 상품 가격이 맞는지, 현재 재고가 충분하지를 검증 150 | */ 151 | @Transactional(readOnly = true) 152 | fun checkItemBeforeOrder(itemId: Long, itemAmount: Int, itemQuantity: Int): Boolean { 153 | 154 | val currentItem = itemRepository.findByIdOrNull(itemId) 155 | 156 | if (currentItem == null) 157 | return false 158 | 159 | // 재고 수량이 0 이하로 내려갈 수 없다 160 | if (currentItem.price == itemAmount && currentItem.stockQuantity - itemQuantity >= 0) 161 | return true 162 | 163 | // 그 이외의 경우는 허용하지 않습니다 164 | throw InAppException(HttpStatus.INTERNAL_SERVER_ERROR, "데이터 형태가 올바르지 않습니다.") 165 | } 166 | 167 | private fun List.convertToEvents(): List = 168 | this.map { OrderChangedVerifiedEvent(it.traceId, it.verification) } 169 | 170 | private fun changeOutBoxStatusToLoad(outboxMap: Map, event: OrderChangedVerifiedEvent) { 171 | 172 | val matchedOutBox = outboxMap[event.orderId]!! 173 | matchedOutBox.load() 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # e-commerce with MSA 2 | 3 | > MSA에 대한 Best Practice 보다는 탐구 목적에 가깝게 개발하였습니다 4 | > 성공적인, 훌륭한 프로젝트는 아님을 말씀드립니다. 해당 프로젝트를 Good Practice로 삼으면 안됩니다 5 | > [Chris Richardson의 Microservices Patterns](https://www.amazon.com/Microservices-Patterns-examples-Chris-Richardson/dp/1617294543)에서 나오는 6 | > 가상의 Food 서비스의 내용을 생각하며 되짚어본 프로젝트입니다 7 | 8 | ## 프로젝트 설명 9 | 10 | | 모듈 명 | 한글 뜻 | 쉬운 설명 (?) | 11 | |-------------|---------------|----------------------------------------------------------------------------------------------------------| 12 | | eureka | 서비스
디스커버리 | 마이크로 서비스를 이름을 통해 실제 IP, Port를 찾을 수 있게 한다 (DNS의 개념)
마이크로서비스 간의 약한 결합을 가능하게 해준다 | 13 | | api-gateway | 게이트웨이 | 각 마이크로 서비스를 라우팅(`길찾기`)하고
인가/로깅 등의 부가 기능(`edge`)을 수행한다 (reverse-proxy) | 14 | | user | 사용자·인증 | `사용자 정보`를 관리하고 인증을 담당(`접근 권한 발급`)하는 서비스 | 15 | | item | 상품 | `상품`을 관리하는 서비스
Command(등록, 수정, 삭제)의 경우 상품 관리자가 이용한다고 가정했다 | 16 | | coupon | 쿠폰 | 상품 `할인 쿠폰` 관리하는 서비스
`고정` 할인 / `비율` 할인이 있다 | 17 | | order | 주문 | 상품들에 대한 `주문`을 관리하는 서비스 | 18 | | payment | 결제 | (`검토 예정 -> 현재 보류`) PG사를 통해 `결제`를 하는 서비스
(실제로는 PG 연동을 하지 않고 모의 상황을 가정했다) | 19 | | query | 조회 | (`구현 예정`) BFF, 클라이언트를 위한 조회 서비스
여러 마이크로서비스의 Datasource가 필요한 Read(통계 등)는 이곳에서 담당한다
이벤트 소싱 방식 채택 | 20 | 21 | ## 하위 도메인 관계도 (Context Map) 22 | 23 | image 24 | 25 | - 본디 이루어졌어야 할 논리적인 도표이며 실제 프로젝트 내부는 메세지큐에 상호 의존성이 존재 26 | - 자세한 내용은 아래에 기술 27 | - user는 모든 도메인의 Upstream이다 28 | 29 | ## 요청 흐름 30 | 31 | ### 주요 UseCase 32 | 33 | > 사용자의 주문 처리가 핵심 비즈니스이다 34 | 35 | - 사용자는 주문 화면에서 다음과 같은 정보를 확인할 수 있다 36 | - 주문할 상품의 정보 (이름, 가격, 수량) 37 | - 사용할 수 있는 쿠폰의 존재 여부 38 | - 적용할 수 있는 쿠폰이 있다면 각 상품에 쿠폰을 적용한다 39 | - 이때 하나의 상품에 2개 이하의 쿠폰 적용 가능 40 | - 적용된 상품은 일정 금액 이하로 내려갈 수 없다 41 | - 주문을 한다 42 | - 상품의 원가와 할인가 모두 서버에 전송한다 43 | 44 | ### 주문 요청이 성공한 경우 45 | 46 |
47 | 도표 48 | image 49 | 50 | 요청을 받은 주문 서버에서는 주문을 바로 처리하지 않고 대기 상태로 둔다(PENDING) 51 | 그리고 각 마이크로서비스에게 비동기 요청을 한다 52 | 53 | 상품 서비스는 아래의 사항을 검증한다 54 | 55 | - 상품의 재고가 현재 존재하는지 56 | - 주문서를 작성하는 도중에 다른 사용자로 인해 재고가 마감될 수 있다 (무신사 블랙프라이데이를 생각하자...) 57 | - 현재 가격 정보와 요청 정보가 일치하는 지 58 | 59 | 검증에 성공하면 상품 재고 수량을 차감하고 정상 응답을 한다 60 | 61 | 쿠폰 서비스는 아래의 사항을 검증한다 62 | 63 | - 쿠폰이 존재하는지 64 | - 쿠폰을 비즈니스 정책에 맞게 사용한 것인지 65 | - 실제 쿠폰 할인가와 요청한 쿠폰 할인가가 일치하는지 66 | - 클라이언트 요청은 항상 위변조가 가능하다! 67 | 68 | 검증에 성공하면 쿠폰 사용여부를 true로 하고 정상 응답을 한다 69 | 70 | 그리고 주문 서버로 응답을 한다 71 | 주문 서버는 (스케줄링) 응답을 리스닝하고 있다가 모든 응답이 정상임을 확인하고 주문 완료 처리를 한다 72 | 73 |
74 | 75 |
76 | 77 | ### 주문 요청이 실패한 경우 78 | 79 |
80 | 도표 81 | image 82 | 83 | 주문 요청을 한 이후 상품, 쿠폰 서비스 둘 중 하나 이상이 검증에 실패한 상황이다 84 | 이 경우 검증에 성공하여 데이터를 변경한 서버들에게만 85 | 현재 요청이 실패했음을 알려서 데이터를 이전 상태로 복구시켜야 한다 (`이것을 보상 트랜잭션이라 한다`) 86 | 87 | 위 도표의 예시는 쿠폰 서비스에서는 검증이 실패하여 데이터 변경을 하지 않고, 88 | 상품 서비스는 검증에 성공하여 데이터 변경이 일어난 경우이다 89 | 응답 받은 이벤트 중 검증에 실패한 이벤트가 있음을 확인한 주문서비스는 PENDING상태에서 FAIL로 변경한다 90 | 그리고 검증에 성공한 상품 서비스에게만 보상 이벤트를 보낸다 91 | 이벤트를 수신한 상품 서비스는 데이터를 롤백하고 주문 서비스에게 응답한다 92 | 롤백 응답을 받은 주문서비스는 해당 주문건을 CANCEL상태로 만든다 93 | 94 |
95 | 96 | ## 설계상 고민한 점 97 | 98 | **분해 전략** 99 | 100 | - DDD의 `sub-domain`과 `bounded-context`를 통해 각 마이크로 서비스를 분리하고자 함 101 | - 각 비즈니스 영역이 다루는 문제가 존재 102 | 예를 들어 옷을 주문하는 사람과 등록하는 사람, 쿠폰을 제공하는 운영진은 웹사이트를 다루는/바라보는 관점이 모두 다르다 103 | 따라서 각 하위 도메인마다 요구하는 비즈니스의 성질이 다를 것이므로 상품, 주문, 쿠폰의 하위 도메인으로 분리 104 | 105 | **데이터 정합성 (`Atomic`)** 106 | 107 | - 분산 레벨에서의 트랜잭션 108 | - Saga 편성 중 코레오그래피 방식을 이용하여 예외 상황에 대한 rollback 109 | - 오케스트레이션 편성의 경우 Axon등 라이브러리 의존성이 생겨서 110 | MSA 자체 문제의 집중보다는 라이브러리를 익히는 것에 집중이 분산될 것으로 보여서 선택을 보류했다 111 | - Outbox를 통해 브로커가 장애가 났을 경우에 대해 보완 가능 112 | - 브로커 복구시 Scheduler가 OutBox테이블에서 미발송된 이벤트를 재 적재한다 113 | - 현재 멀티 인스턴스에서의 스케줄링은 고려되지 않았다 (동시 접근 문제 -> 중복 데이터 발행) 114 | - 해결 방안: shed 락, 스케줄러 서버 분리 (오직 하나만 존재) 115 | - 로컬 레벨에서의 트랜잭션 116 | - `조회시 버전 증가 잠금`을 통해 애그리거트의 정합성이 깨질 위험을 방지 (`낙관적 락`) 117 | - 애그리거트 루트뿐만 아니라 구성요소가 바뀌는 경우를 고려해야 하기 때문에 조회를 할 때부터 읽어야 한다 118 | - 이 부분이 이루어지면 분산 시스템에서의 `동시성 문제`가 해소된다 119 | 어느 한 마이크로서비스에서 올바르게 검증이 실패하면 전체 트랜잭션이 실패하여, 전체 시스템의 데이터 정합성을 지킬 수 있다 120 | 121 | **성능** 122 | 123 | - 비동기 호출: 주문(`POST /orders`)의 경우 되도록 동기 통신이 아닌 비동기 통신을 구현하고자 노력 124 | - 이 부분이 잘 이루어질 경우 DB Connection 선점 시간을 짧게 가져갈 수 있다 125 | - Table 복제: 타 마이크로 서비스의 테이블의 일부 데이터를 복제하여 통신을 일부 제거하고자 노력 126 | - 다만, 각 마이크로 서비스 간의 결합도가 생긴다 (`분산 모놀리스가 될 위험성 존재`) 127 | 128 | **유지보수 성** 129 | 130 | - spring-cloud-gateway의 route 코드: API가 추가될 수록 관리가 용이하기 힘들기 때문에 kotlin의 문법을 통해 간소화하고자 함 131 | - kotlin-dsl로 구현한 테스트 코드를 통해 라우트 정보가 올바로 등록했는 지 알 수 있다 132 | - 공통 모듈 133 | - common 모듈을 성격에 따라 구분해서, 각 모듈이 필요한 모듈을 import할 수 있다 134 | - 코드 레벨 135 | - human-readable한 코드를 작성하려고 노력했다 (네이밍 컨벤션, 약어 제한 등) 136 | - Restful API 원칙 중 Uniform interface를 지키고자 노력 (자원, 행위) 137 | - 통합 테스트를 작성하려 노력 (`진행 중`/`전면 재작성 필요 - contract test`) 138 | - 기본 베이스는 인수테스트로 하되, 이 중 `then`절만 `DB` 조회를 통해서 테스트 139 | 140 | **조회 모델 분리 (`구현 예정`)** 141 | 142 | - 조회 담당 서버를 분리하여 각 마이크로서비스에서 발생한 변경분을 replay하여 데이터를 구성 (이벤트 소싱) 143 | 144 | 사용자 use-case를 다소 고려하여 API를 설계 145 | 146 | - 무신사, 쿠팡 등을 참고하여, 실제 사용자의 API 호출을 가정하여 작성 147 | 148 | ## 이 프로젝트의 한계 149 | 150 | > 실제로 MSA의 극한까지 Best Practice를 지키면서 만들지는 못했다 151 | 152 | - 각 마이크로 서비스가 충분히 크지 않다 153 | - 현업 개발·기획자들이 공감할 수 있을 정도로 충분히 크지 않다 154 | - 실제 이용자 수가 많은 서비스와 비교하면 디테일함이 많이 부족하다 155 | - 각 JVM 인스턴스가 차지하는 성능 156 | - 로컬에서 6개 이상의 서버가 구동될 때, 성능 부담이 된다 (많게는 8.9Gi ~ 10.gGi 메모리 사용) 157 | - 로컬 환경 스펙 (노트북) 158 | - CPU: 3.2Ghz 8Core / Memory: 28GB / SSD 159 | - 현재까지 이루지 못한 약한 결합 160 | - 마이크로서비스의 장점과 메세지 큐의 존재 의의 중 하나는 약한 결합(`low coupling`)이다 161 | 하지만 현재 시점(24.2)에서는 실패했다. 메세지 큐의 이름과 이벤트의 데이터는 논리적으로 상호 참조를 하고 있다 162 | 이상적인 구조라면 Upstream(Publisher)은 Downstream(Subscriber)를 물리적인 것 뿐만 아니라 논리적으로도 참조를 하면 안 된다 163 | 164 | ## 하지 못한 것 165 | 166 | - 실제 결제 연동 167 | - 토스 PG 등 실제 결제 연동까지 진행하지는 못했다 168 | - 또 편의상의 이유로 실제 결제의 흐름을 축약/왜곡하여 작성하였다 169 | - 브로커 클러스터에 문제가 생긴 경우에 대한 장애 처리 170 | - 이 부분까지는 진행하지 못했다 171 | - 물리적으로 분리되지 않은 DB 172 | - 하나의 물리 DB를 논리적으로 나누어 사용하고 있음 173 | - 로컬 성능/비용의 부담으로 실제와 가깝게 구현하지 못함 174 | 175 | ## 현재 상황 176 | 177 | > 여러 일정으로 잠정적으로 보류되어왔던 프로젝트입니다... 178 | > 여유가 될 때마다 구현하고 있습니다 :) 179 | 180 | ## 작업 체크 리스트 181 | 182 |
183 | 184 | 자세히 185 | 186 | 187 | - [x] (마이그레이션) [기존 DDD 프로젝트](https://github.com/progress0407/code-review-simple-orders) (import by `Git Subtree`) 188 | - [x] Java -> `Kotlin` 으로 언어 변경 189 | - [x] Library 버전 최신화 (to Spring Boot 3.x) 190 | - [x] 멀티 모듈 프로젝트로 전환 (`itme`, `order`) 191 | - [x] `Eureka` Module 개발 192 | - [x] `API Gateway` Module 개발 193 | - [x] `공통 Module` 추출 (`common`) 194 | - [x] RabbitMQ 연동 후 주문 상품 이벤트 pub-sub 개발 195 | - [x] H2 -> MySQL로 DB 변경 196 | - [ ] RabbitMQ -> Kafka로 전환 197 | - [x] ( 198 | 마이그레이션) [User Module](https://github.com/progress0407/intergrated-study/tree/main/0.%20study/1.%20alone/%5BMSA%5D%20Spring%20Cloud%20MicroService/leedowon-msa-project/user-service) 199 | 가져오기 200 | - [x] 쿠폰 Module 개발 201 | - [x] `Neflix Passport` 구현 202 | - 보류 (동기 호출시 Blocking 예외 발생) 203 | - [x] 대안으로 토큰 검증 필터 구현 204 | - [x] Order -> Item, Coupon: 주문 생성 이벤트 검증부 구현 205 | - [x] 마이크로 서비스 `2-depth 멀티 모듈`로 그룹화 206 | - [x] `p6spy 로그 포맷터` 적용 207 | - [x] `Coupon 가격 계산 API` 구현 208 | - [x] `repository.saveAll()`에서 `data.sql` 로 초기화하는 구조로 변경 209 | - [x] Item -> Coupon, 상품 Semi 데이터 이벤트 발송 기능 구현 210 | - [ ] 마이크로 서비스 내 애그리거트 트랜잭션 충돌 방지 211 | - 애그리거트 내 구성요소가 바뀔 경우 고려 212 | - 애그리거트 수정시 조회 메서드에 LockModeType.OPTIMISTIC_FORCE_INCREMENT 적용 213 | - [x] Order <-> Coupon, Item 보상 트랜잭션 발신, 송신 구현 214 | - [x] Canceled 상태 추가 및 로직 구현 215 | - [ ] Order 바운디드 컨텍스트에 Orderer 추가 216 | - [ ] k8s 배포 스크립트 작성 217 | - [ ] outbox 동시성 문제 해결 218 | 219 |
220 | 221 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | # 21 | # Gradle start up script for POSIX generated by Gradle. 22 | # 23 | # Important for running: 24 | # 25 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 26 | # noncompliant, but you have some other compliant shell such as ksh or 27 | # bash, then to run this script, type that shell name before the whole 28 | # command line, like: 29 | # 30 | # ksh Gradle 31 | # 32 | # Busybox and similar reduced shells will NOT work, because this script 33 | # requires all of these POSIX shell features: 34 | # * functions; 35 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 36 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 37 | # * compound commands having a testable exit status, especially «case»; 38 | # * various built-in commands including «command», «set», and «ulimit». 39 | # 40 | # Important for patching: 41 | # 42 | # (2) This script targets any POSIX shell, so it avoids extensions provided 43 | # by Bash, Ksh, etc; in particular arrays are avoided. 44 | # 45 | # The "traditional" practice of packing multiple parameters into a 46 | # space-separated string is a well documented source of bugs and security 47 | # problems, so this is (mostly) avoided, by progressively accumulating 48 | # options in "$@", and eventually passing that to Java. 49 | # 50 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 51 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 52 | # see the in-line comments for details. 53 | # 54 | # There are tweaks for specific operating systems such as AIX, CygWin, 55 | # Darwin, MinGW, and NonStop. 56 | # 57 | # (3) This script is generated from the Groovy template 58 | # https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 59 | # within the Gradle project. 60 | # 61 | # You can find Gradle at https://github.com/gradle/gradle/. 62 | # 63 | ############################################################################## 64 | 65 | # Attempt to set APP_HOME 66 | 67 | # Resolve links: $0 may be a link 68 | app_path=$0 69 | 70 | # Need this for daisy-chained symlinks. 71 | while 72 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 73 | [ -h "$app_path" ] 74 | do 75 | ls=$( ls -ld "$app_path" ) 76 | link=${ls#*' -> '} 77 | case $link in #( 78 | /*) app_path=$link ;; #( 79 | *) app_path=$APP_HOME$link ;; 80 | esac 81 | done 82 | 83 | APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit 84 | 85 | APP_NAME="Gradle" 86 | APP_BASE_NAME=${0##*/} 87 | 88 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 89 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 90 | 91 | # Use the maximum available, or set MAX_FD != -1 to use that value. 92 | MAX_FD=maximum 93 | 94 | warn () { 95 | echo "$*" 96 | } >&2 97 | 98 | die () { 99 | echo 100 | echo "$*" 101 | echo 102 | exit 1 103 | } >&2 104 | 105 | # OS specific support (must be 'true' or 'false'). 106 | cygwin=false 107 | msys=false 108 | darwin=false 109 | nonstop=false 110 | case "$( uname )" in #( 111 | CYGWIN* ) cygwin=true ;; #( 112 | Darwin* ) darwin=true ;; #( 113 | MSYS* | MINGW* ) msys=true ;; #( 114 | NONSTOP* ) nonstop=true ;; 115 | esac 116 | 117 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 118 | 119 | 120 | # Determine the Java command to use to start the JVM. 121 | if [ -n "$JAVA_HOME" ] ; then 122 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 123 | # IBM's JDK on AIX uses strange locations for the executables 124 | JAVACMD=$JAVA_HOME/jre/sh/java 125 | else 126 | JAVACMD=$JAVA_HOME/bin/java 127 | fi 128 | if [ ! -x "$JAVACMD" ] ; then 129 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 130 | 131 | Please set the JAVA_HOME variable in your environment to match the 132 | location of your Java installation." 133 | fi 134 | else 135 | JAVACMD=java 136 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 137 | 138 | Please set the JAVA_HOME variable in your environment to match the 139 | location of your Java installation." 140 | fi 141 | 142 | # Increase the maximum file descriptors if we can. 143 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 144 | case $MAX_FD in #( 145 | max*) 146 | MAX_FD=$( ulimit -H -n ) || 147 | warn "Could not query maximum file descriptor limit" 148 | esac 149 | case $MAX_FD in #( 150 | '' | soft) :;; #( 151 | *) 152 | ulimit -n "$MAX_FD" || 153 | warn "Could not set maximum file descriptor limit to $MAX_FD" 154 | esac 155 | fi 156 | 157 | # Collect all arguments for the java command, stacking in reverse order: 158 | # * args from the command line 159 | # * the main class name 160 | # * -classpath 161 | # * -D...appname settings 162 | # * --module-path (only if needed) 163 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 164 | 165 | # For Cygwin or MSYS, switch paths to Windows format before running java 166 | if "$cygwin" || "$msys" ; then 167 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 168 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 169 | 170 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 171 | 172 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 173 | for arg do 174 | if 175 | case $arg in #( 176 | -*) false ;; # don't mess with options #( 177 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 178 | [ -e "$t" ] ;; #( 179 | *) false ;; 180 | esac 181 | then 182 | arg=$( cygpath --path --ignore --mixed "$arg" ) 183 | fi 184 | # Roll the args list around exactly as many times as the number of 185 | # args, so each arg winds up back in the position where it started, but 186 | # possibly modified. 187 | # 188 | # NB: a `for` loop captures its iteration list before it begins, so 189 | # changing the positional parameters here affects neither the number of 190 | # iterations, nor the values presented in `arg`. 191 | shift # remove old arg 192 | set -- "$@" "$arg" # push replacement arg 193 | done 194 | fi 195 | 196 | # Collect all arguments for the java command; 197 | # * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of 198 | # shell script including quotes and variable substitutions, so put them in 199 | # double quotes to make sure that they get re-expanded; and 200 | # * put everything else in single quotes, so that it's not re-expanded. 201 | 202 | set -- \ 203 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 204 | -classpath "$CLASSPATH" \ 205 | org.gradle.wrapper.GradleWrapperMain \ 206 | "$@" 207 | 208 | # Stop when "xargs" is not available. 209 | if ! command -v xargs >/dev/null 2>&1 210 | then 211 | die "xargs is not available" 212 | fi 213 | 214 | # Use "xargs" to parse quoted args. 215 | # 216 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 217 | # 218 | # In Bash we could simply go: 219 | # 220 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 221 | # set -- "${ARGS[@]}" "$@" 222 | # 223 | # but POSIX shell has neither arrays nor command substitution, so instead we 224 | # post-process each arg (as a line of input to sed) to backslash-escape any 225 | # character that might be a shell metacharacter, then use eval to reverse 226 | # that process (while maintaining the separation between arguments), and wrap 227 | # the whole thing up as a single "set" statement. 228 | # 229 | # This will of course break if any of these variables contains a newline or 230 | # an unmatched quote. 231 | # 232 | 233 | eval "set -- $( 234 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 235 | xargs -n1 | 236 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 237 | tr '\n' ' ' 238 | )" '"$@"' 239 | 240 | exec "$JAVACMD" "$@" 241 | --------------------------------------------------------------------------------