diff --git a/.gitignore b/.gitignore index e4abbbb..6fb536a 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,8 @@ bin/ .DS_Store .env +.env.aws +.env.prod .claudeignore .claude .mcp.json diff --git a/chat-service/src/main/java/com/comatching/chat/domain/service/block/BlockServiceImpl.java b/chat-service/src/main/java/com/comatching/chat/domain/service/block/BlockServiceImpl.java index c09ba1e..1829038 100644 --- a/chat-service/src/main/java/com/comatching/chat/domain/service/block/BlockServiceImpl.java +++ b/chat-service/src/main/java/com/comatching/chat/domain/service/block/BlockServiceImpl.java @@ -84,15 +84,26 @@ public List getBlockedUsers(Long blockerUserId) { public Set getBlockedUserIds(Long blockerUserId) { String cacheKey = BLOCK_CACHE_KEY_PREFIX + blockerUserId; - Set cachedBlockedIds = (Set) redisTemplate.opsForValue().get(cacheKey); - if (cachedBlockedIds != null) { - return cachedBlockedIds; + Object cachedValue = redisTemplate.opsForValue().get(cacheKey); + + if (cachedValue != null) { + // Redis 직렬화(JSON) 과정에서 Set이 List(Array)로 변환되어 저장된 경우 처리 + if (cachedValue instanceof java.util.List) { + return new java.util.HashSet<>((java.util.List) cachedValue); + } + // 설정에 따라 Set 타입 그대로 복원된 경우 + if (cachedValue instanceof Set) { + return (Set) cachedValue; + } + // 그 외의 타입이 들어있다면 무시하고 DB 조회로 넘어갑니다. } + // DB 조회 로직 (기존과 동일) Set blockedUserIds = userBlockRepository.findByBlockerUserId(blockerUserId).stream() .map(UserBlock::getBlockedUserId) .collect(Collectors.toSet()); + // 캐시 저장 redisTemplate.opsForValue().set(cacheKey, blockedUserIds, CACHE_TTL); return blockedUserIds; diff --git a/chat-service/src/main/resources/application-aws.yml b/chat-service/src/main/resources/application-aws.yml new file mode 100644 index 0000000..73e9022 --- /dev/null +++ b/chat-service/src/main/resources/application-aws.yml @@ -0,0 +1,42 @@ +# AWS 환경 설정 - Docker 컨테이너 간 통신용 +server: + port: 9003 + +spring: + application: + name: chat-service + + data: + mongodb: + host: mongodb + port: 27017 + database: comatching_chat + auto-index-creation: true + + kafka: + bootstrap-servers: kafka:29092 + producer: + key-serializer: org.apache.kafka.common.serialization.StringSerializer + value-serializer: org.springframework.kafka.support.serializer.JsonSerializer + consumer: + group-id: chat-service-group + auto-offset-reset: latest + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer + value-deserializer: org.apache.kafka.common.serialization.StringDeserializer + + cloud: + aws: + credentials: + access-key: ${AWS_ACCESS_KEY} + secret-key: ${AWS_SECRET_KEY} + region: + static: ap-northeast-2 + s3: + bucket: ${AWS_S3_BUCKET} + +jwt: + secret: ${JWT_SECRET} + access-token: + expiration: ${ACCESS_TOKEN_EXP:86400000} + refresh-token: + expiration: ${REFRESH_TOKEN_EXP:604800000} diff --git a/common-module/src/main/java/com/comatching/common/util/CookieUtil.java b/common-module/src/main/java/com/comatching/common/util/CookieUtil.java index c9dd53e..b88b45d 100644 --- a/common-module/src/main/java/com/comatching/common/util/CookieUtil.java +++ b/common-module/src/main/java/com/comatching/common/util/CookieUtil.java @@ -14,9 +14,10 @@ public static ResponseCookie createAccessTokenCookie(String accessToken) { return ResponseCookie.from("accessToken", accessToken) .path("/") .httpOnly(true) - .secure(false) // HTTPS 환경에서는 true + .secure(false) .maxAge(Duration.ofDays(1).toSeconds()) .sameSite("Lax") + // .domain("comatching.site") .build(); } @@ -25,9 +26,10 @@ public static ResponseCookie createRefreshTokenCookie(String refreshToken) { return ResponseCookie.from("refreshToken", refreshToken) .path("/api/auth") .httpOnly(true) - .secure(false) // HTTPS 환경에서는 true + .secure(false) .maxAge(Duration.ofDays(7).toSeconds()) .sameSite("Lax") + .domain("comatching.site") .build(); } @@ -36,7 +38,8 @@ public static ResponseCookie createExpiredCookie(String cookieName) { return ResponseCookie.from(cookieName, "") .path(cookieName.equals("accessToken") ? "/" :"/api/auth") .httpOnly(true) - .maxAge(0) // 즉시 만료 + .maxAge(0) + .domain("comatching.site") .build(); } } \ No newline at end of file diff --git a/gateway-service/src/main/java/com/comatching/gateway/GatewayServiceApplication.java b/gateway-service/src/main/java/com/comatching/gateway/GatewayServiceApplication.java index a7b114f..3b284ce 100644 --- a/gateway-service/src/main/java/com/comatching/gateway/GatewayServiceApplication.java +++ b/gateway-service/src/main/java/com/comatching/gateway/GatewayServiceApplication.java @@ -1,7 +1,10 @@ package com.comatching.gateway; +import org.redisson.spring.starter.RedissonAutoConfigurationV2; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration; +import org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration; import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; import org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration; import org.springframework.context.annotation.ComponentScan; @@ -12,7 +15,10 @@ @SpringBootApplication( exclude = { DataSourceAutoConfiguration.class, - HibernateJpaAutoConfiguration.class + HibernateJpaAutoConfiguration.class, + RedisAutoConfiguration.class, + RedisRepositoriesAutoConfiguration.class, + RedissonAutoConfigurationV2.class } ) @ComponentScan( diff --git a/gateway-service/src/main/resources/application-aws.yml b/gateway-service/src/main/resources/application-aws.yml new file mode 100644 index 0000000..1960afa --- /dev/null +++ b/gateway-service/src/main/resources/application-aws.yml @@ -0,0 +1,100 @@ +# AWS 환경 설정 - Docker 컨테이너 간 통신용 +server: + port: 8080 + +spring: + application: + name: gateway-service + + cloud: + gateway: + server: + webflux: + # CORS 설정 + globalcors: + cors-configurations: + '[/**]': + allowedOrigins: + - "https://comatching.site" + - "http://localhost:3000" + - "http://localhost:5173" + allowedMethods: [GET, POST, PUT, DELETE, OPTIONS] + allowedHeaders: "*" + allowCredentials: true + + # 라우팅 규칙 - 컨테이너 이름 사용 + routes: + - id: user-service-public + uri: http://user-service:9000 + predicates: + - Path=/api/auth/login, /api/auth/signup, /api/auth/signup/nickname/availability, /api/auth/participants, /api/auth/email/**, /oauth2/**, /login/**, /default-ui.css + + - id: user-service-protected + uri: http://user-service:9000 + predicates: + - Path=/api/auth/signup/profile, /api/auth/logout, /api/auth/reissue, /api/auth/password/**, /api/auth/withdraw, /api/members/**, /api/internal/users/** + filters: + - AuthorizationHeaderFilter + + - id: user-service-swagger + uri: http://user-service:9000 + predicates: + - Path=/user-doc/** + filters: + - RewritePath=/user-doc/(?.*), /$\{segment} + + - id: matching-service + uri: http://matching-service:9001 + predicates: + - Path=/api/matching/**, /api/internal/matching/** + filters: + - AuthorizationHeaderFilter + + - id: matching-service-swagger + uri: http://matching-service:9001 + predicates: + - Path=/matching-doc/** + filters: + - RewritePath=/matching-doc/(?.*), /$\{segment} + + - id: chat-service + uri: http://chat-service:9003 + predicates: + - Path=/api/chat/**, /api/internal/chat/**, /ws/** + filters: + - AuthorizationHeaderFilter + + - id: chat-service-swagger + uri: http://chat-service:9003 + predicates: + - Path=/chat-doc/** + filters: + - RewritePath=/chat-doc/(?.*), /$\{segment} + + - id: item-service + uri: http://item-service:9006 + predicates: + - Path=/api/items/**, /api/internal/items/** + filters: + - AuthorizationHeaderFilter + + - id: notification-service + uri: http://notification:9005 + predicates: + - Path=/api/fcm/** + filters: + - AuthorizationHeaderFilter + + default-filters: + - DedupeResponseHeader=Access-Control-Allow-Origin Access-Control-Allow-Credentials, RETAIN_FIRST + +jwt: + secret: ${JWT_SECRET} + access-token: + expiration: ${ACCESS_TOKEN_EXP:86400000} + refresh-token: + expiration: ${REFRESH_TOKEN_EXP:604800000} + +logging: + level: + org.springframework.cloud.gateway: INFO diff --git a/gateway-service/src/main/resources/application.yml b/gateway-service/src/main/resources/application.yml index 7f7027b..960356c 100644 --- a/gateway-service/src/main/resources/application.yml +++ b/gateway-service/src/main/resources/application.yml @@ -27,7 +27,7 @@ spring: - id: user-service-public uri: http://localhost:9000 predicates: - - Path=/api/auth/login, /api/auth/signup, /api/auth/email/**, /oauth2/**, /login/**, /default-ui.css + - Path=/api/auth/login, /api/auth/signup, /api/auth/signup/nickname/availability, /api/auth/participants, /api/auth/email/**, /oauth2/**, /login/**, /default-ui.css - id: user-service-protected uri: http://localhost:9000 @@ -71,24 +71,10 @@ spring: filters: - RewritePath=/chat-doc/(?.*), /$\{segment} - - id: payment-service - uri: http://localhost:9004 - predicates: - - Path=/api/payment/** - filters: - - AuthorizationHeaderFilter - - - id: payment-service-swagger - uri: http://localhost:9004 - predicates: - - Path=/payment-doc/** - filters: - - RewritePath=/payment-doc/(?.*), /$\{segment} - - id: item-service uri: http://localhost:9006 predicates: - - Path=/api/items/**, /api/internal/items/** + - Path=/api/items/**, /api/internal/items/**, /api/v1/** filters: - AuthorizationHeaderFilter diff --git a/item-service/src/main/java/com/comatching/item/domain/dto/ItemHistoryResponse.java b/item-service/src/main/java/com/comatching/item/domain/item/dto/ItemHistoryResponse.java similarity index 75% rename from item-service/src/main/java/com/comatching/item/domain/dto/ItemHistoryResponse.java rename to item-service/src/main/java/com/comatching/item/domain/item/dto/ItemHistoryResponse.java index 5c90086..4790836 100644 --- a/item-service/src/main/java/com/comatching/item/domain/dto/ItemHistoryResponse.java +++ b/item-service/src/main/java/com/comatching/item/domain/item/dto/ItemHistoryResponse.java @@ -1,9 +1,9 @@ -package com.comatching.item.domain.dto; +package com.comatching.item.domain.item.dto; import java.time.LocalDateTime; -import com.comatching.item.domain.entity.ItemHistory; +import com.comatching.item.domain.item.entity.ItemHistory; import com.comatching.common.domain.enums.ItemType; -import com.comatching.item.domain.enums.ItemHistoryType; +import com.comatching.item.domain.item.enums.ItemHistoryType; public record ItemHistoryResponse( Long historyId, diff --git a/item-service/src/main/java/com/comatching/item/domain/dto/ItemResponse.java b/item-service/src/main/java/com/comatching/item/domain/item/dto/ItemResponse.java similarity index 78% rename from item-service/src/main/java/com/comatching/item/domain/dto/ItemResponse.java rename to item-service/src/main/java/com/comatching/item/domain/item/dto/ItemResponse.java index f984398..7bfe817 100644 --- a/item-service/src/main/java/com/comatching/item/domain/dto/ItemResponse.java +++ b/item-service/src/main/java/com/comatching/item/domain/item/dto/ItemResponse.java @@ -1,9 +1,9 @@ -package com.comatching.item.domain.dto; +package com.comatching.item.domain.item.dto; import java.time.LocalDateTime; import com.comatching.common.domain.enums.ItemType; -import com.comatching.item.domain.entity.Item; +import com.comatching.item.domain.item.entity.Item; public record ItemResponse( Long itemId, diff --git a/item-service/src/main/java/com/comatching/item/domain/entity/Item.java b/item-service/src/main/java/com/comatching/item/domain/item/entity/Item.java similarity index 97% rename from item-service/src/main/java/com/comatching/item/domain/entity/Item.java rename to item-service/src/main/java/com/comatching/item/domain/item/entity/Item.java index 2465df6..d31c63e 100644 --- a/item-service/src/main/java/com/comatching/item/domain/entity/Item.java +++ b/item-service/src/main/java/com/comatching/item/domain/item/entity/Item.java @@ -1,4 +1,4 @@ -package com.comatching.item.domain.entity; +package com.comatching.item.domain.item.entity; import java.time.LocalDateTime; diff --git a/item-service/src/main/java/com/comatching/item/domain/entity/ItemHistory.java b/item-service/src/main/java/com/comatching/item/domain/item/entity/ItemHistory.java similarity index 92% rename from item-service/src/main/java/com/comatching/item/domain/entity/ItemHistory.java rename to item-service/src/main/java/com/comatching/item/domain/item/entity/ItemHistory.java index dfcb107..cf35e5f 100644 --- a/item-service/src/main/java/com/comatching/item/domain/entity/ItemHistory.java +++ b/item-service/src/main/java/com/comatching/item/domain/item/entity/ItemHistory.java @@ -1,8 +1,8 @@ -package com.comatching.item.domain.entity; +package com.comatching.item.domain.item.entity; import java.time.LocalDateTime; -import com.comatching.item.domain.enums.ItemHistoryType; +import com.comatching.item.domain.item.enums.ItemHistoryType; import com.comatching.common.domain.enums.ItemType; import jakarta.persistence.Column; diff --git a/item-service/src/main/java/com/comatching/item/domain/enums/ItemHistoryType.java b/item-service/src/main/java/com/comatching/item/domain/item/enums/ItemHistoryType.java similarity index 85% rename from item-service/src/main/java/com/comatching/item/domain/enums/ItemHistoryType.java rename to item-service/src/main/java/com/comatching/item/domain/item/enums/ItemHistoryType.java index b0a1d92..9790298 100644 --- a/item-service/src/main/java/com/comatching/item/domain/enums/ItemHistoryType.java +++ b/item-service/src/main/java/com/comatching/item/domain/item/enums/ItemHistoryType.java @@ -1,4 +1,4 @@ -package com.comatching.item.domain.enums; +package com.comatching.item.domain.item.enums; import lombok.Getter; import lombok.RequiredArgsConstructor; diff --git a/item-service/src/main/java/com/comatching/item/domain/repository/ItemHistoryRepository.java b/item-service/src/main/java/com/comatching/item/domain/item/repository/ItemHistoryRepository.java similarity index 84% rename from item-service/src/main/java/com/comatching/item/domain/repository/ItemHistoryRepository.java rename to item-service/src/main/java/com/comatching/item/domain/item/repository/ItemHistoryRepository.java index 069d3d4..7462263 100644 --- a/item-service/src/main/java/com/comatching/item/domain/repository/ItemHistoryRepository.java +++ b/item-service/src/main/java/com/comatching/item/domain/item/repository/ItemHistoryRepository.java @@ -1,4 +1,4 @@ -package com.comatching.item.domain.repository; +package com.comatching.item.domain.item.repository; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -8,8 +8,8 @@ import org.springframework.stereotype.Repository; import com.comatching.common.domain.enums.ItemType; -import com.comatching.item.domain.entity.ItemHistory; -import com.comatching.item.domain.enums.ItemHistoryType; +import com.comatching.item.domain.item.entity.ItemHistory; +import com.comatching.item.domain.item.enums.ItemHistoryType; @Repository public interface ItemHistoryRepository extends JpaRepository { diff --git a/item-service/src/main/java/com/comatching/item/domain/repository/ItemRepository.java b/item-service/src/main/java/com/comatching/item/domain/item/repository/ItemRepository.java similarity index 92% rename from item-service/src/main/java/com/comatching/item/domain/repository/ItemRepository.java rename to item-service/src/main/java/com/comatching/item/domain/item/repository/ItemRepository.java index 659f197..450edf5 100644 --- a/item-service/src/main/java/com/comatching/item/domain/repository/ItemRepository.java +++ b/item-service/src/main/java/com/comatching/item/domain/item/repository/ItemRepository.java @@ -1,4 +1,4 @@ -package com.comatching.item.domain.repository; +package com.comatching.item.domain.item.repository; import java.util.List; @@ -10,7 +10,7 @@ import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; -import com.comatching.item.domain.entity.Item; +import com.comatching.item.domain.item.entity.Item; import com.comatching.common.domain.enums.ItemType; import jakarta.persistence.LockModeType; diff --git a/item-service/src/main/java/com/comatching/item/domain/service/ItemHistoryService.java b/item-service/src/main/java/com/comatching/item/domain/item/service/ItemHistoryService.java similarity index 76% rename from item-service/src/main/java/com/comatching/item/domain/service/ItemHistoryService.java rename to item-service/src/main/java/com/comatching/item/domain/item/service/ItemHistoryService.java index 8ce513b..28267fa 100644 --- a/item-service/src/main/java/com/comatching/item/domain/service/ItemHistoryService.java +++ b/item-service/src/main/java/com/comatching/item/domain/item/service/ItemHistoryService.java @@ -1,11 +1,11 @@ -package com.comatching.item.domain.service; +package com.comatching.item.domain.item.service; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import com.comatching.common.dto.response.PagingResponse; -import com.comatching.item.domain.dto.ItemHistoryResponse; -import com.comatching.item.domain.enums.ItemHistoryType; +import com.comatching.item.domain.item.dto.ItemHistoryResponse; +import com.comatching.item.domain.item.enums.ItemHistoryType; import com.comatching.common.domain.enums.ItemType; public interface ItemHistoryService { diff --git a/item-service/src/main/java/com/comatching/item/domain/service/ItemHistoryServiceImpl.java b/item-service/src/main/java/com/comatching/item/domain/item/service/ItemHistoryServiceImpl.java similarity index 82% rename from item-service/src/main/java/com/comatching/item/domain/service/ItemHistoryServiceImpl.java rename to item-service/src/main/java/com/comatching/item/domain/item/service/ItemHistoryServiceImpl.java index b0f48e4..08bf734 100644 --- a/item-service/src/main/java/com/comatching/item/domain/service/ItemHistoryServiceImpl.java +++ b/item-service/src/main/java/com/comatching/item/domain/item/service/ItemHistoryServiceImpl.java @@ -1,4 +1,4 @@ -package com.comatching.item.domain.service; +package com.comatching.item.domain.item.service; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -7,10 +7,10 @@ import com.comatching.common.domain.enums.ItemType; import com.comatching.common.dto.response.PagingResponse; -import com.comatching.item.domain.dto.ItemHistoryResponse; -import com.comatching.item.domain.entity.ItemHistory; -import com.comatching.item.domain.enums.ItemHistoryType; -import com.comatching.item.domain.repository.ItemHistoryRepository; +import com.comatching.item.domain.item.dto.ItemHistoryResponse; +import com.comatching.item.domain.item.entity.ItemHistory; +import com.comatching.item.domain.item.enums.ItemHistoryType; +import com.comatching.item.domain.item.repository.ItemHistoryRepository; import lombok.RequiredArgsConstructor; diff --git a/item-service/src/main/java/com/comatching/item/domain/service/ItemService.java b/item-service/src/main/java/com/comatching/item/domain/item/service/ItemService.java similarity index 76% rename from item-service/src/main/java/com/comatching/item/domain/service/ItemService.java rename to item-service/src/main/java/com/comatching/item/domain/item/service/ItemService.java index 084db72..2722f47 100644 --- a/item-service/src/main/java/com/comatching/item/domain/service/ItemService.java +++ b/item-service/src/main/java/com/comatching/item/domain/item/service/ItemService.java @@ -1,12 +1,11 @@ -package com.comatching.item.domain.service; +package com.comatching.item.domain.item.service; -import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; -import com.comatching.common.dto.item.AddItemRequest; import com.comatching.common.domain.enums.ItemType; +import com.comatching.common.dto.item.AddItemRequest; import com.comatching.common.dto.response.PagingResponse; -import com.comatching.item.domain.dto.ItemResponse; +import com.comatching.item.domain.item.dto.ItemResponse; public interface ItemService { diff --git a/item-service/src/main/java/com/comatching/item/domain/service/ItemServiceImpl.java b/item-service/src/main/java/com/comatching/item/domain/item/service/ItemServiceImpl.java similarity index 90% rename from item-service/src/main/java/com/comatching/item/domain/service/ItemServiceImpl.java rename to item-service/src/main/java/com/comatching/item/domain/item/service/ItemServiceImpl.java index f32668d..72a1d60 100644 --- a/item-service/src/main/java/com/comatching/item/domain/service/ItemServiceImpl.java +++ b/item-service/src/main/java/com/comatching/item/domain/item/service/ItemServiceImpl.java @@ -1,4 +1,4 @@ -package com.comatching.item.domain.service; +package com.comatching.item.domain.item.service; import java.time.LocalDateTime; import java.util.List; @@ -12,10 +12,10 @@ import com.comatching.common.dto.item.AddItemRequest; import com.comatching.common.dto.response.PagingResponse; import com.comatching.common.exception.BusinessException; -import com.comatching.item.domain.dto.ItemResponse; -import com.comatching.item.domain.entity.Item; -import com.comatching.item.domain.enums.ItemHistoryType; -import com.comatching.item.domain.repository.ItemRepository; +import com.comatching.item.domain.item.dto.ItemResponse; +import com.comatching.item.domain.item.entity.Item; +import com.comatching.item.domain.item.enums.ItemHistoryType; +import com.comatching.item.domain.item.repository.ItemRepository; import com.comatching.item.global.exception.ItemErrorCode; import lombok.RequiredArgsConstructor; diff --git a/item-service/src/main/java/com/comatching/item/domain/product/dto/ProductResponse.java b/item-service/src/main/java/com/comatching/item/domain/product/dto/ProductResponse.java new file mode 100644 index 0000000..7093e46 --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/domain/product/dto/ProductResponse.java @@ -0,0 +1,39 @@ +package com.comatching.item.domain.product.dto; + +import com.comatching.common.domain.enums.ItemType; +import com.comatching.item.domain.product.entity.Product; +import com.comatching.item.domain.product.entity.ProductReward; + +import java.util.List; + +public record ProductResponse( + Long id, + String name, + int price, + List rewards +) { + public static ProductResponse from(Product product) { + return new ProductResponse( + product.getId(), + product.getName(), + product.getPrice(), + product.getRewards().stream() + .map(ProductRewardDto::from) + .toList() + ); + } +} + +record ProductRewardDto( + ItemType itemType, + String itemName, + int quantity +) { + public static ProductRewardDto from(ProductReward reward) { + return new ProductRewardDto( + reward.getItemType(), + reward.getItemType().getName(), + reward.getQuantity() + ); + } +} \ No newline at end of file diff --git a/item-service/src/main/java/com/comatching/item/domain/product/dto/PurchaseRequestDto.java b/item-service/src/main/java/com/comatching/item/domain/product/dto/PurchaseRequestDto.java new file mode 100644 index 0000000..4796a60 --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/domain/product/dto/PurchaseRequestDto.java @@ -0,0 +1,26 @@ +package com.comatching.item.domain.product.dto; + +import com.comatching.item.domain.product.entity.PurchaseRequest; +import com.comatching.item.domain.product.enums.PurchaseStatus; + +import java.time.LocalDateTime; + +public record PurchaseRequestDto( + Long requestId, + Long memberId, + String productName, + int paymentPrice, + PurchaseStatus status, + LocalDateTime requestedAt +) { + public static PurchaseRequestDto from(PurchaseRequest request) { + return new PurchaseRequestDto( + request.getId(), + request.getMemberId(), + request.getProductName(), + request.getPaymentPrice(), + request.getStatus(), + request.getRequestedAt() + ); + } +} \ No newline at end of file diff --git a/item-service/src/main/java/com/comatching/item/domain/product/entity/Product.java b/item-service/src/main/java/com/comatching/item/domain/product/entity/Product.java new file mode 100644 index 0000000..890f304 --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/domain/product/entity/Product.java @@ -0,0 +1,44 @@ +package com.comatching.item.domain.product.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Product { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String name; + + @Column(nullable = false) + private int price; + + @Column(nullable = false) + private boolean isActive; + + @OneToMany(mappedBy = "product", cascade = CascadeType.ALL, orphanRemoval = true) + private List rewards = new ArrayList<>(); + + @Builder + public Product(String name, int price, boolean isActive) { + this.name = name; + this.price = price; + this.isActive = isActive; + } + + // 연관관계 편의 메서드 (상품 생성 시 구성품을 쉽게 추가하기 위함) + public void addReward(ProductReward reward) { + this.rewards.add(reward); + reward.setProduct(this); + } +} \ No newline at end of file diff --git a/item-service/src/main/java/com/comatching/item/domain/product/entity/ProductReward.java b/item-service/src/main/java/com/comatching/item/domain/product/entity/ProductReward.java new file mode 100644 index 0000000..92ed2ef --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/domain/product/entity/ProductReward.java @@ -0,0 +1,39 @@ +package com.comatching.item.domain.product.entity; + +import com.comatching.common.domain.enums.ItemType; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ProductReward { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "product_id") + private Product product; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private ItemType itemType; + + @Column(nullable = false) + private int quantity; + + @Builder + public ProductReward(ItemType itemType, int quantity) { + this.itemType = itemType; + this.quantity = quantity; + } + + // Product에서 호출하기 위한 설정자 + protected void setProduct(Product product) { + this.product = product; + } +} \ No newline at end of file diff --git a/item-service/src/main/java/com/comatching/item/domain/product/entity/PurchaseRequest.java b/item-service/src/main/java/com/comatching/item/domain/product/entity/PurchaseRequest.java new file mode 100644 index 0000000..11d6757 --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/domain/product/entity/PurchaseRequest.java @@ -0,0 +1,53 @@ +package com.comatching.item.domain.product.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import java.time.LocalDateTime; + +import com.comatching.item.domain.product.enums.PurchaseStatus; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "purchase_request") +public class PurchaseRequest { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private Long memberId; + + private Long productId; // 어떤 상품을 샀는지 기록 + + private String productName; // 상품명이 바뀌어도 기록 남기기용 + + private int paymentPrice; // 구매 요청 당시의 가격을 저장 (가격 변동 대비) + + @Enumerated(EnumType.STRING) + private PurchaseStatus status; + + private LocalDateTime requestedAt; + private LocalDateTime approvedAt; + + @Builder + public PurchaseRequest(Long memberId, Long productId, String productName, int paymentPrice) { + this.memberId = memberId; + this.productId = productId; + this.productName = productName; + this.paymentPrice = paymentPrice; + this.status = PurchaseStatus.PENDING; + this.requestedAt = LocalDateTime.now(); + } + + public void approve() { + this.status = PurchaseStatus.APPROVED; + this.approvedAt = LocalDateTime.now(); + } + + public void reject() { + this.status = PurchaseStatus.REJECTED; + } +} \ No newline at end of file diff --git a/item-service/src/main/java/com/comatching/item/domain/product/enums/PurchaseStatus.java b/item-service/src/main/java/com/comatching/item/domain/product/enums/PurchaseStatus.java new file mode 100644 index 0000000..233a5b6 --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/domain/product/enums/PurchaseStatus.java @@ -0,0 +1,5 @@ +package com.comatching.item.domain.product.enums; + +public enum PurchaseStatus { + PENDING, APPROVED, REJECTED +} diff --git a/item-service/src/main/java/com/comatching/item/domain/product/repository/ProductRepository.java b/item-service/src/main/java/com/comatching/item/domain/product/repository/ProductRepository.java new file mode 100644 index 0000000..8cea888 --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/domain/product/repository/ProductRepository.java @@ -0,0 +1,10 @@ +package com.comatching.item.domain.product.repository; + +import com.comatching.item.domain.product.entity.Product; +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; + +public interface ProductRepository extends JpaRepository { + // 판매 중인 상품만 조회 + List findByIsActiveTrue(); +} \ No newline at end of file diff --git a/item-service/src/main/java/com/comatching/item/domain/product/repository/PurchaseRequestRepository.java b/item-service/src/main/java/com/comatching/item/domain/product/repository/PurchaseRequestRepository.java new file mode 100644 index 0000000..e1ce5f9 --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/domain/product/repository/PurchaseRequestRepository.java @@ -0,0 +1,10 @@ +package com.comatching.item.domain.product.repository; + +import com.comatching.item.domain.product.entity.PurchaseRequest; +import com.comatching.item.domain.product.enums.PurchaseStatus; +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; + +public interface PurchaseRequestRepository extends JpaRepository { + List findAllByStatusOrderByRequestedAtDesc(PurchaseStatus status); +} \ No newline at end of file diff --git a/item-service/src/main/java/com/comatching/item/domain/product/service/AdminPaymentService.java b/item-service/src/main/java/com/comatching/item/domain/product/service/AdminPaymentService.java new file mode 100644 index 0000000..0bfce39 --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/domain/product/service/AdminPaymentService.java @@ -0,0 +1,12 @@ +package com.comatching.item.domain.product.service; + +import java.util.List; + +import com.comatching.item.domain.product.dto.PurchaseRequestDto; + +public interface AdminPaymentService { + + List getPendingRequests(); + + void approvePurchase(Long requestId); +} diff --git a/item-service/src/main/java/com/comatching/item/domain/product/service/AdminPaymentServiceImpl.java b/item-service/src/main/java/com/comatching/item/domain/product/service/AdminPaymentServiceImpl.java new file mode 100644 index 0000000..72a1f45 --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/domain/product/service/AdminPaymentServiceImpl.java @@ -0,0 +1,77 @@ +package com.comatching.item.domain.product.service; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.comatching.common.domain.enums.ItemRoute; +import com.comatching.common.dto.item.AddItemRequest; +import com.comatching.common.exception.BusinessException; +import com.comatching.item.domain.item.service.ItemService; +import com.comatching.item.domain.product.dto.PurchaseRequestDto; +import com.comatching.item.domain.product.entity.Product; +import com.comatching.item.domain.product.entity.ProductReward; +import com.comatching.item.domain.product.entity.PurchaseRequest; +import com.comatching.item.domain.product.enums.PurchaseStatus; +import com.comatching.item.domain.product.repository.ProductRepository; +import com.comatching.item.domain.product.repository.PurchaseRequestRepository; +import com.comatching.item.global.exception.ItemErrorCode; +import com.comatching.item.global.exception.PaymentErrorCode; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional +public class AdminPaymentServiceImpl implements AdminPaymentService { + + private final PurchaseRequestRepository purchaseRequestRepository; + private final ProductRepository productRepository; + private final ItemService itemService; + + // 상수: 구매 아이템의 유효기간 (약 100년) + private static final int PURCHASED_ITEM_EXPIRE_DAYS = 36500; + + @Override + @Transactional(readOnly = true) + public List getPendingRequests() { + // PENDING 상태인 요청을 최신순으로 조회하여 DTO로 변환 + return purchaseRequestRepository.findAllByStatusOrderByRequestedAtDesc(PurchaseStatus.PENDING) + .stream() + .map(PurchaseRequestDto::from) + .toList(); + } + + @Override + public void approvePurchase(Long requestId) { + // 1. 요청 조회 + PurchaseRequest request = purchaseRequestRepository.findById(requestId) + .orElseThrow(() -> new BusinessException(PaymentErrorCode.REQUEST_NOT_FOUND)); + + // 2. 상태 검증 (이미 처리된 건인지) + if (request.getStatus() != PurchaseStatus.PENDING) { + throw new BusinessException(PaymentErrorCode.ALREADY_PROCESSED); + } + + // 3. 요청 상태 변경 (승인) + request.approve(); + + // 4. 상품 정보 조회 (구성품 확인용) + Product product = productRepository.findById(request.getProductId()) + .orElseThrow(() -> new BusinessException(ItemErrorCode.PRODUCT_NOT_FOUND)); + + // 5. 구성품 지급 (루프) + for (ProductReward reward : product.getRewards()) { + AddItemRequest addItemRequest = new AddItemRequest( + reward.getItemType(), + reward.getQuantity(), + ItemRoute.CHARGE, // 경로는 '충전' + PURCHASED_ITEM_EXPIRE_DAYS // 유효기간 설정 + ); + + // 기존 ItemService의 addItem 재사용 (히스토리 저장 등 로직 포함됨) + itemService.addItem(request.getMemberId(), addItemRequest); + } + } +} \ No newline at end of file diff --git a/item-service/src/main/java/com/comatching/item/domain/product/service/ShopService.java b/item-service/src/main/java/com/comatching/item/domain/product/service/ShopService.java new file mode 100644 index 0000000..b989ec3 --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/domain/product/service/ShopService.java @@ -0,0 +1,12 @@ +package com.comatching.item.domain.product.service; + +import java.util.List; + +import com.comatching.item.domain.product.dto.ProductResponse; + +public interface ShopService { + + List getActiveProducts(); + + void requestPurchase(Long memberId, Long productId); +} diff --git a/item-service/src/main/java/com/comatching/item/domain/product/service/ShopServiceImpl.java b/item-service/src/main/java/com/comatching/item/domain/product/service/ShopServiceImpl.java new file mode 100644 index 0000000..8030295 --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/domain/product/service/ShopServiceImpl.java @@ -0,0 +1,52 @@ +package com.comatching.item.domain.product.service; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.comatching.common.exception.BusinessException; +import com.comatching.item.domain.product.dto.ProductResponse; +import com.comatching.item.domain.product.entity.Product; +import com.comatching.item.domain.product.entity.PurchaseRequest; +import com.comatching.item.domain.product.repository.ProductRepository; +import com.comatching.item.domain.product.repository.PurchaseRequestRepository; +import com.comatching.item.global.exception.ItemErrorCode; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional +public class ShopServiceImpl implements ShopService { + + private final ProductRepository productRepository; + private final PurchaseRequestRepository purchaseRequestRepository; + + @Override + @Transactional(readOnly = true) + public List getActiveProducts() { + return productRepository.findByIsActiveTrue().stream() + .map(ProductResponse::from) + .toList(); + } + + @Override + public void requestPurchase(Long memberId, Long productId) { + Product product = productRepository.findById(productId) + .orElseThrow(() -> new BusinessException(ItemErrorCode.PRODUCT_NOT_FOUND)); + + if (!product.isActive()) { + throw new BusinessException(ItemErrorCode.PRODUCT_NOT_AVAILABLE); + } + + PurchaseRequest request = PurchaseRequest.builder() + .memberId(memberId) + .productId(product.getId()) + .productName(product.getName()) + .paymentPrice(product.getPrice()) // 구매 시점 가격 고정 + .build(); + + purchaseRequestRepository.save(request); + } +} \ No newline at end of file diff --git a/item-service/src/main/java/com/comatching/item/global/exception/ItemErrorCode.java b/item-service/src/main/java/com/comatching/item/global/exception/ItemErrorCode.java index a23a162..ebf1982 100644 --- a/item-service/src/main/java/com/comatching/item/global/exception/ItemErrorCode.java +++ b/item-service/src/main/java/com/comatching/item/global/exception/ItemErrorCode.java @@ -10,6 +10,8 @@ public enum ItemErrorCode implements ErrorCode { NOT_ENOUGH_ITEM("ITEM-001", HttpStatus.BAD_REQUEST, "아이템이 부족합니다."), + PRODUCT_NOT_FOUND("ITEM-002", HttpStatus.BAD_REQUEST, "상품을 찾을 수 없습니다."), + PRODUCT_NOT_AVAILABLE("ITEM-003", HttpStatus.BAD_REQUEST, "유효하지 않은 상품"), ; private final String code; diff --git a/item-service/src/main/java/com/comatching/item/global/exception/PaymentErrorCode.java b/item-service/src/main/java/com/comatching/item/global/exception/PaymentErrorCode.java new file mode 100644 index 0000000..72fd5fd --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/global/exception/PaymentErrorCode.java @@ -0,0 +1,25 @@ +package com.comatching.item.global.exception; + +import org.springframework.http.HttpStatus; + +import com.comatching.common.exception.code.ErrorCode; + +import lombok.Getter; + +@Getter +public enum PaymentErrorCode implements ErrorCode { + + REQUEST_NOT_FOUND("PAY-001", HttpStatus.BAD_REQUEST, "존재하지 않는 결제건"), + ALREADY_PROCESSED("PAY-002", HttpStatus.BAD_REQUEST, "이미 처리된 결제건"), + ; + + private final String code; + private final HttpStatus httpStatus; + private final String message; + + PaymentErrorCode(String code, HttpStatus httpStatus, String message) { + this.code = code; + this.httpStatus = httpStatus; + this.message = message; + } +} \ No newline at end of file diff --git a/item-service/src/main/java/com/comatching/item/global/init/ShopDataInitializer.java b/item-service/src/main/java/com/comatching/item/global/init/ShopDataInitializer.java new file mode 100644 index 0000000..20bc162 --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/global/init/ShopDataInitializer.java @@ -0,0 +1,85 @@ +package com.comatching.item.global.init; + +import java.util.List; + +import org.springframework.boot.CommandLineRunner; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import com.comatching.common.domain.enums.ItemType; +import com.comatching.item.domain.product.entity.Product; +import com.comatching.item.domain.product.entity.ProductReward; +import com.comatching.item.domain.product.repository.ProductRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ShopDataInitializer implements CommandLineRunner { + + private final ProductRepository productRepository; + + @Override + @Transactional + public void run(String... args) throws Exception { + if (productRepository.count() > 0) { + log.info("[ShopDataInitializer] 이미 상품 데이터가 존재하여 초기화를 건너뜁니다."); + return; + } + + log.info("[ShopDataInitializer] 초기 상품 데이터를 생성합니다..."); + + // 1. 뽑기권 X1 (1,000원) + Product p1 = Product.builder() + .name("매칭권 1개") + .price(1000) + .isActive(true) + .build(); + p1.addReward(ProductReward.builder() + .itemType(ItemType.MATCHING_TICKET) + .quantity(1) + .build()); + + // 2. 뽑기권 X5 + 옵션권 X1 (5,000원) + Product p2 = Product.builder() + .name("매칭권 5개 (+옵션권 1개)") + .price(5000) + .isActive(true) + .build(); + p2.addReward(ProductReward.builder() + .itemType(ItemType.MATCHING_TICKET) + .quantity(5) + .build()); + p2.addReward(ProductReward.builder() + .itemType(ItemType.OPTION_TICKET) + .quantity(1) + .build()); + + // 3. 뽑기권 X10 (9,000원) - 할인 상품 + Product p3 = Product.builder() + .name("매칭권 10개 (10% 할인)") + .price(9000) + .isActive(true) + .build(); + p3.addReward(ProductReward.builder() + .itemType(ItemType.MATCHING_TICKET) + .quantity(10) + .build()); + + // 4. 옵션권 X1 (300원) + Product p4 = Product.builder() + .name("옵션권 1개") + .price(300) + .isActive(true) + .build(); + p4.addReward(ProductReward.builder() + .itemType(ItemType.OPTION_TICKET) + .quantity(1) + .build()); + productRepository.saveAll(List.of(p1, p2, p3, p4)); + + log.info("[ShopDataInitializer] 상품 {}개 생성 완료.", 4); + } +} \ No newline at end of file diff --git a/item-service/src/main/java/com/comatching/item/infra/controller/AdminPaymentController.java b/item-service/src/main/java/com/comatching/item/infra/controller/AdminPaymentController.java new file mode 100644 index 0000000..1fa3b27 --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/infra/controller/AdminPaymentController.java @@ -0,0 +1,46 @@ +package com.comatching.item.infra.controller; + +import java.util.List; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.comatching.common.annotation.CurrentMember; +import com.comatching.common.annotation.RequireRole; +import com.comatching.common.domain.enums.MemberRole; +import com.comatching.common.dto.member.MemberInfo; +import com.comatching.common.dto.response.ApiResponse; +import com.comatching.item.domain.product.dto.PurchaseRequestDto; +import com.comatching.item.domain.product.service.AdminPaymentService; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; + +@Tag(name = "Admin Payment API", description = "관리자 전용 결제 승인 및 관리") +@RestController +@RequestMapping("/api/v1/admin/payment") +@RequiredArgsConstructor +public class AdminPaymentController { + + private final AdminPaymentService adminPaymentService; + + // @RequireRole(MemberRole.ROLE_ADMIN) + @Operation(summary = "승인 대기 목록 조회", description = "아직 처리되지 않은(PENDING) 구매 요청 목록을 최신순으로 조회합니다.") + @GetMapping("/requests") + public ResponseEntity>> getPendingRequests(@CurrentMember MemberInfo memberInfo) { + return ResponseEntity.ok(ApiResponse.ok(adminPaymentService.getPendingRequests())); + } + + @RequireRole(MemberRole.ROLE_ADMIN) + @Operation(summary = "구매 승인 및 아이템 지급", description = "입금이 확인된 요청 건을 승인하고 사용자에게 아이템을 지급합니다.") + @PostMapping("/approve/{requestId}") + public ResponseEntity> approvePurchase(@PathVariable Long requestId, @CurrentMember MemberInfo memberInfo) { + adminPaymentService.approvePurchase(requestId); + return ResponseEntity.ok(ApiResponse.ok()); + } +} \ No newline at end of file diff --git a/item-service/src/main/java/com/comatching/item/infra/controller/ItemController.java b/item-service/src/main/java/com/comatching/item/infra/controller/ItemController.java index 02fd739..6d9f593 100644 --- a/item-service/src/main/java/com/comatching/item/infra/controller/ItemController.java +++ b/item-service/src/main/java/com/comatching/item/infra/controller/ItemController.java @@ -1,25 +1,20 @@ package com.comatching.item.infra.controller; -import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.data.web.PageableDefault; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import com.comatching.common.annotation.CurrentMember; -import com.comatching.common.annotation.RequireRole; -import com.comatching.common.domain.enums.MemberRole; -import com.comatching.common.dto.member.MemberInfo; import com.comatching.common.dto.response.ApiResponse; import com.comatching.common.dto.item.AddItemRequest; import com.comatching.common.dto.response.PagingResponse; -import com.comatching.item.domain.dto.ItemHistoryResponse; +import com.comatching.item.domain.item.dto.ItemHistoryResponse; import com.comatching.common.domain.enums.ItemType; -import com.comatching.item.domain.dto.ItemResponse; -import com.comatching.item.domain.enums.ItemHistoryType; -import com.comatching.item.domain.service.ItemHistoryService; -import com.comatching.item.domain.service.ItemService; +import com.comatching.item.domain.item.dto.ItemResponse; +import com.comatching.item.domain.item.enums.ItemHistoryType; +import com.comatching.item.domain.item.service.ItemHistoryService; +import com.comatching.item.domain.item.service.ItemService; import lombok.RequiredArgsConstructor; diff --git a/item-service/src/main/java/com/comatching/item/infra/controller/ShopController.java b/item-service/src/main/java/com/comatching/item/infra/controller/ShopController.java new file mode 100644 index 0000000..451f224 --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/infra/controller/ShopController.java @@ -0,0 +1,45 @@ +package com.comatching.item.infra.controller; + +import java.util.List; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.comatching.common.annotation.CurrentMember; +import com.comatching.common.dto.member.MemberInfo; +import com.comatching.common.dto.response.ApiResponse; +import com.comatching.item.domain.product.dto.ProductResponse; +import com.comatching.item.domain.product.service.ShopService; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; + +@Tag(name = "Shop API", description = "아이템 상점 및 구매 요청") +@RestController +@RequestMapping("/api/v1/shop") +@RequiredArgsConstructor +public class ShopController { + + private final ShopService shopService; + + @Operation(summary = "상품 목록 조회", description = "현재 판매 중인 모든 아이템 패키지 목록을 조회합니다.") + @GetMapping("/products") + public ResponseEntity>> getActiveProducts() { + return ResponseEntity.ok(ApiResponse.ok(shopService.getActiveProducts())); + } + + @Operation(summary = "아이템 구매 요청", description = "특정 상품에 대한 구매 요청(입금 대기)을 생성합니다.") + @PostMapping("/purchase/{productId}") + public ResponseEntity> requestPurchase( + @CurrentMember MemberInfo memberInfo, + @PathVariable Long productId + ) { + shopService.requestPurchase(memberInfo.memberId(), productId); + return ResponseEntity.ok(ApiResponse.ok()); + } +} \ No newline at end of file diff --git a/item-service/src/main/resources/application-aws.yml b/item-service/src/main/resources/application-aws.yml new file mode 100644 index 0000000..6ed68ac --- /dev/null +++ b/item-service/src/main/resources/application-aws.yml @@ -0,0 +1,55 @@ +# AWS 환경 설정 - Docker 컨테이너 간 통신용 +server: + port: 9006 + +member-service: + url: http://user-service:9000 + +spring: + application: + name: item-service + + datasource: + url: jdbc:mysql://${RDS_ENDPOINT}:3306/comatching_item?serverTimezone=Asia/Seoul&characterEncoding=UTF-8&useSSL=true&requireSSL=true + username: ${RDS_USERNAME} + password: ${RDS_PASSWORD} + hikari: + pool-name: HikariPool-${spring.application.name} + maximum-pool-size: 10 + minimum-idle: 5 + connection-timeout: 30000 + max-lifetime: 1800000 + validation-timeout: 5000 + + jpa: + hibernate: + ddl-auto: update + show-sql: false + + kafka: + bootstrap-servers: kafka:29092 + producer: + key-serializer: org.apache.kafka.common.serialization.StringSerializer + value-serializer: org.springframework.kafka.support.serializer.JsonSerializer + consumer: + group-id: item-group + auto-offset-reset: earliest + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer + value-deserializer: org.apache.kafka.common.serialization.StringDeserializer + + cloud: + aws: + credentials: + access-key: ${AWS_ACCESS_KEY} + secret-key: ${AWS_SECRET_KEY} + region: + static: ap-northeast-2 + s3: + bucket: ${AWS_S3_BUCKET} + +jwt: + secret: ${JWT_SECRET} + access-token: + expiration: ${ACCESS_TOKEN_EXP:86400000} + refresh-token: + expiration: ${REFRESH_TOKEN_EXP:604800000} diff --git a/item-service/src/test/java/com/comatching/item/domain/service/ItemServiceTest.java b/item-service/src/test/java/com/comatching/item/domain/service/ItemServiceTest.java index 2d30065..e502ba4 100644 --- a/item-service/src/test/java/com/comatching/item/domain/service/ItemServiceTest.java +++ b/item-service/src/test/java/com/comatching/item/domain/service/ItemServiceTest.java @@ -14,9 +14,11 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import com.comatching.item.domain.entity.Item; +import com.comatching.item.domain.item.entity.Item; import com.comatching.common.domain.enums.ItemType; -import com.comatching.item.domain.repository.ItemRepository; +import com.comatching.item.domain.item.repository.ItemRepository; +import com.comatching.item.domain.item.service.ItemHistoryService; +import com.comatching.item.domain.item.service.ItemServiceImpl; @ExtendWith(MockitoExtension.class) @DisplayName("ItemServiceImpl 테스트") diff --git a/matching-service/src/main/resources/application-aws.yml b/matching-service/src/main/resources/application-aws.yml new file mode 100644 index 0000000..2fb9c50 --- /dev/null +++ b/matching-service/src/main/resources/application-aws.yml @@ -0,0 +1,66 @@ +# AWS 환경 설정 - Docker 컨테이너 간 통신용 +server: + port: 9001 + +user-service: + url: http://user-service:9000 + +item-service: + url: http://item-service:9006 + +spring: + application: + name: matching-service + + datasource: + url: jdbc:mysql://${RDS_ENDPOINT}:3306/comatching_matching?serverTimezone=Asia/Seoul&characterEncoding=UTF-8&useSSL=true&requireSSL=true + username: ${RDS_USERNAME} + password: ${RDS_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver + hikari: + pool-name: HikariPool-${spring.application.name} + maximum-pool-size: 10 + minimum-idle: 5 + connection-timeout: 30000 + max-lifetime: 1800000 + validation-timeout: 5000 + + jpa: + hibernate: + ddl-auto: update + show-sql: false + properties: + hibernate: + default_batch_fetch_size: 100 + + data: + redis: + host: ${REDIS_ENDPOINT} + port: 6379 + ssl: + enabled: true + + kafka: + bootstrap-servers: kafka:29092 + consumer: + group-id: matching-service-group + auto-offset-reset: latest + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer + value-deserializer: org.apache.kafka.common.serialization.StringDeserializer + + cloud: + aws: + credentials: + access-key: ${AWS_ACCESS_KEY} + secret-key: ${AWS_SECRET_KEY} + region: + static: ap-northeast-2 + s3: + bucket: ${AWS_S3_BUCKET} + +jwt: + secret: ${JWT_SECRET} + access-token: + expiration: ${ACCESS_TOKEN_EXP:86400000} + refresh-token: + expiration: ${REFRESH_TOKEN_EXP:604800000} diff --git a/matching-service/src/main/resources/application.yml b/matching-service/src/main/resources/application.yml index 033a0c0..7d43908 100644 --- a/matching-service/src/main/resources/application.yml +++ b/matching-service/src/main/resources/application.yml @@ -14,7 +14,7 @@ spring: datasource: url: jdbc:mysql://localhost:3307/comatching_matching?serverTimezone=Asia/Seoul&characterEncoding=UTF-8 username: ${SPRING_DATASOURCE_USERNAME:root} - password: ${SPRING_DATASOURCE_PASSWORD:comatching12!@} + password: ${MYSQL_ROOT_PASSWORD:comatching12!@} driver-class-name: com.mysql.cj.jdbc.Driver hikari: pool-name: HikariPool-${spring.application.name} diff --git a/notification/src/main/resources/application-aws.yml b/notification/src/main/resources/application-aws.yml new file mode 100644 index 0000000..2ca5c7d --- /dev/null +++ b/notification/src/main/resources/application-aws.yml @@ -0,0 +1,27 @@ +# AWS 환경 설정 - Docker 컨테이너 간 통신용 +server: + port: 9005 + +spring: + kafka: + bootstrap-servers: kafka:29092 + consumer: + group-id: notification-group + auto-offset-reset: latest + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer + value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer + producer: + key-serializer: org.apache.kafka.common.serialization.StringSerializer + value-serializer: org.springframework.kafka.support.serializer.JsonSerializer + + mail: + host: smtp.gmail.com + port: 587 + username: recordaydev@gmail.com + password: ${SMTP_PASSWORD} + properties: + mail: + smtp: + auth: true + starttls: + enable: true diff --git a/payment-service/build.gradle b/payment-service/build.gradle deleted file mode 100644 index 1b0372a..0000000 --- a/payment-service/build.gradle +++ /dev/null @@ -1,27 +0,0 @@ -plugins { - id 'java' - id 'org.springframework.boot' - id 'io.spring.dependency-management' -} - -description = 'payment-service' - -ext { - set('springCloudVersion', "2025.0.1") -} - -dependencies { - implementation 'org.springframework.boot:spring-boot-starter-data-jpa' - runtimeOnly 'com.mysql:mysql-connector-j' - implementation project(':common-module') - - compileOnly 'org.projectlombok:lombok' - annotationProcessor 'org.projectlombok:lombok' - testImplementation 'org.springframework.boot:spring-boot-starter-test' -} - -dependencyManagement { - imports { - mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}" - } -} \ No newline at end of file diff --git a/payment-service/src/main/java/com/comatching/payment/PaymentServiceApplication.java b/payment-service/src/main/java/com/comatching/payment/PaymentServiceApplication.java deleted file mode 100644 index 48905be..0000000 --- a/payment-service/src/main/java/com/comatching/payment/PaymentServiceApplication.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.comatching.payment; - -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.context.annotation.ComponentScan; - -@SpringBootApplication -@ComponentScan(basePackages = {"com.comatching.payment", "com.comatching.common"}) -public class PaymentServiceApplication { - - public static void main(String[] args) { - SpringApplication.run(PaymentServiceApplication.class, args); - } - -} diff --git a/payment-service/src/main/resources/application.yml b/payment-service/src/main/resources/application.yml deleted file mode 100644 index 254e069..0000000 --- a/payment-service/src/main/resources/application.yml +++ /dev/null @@ -1,24 +0,0 @@ -server: - port: 9004 - -spring: - application: - name: payment-service - - config: - import: optional:classpath:application-secret.yml - - datasource: - url: jdbc:mysql://localhost:3307/comatching_payment?serverTimezone=Asia/Seoul&characterEncoding=UTF-8 - hikari: - pool-name: HikariPool-${spring.application.name} - maximum-pool-size: 10 - minimum-idle: 5 - connection-timeout: 30000 - max-lifetime: 1800000 - validation-timeout: 5000 - - jpa: - hibernate: - ddl-auto: update - show-sql: true \ No newline at end of file diff --git a/payment-service/src/test/java/com/comatching/payment/PaymentServiceApplicationTests.java b/payment-service/src/test/java/com/comatching/payment/PaymentServiceApplicationTests.java deleted file mode 100644 index 3791c5f..0000000 --- a/payment-service/src/test/java/com/comatching/payment/PaymentServiceApplicationTests.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.comatching.payment; - -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@Disabled("통합 테스트 환경에서만 실행") -@SpringBootTest -class PaymentServiceApplicationTests { - - @Test - void contextLoads() { - } - -} diff --git a/settings.gradle b/settings.gradle index 524fcf8..3afc023 100644 --- a/settings.gradle +++ b/settings.gradle @@ -3,7 +3,6 @@ rootProject.name = 'backend' include 'common-module' include 'gateway-service' include 'matching-service' -include 'payment-service' include 'chat-service' include 'item-service' include 'notification' diff --git a/user-service/src/main/java/com/comatching/user/domain/auth/dto/NicknameAvailabilityResponse.java b/user-service/src/main/java/com/comatching/user/domain/auth/dto/NicknameAvailabilityResponse.java new file mode 100644 index 0000000..0b3f428 --- /dev/null +++ b/user-service/src/main/java/com/comatching/user/domain/auth/dto/NicknameAvailabilityResponse.java @@ -0,0 +1,6 @@ +package com.comatching.user.domain.auth.dto; + +public record NicknameAvailabilityResponse( + boolean available +) { +} diff --git a/user-service/src/main/java/com/comatching/user/domain/member/component/RandomNicknameGenerator.java b/user-service/src/main/java/com/comatching/user/domain/member/component/RandomNicknameGenerator.java deleted file mode 100644 index c6f8c8d..0000000 --- a/user-service/src/main/java/com/comatching/user/domain/member/component/RandomNicknameGenerator.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.comatching.user.domain.member.component; - -import java.util.List; -import java.util.concurrent.ThreadLocalRandom; - -import org.springframework.stereotype.Component; - -@Component -public class RandomNicknameGenerator { - - private static final List DETERMINERS = List.of( - "노래하는", "춤추는", "뛰어노는", "구르는", "빙글도는", "날아가는", "흐느적이는", "뒹구는", "달리는", "흔들리는", - "웃는", "반짝이는", "두근대는", "깔깔대는", "놀라는", "감탄하는", "감동한", "행복한", "수줍은", "즐거운", - "반짝반짝한", "포근한", "시원한", "따뜻한", "향기나는", "알록달록한", "몽글몽글한", "부드러운", "촉촉한", - "장난치는", "휘파람 부는", "손흔드는", "인사하는", "하품하는", "손뻗는", "끄덕이는", "기지개 켜는", "숨바꼭질하는", - "멋부린", "잠에서 깬", "바람 타는", "구경하는", "편지 쓰는", "노을 보는", "초콜릿 든", "선물 고르는" - ); - - private static final List ANIMALS_AND_THINGS = List.of( - "나무늘보", "햄스터", "다람쥐", "고슴도치", "기린", "오리", "판다", "앵무새", "물개", "코알라", "돌고래", "코끼리", "라마", "고래", "아기곰", - "데이지", "민들레", "코스모스", "라벤더", "동백꽃", "연꽃", "수국", "벚꽃잎", "클로버", "벚꽃", "해바라기", - "파도", "구름", "별똥별", "바람", "노을", "햇살", "모래성", "무지개", "달빛", "눈송이", - "화가", "작가", "바리스타", "제빵사", "소방관", "탐험가", "마술사", "사진작가", "연기자", "시인", "조향사", "고고학자" - ); - - public String generate() { - String determiner = DETERMINERS.get(ThreadLocalRandom.current().nextInt(DETERMINERS.size())); - String animalOrThing = ANIMALS_AND_THINGS.get(ThreadLocalRandom.current().nextInt(ANIMALS_AND_THINGS.size())); - int randomNumber = ThreadLocalRandom.current().nextInt(10000); - - return determiner + " " + animalOrThing + randomNumber; - } -} diff --git a/user-service/src/main/java/com/comatching/user/domain/member/entity/Profile.java b/user-service/src/main/java/com/comatching/user/domain/member/entity/Profile.java index 78e972a..3e74103 100644 --- a/user-service/src/main/java/com/comatching/user/domain/member/entity/Profile.java +++ b/user-service/src/main/java/com/comatching/user/domain/member/entity/Profile.java @@ -9,13 +9,10 @@ import java.time.LocalDate; import java.util.ArrayList; import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; import com.comatching.common.domain.enums.ContactFrequency; import com.comatching.common.domain.enums.Gender; import com.comatching.common.domain.enums.HobbyCategory; -import com.comatching.common.domain.enums.ProfileTagCategory; import com.comatching.common.domain.enums.SocialAccountType; import com.comatching.common.exception.BusinessException; import com.comatching.user.global.exception.UserErrorCode; @@ -161,7 +158,7 @@ public void update( } public void addHobbies(List newHobbies) { - if (newHobbies == null || newHobbies.isEmpty() || newHobbies.size() > 10 || newHobbies.size() < 1) { + if (newHobbies == null || newHobbies.isEmpty() || newHobbies.size() > 10 || newHobbies.size() < 2) { throw new BusinessException(UserErrorCode.INVALID_HOBBY_COUNT); } @@ -179,17 +176,8 @@ public List getHobbyCategories() { } public void addTags(List newTags) { - if (newTags != null) { - Map countByCategory = newTags.stream() - .collect(Collectors.groupingBy( - t -> t.getTag().getGroup().getCategory(), - Collectors.counting() - )); - countByCategory.forEach((cat, count) -> { - if (count > 3) { - throw new BusinessException(UserErrorCode.TAG_LIMIT_PER_CATEGORY_EXCEEDED); - } - }); + if (newTags != null && newTags.size() > 5) { + throw new BusinessException(UserErrorCode.TAG_LIMIT_PER_CATEGORY_EXCEEDED); } this.tags.clear(); if (newTags != null) { diff --git a/user-service/src/main/java/com/comatching/user/domain/member/repository/ProfileRepository.java b/user-service/src/main/java/com/comatching/user/domain/member/repository/ProfileRepository.java index 2fa31e8..b2176c3 100644 --- a/user-service/src/main/java/com/comatching/user/domain/member/repository/ProfileRepository.java +++ b/user-service/src/main/java/com/comatching/user/domain/member/repository/ProfileRepository.java @@ -14,6 +14,8 @@ public interface ProfileRepository extends JpaRepository { Optional findByMemberId(Long memberId); + boolean existsByNickname(String nickname); + boolean existsByNicknameAndMemberIdNot(String nickname, Long memberId); @Query("SELECT DISTINCT p FROM Profile p " + "JOIN FETCH p.member m " + diff --git a/user-service/src/main/java/com/comatching/user/domain/member/service/ProfileManageService.java b/user-service/src/main/java/com/comatching/user/domain/member/service/ProfileManageService.java index 3ad7263..50eb651 100644 --- a/user-service/src/main/java/com/comatching/user/domain/member/service/ProfileManageService.java +++ b/user-service/src/main/java/com/comatching/user/domain/member/service/ProfileManageService.java @@ -8,6 +8,7 @@ public interface ProfileManageService { ProfileResponse getProfile(Long memberId); + boolean isNicknameAvailable(String nickname); List getProfilesByIds(List memberIds); diff --git a/user-service/src/main/java/com/comatching/user/domain/member/service/ProfileServiceImpl.java b/user-service/src/main/java/com/comatching/user/domain/member/service/ProfileServiceImpl.java index 55e3ecf..65bf15c 100644 --- a/user-service/src/main/java/com/comatching/user/domain/member/service/ProfileServiceImpl.java +++ b/user-service/src/main/java/com/comatching/user/domain/member/service/ProfileServiceImpl.java @@ -1,7 +1,9 @@ package com.comatching.user.domain.member.service; import java.util.List; -import java.util.concurrent.ThreadLocalRandom; +import java.util.Locale; +import java.util.Objects; +import java.util.Set; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -15,8 +17,8 @@ import com.comatching.common.dto.member.ProfileResponse; import com.comatching.common.dto.member.ProfileTagDto; import com.comatching.common.exception.BusinessException; +import com.comatching.common.service.S3Service; import com.comatching.user.domain.event.UserEventPublisher; -import com.comatching.user.domain.member.component.RandomNicknameGenerator; import com.comatching.user.domain.member.dto.ProfileUpdateRequest; import com.comatching.user.domain.member.entity.Member; import com.comatching.user.domain.member.entity.Profile; @@ -34,11 +36,18 @@ @Transactional public class ProfileServiceImpl implements ProfileCreateService, ProfileManageService { + private static final String DEFAULT_IMAGE_VALUE = "default"; + private static final String DEFAULT_IMAGE_PREFIX = "default_"; + private static final String DEFAULT_IMAGE_EXTENSION = ".png"; + private static final Set DEFAULT_IMAGE_ANIMALS = Set.of( + "dog", "cat", "dinosaur", "otter", "bear", "fox", "penguin", "wolf", "rabbit", "snake", "horse", "frog" + ); + private final MemberRepository memberRepository; private final ProfileRepository profileRepository; private final UserEventPublisher eventPublisher; private final ProfileImageProperties profileImageProperties; - private final RandomNicknameGenerator nicknameGenerator; + private final S3Service s3Service; @Override public ProfileResponse createProfile(Long memberId, ProfileCreateRequest request) { @@ -70,6 +79,13 @@ public ProfileResponse getProfile(Long memberId) { return toProfileResponse(profile); } + @Override + @Transactional(readOnly = true) + public boolean isNicknameAvailable(String nickname) { + String normalizedNickname = normalizeNickname(nickname); + return !profileRepository.existsByNickname(normalizedNickname); + } + @Override @Transactional(readOnly = true) public List getProfilesByIds(List memberIds) { @@ -87,11 +103,14 @@ public ProfileResponse updateProfile(Long memberId, ProfileUpdateRequest request Profile profile = profileRepository.findByMemberId(memberId) .orElseThrow(() -> new BusinessException(UserErrorCode.PROFILE_NOT_EXISTS)); + String normalizedNickname = normalizeNicknameForUpdate(request.nickname(), profile.getNickname(), memberId); + String profileImageUrl = resolveProfileImageUrlForUpdate(request.profileImageUrl()); + profile.update( - request.nickname(), + normalizedNickname, request.intro(), request.mbti(), - request.profileImageUrl(), + profileImageUrl, request.gender(), request.birthDate(), request.socialType(), @@ -139,13 +158,10 @@ private void publishMatchingEvent(Profile profile) { private Profile saveProfile(ProfileCreateRequest request, Member member) { + String finalNickname = normalizeNickname(request.nickname()); + validateNicknameDuplicateOnCreate(finalNickname); String finalProfileImageUrl = resolveProfileImageUrl(request.profileImageKey()); - String finalNickname = request.nickname(); - if (!StringUtils.hasText(finalNickname)) { - finalNickname = nicknameGenerator.generate(); - } - Profile profile = Profile.builder() .member(member) .nickname(finalNickname) @@ -167,20 +183,72 @@ private Profile saveProfile(ProfileCreateRequest request, Member member) { return profileRepository.save(profile); } - private String resolveProfileImageUrl(String inputImageKey) { - if (StringUtils.hasText(inputImageKey)) { - return profileImageProperties.baseUrl() + inputImageKey; + private String normalizeNickname(String nickname) { + if (!StringUtils.hasText(nickname)) { + throw new BusinessException(UserErrorCode.INVALID_NICKNAME); } - List defaults = profileImageProperties.filenames(); - if (defaults == null || defaults.isEmpty()) { + return nickname.trim(); + } + + private String normalizeNicknameForUpdate(String nickname, String currentNickname, Long memberId) { + if (nickname == null) { return null; } - int randomIndex = ThreadLocalRandom.current().nextInt(defaults.size()); - String selectedFilename = defaults.get(randomIndex); + String normalizedNickname = normalizeNickname(nickname); + if (!Objects.equals(normalizedNickname, currentNickname) + && profileRepository.existsByNicknameAndMemberIdNot(normalizedNickname, memberId)) { + throw new BusinessException(UserErrorCode.DUPLICATE_NICKNAME); + } + + return normalizedNickname; + } + + private void validateNicknameDuplicateOnCreate(String nickname) { + if (profileRepository.existsByNickname(nickname)) { + throw new BusinessException(UserErrorCode.DUPLICATE_NICKNAME); + } + } - return profileImageProperties.baseUrl() + selectedFilename; + private String resolveProfileImageUrlForUpdate(String profileImageValue) { + if (profileImageValue == null) { + return null; + } + return resolveProfileImageUrl(profileImageValue); + } + + private String resolveProfileImageUrl(String profileImageValue) { + if (!StringUtils.hasText(profileImageValue)) { + return buildDefaultProfileImageUrl(DEFAULT_IMAGE_VALUE + DEFAULT_IMAGE_EXTENSION); + } + + String normalizedValue = profileImageValue.trim(); + String loweredValue = normalizedValue.toLowerCase(Locale.ROOT); + + if (DEFAULT_IMAGE_VALUE.equals(loweredValue)) { + return buildDefaultProfileImageUrl(DEFAULT_IMAGE_VALUE + DEFAULT_IMAGE_EXTENSION); + } + + if (loweredValue.startsWith(DEFAULT_IMAGE_PREFIX)) { + String animalName = loweredValue.substring(DEFAULT_IMAGE_PREFIX.length()); + if (DEFAULT_IMAGE_ANIMALS.contains(animalName)) { + return buildDefaultProfileImageUrl(animalName + DEFAULT_IMAGE_EXTENSION); + } + } + + if (normalizedValue.startsWith("http://") || normalizedValue.startsWith("https://")) { + return normalizedValue; + } + + return s3Service.getFileUrl(normalizedValue); + } + + private String buildDefaultProfileImageUrl(String filename) { + if (!StringUtils.hasText(profileImageProperties.baseUrl())) { + return null; + } + return profileImageProperties.baseUrl() + filename; } private static List getProfileHobbies(List hobbies) { diff --git a/user-service/src/main/java/com/comatching/user/global/config/SecurityConfig.java b/user-service/src/main/java/com/comatching/user/global/config/SecurityConfig.java index 70192ab..a8acdbd 100644 --- a/user-service/src/main/java/com/comatching/user/global/config/SecurityConfig.java +++ b/user-service/src/main/java/com/comatching/user/global/config/SecurityConfig.java @@ -57,6 +57,11 @@ public AuthenticationManager authenticationManager() throws Exception { return authenticationConfiguration.getAuthenticationManager(); } + @Bean + public LoginSuccessHandler loginSuccessHandler() { + return new LoginSuccessHandler(jwtUtil, objectMapper, refreshTokenRepository); + } + @Bean public CustomJsonUsernamePasswordAuthenticationFilter customJsonUsernamePasswordAuthenticationFilter() throws Exception { @@ -67,7 +72,7 @@ public CustomJsonUsernamePasswordAuthenticationFilter customJsonUsernamePassword filter.setAuthenticationManager(authenticationManager()); // 핸들러 설정 - filter.setAuthenticationSuccessHandler(new LoginSuccessHandler(jwtUtil, objectMapper, refreshTokenRepository)); + filter.setAuthenticationSuccessHandler(loginSuccessHandler()); filter.setAuthenticationFailureHandler(new LoginFailureHandler(objectMapper)); return filter; diff --git a/user-service/src/main/java/com/comatching/user/global/exception/UserErrorCode.java b/user-service/src/main/java/com/comatching/user/global/exception/UserErrorCode.java index 201bf75..e25339f 100644 --- a/user-service/src/main/java/com/comatching/user/global/exception/UserErrorCode.java +++ b/user-service/src/main/java/com/comatching/user/global/exception/UserErrorCode.java @@ -26,8 +26,10 @@ public enum UserErrorCode implements ErrorCode { PROFILE_ALREADY_EXISTS("MEM-003", HttpStatus.BAD_REQUEST, "프로필이 이미 존재합니다."), PROFILE_NOT_EXISTS("MEM-004", HttpStatus.BAD_REQUEST, "프로필이 존재하지 않습니다."), INVALID_SOCIAL_INFO("MEM-005", HttpStatus.BAD_REQUEST, "소셜 정보는 타입과 ID가 함께 입력되어야 합니다."), - INVALID_HOBBY_COUNT("MEM-006", HttpStatus.BAD_REQUEST, "취미는 최소 2개 이상 최대 5개 이하를 등록해야 합니다."), - TAG_LIMIT_PER_CATEGORY_EXCEEDED("MEM-007", HttpStatus.BAD_REQUEST, "카테고리별 태그는 최대 3개까지 선택 가능합니다."), + INVALID_HOBBY_COUNT("MEM-006", HttpStatus.BAD_REQUEST, "취미는 최소 2개 이상 최대 10개 이하를 등록해야 합니다."), + TAG_LIMIT_PER_CATEGORY_EXCEEDED("MEM-007", HttpStatus.BAD_REQUEST, "장점 태그는 전체 최대 5개까지 선택 가능합니다."), + DUPLICATE_NICKNAME("MEM-008", HttpStatus.BAD_REQUEST, "이미 사용 중인 닉네임입니다."), + INVALID_NICKNAME("MEM-009", HttpStatus.BAD_REQUEST, "닉네임은 공백일 수 없습니다."), ; private final String code; diff --git a/user-service/src/main/java/com/comatching/user/global/security/handler/LoginSuccessHandler.java b/user-service/src/main/java/com/comatching/user/global/security/handler/LoginSuccessHandler.java index 1d9d567..00ea90c 100644 --- a/user-service/src/main/java/com/comatching/user/global/security/handler/LoginSuccessHandler.java +++ b/user-service/src/main/java/com/comatching/user/global/security/handler/LoginSuccessHandler.java @@ -2,6 +2,7 @@ import java.io.IOException; +import org.springframework.beans.factory.annotation.Value; import org.springframework.http.MediaType; import org.springframework.http.ResponseCookie; import org.springframework.security.core.Authentication; @@ -27,11 +28,15 @@ public class LoginSuccessHandler implements AuthenticationSuccessHandler { private final ObjectMapper objectMapper; private final RefreshTokenRepository refreshTokenRepository; + @Value("${client.url}") + private String clientUrl; + @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { UserPrincipal principal = (UserPrincipal)authentication.getPrincipal(); + String role = principal.getRole(); // 토큰 생성 String accessToken = jwtUtil.createAccessToken(principal.getId(), principal.getUsername(), principal.getRole(), @@ -52,12 +57,19 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo response.addHeader("Set-Cookie", accessCookie.toString()); response.addHeader("Set-Cookie", refreshCookie.toString()); - // json 응답 - response.setStatus(HttpServletResponse.SC_OK); - response.setContentType(MediaType.APPLICATION_JSON_VALUE); - response.setCharacterEncoding("UTF-8"); - ApiResponse apiResponse = ApiResponse.ok(); + if (role.equals("ROLE_ADMIN")) { + response.setStatus(HttpServletResponse.SC_OK); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + ApiResponse apiResponse = ApiResponse.ok(); + objectMapper.writeValue(response.getWriter(), apiResponse); + return; + } - objectMapper.writeValue(response.getWriter(), apiResponse); + if (role.equals("ROLE_GUEST")) { + response.sendRedirect(clientUrl + "/onboarding"); + } else { + response.sendRedirect(clientUrl + "/main"); + } } } diff --git a/user-service/src/main/java/com/comatching/user/global/security/oauth2/handler/CustomOAuth2SuccessHandler.java b/user-service/src/main/java/com/comatching/user/global/security/oauth2/handler/CustomOAuth2SuccessHandler.java index 45fd262..37bb17d 100644 --- a/user-service/src/main/java/com/comatching/user/global/security/oauth2/handler/CustomOAuth2SuccessHandler.java +++ b/user-service/src/main/java/com/comatching/user/global/security/oauth2/handler/CustomOAuth2SuccessHandler.java @@ -60,6 +60,10 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo response.addHeader("Set-Cookie", accessCookie.toString()); response.addHeader("Set-Cookie", refreshCookie.toString()); - getRedirectStrategy().sendRedirect(request, response, clientUrl + "/oauth2/callback/success"); + if (role.equals("ROLE_GUEST")) { + getRedirectStrategy().sendRedirect(request, response, clientUrl + "/onboarding"); + } else { + getRedirectStrategy().sendRedirect(request, response, clientUrl + "/main"); + } } } diff --git a/user-service/src/main/java/com/comatching/user/infra/controller/AuthController.java b/user-service/src/main/java/com/comatching/user/infra/controller/AuthController.java index 9923c56..964ef41 100644 --- a/user-service/src/main/java/com/comatching/user/infra/controller/AuthController.java +++ b/user-service/src/main/java/com/comatching/user/infra/controller/AuthController.java @@ -4,20 +4,24 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.CookieValue; import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import com.comatching.user.domain.auth.dto.ChangePasswordRequest; import com.comatching.user.domain.auth.dto.CompleteSignupResponse; +import com.comatching.user.domain.auth.dto.NicknameAvailabilityResponse; import com.comatching.user.domain.auth.dto.PasswordResetCodeRequest; import com.comatching.user.domain.auth.dto.ResetPasswordRequest; import com.comatching.user.domain.auth.dto.TokenResponse; import com.comatching.user.domain.auth.service.AuthService; import com.comatching.user.domain.auth.service.SignupService; import com.comatching.user.domain.mail.service.EmailService; +import com.comatching.user.domain.member.service.ProfileManageService; import com.comatching.common.annotation.CurrentMember; import com.comatching.common.annotation.RequireRole; import com.comatching.common.domain.enums.MemberRole; @@ -39,6 +43,7 @@ public class AuthController { private final AuthService authService; private final SignupService signupService; private final EmailService emailService; + private final ProfileManageService profileManageService; @PostMapping("/signup") public ResponseEntity> signup(@RequestBody @Valid SignupRequest request) { @@ -56,6 +61,14 @@ public ResponseEntity> completeSignup( return ResponseEntity.ok(ApiResponse.ok(result)); } + @GetMapping("/signup/nickname/availability") + public ResponseEntity> checkNicknameAvailability( + @RequestParam String nickname + ) { + boolean available = profileManageService.isNicknameAvailable(nickname); + return ResponseEntity.ok(ApiResponse.ok(new NicknameAvailabilityResponse(available))); + } + @PostMapping("/reissue") public ResponseEntity> reissue( @CookieValue(name = "refreshToken") String refreshToken, diff --git a/user-service/src/main/resources/application-aws.yml b/user-service/src/main/resources/application-aws.yml new file mode 100644 index 0000000..84f359f --- /dev/null +++ b/user-service/src/main/resources/application-aws.yml @@ -0,0 +1,122 @@ +# AWS 환경 설정 - Docker 컨테이너 간 통신용 +server: + port: 9000 + servlet: + session: + timeout: 30m + cookie: + name: JSESSIONID + path: / + http-only: true + secure: true + +client: + url: ${CLIENT_URL:https://comatching.site} + +comatching: + profile: + default-images: + base-url: "https://${AWS_S3_BUCKET}.s3.ap-northeast-2.amazonaws.com/defaults/profile/" + filenames: + - "userDefaultImage1.jpg" + - "userDefaultImage2.jpg" + - "userDefaultImage3.jpg" + +spring: + application: + name: user-service + + datasource: + url: jdbc:mysql://${RDS_ENDPOINT}:3306/comatching_user?serverTimezone=Asia/Seoul&characterEncoding=UTF-8&useSSL=true&requireSSL=true + username: ${RDS_USERNAME} + password: ${RDS_PASSWORD} + hikari: + pool-name: HikariPool-${spring.application.name} + maximum-pool-size: 10 + minimum-idle: 5 + connection-timeout: 30000 + max-lifetime: 1800000 + validation-timeout: 5000 + + jpa: + hibernate: + ddl-auto: update + show-sql: false + properties: + default_batch_fetch_size: 100 + + data: + redis: + host: ${REDIS_ENDPOINT} + port: 6379 + ssl: + enabled: true + + kafka: + bootstrap-servers: kafka:29092 + producer: + key-serializer: org.apache.kafka.common.serialization.StringSerializer + value-serializer: org.springframework.kafka.support.serializer.JsonSerializer + consumer: + group-id: user-group + auto-offset-reset: earliest + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer + value-deserializer: org.apache.kafka.common.serialization.StringDeserializer + + mail: + host: smtp.gmail.com + port: 587 + username: recordaydev@gmail.com + password: ${SMTP_PASSWORD} + properties: + mail: + smtp: + auth: true + starttls: + enable: true + + cloud: + aws: + credentials: + access-key: ${AWS_ACCESS_KEY} + secret-key: ${AWS_SECRET_KEY} + region: + static: ap-northeast-2 + s3: + bucket: ${AWS_S3_BUCKET} + + security: + oauth2: + client: + registration: + kakao: + client-name: kakao + client-id: ${KAKAO_CLIENT_ID} + client-secret: ${KAKAO_CLIENT_SECRET} + redirect-uri: https://srv.comatching.site/login/oauth2/code/kakao + authorization-grant-type: authorization_code + client-authentication-method: client_secret_post + scope: + - openid + + provider: + kakao: + authorization-uri: https://kauth.kakao.com/oauth/authorize + token-uri: https://kauth.kakao.com/oauth/token + user-info-uri: https://kapi.kakao.com/v1/oidc/userinfo + issuer-uri: https://kauth.kakao.com + user-name-attribute: sub + +jwt: + secret: ${JWT_SECRET} + access-token: + expiration: ${ACCESS_TOKEN_EXP:86400000} + refresh-token: + expiration: ${REFRESH_TOKEN_EXP:604800000} + +kakao: + auth: + admin-key: ${KAKAO_ADMIN_KEY} + unlink-url: https://kapi.kakao.com/v1/user/unlink + unlink-content-type: application/x-www-form-urlencoded;charset=utf-8 + unlink-target-id-type: user_id diff --git a/user-service/src/main/resources/application.yml b/user-service/src/main/resources/application.yml index 82cd546..debfd3d 100644 --- a/user-service/src/main/resources/application.yml +++ b/user-service/src/main/resources/application.yml @@ -27,8 +27,8 @@ spring: datasource: url: jdbc:mysql://localhost:3307/comatching_user?serverTimezone=Asia/Seoul&characterEncoding=UTF-8 - username: ${SPRING_DATASOURCE_USERNAME} - password: ${SPRING_DATASOURCE_PASSWORD} + username: ${SPRING_DATASOURCE_USERNAME:root} + password: ${MYSQL_ROOT_PASSWORD:comatching12!@} hikari: pool-name: HikariPool-${spring.application.name} maximum-pool-size: 10 @@ -39,7 +39,7 @@ spring: jpa: hibernate: - ddl-auto: update + ddl-auto: create show-sql: true properties: default_batch_fetch_size: 100 diff --git a/user-service/src/test/java/com/comatching/user/domain/member/entity/ProfileTest.java b/user-service/src/test/java/com/comatching/user/domain/member/entity/ProfileTest.java index 31b3723..b3a74ae 100644 --- a/user-service/src/test/java/com/comatching/user/domain/member/entity/ProfileTest.java +++ b/user-service/src/test/java/com/comatching/user/domain/member/entity/ProfileTest.java @@ -55,8 +55,8 @@ void shouldAddTagsSuccessfully() { } @Test - @DisplayName("카테고리별 3개를 초과하면 BusinessException이 발생한다") - void shouldThrowWhenExceedingCategoryLimit() { + @DisplayName("같은 카테고리여도 전체 5개 이하면 허용된다") + void shouldAllowSameCategoryWhenWithinTotalLimit() { // given - 외모(APPEARANCE) 카테고리 태그 4개 List tags = List.of( new ProfileTag(ProfileTagItem.EGG_FACE), @@ -65,9 +65,11 @@ void shouldThrowWhenExceedingCategoryLimit() { new ProfileTag(ProfileTagItem.SHARP_FACE) ); - // when & then - assertThatThrownBy(() -> profile.addTags(tags)) - .isInstanceOf(BusinessException.class); + // when + profile.addTags(tags); + + // then + assertThat(profile.getTags()).hasSize(4); } @Test @@ -107,9 +109,9 @@ void shouldReplaceExistingTags() { } @Test - @DisplayName("서로 다른 카테고리의 태그는 각각 최대 3개까지 허용된다") - void shouldAllowThreeTagsPerCategory() { - // given - 외모 3개 + 성격 3개 = 총 6개 + @DisplayName("전체 태그가 5개를 초과하면 BusinessException이 발생한다") + void shouldThrowWhenExceedingTotalTagLimit() { + // given - 총 6개 List tags = List.of( new ProfileTag(ProfileTagItem.EGG_FACE), new ProfileTag(ProfileTagItem.DIMPLE), @@ -119,11 +121,9 @@ void shouldAllowThreeTagsPerCategory() { new ProfileTag(ProfileTagItem.LOGICAL) ); - // when - profile.addTags(tags); - - // then - assertThat(profile.getTags()).hasSize(6); + // when & then + assertThatThrownBy(() -> profile.addTags(tags)) + .isInstanceOf(BusinessException.class); } @Test diff --git a/user-service/src/test/java/com/comatching/user/domain/member/service/ProfileServiceImplTest.java b/user-service/src/test/java/com/comatching/user/domain/member/service/ProfileServiceImplTest.java index 9609d85..7759949 100644 --- a/user-service/src/test/java/com/comatching/user/domain/member/service/ProfileServiceImplTest.java +++ b/user-service/src/test/java/com/comatching/user/domain/member/service/ProfileServiceImplTest.java @@ -26,8 +26,8 @@ import com.comatching.common.dto.member.ProfileResponse; import com.comatching.common.dto.member.ProfileTagDto; import com.comatching.common.exception.BusinessException; +import com.comatching.common.service.S3Service; import com.comatching.user.domain.event.UserEventPublisher; -import com.comatching.user.domain.member.component.RandomNicknameGenerator; import com.comatching.user.domain.member.dto.ProfileUpdateRequest; import com.comatching.user.domain.member.entity.Member; import com.comatching.user.domain.member.entity.Profile; @@ -36,6 +36,7 @@ import com.comatching.user.domain.member.repository.MemberRepository; import com.comatching.user.domain.member.repository.ProfileRepository; import com.comatching.user.global.config.ProfileImageProperties; +import com.comatching.user.global.exception.UserErrorCode; @ExtendWith(MockitoExtension.class) @DisplayName("ProfileServiceImpl 테스트") @@ -57,7 +58,7 @@ class ProfileServiceImplTest { private ProfileImageProperties profileImageProperties; @Mock - private RandomNicknameGenerator nicknameGenerator; + private S3Service s3Service; @Nested @DisplayName("프로필 생성") @@ -77,7 +78,10 @@ void shouldCreateProfileWithTags() { .university("한국대학교") .major("컴퓨터공학과") .contactFrequency(ContactFrequency.FREQUENT) - .hobbies(List.of(new HobbyDto(HobbyCategory.SPORTS, "축구"))) + .hobbies(List.of( + new HobbyDto(HobbyCategory.SPORTS, "축구"), + new HobbyDto(HobbyCategory.CULTURE, "영화감상") + )) .tags(List.of( new ProfileTagDto("EGG_FACE"), new ProfileTagDto("BRIGHT") @@ -86,7 +90,6 @@ void shouldCreateProfileWithTags() { given(memberRepository.findById(memberId)).willReturn(Optional.of(member)); given(profileImageProperties.baseUrl()).willReturn("https://img.com/"); - given(profileImageProperties.filenames()).willReturn(List.of("default.png")); given(profileRepository.save(any(Profile.class))).willAnswer(invocation -> invocation.getArgument(0)); willDoNothing().given(eventPublisher).sendProfileUpdatedMatchingEvent(any()); willDoNothing().given(eventPublisher).sendSignupEvent(any()); @@ -114,13 +117,15 @@ void shouldCreateProfileWithoutTags() { .university("한국대학교") .major("컴퓨터공학과") .contactFrequency(ContactFrequency.FREQUENT) - .hobbies(List.of(new HobbyDto(HobbyCategory.SPORTS, "축구"))) + .hobbies(List.of( + new HobbyDto(HobbyCategory.SPORTS, "축구"), + new HobbyDto(HobbyCategory.CULTURE, "영화감상") + )) .tags(null) .build(); given(memberRepository.findById(memberId)).willReturn(Optional.of(member)); given(profileImageProperties.baseUrl()).willReturn("https://img.com/"); - given(profileImageProperties.filenames()).willReturn(List.of("default.png")); given(profileRepository.save(any(Profile.class))).willAnswer(invocation -> invocation.getArgument(0)); willDoNothing().given(eventPublisher).sendProfileUpdatedMatchingEvent(any()); willDoNothing().given(eventPublisher).sendSignupEvent(any()); @@ -132,6 +137,109 @@ void shouldCreateProfileWithoutTags() { assertThat(response).isNotNull(); assertThat(response.tags()).isEmpty(); } + + @Test + @DisplayName("프로필 이미지 값이 S3 key면 퍼블릭 URL로 변환해 저장한다") + void shouldConvertS3KeyToPublicUrlWhenCreatingProfile() { + // given + Long memberId = 1L; + Member member = createMember(memberId); + String imageKey = "profiles/1/test.png"; + String imageUrl = "https://bucket.s3.ap-northeast-2.amazonaws.com/profiles/1/test.png"; + ProfileCreateRequest request = ProfileCreateRequest.builder() + .nickname("테스트유저") + .gender(Gender.MALE) + .birthDate(LocalDate.of(2000, 1, 1)) + .mbti("ENFP") + .university("한국대학교") + .major("컴퓨터공학과") + .contactFrequency(ContactFrequency.FREQUENT) + .profileImageKey(imageKey) + .hobbies(List.of( + new HobbyDto(HobbyCategory.SPORTS, "축구"), + new HobbyDto(HobbyCategory.CULTURE, "영화감상") + )) + .tags(null) + .build(); + + given(memberRepository.findById(memberId)).willReturn(Optional.of(member)); + given(s3Service.getFileUrl(imageKey)).willReturn(imageUrl); + given(profileRepository.save(any(Profile.class))).willAnswer(invocation -> invocation.getArgument(0)); + willDoNothing().given(eventPublisher).sendProfileUpdatedMatchingEvent(any()); + willDoNothing().given(eventPublisher).sendSignupEvent(any()); + + // when + ProfileResponse response = profileService.createProfile(memberId, request); + + // then + assertThat(response.profileImageUrl()).isEqualTo(imageUrl); + } + + @Test + @DisplayName("프로필 이미지 값이 default_동물이름이면 해당 기본 이미지 URL을 저장한다") + void shouldUseAnimalDefaultImageWhenCreatingProfile() { + // given + Long memberId = 1L; + Member member = createMember(memberId); + ProfileCreateRequest request = ProfileCreateRequest.builder() + .nickname("테스트유저") + .gender(Gender.MALE) + .birthDate(LocalDate.of(2000, 1, 1)) + .mbti("ENFP") + .university("한국대학교") + .major("컴퓨터공학과") + .contactFrequency(ContactFrequency.FREQUENT) + .profileImageKey("default_dog") + .hobbies(List.of( + new HobbyDto(HobbyCategory.SPORTS, "축구"), + new HobbyDto(HobbyCategory.CULTURE, "영화감상") + )) + .tags(null) + .build(); + + given(memberRepository.findById(memberId)).willReturn(Optional.of(member)); + given(profileImageProperties.baseUrl()).willReturn("https://img.com/defaults/profile/"); + given(profileRepository.save(any(Profile.class))).willAnswer(invocation -> invocation.getArgument(0)); + willDoNothing().given(eventPublisher).sendProfileUpdatedMatchingEvent(any()); + willDoNothing().given(eventPublisher).sendSignupEvent(any()); + + // when + ProfileResponse response = profileService.createProfile(memberId, request); + + // then + assertThat(response.profileImageUrl()).isEqualTo("https://img.com/defaults/profile/dog.png"); + } + + @Test + @DisplayName("닉네임이 중복되면 프로필 생성 시 예외가 발생한다") + void shouldThrowWhenNicknameDuplicatedOnCreate() { + // given + Long memberId = 1L; + Member member = createMember(memberId); + ProfileCreateRequest request = ProfileCreateRequest.builder() + .nickname("중복닉네임") + .gender(Gender.MALE) + .birthDate(LocalDate.of(2000, 1, 1)) + .mbti("ENFP") + .university("한국대학교") + .major("컴퓨터공학과") + .contactFrequency(ContactFrequency.FREQUENT) + .hobbies(List.of( + new HobbyDto(HobbyCategory.SPORTS, "축구"), + new HobbyDto(HobbyCategory.CULTURE, "영화감상") + )) + .tags(null) + .build(); + + given(memberRepository.findById(memberId)).willReturn(Optional.of(member)); + given(profileRepository.existsByNickname("중복닉네임")).willReturn(true); + + // when & then + assertThatThrownBy(() -> profileService.createProfile(memberId, request)) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(UserErrorCode.DUPLICATE_NICKNAME); + } } @Nested @@ -164,21 +272,23 @@ void shouldUpdateTagsSuccessfully() { } @Test - @DisplayName("카테고리별 3개 초과 태그로 수정 시 예외가 발생한다") - void shouldThrowWhenExceedingCategoryLimitOnUpdate() { + @DisplayName("전체 5개 초과 태그로 수정 시 예외가 발생한다") + void shouldThrowWhenExceedingTotalTagLimitOnUpdate() { // given Long memberId = 1L; Profile profile = createProfileWithTags(memberId); - // 외모 카테고리 태그 4개 (FACE_SHAPE 그룹) + // 총 6개 태그 ProfileUpdateRequest request = new ProfileUpdateRequest( null, null, null, null, null, null, null, null, null, null, null, null, null, List.of( new ProfileTagDto("EGG_FACE"), - new ProfileTagDto("ANGULAR_FACE"), - new ProfileTagDto("ROUND_FACE"), - new ProfileTagDto("SHARP_FACE") + new ProfileTagDto("DIMPLE"), + new ProfileTagDto("FAIR_SKIN"), + new ProfileTagDto("EXTROVERT"), + new ProfileTagDto("CARING"), + new ProfileTagDto("LOGICAL") ), null ); @@ -189,6 +299,60 @@ void shouldThrowWhenExceedingCategoryLimitOnUpdate() { assertThatThrownBy(() -> profileService.updateProfile(memberId, request)) .isInstanceOf(BusinessException.class); } + + @Test + @DisplayName("프로필 이미지 값이 default면 기본 프로필 이미지로 변경된다") + void shouldSetDefaultProfileImageOnUpdateWhenDefaultValueProvided() { + // given + Long memberId = 1L; + Profile profile = createProfileWithTags(memberId); + ProfileUpdateRequest request = new ProfileUpdateRequest( + null, null, null, "default", null, null, null, null, + null, null, null, null, null, null, null + ); + + given(profileRepository.findByMemberId(memberId)).willReturn(Optional.of(profile)); + given(profileImageProperties.baseUrl()).willReturn("https://img.com/defaults/profile/"); + willDoNothing().given(eventPublisher).sendProfileUpdatedMatchingEvent(any()); + willDoNothing().given(eventPublisher).sendUpdateEvent(any()); + + // when + ProfileResponse response = profileService.updateProfile(memberId, request); + + // then + assertThat(response.profileImageUrl()).isEqualTo("https://img.com/defaults/profile/default.png"); + } + } + + @Nested + @DisplayName("닉네임 중복 확인") + class NicknameAvailability { + + @Test + @DisplayName("중복 닉네임이면 사용 불가를 반환한다") + void shouldReturnFalseWhenNicknameDuplicated() { + // given + given(profileRepository.existsByNickname("중복닉네임")).willReturn(true); + + // when + boolean available = profileService.isNicknameAvailable("중복닉네임"); + + // then + assertThat(available).isFalse(); + } + + @Test + @DisplayName("미사용 닉네임이면 사용 가능을 반환한다") + void shouldReturnTrueWhenNicknameAvailable() { + // given + given(profileRepository.existsByNickname("신규닉네임")).willReturn(false); + + // when + boolean available = profileService.isNicknameAvailable("신규닉네임"); + + // then + assertThat(available).isTrue(); + } } @Nested @@ -225,7 +389,10 @@ private Member createMember(Long memberId) { private Profile createProfileWithTags(Long memberId) { Member member = createMember(memberId); - List hobbies = List.of(new ProfileHobby(HobbyCategory.SPORTS, "축구")); + List hobbies = List.of( + new ProfileHobby(HobbyCategory.SPORTS, "축구"), + new ProfileHobby(HobbyCategory.CULTURE, "영화감상") + ); List tags = List.of( new ProfileTag(ProfileTagItem.EGG_FACE), new ProfileTag(ProfileTagItem.BRIGHT)