diff --git a/.codeguide/dip-insights.md b/.codeguide/dip-insights.md new file mode 100644 index 000000000..f43d24935 --- /dev/null +++ b/.codeguide/dip-insights.md @@ -0,0 +1,82 @@ +# DIP & 아키텍처 인사이트 + +> 3주차 학습 과정에서 수집한 실무적 인사이트 정리 + +--- + +## 1. DIP 정석 vs 실무 타협 + +### DIP 정석 구조 (이미지: [Kev] DIP정석) + +``` +[Domain Layer] [Infrastructure Layer] +Order (순수 도메인 객체) OrderEntity (@Entity, JPA) +OrderRepository (interface) ◀── OrderJpaRepositoryImpl (구현체) +``` + +- Domain Entity와 JPA Entity를 **완전 분리** +- Infrastructure에서 Entity 간 변환 처리 + +### 실무 타협 (이미지: [Kev] 장바구니 도메인 구현 사례) + +**장바구니 사례**: Cart(Domain) / CartEntity(JPA) / CartRedisEntity(Redis) + +- 다중 저장소(JPA + Redis) 지원 시 분리가 의미 있음 +- CartRepository 인터페이스 하나로 JPA/Redis 모두 지원 가능 + +### 수강생 채팅에서 나온 분리의 비용 + +| 비용 | 내용 | +|------|------| +| 보일러플레이트 | Entity ↔ Domain 변환 로직(매퍼) 필요 | +| 더티체킹 포기 | JPA의 강력한 기능 못 씀, 명시적 save() 필요 | +| 클래스 폭발 | Order, OrderEntity 둘 다 관리 | +| 기능 제약 | JPA가 지원하는 편의 기능 활용 불가 | + +--- + +## 2. DDD 저자의 실무 타협 (최범균, 도메인 주도 개발 시작하기) + +### 명언 1: 변경이 거의 없는 상황에서 미리 대비하는 것은 과하다 + +> "DIP를 적용하는 주된 이유는 저수준 구현이 변경되더라도 고수준이 영향을 받지 않도록 하기 위함이다. +> 하지만, 리포지터리와 도메인 모델의 구현 기술은 거의 바뀌지 않는다. +> JPA로 구현한 리포지터리를 마이바티스나 다른 기술로 변경한 적이 없고, +> RDBMS를 사용하다 몽고DB로 변경한 적도 없다. +> 변경이 거의 없는 상황에서 변경을 미리 대비하는 것은 과하다고 생각한다." + +### 명언 2: 복잡도를 높이지 않으면서 구조적 유연함 유지 + +> "JPA 전용 애너테이션을 사용하긴 했지만 도메인 모델을 단위 테스트하는 데 문제는 없다. +> 리포지터리도 마찬가지다. 스프링 데이터 JPA가 제공하는 Repository 인터페이스를 상속하고 있지만 +> 리포지터리 자체는 인터페이스이고 테스트 가능성을 해치지 않는다. +> DIP를 완벽하게 지키면 좋겠지만 개발 편의성과 실용성을 가져가면서 구조적인 유연함은 어느정도 유지했다. +> 복잡도를 높이지 않으면서 기술에 따른 구현 제약이 낮다면 합리적인 선택이라고 생각한다." + +--- + +## 3. 우리 과제에 적용할 인사이트 + +### 타협하는 부분 (실용성 우선) + +| 항목 | 정석 | 우리의 타협 | 이유 | +|------|------|-----------|------| +| Domain Entity | 순수 POJO | @Entity 사용 | 더티체킹 활용, 보일러플레이트 감소 | +| VO | JPA 무관 | @Embeddable 사용 | 테스트에 영향 없음 | + +### 지키는 부분 (구조적 유연함) + +| 항목 | 적용 방식 | 이유 | +|------|----------|------| +| Repository Interface | Domain Layer에 정의 | 테스트 가능성 확보 (Fake 구현체 교체) | +| Repository 구현체 | Infrastructure Layer에 위치 | 의존 방향: Domain ← Infrastructure | +| Application Layer | Facade로 도메인 조합 | 유스케이스 조율과 비즈니스 로직 분리 | + +### 판단 기준 (한 줄 요약) + +``` +"테스트 가능성을 해치지 않는 범위에서 타협한다" +``` + +- @Entity 사용해도 단위 테스트 가능? → ✅ 타협 OK +- Repository를 Infrastructure에서 직접 사용하면 테스트 어려움? → ❌ Interface 분리 필요 diff --git a/.codeguide/loopers-1-week.md b/.codeguide/loopers-1-week.md deleted file mode 100644 index a8ace53e5..000000000 --- a/.codeguide/loopers-1-week.md +++ /dev/null @@ -1,45 +0,0 @@ -## 🧪 Implementation Quest - -> 지정된 **단위 테스트 / 통합 테스트 / E2E 테스트 케이스**를 필수로 구현하고, 모든 테스트를 통과시키는 것을 목표로 합니다. - -### 회원 가입 - -**🧱 단위 테스트** - -- [ ] ID 가 `영문 및 숫자 10자 이내` 형식에 맞지 않으면, User 객체 생성에 실패한다. -- [ ] 이메일이 `xx@yy.zz` 형식에 맞지 않으면, User 객체 생성에 실패한다. -- [ ] 생년월일이 `yyyy-MM-dd` 형식에 맞지 않으면, User 객체 생성에 실패한다. - -**🔗 통합 테스트** - -- [ ] 회원 가입시 User 저장이 수행된다. ( spy 검증 ) -- [ ] 이미 가입된 ID 로 회원가입 시도 시, 실패한다. - -**🌐 E2E 테스트** - -- [ ] 회원 가입이 성공할 경우, 생성된 유저 정보를 응답으로 반환한다. -- [ ] 회원 가입 시에 성별이 없을 경우, `400 Bad Request` 응답을 반환한다. - -### 내 정보 조회 - -**🔗 통합 테스트** - -- [ ] 해당 ID 의 회원이 존재할 경우, 회원 정보가 반환된다. -- [ ] 해당 ID 의 회원이 존재하지 않을 경우, null 이 반환된다. - -**🌐 E2E 테스트** - -- [ ] 내 정보 조회에 성공할 경우, 해당하는 유저 정보를 응답으로 반환한다. -- [ ] 존재하지 않는 ID 로 조회할 경우, `404 Not Found` 응답을 반환한다. - -### 포인트 조회 - -**🔗 통합 테스트** - -- [ ] 해당 ID 의 회원이 존재할 경우, 보유 포인트가 반환된다. -- [ ] 해당 ID 의 회원이 존재하지 않을 경우, null 이 반환된다. - -**🌐 E2E 테스트** - -- [ ] 포인트 조회에 성공할 경우, 보유 포인트를 응답으로 반환한다. -- [ ] `X-USER-ID` 헤더가 없을 경우, `400 Bad Request` 응답을 반환한다. diff --git a/.gitignore b/.gitignore index 5a979af6f..75fe145db 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,7 @@ out/ ### Kotlin ### .kotlin + +### Claude Code ### +*.md +!docs/**/*.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..a34e69038 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,88 @@ +# CLAUDE.md + +## 역할 + +- 20년 경력의 백엔드 개발자 +- 현재 네이버 백엔드 개발팀 팀장이자 면접관 +- 코드 리뷰, PR 작성, 설계 피드백 시 이 역할 기준으로 판단하고 조언한다 + +--- + +## 도메인 & 객체 설계 전략 + +### Entity / VO / Domain Service 구분 + +| 구분 | 기준 | 예시 | +|------|------|------| +| **Entity** | 식별자(ID) + 상태 변화 + 연속성 | Product, Brand, Order, Like | +| **Value Object** | 값 동등성 + 불변 + 자기 검증 | Price, Stock | +| **Domain Service** | 상태 없음 + 여러 객체 협력 로직 | 단일 Entity로 처리 어려운 도메인 규칙 | + +### 설계 규칙 + +1. 도메인 객체는 비즈니스 규칙을 캡슐화한다 (예: `Stock.decrease()`에서 음수 방지) +2. Application Layer(Facade)는 도메인을 조립하여 유스케이스를 완성한다 +3. 도메인 로직이 여러 서비스에 중복되면 도메인 객체로 이동시킨다 +4. Aggregate 간 참조는 ID로만 한다 (느슨한 결합) +5. VO는 불변(immutable)이며, 생성자에서 자기 검증을 수행한다 + +--- + +## 아키텍처 & 패키지 전략 + +### 레이어드 아키텍처 + DIP + +``` +interfaces/api/{domain}/ → Controller, Request/Response DTO +application/{domain}/ → Facade (유스케이스 조율, 트랜잭션) +domain/{domain}/ → Entity, VO, Repository Interface +infrastructure/{domain}/ → Repository 구현체 (JPA) +``` + +### 의존 방향 + +``` +Interfaces → Application → Domain ← Infrastructure +``` + +- Domain은 다른 레이어에 의존하지 않는다 +- Infrastructure가 Domain의 Repository 인터페이스를 구현한다 (DIP) + +### DIP 실무 타협 기준 + +- **타협**: @Entity, @Embeddable을 Domain에서 사용 (테스트 가능성 해치지 않으므로) +- **준수**: Repository Interface는 Domain에, 구현체는 Infrastructure에 분리 + +> "테스트 가능성을 해치지 않는 범위에서 타협한다" + +### 패키지 구조 (계층 + 도메인) + +``` +/interfaces/api/member/ +/interfaces/api/brand/ +/interfaces/api/product/ +/interfaces/api/order/ +/interfaces/api/like/ +/application/member/ +/application/brand/ +/application/product/ +/application/order/ +/application/like/ +/domain/member/ +/domain/brand/ +/domain/product/ +/domain/order/ +/domain/like/ +/infrastructure/member/ +/infrastructure/brand/ +/infrastructure/product/ +/infrastructure/order/ +/infrastructure/like/ +``` + +### Application Layer 규칙 + +- Facade는 유스케이스 조율과 트랜잭션 경계를 담당한다 +- 비즈니스 규칙 판단, 값 검증, 상태 변경 로직은 Domain에 위임한다 +- 여러 도메인의 정보 조합은 Application Layer에서 처리한다 + - 예: `ProductFacade.getProductDetail()` → Product + Brand 조합 diff --git a/apps/commerce-api/build.gradle.kts b/apps/commerce-api/build.gradle.kts index 03ce68f02..6d6b8bf46 100644 --- a/apps/commerce-api/build.gradle.kts +++ b/apps/commerce-api/build.gradle.kts @@ -8,6 +8,10 @@ dependencies { // web implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.boot:spring-boot-starter-validation") + + // security (password encryption only) + implementation("org.springframework.security:spring-security-crypto") implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:${project.properties["springDocOpenApiVersion"]}") diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java new file mode 100644 index 000000000..748f0f707 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java @@ -0,0 +1,60 @@ +package com.loopers.application.brand; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.like.LikeRepository; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class BrandFacade { + + private final BrandRepository brandRepository; + private final ProductRepository productRepository; + private final LikeRepository likeRepository; + + public Brand getBrand(Long brandId) { + return brandRepository.findById(brandId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다.")); + } + + public List getAllBrands() { + return brandRepository.findAll(); + } + + @Transactional + public Brand createBrand(String name, String description) { + return brandRepository.save(new Brand(name, description)); + } + + @Transactional + public Brand updateBrand(Long brandId, String name, String description) { + Brand brand = brandRepository.findById(brandId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다.")); + brand.changeName(name); + brand.changeDescription(description); + return brand; + } + + @Transactional + public void deleteBrand(Long brandId) { + Brand brand = brandRepository.findById(brandId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다.")); + + List products = productRepository.findAllByBrandId(brandId); + for (Product product : products) { + likeRepository.deleteAllByProductId(product.getId()); + product.delete(); + } + brand.delete(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleFacade.java deleted file mode 100644 index 552a9ad62..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleFacade.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.loopers.application.example; - -import com.loopers.domain.example.ExampleModel; -import com.loopers.domain.example.ExampleService; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -@RequiredArgsConstructor -@Component -public class ExampleFacade { - private final ExampleService exampleService; - - public ExampleInfo getExample(Long id) { - ExampleModel example = exampleService.getExample(id); - return ExampleInfo.from(example); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleInfo.java deleted file mode 100644 index 877aba96c..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleInfo.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.loopers.application.example; - -import com.loopers.domain.example.ExampleModel; - -public record ExampleInfo(Long id, String name, String description) { - public static ExampleInfo from(ExampleModel model) { - return new ExampleInfo( - model.getId(), - model.getName(), - model.getDescription() - ); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java new file mode 100644 index 000000000..0ac73daa8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java @@ -0,0 +1,54 @@ +package com.loopers.application.like; + +import com.loopers.domain.like.Like; +import com.loopers.domain.like.LikeRepository; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class LikeFacade { + + private final LikeRepository likeRepository; + private final ProductRepository productRepository; + + @Transactional + public void addLike(Long memberId, Long productId) { + Product product = productRepository.findById(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.")); + + if (likeRepository.existsByMemberIdAndProductId(memberId, productId)) { + return; + } + + likeRepository.save(new Like(memberId, productId)); + product.incrementLikeCount(); + } + + @Transactional + public void removeLike(Long memberId, Long productId) { + Optional likeOpt = likeRepository.findByMemberIdAndProductId(memberId, productId); + if (likeOpt.isEmpty()) { + return; + } + + likeRepository.delete(likeOpt.get()); + + Product product = productRepository.findById(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.")); + product.decrementLikeCount(); + } + + public List getLikesByMemberId(Long memberId) { + return likeRepository.findAllByMemberId(memberId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java new file mode 100644 index 000000000..3a92b3c38 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java @@ -0,0 +1,62 @@ +package com.loopers.application.member; + +import com.loopers.domain.member.Member; +import com.loopers.domain.member.MemberRepository; +import com.loopers.domain.member.vo.BirthDate; +import com.loopers.domain.member.vo.Email; +import com.loopers.domain.member.vo.LoginId; +import com.loopers.domain.member.vo.Password; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +@RequiredArgsConstructor +@Service +@Transactional(readOnly = true) +public class MemberFacade { + + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + + @Transactional + public Member register(String loginId, String plainPassword, String name, + String birthDate, String email) { + LoginId loginIdVo = new LoginId(loginId); + + if (memberRepository.existsByLoginId(loginIdVo)) { + throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 ID입니다."); + } + + BirthDate birthDateVo = BirthDate.from(birthDate); + Password password = Password.create(plainPassword, birthDateVo.value(), passwordEncoder); + Email emailVo = new Email(email); + + Member member = new Member(loginIdVo, password, name, birthDateVo, emailVo); + return memberRepository.save(member); + } + + public Optional findByLoginId(String loginId) { + return memberRepository.findByLoginId(new LoginId(loginId)); + } + + @Transactional + public void changePassword(Member member, String currentPlain, String newPlain) { + if (!member.getPassword().matches(currentPlain, passwordEncoder)) { + throw new CoreException(ErrorType.BAD_REQUEST, "현재 비밀번호가 일치하지 않습니다."); + } + + if (member.getPassword().matches(newPlain, passwordEncoder)) { + throw new CoreException(ErrorType.BAD_REQUEST, + "새 비밀번호는 현재 비밀번호와 달라야 합니다."); + } + + Password newPassword = Password.create( + newPlain, member.getBirthDate().value(), passwordEncoder); + member.changePassword(newPassword); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java new file mode 100644 index 000000000..cb09021b1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java @@ -0,0 +1,112 @@ +package com.loopers.application.order; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderItem; +import com.loopers.domain.order.OrderRepository; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class OrderFacade { + + private final OrderRepository orderRepository; + private final ProductRepository productRepository; + private final BrandRepository brandRepository; + + @Transactional + public Order createOrder(Long memberId, List itemRequests) { + // 1. 상품 조회 + 재고 차감 (엔티티 로드 필요) + List products = new ArrayList<>(); + for (OrderItemRequest req : itemRequests) { + Product product = productRepository.findById(req.productId()) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.")); + product.decreaseStock(req.quantity()); + products.add(product); + } + + // 2. 브랜드 한 번에 조회 (N+1 방지) + Set brandIds = products.stream() + .map(Product::getBrandId) + .collect(Collectors.toSet()); + Map brandMap = brandRepository.findAllByIds(brandIds).stream() + .collect(Collectors.toMap(Brand::getId, Function.identity())); + + // 3. 스냅샷 생성 + List snapshots = new ArrayList<>(); + for (int i = 0; i < itemRequests.size(); i++) { + Product product = products.get(i); + Brand brand = brandMap.get(product.getBrandId()); + String brandName = brand != null ? brand.getName() : null; + + snapshots.add(new Order.ItemSnapshot( + product.getId(), + product.getName(), + product.getPrice().getValue(), + brandName, + itemRequests.get(i).quantity() + )); + } + + // 4. 주문 저장 + return orderRepository.save(Order.create(memberId, snapshots)); + } + + public Order getOrder(Long orderId) { + return orderRepository.findById(orderId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다.")); + } + + public Order getOrder(Long orderId, Long memberId) { + Order order = orderRepository.findById(orderId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다.")); + if (!order.getMemberId().equals(memberId)) { + throw new CoreException(ErrorType.FORBIDDEN, "본인의 주문만 조회할 수 있습니다."); + } + return order; + } + + @Transactional + public void cancelOrder(Long orderId, Long memberId) { + Order order = orderRepository.findById(orderId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다.")); + if (!order.getMemberId().equals(memberId)) { + throw new CoreException(ErrorType.FORBIDDEN, "본인의 주문만 취소할 수 있습니다."); + } + order.cancel(); + for (OrderItem item : order.getItems()) { + Product product = productRepository.findById(item.getProductId()) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.")); + product.increaseStock(item.getQuantity()); + } + } + + public List getOrdersByMemberId(Long memberId, ZonedDateTime startAt, ZonedDateTime endAt) { + if (startAt != null && endAt != null) { + return orderRepository.findAllByMemberIdAndCreatedAtBetween(memberId, startAt, endAt); + } + return orderRepository.findAllByMemberId(memberId); + } + + public List getAllOrders() { + return orderRepository.findAll(); + } + + public record OrderItemRequest(Long productId, int quantity) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java new file mode 100644 index 000000000..f6af97d49 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -0,0 +1,75 @@ +package com.loopers.application.product; + +import com.loopers.domain.like.LikeRepository; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.ProductWithBrand; +import com.loopers.domain.product.vo.Price; +import com.loopers.domain.product.vo.Stock; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ProductFacade { + + private final ProductRepository productRepository; + private final BrandRepository brandRepository; + private final LikeRepository likeRepository; + + public ProductWithBrand getProductDetail(Long productId) { + Product product = productRepository.findById(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.")); + Brand brand = brandRepository.findById(product.getBrandId()).orElse(null); + String brandName = (brand != null) ? brand.getName() : null; + return new ProductWithBrand(product, brandName); + } + + public List getAllProducts() { + return productRepository.findAllWithBrand(); + } + + public List getAllProducts(String sort) { + return productRepository.findAllWithBrand(sort); + } + + public List getProductsByBrandId(Long brandId) { + brandRepository.findById(brandId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다.")); + return productRepository.findAllByBrandIdWithBrand(brandId); + } + + @Transactional + public Product createProduct(Long brandId, String name, int price, int stockQuantity) { + brandRepository.findById(brandId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다.")); + Product product = new Product(brandId, name, new Price(price), new Stock(stockQuantity)); + return productRepository.save(product); + } + + @Transactional + public Product updateProduct(Long productId, String name, int price, int stockQuantity) { + Product product = productRepository.findById(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.")); + product.changeName(name); + product.changePrice(new Price(price)); + product.changeStock(new Stock(stockQuantity)); + return product; + } + + @Transactional + public void deleteProduct(Long productId) { + Product product = productRepository.findById(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.")); + likeRepository.deleteAllByProductId(productId); + product.delete(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java new file mode 100644 index 000000000..982147e1f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java @@ -0,0 +1,32 @@ +package com.loopers.domain.brand; + +import com.loopers.domain.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "brand") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Brand extends BaseEntity { + + @Column(nullable = false) + private String name; + + private String description; + + public Brand(String name, String description) { + this.name = name; + this.description = description; + } + + public void changeName(String name) { + this.name = name; + } + + public void changeDescription(String description) { + this.description = description; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java new file mode 100644 index 000000000..18f8dd2c1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java @@ -0,0 +1,12 @@ +package com.loopers.domain.brand; + +import java.util.List; +import java.util.Optional; +import java.util.Set; + +public interface BrandRepository { + Brand save(Brand brand); + Optional findById(Long id); + List findAll(); + List findAllByIds(Set ids); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java deleted file mode 100644 index c588c4a8a..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.loopers.domain.example; - -import com.loopers.domain.BaseEntity; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import jakarta.persistence.Entity; -import jakarta.persistence.Table; - -@Entity -@Table(name = "example") -public class ExampleModel extends BaseEntity { - - private String name; - private String description; - - protected ExampleModel() {} - - public ExampleModel(String name, String description) { - if (name == null || name.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "이름은 비어있을 수 없습니다."); - } - if (description == null || description.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "설명은 비어있을 수 없습니다."); - } - - this.name = name; - this.description = description; - } - - public String getName() { - return name; - } - - public String getDescription() { - return description; - } - - public void update(String newDescription) { - if (newDescription == null || newDescription.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "설명은 비어있을 수 없습니다."); - } - this.description = newDescription; - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleRepository.java deleted file mode 100644 index 3625e5662..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleRepository.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.loopers.domain.example; - -import java.util.Optional; - -public interface ExampleRepository { - Optional find(Long id); -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java deleted file mode 100644 index c0e8431e8..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.loopers.domain.example; - -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -@RequiredArgsConstructor -@Component -public class ExampleService { - - private final ExampleRepository exampleRepository; - - @Transactional(readOnly = true) - public ExampleModel getExample(Long id) { - return exampleRepository.find(id) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "[id = " + id + "] 예시를 찾을 수 없습니다.")); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java new file mode 100644 index 000000000..23fd03c21 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java @@ -0,0 +1,36 @@ +package com.loopers.domain.like; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.ZonedDateTime; + +@Entity +@Table(name = "likes", uniqueConstraints = { + @UniqueConstraint(name = "uk_likes_member_product", columnNames = {"member_id", "product_id"}) +}) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Like { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "member_id", nullable = false) + private Long memberId; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "created_at", nullable = false, updatable = false) + private ZonedDateTime createdAt; + + public Like(Long memberId, Long productId) { + this.memberId = memberId; + this.productId = productId; + this.createdAt = ZonedDateTime.now(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java new file mode 100644 index 000000000..e90af13b8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java @@ -0,0 +1,13 @@ +package com.loopers.domain.like; + +import java.util.List; +import java.util.Optional; + +public interface LikeRepository { + Like save(Like like); + void delete(Like like); + Optional findByMemberIdAndProductId(Long memberId, Long productId); + boolean existsByMemberIdAndProductId(Long memberId, Long productId); + List findAllByMemberId(Long memberId); + void deleteAllByProductId(Long productId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java new file mode 100644 index 000000000..1cad27697 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java @@ -0,0 +1,57 @@ +package com.loopers.domain.member; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.member.vo.BirthDate; +import com.loopers.domain.member.vo.Email; +import com.loopers.domain.member.vo.LoginId; +import com.loopers.domain.member.vo.Password; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +@Entity +@Table(name = "member") +public class Member extends BaseEntity { + + @Embedded + private LoginId loginId; + + @Embedded + private Password password; + + @Column(nullable = false, length = 50) + private String name; + + @Embedded + private BirthDate birthDate; + + @Embedded + private Email email; + + protected Member() {} + + public Member(LoginId loginId, Password password, String name, + BirthDate birthDate, Email email) { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "이름은 필수입니다."); + } + this.loginId = loginId; + this.password = password; + this.name = name; + this.birthDate = birthDate; + this.email = email; + } + + public LoginId getLoginId() { return loginId; } + public Password getPassword() { return password; } + public String getName() { return name; } + public BirthDate getBirthDate() { return birthDate; } + public Email getEmail() { return email; } + + public void changePassword(Password newPassword) { + this.password = newPassword; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java new file mode 100644 index 000000000..923b7f0df --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java @@ -0,0 +1,11 @@ +package com.loopers.domain.member; + +import com.loopers.domain.member.vo.LoginId; + +import java.util.Optional; + +public interface MemberRepository { + Member save(Member member); + Optional findByLoginId(LoginId loginId); + boolean existsByLoginId(LoginId loginId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/policy/PasswordPolicy.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/policy/PasswordPolicy.java new file mode 100644 index 000000000..6f4db2d87 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/policy/PasswordPolicy.java @@ -0,0 +1,45 @@ +package com.loopers.domain.member.policy; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.regex.Pattern; + +public class PasswordPolicy { + + private static final Pattern FORMAT_PATTERN = + Pattern.compile("^[A-Za-z0-9!@#$%^&*()_+=-]{8,16}$"); + + public static void validate(String plain, LocalDate birthDate) { + validateFormat(plain); + validateNotContainsSubstrings(plain, + extractBirthDateStrings(birthDate), + "비밀번호에 생년월일을 포함할 수 없습니다."); + } + + public static void validateFormat(String plain) { + if (plain == null || !FORMAT_PATTERN.matcher(plain).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, + "비밀번호는 8~16자의 영문, 숫자, 특수문자만 허용됩니다."); + } + } + + public static void validateNotContainsSubstrings( + String plain, List forbidden, String errorMessage) { + for (String s : forbidden) { + if (plain.contains(s)) { + throw new CoreException(ErrorType.BAD_REQUEST, errorMessage); + } + } + } + + public static List extractBirthDateStrings(LocalDate birthDate) { + return List.of( + birthDate.format(DateTimeFormatter.ofPattern("yyyyMMdd")), + birthDate.format(DateTimeFormatter.ofPattern("yyMMdd")) + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/BirthDate.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/BirthDate.java new file mode 100644 index 000000000..cd9725968 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/BirthDate.java @@ -0,0 +1,56 @@ +package com.loopers.domain.member.vo; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.Objects; + +@Embeddable +public class BirthDate { + + private static final DateTimeFormatter FORMATTER = + DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + @Column(name = "birth_date", nullable = false) + private LocalDate value; + + protected BirthDate() {} + + public BirthDate(LocalDate value) { + if (value == null) { + throw new CoreException(ErrorType.BAD_REQUEST, + "생년월일은 필수입니다."); + } + this.value = value; + } + + public static BirthDate from(String dateString) { + if (dateString == null || dateString.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, + "생년월일은 필수입니다."); + } + try { + return new BirthDate(LocalDate.parse(dateString, FORMATTER)); + } catch (DateTimeParseException e) { + throw new CoreException(ErrorType.BAD_REQUEST, + "생년월일은 yyyy-MM-dd 형식이어야 합니다."); + } + } + + public LocalDate value() { return value; } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof BirthDate birthDate)) return false; + return Objects.equals(value, birthDate.value); + } + + @Override + public int hashCode() { return Objects.hash(value); } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/Email.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/Email.java new file mode 100644 index 000000000..7562e18a0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/Email.java @@ -0,0 +1,41 @@ +package com.loopers.domain.member.vo; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; + +import java.util.Objects; +import java.util.regex.Pattern; + +@Embeddable +public class Email { + + private static final Pattern PATTERN = + Pattern.compile("^[\\w-.]+@[\\w-]+(\\.[a-z]{2,})+$"); + + @Column(name = "email", nullable = false, length = 100) + private String value; + + protected Email() {} + + public Email(String value) { + if (value == null || !PATTERN.matcher(value).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, + "올바른 이메일 형식이 아닙니다."); + } + this.value = value; + } + + public String value() { return value; } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Email email)) return false; + return Objects.equals(value, email.value); + } + + @Override + public int hashCode() { return Objects.hash(value); } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/LoginId.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/LoginId.java new file mode 100644 index 000000000..d003c5203 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/LoginId.java @@ -0,0 +1,40 @@ +package com.loopers.domain.member.vo; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; + +import java.util.Objects; +import java.util.regex.Pattern; + +@Embeddable +public class LoginId { + + private static final Pattern PATTERN = Pattern.compile("^[A-Za-z0-9]{1,10}$"); + + @Column(name = "login_id", nullable = false, unique = true, length = 20) + private String value; + + protected LoginId() {} + + public LoginId(String value) { + if (value == null || !PATTERN.matcher(value).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, + "ID는 영문 및 숫자 10자 이내여야 합니다."); + } + this.value = value; + } + + public String value() { return value; } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof LoginId loginId)) return false; + return Objects.equals(value, loginId.value); + } + + @Override + public int hashCode() { return Objects.hash(value); } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/Password.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/Password.java new file mode 100644 index 000000000..d44acd588 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/Password.java @@ -0,0 +1,49 @@ +package com.loopers.domain.member.vo; + +import com.loopers.domain.member.policy.PasswordPolicy; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.time.LocalDate; +import java.util.Objects; + +@Embeddable +public class Password { + + @Column(name = "password", nullable = false) + private String encoded; + + protected Password() {} + + public Password(String encoded) { + if (encoded == null || encoded.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 필수입니다."); + } + this.encoded = encoded; + } + + public static Password create(String plain, LocalDate birthDate, + PasswordEncoder encoder) { + PasswordPolicy.validate(plain, birthDate); + return new Password(encoder.encode(plain)); + } + + public boolean matches(String plain, PasswordEncoder encoder) { + return encoder.matches(plain, this.encoded); + } + + public String encoded() { return encoded; } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Password password)) return false; + return Objects.equals(encoded, password.encoded); + } + + @Override + public int hashCode() { return Objects.hash(encoded); } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java new file mode 100644 index 000000000..3ac7248c4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java @@ -0,0 +1,68 @@ +package com.loopers.domain.order; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +@Entity +@Table(name = "orders", indexes = { + @Index(name = "idx_orders_member_id", columnList = "member_id"), + @Index(name = "idx_orders_member_created_at", columnList = "member_id, created_at") +}) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Order extends BaseEntity { + + @Column(name = "member_id", nullable = false) + private Long memberId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private OrderStatus status; + + @Column(name = "total_price", nullable = false) + private int totalPrice; + + @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true) + @JoinColumn(name = "order_id") + private List items = new ArrayList<>(); + + public static Order create(Long memberId, List snapshots) { + if (snapshots == null || snapshots.isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문 항목은 1개 이상이어야 합니다."); + } + Order order = new Order(); + order.memberId = memberId; + order.status = OrderStatus.CREATED; + for (ItemSnapshot s : snapshots) { + order.items.add(new OrderItem( + s.productId(), s.productName(), s.productPrice(), s.brandName(), s.quantity() + )); + } + order.totalPrice = order.items.stream().mapToInt(OrderItem::getSubtotal).sum(); + return order; + } + + public record ItemSnapshot( + Long productId, String productName, int productPrice, String brandName, int quantity + ) {} + + public List getItems() { + return Collections.unmodifiableList(items); + } + + public void cancel() { + if (this.status == OrderStatus.CANCELLED) { + throw new CoreException(ErrorType.BAD_REQUEST, "이미 취소된 주문입니다."); + } + this.status = OrderStatus.CANCELLED; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java new file mode 100644 index 000000000..c45d59d32 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java @@ -0,0 +1,44 @@ +package com.loopers.domain.order; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "order_item") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class OrderItem { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "product_name", nullable = false) + private String productName; + + @Column(name = "product_price", nullable = false) + private int productPrice; + + @Column(name = "brand_name") + private String brandName; + + @Column(nullable = false) + private int quantity; + + OrderItem(Long productId, String productName, int productPrice, String brandName, int quantity) { + this.productId = productId; + this.productName = productName; + this.productPrice = productPrice; + this.brandName = brandName; + this.quantity = quantity; + } + + public int getSubtotal() { + return productPrice * quantity; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java new file mode 100644 index 000000000..0f054726e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java @@ -0,0 +1,13 @@ +package com.loopers.domain.order; + +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Optional; + +public interface OrderRepository { + Order save(Order order); + Optional findById(Long id); + List findAllByMemberId(Long memberId); + List findAllByMemberIdAndCreatedAtBetween(Long memberId, ZonedDateTime startAt, ZonedDateTime endAt); + List findAll(); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java new file mode 100644 index 000000000..107179124 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java @@ -0,0 +1,7 @@ +package com.loopers.domain.order; + +public enum OrderStatus { + CREATED, + PAID, + CANCELLED +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java new file mode 100644 index 000000000..cd6b0dcbe --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java @@ -0,0 +1,72 @@ +package com.loopers.domain.product; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.product.vo.Price; +import com.loopers.domain.product.vo.Stock; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "product", indexes = { + @Index(name = "idx_product_brand_id", columnList = "brand_id"), + @Index(name = "idx_product_like_count", columnList = "like_count") +}) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Product extends BaseEntity { + + @Column(name = "brand_id", nullable = false) + private Long brandId; + + @Column(nullable = false) + private String name; + + @Embedded + private Price price; + + @Embedded + private Stock stock; + + @Column(name = "like_count", nullable = false) + private int likeCount; + + public Product(Long brandId, String name, Price price, Stock stock) { + this.brandId = brandId; + this.name = name; + this.price = price; + this.stock = stock; + this.likeCount = 0; + } + + public void changeName(String name) { + this.name = name; + } + + public void changePrice(Price price) { + this.price = price; + } + + public void changeStock(Stock stock) { + this.stock = stock; + } + + public void decreaseStock(int quantity) { + this.stock = this.stock.decrease(quantity); + } + + public void incrementLikeCount() { + this.likeCount++; + } + + public void increaseStock(int quantity) { + this.stock = this.stock.increase(quantity); + } + + public void decrementLikeCount() { + if (this.likeCount > 0) { + this.likeCount--; + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java new file mode 100644 index 000000000..b44d67825 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -0,0 +1,16 @@ +package com.loopers.domain.product; + +import java.util.List; +import java.util.Optional; + +public interface ProductRepository { + Product save(Product product); + Optional findById(Long id); + List findAll(); + List findAllByBrandId(Long brandId); + + // 조회 전용 (Brand JOIN) + List findAllWithBrand(); + List findAllWithBrand(String sort); + List findAllByBrandIdWithBrand(Long brandId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductWithBrand.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductWithBrand.java new file mode 100644 index 000000000..6d92aa42d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductWithBrand.java @@ -0,0 +1,4 @@ +package com.loopers.domain.product; + +public record ProductWithBrand(Product product, String brandName) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/vo/Price.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/vo/Price.java new file mode 100644 index 000000000..b537bbee4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/vo/Price.java @@ -0,0 +1,25 @@ +package com.loopers.domain.product.vo; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Embeddable +@Getter +@EqualsAndHashCode +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Price { + + @Column(name = "price", nullable = false) + private int value; + + public Price(int value) { + if (value < 0) { + throw new IllegalArgumentException("가격은 0 이상이어야 합니다."); + } + this.value = value; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/vo/Stock.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/vo/Stock.java new file mode 100644 index 000000000..7c52d18be --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/vo/Stock.java @@ -0,0 +1,42 @@ +package com.loopers.domain.product.vo; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Embeddable +@Getter +@EqualsAndHashCode +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Stock { + + @Column(name = "stock_quantity", nullable = false) + private int quantity; + + public Stock(int quantity) { + if (quantity < 0) { + throw new IllegalArgumentException("재고는 0 이상이어야 합니다."); + } + this.quantity = quantity; + } + + public boolean hasEnough(int amount) { + return this.quantity >= amount; + } + + public Stock decrease(int amount) { + if (!hasEnough(amount)) { + throw new CoreException(ErrorType.BAD_REQUEST, "재고가 부족합니다."); + } + return new Stock(this.quantity - amount); + } + + public Stock increase(int amount) { + return new Stock(this.quantity + amount); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java new file mode 100644 index 000000000..501b9d8a9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java @@ -0,0 +1,14 @@ +package com.loopers.infrastructure.brand; + +import com.loopers.domain.brand.Brand; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +public interface BrandJpaRepository extends JpaRepository { + Optional findByIdAndDeletedAtIsNull(Long id); + List findAllByDeletedAtIsNull(); + List findAllByIdInAndDeletedAtIsNull(Collection ids); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java new file mode 100644 index 000000000..06d37c31a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java @@ -0,0 +1,37 @@ +package com.loopers.infrastructure.brand; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; +import java.util.Set; + +@Repository +@RequiredArgsConstructor +public class BrandRepositoryImpl implements BrandRepository { + + private final BrandJpaRepository brandJpaRepository; + + @Override + public Brand save(Brand brand) { + return brandJpaRepository.save(brand); + } + + @Override + public Optional findById(Long id) { + return brandJpaRepository.findByIdAndDeletedAtIsNull(id); + } + + @Override + public List findAll() { + return brandJpaRepository.findAllByDeletedAtIsNull(); + } + + @Override + public List findAllByIds(Set ids) { + return brandJpaRepository.findAllByIdInAndDeletedAtIsNull(ids); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleJpaRepository.java deleted file mode 100644 index ce6d3ead0..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleJpaRepository.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.loopers.infrastructure.example; - -import com.loopers.domain.example.ExampleModel; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface ExampleJpaRepository extends JpaRepository {} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleRepositoryImpl.java deleted file mode 100644 index 37f2272f0..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleRepositoryImpl.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.loopers.infrastructure.example; - -import com.loopers.domain.example.ExampleModel; -import com.loopers.domain.example.ExampleRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -import java.util.Optional; - -@RequiredArgsConstructor -@Component -public class ExampleRepositoryImpl implements ExampleRepository { - private final ExampleJpaRepository exampleJpaRepository; - - @Override - public Optional find(Long id) { - return exampleJpaRepository.findById(id); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java new file mode 100644 index 000000000..4c0432b4a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java @@ -0,0 +1,14 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.like.Like; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface LikeJpaRepository extends JpaRepository { + Optional findByMemberIdAndProductId(Long memberId, Long productId); + boolean existsByMemberIdAndProductId(Long memberId, Long productId); + List findAllByMemberId(Long memberId); + void deleteAllByProductId(Long productId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java new file mode 100644 index 000000000..0b9bf741c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java @@ -0,0 +1,46 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.like.Like; +import com.loopers.domain.like.LikeRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class LikeRepositoryImpl implements LikeRepository { + + private final LikeJpaRepository likeJpaRepository; + + @Override + public Like save(Like like) { + return likeJpaRepository.save(like); + } + + @Override + public void delete(Like like) { + likeJpaRepository.delete(like); + } + + @Override + public Optional findByMemberIdAndProductId(Long memberId, Long productId) { + return likeJpaRepository.findByMemberIdAndProductId(memberId, productId); + } + + @Override + public boolean existsByMemberIdAndProductId(Long memberId, Long productId) { + return likeJpaRepository.existsByMemberIdAndProductId(memberId, productId); + } + + @Override + public List findAllByMemberId(Long memberId) { + return likeJpaRepository.findAllByMemberId(memberId); + } + + @Override + public void deleteAllByProductId(Long productId) { + likeJpaRepository.deleteAllByProductId(productId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java new file mode 100644 index 000000000..edaadac00 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java @@ -0,0 +1,11 @@ +package com.loopers.infrastructure.member; + +import com.loopers.domain.member.Member; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface MemberJpaRepository extends JpaRepository { + Optional findByLoginIdValue(String loginId); + boolean existsByLoginIdValue(String loginId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java new file mode 100644 index 000000000..a8be0aeaa --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java @@ -0,0 +1,31 @@ +package com.loopers.infrastructure.member; + +import com.loopers.domain.member.Member; +import com.loopers.domain.member.MemberRepository; +import com.loopers.domain.member.vo.LoginId; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@RequiredArgsConstructor +@Repository +public class MemberRepositoryImpl implements MemberRepository { + + private final MemberJpaRepository memberJpaRepository; + + @Override + public Member save(Member member) { + return memberJpaRepository.save(member); + } + + @Override + public Optional findByLoginId(LoginId loginId) { + return memberJpaRepository.findByLoginIdValue(loginId.value()); + } + + @Override + public boolean existsByLoginId(LoginId loginId) { + return memberJpaRepository.existsByLoginIdValue(loginId.value()); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java new file mode 100644 index 000000000..2f4a36d4c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java @@ -0,0 +1,16 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.Order; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Optional; + +public interface OrderJpaRepository extends JpaRepository { + Optional findByIdAndDeletedAtIsNull(Long id); + List findAllByMemberIdAndDeletedAtIsNull(Long memberId); + List findAllByMemberIdAndCreatedAtBetweenAndDeletedAtIsNull( + Long memberId, ZonedDateTime startAt, ZonedDateTime endAt); + List findAllByDeletedAtIsNull(); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java new file mode 100644 index 000000000..5fd7b1455 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java @@ -0,0 +1,42 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class OrderRepositoryImpl implements OrderRepository { + + private final OrderJpaRepository orderJpaRepository; + + @Override + public Order save(Order order) { + return orderJpaRepository.save(order); + } + + @Override + public Optional findById(Long id) { + return orderJpaRepository.findByIdAndDeletedAtIsNull(id); + } + + @Override + public List findAllByMemberId(Long memberId) { + return orderJpaRepository.findAllByMemberIdAndDeletedAtIsNull(memberId); + } + + @Override + public List findAllByMemberIdAndCreatedAtBetween(Long memberId, ZonedDateTime startAt, ZonedDateTime endAt) { + return orderJpaRepository.findAllByMemberIdAndCreatedAtBetweenAndDeletedAtIsNull(memberId, startAt, endAt); + } + + @Override + public List findAll() { + return orderJpaRepository.findAllByDeletedAtIsNull(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java new file mode 100644 index 000000000..52f73a24f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java @@ -0,0 +1,28 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.Product; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface ProductJpaRepository extends JpaRepository { + Optional findByIdAndDeletedAtIsNull(Long id); + List findAllByDeletedAtIsNull(); + List findAllByBrandIdAndDeletedAtIsNull(Long brandId); + + @Query("SELECT p, b.name FROM Product p LEFT JOIN Brand b ON b.id = p.brandId" + + " WHERE p.deletedAt IS NULL AND (b.deletedAt IS NULL OR b.id IS NULL)") + List findAllWithBrand(); + + @Query("SELECT p, b.name FROM Product p LEFT JOIN Brand b ON b.id = p.brandId" + + " WHERE p.deletedAt IS NULL AND (b.deletedAt IS NULL OR b.id IS NULL)") + List findAllWithBrand(Sort sort); + + @Query("SELECT p, b.name FROM Product p LEFT JOIN Brand b ON b.id = p.brandId" + + " WHERE p.brandId = :brandId AND p.deletedAt IS NULL AND (b.deletedAt IS NULL OR b.id IS NULL)") + List findAllByBrandIdWithBrand(@Param("brandId") Long brandId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java new file mode 100644 index 000000000..6014434ef --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -0,0 +1,74 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.ProductWithBrand; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class ProductRepositoryImpl implements ProductRepository { + + private final ProductJpaRepository productJpaRepository; + + @Override + public Product save(Product product) { + return productJpaRepository.save(product); + } + + @Override + public Optional findById(Long id) { + return productJpaRepository.findByIdAndDeletedAtIsNull(id); + } + + @Override + public List findAll() { + return productJpaRepository.findAllByDeletedAtIsNull(); + } + + @Override + public List findAllByBrandId(Long brandId) { + return productJpaRepository.findAllByBrandIdAndDeletedAtIsNull(brandId); + } + + @Override + public List findAllWithBrand() { + return productJpaRepository.findAllWithBrand().stream() + .map(this::toProductWithBrand) + .toList(); + } + + @Override + public List findAllWithBrand(String sort) { + return productJpaRepository.findAllWithBrand(toSort(sort)).stream() + .map(this::toProductWithBrand) + .toList(); + } + + @Override + public List findAllByBrandIdWithBrand(Long brandId) { + return productJpaRepository.findAllByBrandIdWithBrand(brandId).stream() + .map(this::toProductWithBrand) + .toList(); + } + + private ProductWithBrand toProductWithBrand(Object[] row) { + return new ProductWithBrand((Product) row[0], (String) row[1]); + } + + private Sort toSort(String sort) { + if (sort == null) { + return Sort.by("createdAt").descending(); + } + return switch (sort) { + case "price_asc" -> Sort.by("price.value").ascending(); + case "likes_desc" -> Sort.by("likeCount").descending(); + default -> Sort.by("createdAt").descending(); + }; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java index 20b2809c8..f24379f0d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java @@ -8,6 +8,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -46,6 +47,14 @@ public ResponseEntity> handleBadRequest(MissingServletRequestPara return failureResponse(ErrorType.BAD_REQUEST, message); } + @ExceptionHandler + public ResponseEntity> handleBadRequest(MethodArgumentNotValidException e) { + String message = e.getBindingResult().getFieldErrors().stream() + .map(error -> String.format("'%s' %s", error.getField(), error.getDefaultMessage())) + .collect(Collectors.joining(", ")); + return failureResponse(ErrorType.BAD_REQUEST, message); + } + @ExceptionHandler public ResponseEntity> handleBadRequest(HttpMessageNotReadableException e) { String errorMessage; diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminController.java new file mode 100644 index 000000000..149007c99 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminController.java @@ -0,0 +1,55 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.application.brand.BrandFacade; +import com.loopers.domain.brand.Brand; +import com.loopers.interfaces.api.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api-admin/v1/brands") +public class BrandAdminController { + + private final BrandFacade brandFacade; + + @GetMapping + public ApiResponse> getAllBrands() { + List responses = brandFacade.getAllBrands().stream() + .map(BrandDto.BrandResponse::from) + .toList(); + return ApiResponse.success(responses); + } + + @GetMapping("/{brandId}") + public ApiResponse getBrand(@PathVariable Long brandId) { + Brand brand = brandFacade.getBrand(brandId); + return ApiResponse.success(BrandDto.BrandResponse.from(brand)); + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public ApiResponse createBrand(@Valid @RequestBody BrandDto.CreateRequest request) { + Brand brand = brandFacade.createBrand(request.name(), request.description()); + return ApiResponse.success(BrandDto.BrandResponse.from(brand)); + } + + @PutMapping("/{brandId}") + public ApiResponse updateBrand( + @PathVariable Long brandId, + @Valid @RequestBody BrandDto.UpdateRequest request + ) { + Brand brand = brandFacade.updateBrand(brandId, request.name(), request.description()); + return ApiResponse.success(BrandDto.BrandResponse.from(brand)); + } + + @DeleteMapping("/{brandId}") + public ApiResponse deleteBrand(@PathVariable Long brandId) { + brandFacade.deleteBrand(brandId); + return ApiResponse.success(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandController.java new file mode 100644 index 000000000..b1e439a98 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandController.java @@ -0,0 +1,21 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.application.brand.BrandFacade; +import com.loopers.domain.brand.Brand; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/brands") +public class BrandController { + + private final BrandFacade brandFacade; + + @GetMapping("/{brandId}") + public ApiResponse getBrand(@PathVariable Long brandId) { + Brand brand = brandFacade.getBrand(brandId); + return ApiResponse.success(BrandDto.BrandResponse.from(brand)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandDto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandDto.java new file mode 100644 index 000000000..65c947bac --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandDto.java @@ -0,0 +1,27 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.domain.brand.Brand; +import jakarta.validation.constraints.NotBlank; + +public class BrandDto { + + public record CreateRequest( + @NotBlank String name, + String description + ) {} + + public record UpdateRequest( + @NotBlank String name, + String description + ) {} + + public record BrandResponse( + Long id, + String name, + String description + ) { + public static BrandResponse from(Brand brand) { + return new BrandResponse(brand.getId(), brand.getName(), brand.getDescription()); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1ApiSpec.java deleted file mode 100644 index 219e3101e..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1ApiSpec.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.loopers.interfaces.api.example; - -import com.loopers.interfaces.api.ApiResponse; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.tags.Tag; - -@Tag(name = "Example V1 API", description = "Loopers 예시 API 입니다.") -public interface ExampleV1ApiSpec { - - @Operation( - summary = "예시 조회", - description = "ID로 예시를 조회합니다." - ) - ApiResponse getExample( - @Schema(name = "예시 ID", description = "조회할 예시의 ID") - Long exampleId - ); -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Controller.java deleted file mode 100644 index 917376016..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Controller.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.loopers.interfaces.api.example; - -import com.loopers.application.example.ExampleFacade; -import com.loopers.application.example.ExampleInfo; -import com.loopers.interfaces.api.ApiResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RequiredArgsConstructor -@RestController -@RequestMapping("/api/v1/examples") -public class ExampleV1Controller implements ExampleV1ApiSpec { - - private final ExampleFacade exampleFacade; - - @GetMapping("/{exampleId}") - @Override - public ApiResponse getExample( - @PathVariable(value = "exampleId") Long exampleId - ) { - ExampleInfo info = exampleFacade.getExample(exampleId); - ExampleV1Dto.ExampleResponse response = ExampleV1Dto.ExampleResponse.from(info); - return ApiResponse.success(response); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Dto.java deleted file mode 100644 index 4ecf0eea5..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Dto.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.loopers.interfaces.api.example; - -import com.loopers.application.example.ExampleInfo; - -public class ExampleV1Dto { - public record ExampleResponse(Long id, String name, String description) { - public static ExampleResponse from(ExampleInfo info) { - return new ExampleResponse( - info.id(), - info.name(), - info.description() - ); - } - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeController.java new file mode 100644 index 000000000..a261247f0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeController.java @@ -0,0 +1,46 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.application.like.LikeFacade; +import com.loopers.domain.like.Like; +import com.loopers.domain.member.Member; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.support.auth.AuthMember; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +public class LikeController { + + private final LikeFacade likeFacade; + + @PostMapping("/api/v1/products/{productId}/likes") + public ApiResponse addLike(@AuthMember Member member, @PathVariable Long productId) { + likeFacade.addLike(member.getId(), productId); + return ApiResponse.success(null); + } + + @DeleteMapping("/api/v1/products/{productId}/likes") + public ApiResponse removeLike(@AuthMember Member member, @PathVariable Long productId) { + likeFacade.removeLike(member.getId(), productId); + return ApiResponse.success(null); + } + + @GetMapping("/api/v1/users/{userId}/likes") + public ApiResponse> getLikes( + @AuthMember Member member, + @PathVariable Long userId + ) { + if (!member.getId().equals(userId)) { + throw new CoreException(ErrorType.FORBIDDEN, "본인의 좋아요 목록만 조회할 수 있습니다."); + } + List responses = likeFacade.getLikesByMemberId(userId).stream() + .map(LikeDto.LikeResponse::from) + .toList(); + return ApiResponse.success(responses); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeDto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeDto.java new file mode 100644 index 000000000..a7d00431e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeDto.java @@ -0,0 +1,16 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.domain.like.Like; + +public class LikeDto { + + public record LikeResponse( + Long id, + Long memberId, + Long productId + ) { + public static LikeResponse from(Like like) { + return new LikeResponse(like.getId(), like.getMemberId(), like.getProductId()); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java new file mode 100644 index 000000000..3b276748b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java @@ -0,0 +1,59 @@ +package com.loopers.interfaces.api.member; + +import com.loopers.application.member.MemberFacade; +import com.loopers.domain.member.Member; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.support.auth.AuthMember; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +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.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/members") +public class MemberV1Controller { + + private final MemberFacade memberFacade; + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public ApiResponse signUp(@Valid @RequestBody MemberV1Dto.SignUpRequest request) { + Member member = memberFacade.register( + request.loginId(), + request.password(), + request.name(), + request.birthDate(), + request.email() + ); + + MemberV1Dto.SignUpResponse response = new MemberV1Dto.SignUpResponse( + member.getId(), + member.getLoginId().value(), + member.getName(), + member.getEmail().value() + ); + + return ApiResponse.success(response); + } + + @GetMapping("/me") + public ApiResponse getMyInfo(@AuthMember Member member) { + return ApiResponse.success(MemberV1Dto.MyInfoResponse.from(member)); + } + + @PatchMapping("/me/password") + public ApiResponse changePassword( + @AuthMember Member member, + @Valid @RequestBody MemberV1Dto.ChangePasswordRequest request + ) { + memberFacade.changePassword(member, request.currentPassword(), request.newPassword()); + return ApiResponse.success(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java new file mode 100644 index 000000000..13113ae1f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java @@ -0,0 +1,52 @@ +package com.loopers.interfaces.api.member; + +import com.loopers.domain.member.Member; +import jakarta.validation.constraints.NotBlank; + +import java.time.LocalDate; + +public class MemberV1Dto { + + public record SignUpRequest( + @NotBlank String loginId, + @NotBlank String password, + @NotBlank String name, + @NotBlank String birthDate, + @NotBlank String email + ) {} + + public record SignUpResponse( + Long id, + String loginId, + String name, + String email + ) {} + + public record MyInfoResponse( + String loginId, + String name, + LocalDate birthDate, + String email + ) { + public static MyInfoResponse from(Member member) { + return new MyInfoResponse( + member.getLoginId().value(), + maskName(member.getName()), + member.getBirthDate().value(), + member.getEmail().value() + ); + } + + private static String maskName(String name) { + if (name == null || name.length() < 2) { + return name; + } + return name.substring(0, name.length() - 1) + "*"; + } + } + + public record ChangePasswordRequest( + @NotBlank String currentPassword, + @NotBlank String newPassword + ) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminController.java new file mode 100644 index 000000000..fb1df372c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminController.java @@ -0,0 +1,31 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderFacade; +import com.loopers.domain.order.Order; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api-admin/v1/orders") +public class OrderAdminController { + + private final OrderFacade orderFacade; + + @GetMapping + public ApiResponse> getAllOrders() { + List responses = orderFacade.getAllOrders().stream() + .map(OrderDto.OrderResponse::from) + .toList(); + return ApiResponse.success(responses); + } + + @GetMapping("/{orderId}") + public ApiResponse getOrder(@PathVariable Long orderId) { + Order order = orderFacade.getOrder(orderId); + return ApiResponse.success(OrderDto.OrderResponse.from(order)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java new file mode 100644 index 000000000..c59e488b2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java @@ -0,0 +1,71 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderFacade; +import com.loopers.domain.member.Member; +import com.loopers.domain.order.Order; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.support.auth.AuthMember; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/orders") +public class OrderController { + + private final OrderFacade orderFacade; + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public ApiResponse createOrder( + @AuthMember Member member, + @Valid @RequestBody OrderDto.CreateRequest request + ) { + List items = request.items().stream() + .map(i -> new OrderFacade.OrderItemRequest(i.productId(), i.quantity())) + .toList(); + Order order = orderFacade.createOrder(member.getId(), items); + return ApiResponse.success(OrderDto.OrderResponse.from(order)); + } + + @GetMapping + public ApiResponse> getOrders( + @AuthMember Member member, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime startAt, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime endAt + ) { + ZonedDateTime start = startAt != null ? startAt.atZone(ZoneId.systemDefault()) : null; + ZonedDateTime end = endAt != null ? endAt.atZone(ZoneId.systemDefault()) : null; + List responses = orderFacade.getOrdersByMemberId(member.getId(), start, end) + .stream() + .map(OrderDto.OrderResponse::from) + .toList(); + return ApiResponse.success(responses); + } + + @GetMapping("/{orderId}") + public ApiResponse getOrder( + @AuthMember Member member, + @PathVariable Long orderId + ) { + Order order = orderFacade.getOrder(orderId, member.getId()); + return ApiResponse.success(OrderDto.OrderResponse.from(order)); + } + + @PostMapping("/{orderId}/cancel") + public ApiResponse cancelOrder( + @AuthMember Member member, + @PathVariable Long orderId + ) { + orderFacade.cancelOrder(orderId, member.getId()); + return ApiResponse.success(null); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderDto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderDto.java new file mode 100644 index 000000000..ffc828b02 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderDto.java @@ -0,0 +1,62 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderItem; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; + +import java.util.List; + +public class OrderDto { + + public record CreateRequest( + @NotEmpty List items + ) {} + + public record OrderItemRequest( + @NotNull Long productId, + @Min(1) int quantity + ) {} + + public record OrderResponse( + Long id, + Long memberId, + String status, + int totalPrice, + List items + ) { + public static OrderResponse from(Order order) { + List itemResponses = order.getItems().stream() + .map(OrderItemResponse::from) + .toList(); + return new OrderResponse( + order.getId(), + order.getMemberId(), + order.getStatus().name(), + order.getTotalPrice(), + itemResponses + ); + } + } + + public record OrderItemResponse( + Long productId, + String productName, + int productPrice, + String brandName, + int quantity, + int subtotal + ) { + public static OrderItemResponse from(OrderItem item) { + return new OrderItemResponse( + item.getProductId(), + item.getProductName(), + item.getProductPrice(), + item.getBrandName(), + item.getQuantity(), + item.getSubtotal() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminController.java new file mode 100644 index 000000000..8985e93eb --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminController.java @@ -0,0 +1,58 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductFacade; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductWithBrand; +import com.loopers.interfaces.api.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api-admin/v1/products") +public class ProductAdminController { + + private final ProductFacade productFacade; + + @GetMapping + public ApiResponse> getAllProducts() { + List responses = productFacade.getAllProducts().stream() + .map(ProductDto.ProductResponse::from) + .toList(); + return ApiResponse.success(responses); + } + + @GetMapping("/{productId}") + public ApiResponse getProduct(@PathVariable Long productId) { + ProductWithBrand info = productFacade.getProductDetail(productId); + return ApiResponse.success(ProductDto.ProductResponse.from(info)); + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public ApiResponse createProduct(@Valid @RequestBody ProductDto.CreateRequest request) { + Product product = productFacade.createProduct( + request.brandId(), request.name(), request.price(), request.stockQuantity()); + return ApiResponse.success(ProductDto.ProductResponse.from(product)); + } + + @PutMapping("/{productId}") + public ApiResponse updateProduct( + @PathVariable Long productId, + @Valid @RequestBody ProductDto.UpdateRequest request + ) { + Product product = productFacade.updateProduct( + productId, request.name(), request.price(), request.stockQuantity()); + return ApiResponse.success(ProductDto.ProductResponse.from(product)); + } + + @DeleteMapping("/{productId}") + public ApiResponse deleteProduct(@PathVariable Long productId) { + productFacade.deleteProduct(productId); + return ApiResponse.success(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.java new file mode 100644 index 000000000..05383244c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.java @@ -0,0 +1,40 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductFacade; +import com.loopers.domain.product.ProductWithBrand; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/products") +public class ProductController { + + private final ProductFacade productFacade; + + @GetMapping + public ApiResponse> getProducts( + @RequestParam(required = false) Long brandId, + @RequestParam(defaultValue = "latest") String sort + ) { + List products; + if (brandId != null) { + products = productFacade.getProductsByBrandId(brandId); + } else { + products = productFacade.getAllProducts(sort); + } + List responses = products.stream() + .map(ProductDto.ProductResponse::from) + .toList(); + return ApiResponse.success(responses); + } + + @GetMapping("/{productId}") + public ApiResponse getProduct(@PathVariable Long productId) { + ProductWithBrand info = productFacade.getProductDetail(productId); + return ApiResponse.success(ProductDto.ProductResponse.from(info)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductDto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductDto.java new file mode 100644 index 000000000..f63a1bbc8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductDto.java @@ -0,0 +1,58 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductWithBrand; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public class ProductDto { + + public record CreateRequest( + @NotNull Long brandId, + @NotBlank String name, + @Min(0) int price, + @Min(0) int stockQuantity + ) {} + + public record UpdateRequest( + @NotBlank String name, + @Min(0) int price, + @Min(0) int stockQuantity + ) {} + + public record ProductResponse( + Long id, + Long brandId, + String brandName, + String name, + int price, + int stockQuantity, + int likeCount + ) { + public static ProductResponse from(ProductWithBrand info) { + Product product = info.product(); + return new ProductResponse( + product.getId(), + product.getBrandId(), + info.brandName(), + product.getName(), + product.getPrice().getValue(), + product.getStock().getQuantity(), + product.getLikeCount() + ); + } + + public static ProductResponse from(Product product) { + return new ProductResponse( + product.getId(), + product.getBrandId(), + null, + product.getName(), + product.getPrice().getValue(), + product.getStock().getQuantity(), + product.getLikeCount() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/support/auth/AuthMember.java b/apps/commerce-api/src/main/java/com/loopers/support/auth/AuthMember.java new file mode 100644 index 000000000..9089d89dc --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/support/auth/AuthMember.java @@ -0,0 +1,11 @@ +package com.loopers.support.auth; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface AuthMember { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/support/auth/AuthMemberResolver.java b/apps/commerce-api/src/main/java/com/loopers/support/auth/AuthMemberResolver.java new file mode 100644 index 000000000..978e677f7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/support/auth/AuthMemberResolver.java @@ -0,0 +1,60 @@ +package com.loopers.support.auth; + +import com.loopers.domain.member.Member; +import com.loopers.domain.member.MemberRepository; +import com.loopers.domain.member.vo.LoginId; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.core.MethodParameter; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +@RequiredArgsConstructor +@Component +public class AuthMemberResolver implements HandlerMethodArgumentResolver { + + private static final String HEADER_LOGIN_ID = "X-Loopers-LoginId"; + private static final String HEADER_LOGIN_PW = "X-Loopers-LoginPw"; + + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(AuthMember.class); + } + + @Override + public Object resolveArgument( + MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory + ) { + HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest(); + + String loginId = request.getHeader(HEADER_LOGIN_ID); + String password = request.getHeader(HEADER_LOGIN_PW); + + if (loginId == null || loginId.isBlank() || password == null || password.isBlank()) { + throw new CoreException(ErrorType.UNAUTHORIZED, "인증 헤더가 필요합니다."); + } + + Member member = memberRepository.findByLoginId(new LoginId(loginId)) + .orElseThrow(() -> new CoreException(ErrorType.UNAUTHORIZED, + "아이디 또는 비밀번호가 일치하지 않습니다.")); + + if (!member.getPassword().matches(password, passwordEncoder)) { + throw new CoreException(ErrorType.UNAUTHORIZED, + "아이디 또는 비밀번호가 일치하지 않습니다."); + } + + return member; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/support/auth/PasswordEncoderConfig.java b/apps/commerce-api/src/main/java/com/loopers/support/auth/PasswordEncoderConfig.java new file mode 100644 index 000000000..b8cf05474 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/support/auth/PasswordEncoderConfig.java @@ -0,0 +1,15 @@ +package com.loopers.support.auth; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +@Configuration +public class PasswordEncoderConfig { + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/support/auth/WebMvcConfig.java b/apps/commerce-api/src/main/java/com/loopers/support/auth/WebMvcConfig.java new file mode 100644 index 000000000..edb1b604d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/support/auth/WebMvcConfig.java @@ -0,0 +1,20 @@ +package com.loopers.support.auth; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.util.List; + +@RequiredArgsConstructor +@Configuration +public class WebMvcConfig implements WebMvcConfigurer { + + private final AuthMemberResolver authMemberResolver; + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(authMemberResolver); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java index 5d142efbf..58ab01ccc 100644 --- a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java +++ b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java @@ -10,7 +10,9 @@ public enum ErrorType { /** 범용 에러 */ INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase(), "일시적인 오류가 발생했습니다."), BAD_REQUEST(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.getReasonPhrase(), "잘못된 요청입니다."), + UNAUTHORIZED(HttpStatus.UNAUTHORIZED, HttpStatus.UNAUTHORIZED.getReasonPhrase(), "인증이 필요합니다."), NOT_FOUND(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.getReasonPhrase(), "존재하지 않는 요청입니다."), + FORBIDDEN(HttpStatus.FORBIDDEN, HttpStatus.FORBIDDEN.getReasonPhrase(), "접근 권한이 없습니다."), CONFLICT(HttpStatus.CONFLICT, HttpStatus.CONFLICT.getReasonPhrase(), "이미 존재하는 리소스입니다."); private final HttpStatus status; diff --git a/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java new file mode 100644 index 000000000..1df646ec2 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java @@ -0,0 +1,230 @@ +package com.loopers.application.brand; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.like.Like; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.vo.Price; +import com.loopers.domain.product.vo.Stock; +import com.loopers.fake.FakeBrandRepository; +import com.loopers.fake.FakeLikeRepository; +import com.loopers.fake.FakeProductRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class BrandFacadeTest { + + private BrandFacade brandFacade; + private FakeBrandRepository brandRepository; + private FakeProductRepository productRepository; + private FakeLikeRepository likeRepository; + + @BeforeEach + void setUp() { + brandRepository = new FakeBrandRepository(); + productRepository = new FakeProductRepository(); + likeRepository = new FakeLikeRepository(); + brandFacade = new BrandFacade(brandRepository, productRepository, likeRepository); + } + + @Nested + @DisplayName("브랜드 단건 조회") + class GetBrand { + + @DisplayName("존재하는 브랜드를 조회하면 브랜드가 반환된다") + @Test + void getBrand_whenExists_returnsBrand() { + // arrange + Brand saved = brandRepository.save(new Brand("나이키", "스포츠 브랜드")); + + // act + Brand result = brandFacade.getBrand(saved.getId()); + + // assert + assertThat(result.getId()).isEqualTo(saved.getId()); + assertThat(result.getName()).isEqualTo("나이키"); + assertThat(result.getDescription()).isEqualTo("스포츠 브랜드"); + } + + @DisplayName("존재하지 않는 브랜드를 조회하면 예외가 발생한다") + @Test + void getBrand_whenNotExists_throwsCoreException() { + assertThatThrownBy(() -> brandFacade.getBrand(999L)) + .isInstanceOf(CoreException.class) + .extracting(e -> ((CoreException) e).getErrorType()) + .isEqualTo(ErrorType.NOT_FOUND); + } + } + + @Nested + @DisplayName("브랜드 전체 조회") + class GetAllBrands { + + @DisplayName("저장된 모든 브랜드가 반환된다") + @Test + void getAllBrands_returnsAll() { + // arrange + brandRepository.save(new Brand("나이키", "스포츠 브랜드")); + brandRepository.save(new Brand("아디다스", "스포츠 브랜드")); + + // act + List result = brandFacade.getAllBrands(); + + // assert + assertThat(result).hasSize(2); + } + + @DisplayName("저장된 브랜드가 없으면 빈 리스트가 반환된다") + @Test + void getAllBrands_whenEmpty_returnsEmptyList() { + // act + List result = brandFacade.getAllBrands(); + + // assert + assertThat(result).isEmpty(); + } + } + + @Nested + @DisplayName("브랜드 생성") + class CreateBrand { + + @DisplayName("브랜드를 생성하면 ID가 부여되어 반환된다") + @Test + void createBrand_returnsWithId() { + // act + Brand result = brandFacade.createBrand("나이키", "스포츠 브랜드"); + + // assert + assertThat(result.getId()).isNotNull(); + assertThat(result.getId()).isGreaterThan(0L); + assertThat(result.getName()).isEqualTo("나이키"); + assertThat(result.getDescription()).isEqualTo("스포츠 브랜드"); + } + + @DisplayName("생성된 브랜드가 저장소에 저장된다") + @Test + void createBrand_persistsInRepository() { + // act + Brand result = brandFacade.createBrand("나이키", "스포츠 브랜드"); + + // assert + assertThat(brandRepository.findById(result.getId())).isPresent(); + } + } + + @Nested + @DisplayName("브랜드 수정") + class UpdateBrand { + + @DisplayName("존재하는 브랜드를 수정하면 변경된 정보가 반환된다") + @Test + void updateBrand_whenExists_returnsUpdated() { + // arrange + Brand saved = brandRepository.save(new Brand("나이키", "스포츠 브랜드")); + + // act + Brand result = brandFacade.updateBrand(saved.getId(), "뉴나이키", "프리미엄 스포츠 브랜드"); + + // assert + assertThat(result.getName()).isEqualTo("뉴나이키"); + assertThat(result.getDescription()).isEqualTo("프리미엄 스포츠 브랜드"); + } + + @DisplayName("존재하지 않는 브랜드를 수정하면 예외가 발생한다") + @Test + void updateBrand_whenNotExists_throwsCoreException() { + assertThatThrownBy(() -> brandFacade.updateBrand(999L, "뉴나이키", "설명")) + .isInstanceOf(CoreException.class) + .extracting(e -> ((CoreException) e).getErrorType()) + .isEqualTo(ErrorType.NOT_FOUND); + } + } + + @Nested + @DisplayName("브랜드 삭제") + class DeleteBrand { + + @DisplayName("브랜드를 삭제하면 브랜드가 소프트 삭제된다") + @Test + void deleteBrand_softDeletesBrand() { + // arrange + Brand saved = brandRepository.save(new Brand("나이키", "스포츠 브랜드")); + + // act + brandFacade.deleteBrand(saved.getId()); + + // assert + assertThat(brandRepository.findById(saved.getId())).isEmpty(); + } + + @DisplayName("브랜드를 삭제하면 해당 브랜드의 상품도 소프트 삭제된다") + @Test + void deleteBrand_cascadeSoftDeletesProducts() { + // arrange + Brand brand = brandRepository.save(new Brand("나이키", "스포츠 브랜드")); + Product product1 = productRepository.save( + new Product(brand.getId(), "에어맥스", new Price(150000), new Stock(10))); + Product product2 = productRepository.save( + new Product(brand.getId(), "에어포스", new Price(120000), new Stock(20))); + + // act + brandFacade.deleteBrand(brand.getId()); + + // assert + assertThat(productRepository.findById(product1.getId())).isEmpty(); + assertThat(productRepository.findById(product2.getId())).isEmpty(); + } + + @DisplayName("존재하지 않는 브랜드를 삭제하면 예외가 발생한다") + @Test + void deleteBrand_whenNotExists_throwsCoreException() { + assertThatThrownBy(() -> brandFacade.deleteBrand(999L)) + .isInstanceOf(CoreException.class) + .extracting(e -> ((CoreException) e).getErrorType()) + .isEqualTo(ErrorType.NOT_FOUND); + } + + @DisplayName("브랜드에 속한 상품이 없어도 삭제에 성공한다") + @Test + void deleteBrand_withNoProducts_succeeds() { + // arrange + Brand brand = brandRepository.save(new Brand("나이키", "스포츠 브랜드")); + + // act + brandFacade.deleteBrand(brand.getId()); + + // assert + assertThat(brandRepository.findById(brand.getId())).isEmpty(); + } + + @DisplayName("브랜드 삭제 시 해당 상품들의 좋아요가 hard delete 된다") + @Test + void deleteBrand_hardDeletesLikesOfProducts() { + // arrange + Brand brand = brandRepository.save(new Brand("나이키", "스포츠 브랜드")); + Product product1 = productRepository.save( + new Product(brand.getId(), "에어맥스", new Price(150000), new Stock(10))); + Product product2 = productRepository.save( + new Product(brand.getId(), "에어포스", new Price(120000), new Stock(20))); + likeRepository.save(new Like(1L, product1.getId())); + likeRepository.save(new Like(2L, product1.getId())); + likeRepository.save(new Like(1L, product2.getId())); + + // act + brandFacade.deleteBrand(brand.getId()); + + // assert + assertThat(likeRepository.findAllByMemberId(1L)).isEmpty(); + assertThat(likeRepository.findAllByMemberId(2L)).isEmpty(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java new file mode 100644 index 000000000..c5c14a0e9 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java @@ -0,0 +1,183 @@ +package com.loopers.application.like; + +import com.loopers.domain.like.Like; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.vo.Price; +import com.loopers.domain.product.vo.Stock; +import com.loopers.fake.FakeLikeRepository; +import com.loopers.fake.FakeProductRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class LikeFacadeTest { + + private LikeFacade likeFacade; + private FakeLikeRepository likeRepository; + private FakeProductRepository productRepository; + + @BeforeEach + void setUp() { + likeRepository = new FakeLikeRepository(); + productRepository = new FakeProductRepository(); + likeFacade = new LikeFacade(likeRepository, productRepository); + } + + @Nested + @DisplayName("좋아요 추가") + class AddLike { + + @DisplayName("좋아요를 추가하면 저장되고 상품의 좋아요 수가 증가한다") + @Test + void addLike_savesLikeAndIncrementsCount() { + // arrange + Product product = productRepository.save( + new Product(1L, "에어맥스", new Price(150000), new Stock(10))); + Long memberId = 1L; + + // act + likeFacade.addLike(memberId, product.getId()); + + // assert + assertThat(likeRepository.existsByMemberIdAndProductId(memberId, product.getId())).isTrue(); + assertThat(product.getLikeCount()).isEqualTo(1); + } + + @DisplayName("이미 좋아요한 상품에 다시 좋아요하면 멱등하게 처리된다") + @Test + void addLike_whenAlreadyLiked_isIdempotent() { + // arrange + Product product = productRepository.save( + new Product(1L, "에어맥스", new Price(150000), new Stock(10))); + Long memberId = 1L; + likeFacade.addLike(memberId, product.getId()); + + // act + likeFacade.addLike(memberId, product.getId()); + + // assert + assertThat(product.getLikeCount()).isEqualTo(1); + assertThat(likeRepository.findAllByMemberId(memberId)).hasSize(1); + } + + @DisplayName("존재하지 않는 상품에 좋아요하면 예외가 발생한다") + @Test + void addLike_whenProductNotExists_throwsCoreException() { + assertThatThrownBy(() -> likeFacade.addLike(1L, 999L)) + .isInstanceOf(CoreException.class) + .extracting(e -> ((CoreException) e).getErrorType()) + .isEqualTo(ErrorType.NOT_FOUND); + } + + @DisplayName("여러 회원이 같은 상품에 좋아요하면 좋아요 수가 누적된다") + @Test + void addLike_byMultipleMembers_accumulatesCount() { + // arrange + Product product = productRepository.save( + new Product(1L, "에어맥스", new Price(150000), new Stock(10))); + + // act + likeFacade.addLike(1L, product.getId()); + likeFacade.addLike(2L, product.getId()); + likeFacade.addLike(3L, product.getId()); + + // assert + assertThat(product.getLikeCount()).isEqualTo(3); + } + } + + @Nested + @DisplayName("좋아요 취소") + class RemoveLike { + + @DisplayName("좋아요를 취소하면 삭제되고 상품의 좋아요 수가 감소한다") + @Test + void removeLike_deletesLikeAndDecrementsCount() { + // arrange + Product product = productRepository.save( + new Product(1L, "에어맥스", new Price(150000), new Stock(10))); + Long memberId = 1L; + likeFacade.addLike(memberId, product.getId()); + + // act + likeFacade.removeLike(memberId, product.getId()); + + // assert + assertThat(likeRepository.existsByMemberIdAndProductId(memberId, product.getId())).isFalse(); + assertThat(product.getLikeCount()).isEqualTo(0); + } + + @DisplayName("좋아요하지 않은 상품의 좋아요를 취소해도 예외 없이 멱등하게 처리된다") + @Test + void removeLike_whenNotLiked_isIdempotent() { + // arrange + Product product = productRepository.save( + new Product(1L, "에어맥스", new Price(150000), new Stock(10))); + + // act - 예외가 발생하지 않아야 한다 + likeFacade.removeLike(1L, product.getId()); + + // assert + assertThat(product.getLikeCount()).isEqualTo(0); + } + } + + @Nested + @DisplayName("회원별 좋아요 목록 조회") + class GetLikesByMemberId { + + @DisplayName("회원의 좋아요 목록이 반환된다") + @Test + void getLikesByMemberId_returnsLikes() { + // arrange + Product product1 = productRepository.save( + new Product(1L, "에어맥스", new Price(150000), new Stock(10))); + Product product2 = productRepository.save( + new Product(1L, "에어포스", new Price(120000), new Stock(20))); + Long memberId = 1L; + likeFacade.addLike(memberId, product1.getId()); + likeFacade.addLike(memberId, product2.getId()); + + // act + List result = likeFacade.getLikesByMemberId(memberId); + + // assert + assertThat(result).hasSize(2); + } + + @DisplayName("좋아요한 상품이 없으면 빈 리스트가 반환된다") + @Test + void getLikesByMemberId_whenEmpty_returnsEmptyList() { + // act + List result = likeFacade.getLikesByMemberId(1L); + + // assert + assertThat(result).isEmpty(); + } + + @DisplayName("다른 회원의 좋아요는 포함되지 않는다") + @Test + void getLikesByMemberId_excludesOtherMembers() { + // arrange + Product product = productRepository.save( + new Product(1L, "에어맥스", new Price(150000), new Stock(10))); + likeFacade.addLike(1L, product.getId()); + likeFacade.addLike(2L, product.getId()); + + // act + List result = likeFacade.getLikesByMemberId(1L); + + // assert + assertThat(result).hasSize(1); + assertThat(result.get(0).getMemberId()).isEqualTo(1L); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/member/MemberFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/member/MemberFacadeIntegrationTest.java new file mode 100644 index 000000000..281bd1ce0 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/member/MemberFacadeIntegrationTest.java @@ -0,0 +1,98 @@ +package com.loopers.application.member; + +import com.loopers.domain.member.Member; +import com.loopers.domain.member.MemberRepository; +import com.loopers.support.error.CoreException; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; + +@SpringBootTest +class MemberFacadeIntegrationTest { + + @MockitoSpyBean + private MemberRepository memberRepository; + + @Autowired + private MemberFacade memberFacade; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("회원 가입") + @Nested + class Register { + + @DisplayName("회원 가입시 User 저장이 수행된다") + @Test + void register_savesUser_verifiedBySpy() { + // act + Member result = memberFacade.register( + "user1", "Password1!", "홍길동", "1990-01-15", "test@example.com"); + + // assert + verify(memberRepository).save(any(Member.class)); + assertThat(result.getId()).isNotNull(); + } + + @DisplayName("이미 가입된 ID로 회원가입 시도 시 실패한다") + @Test + void register_withDuplicateId_throwsException() { + // arrange + memberFacade.register( + "user1", "Password1!", "홍길동", "1990-01-15", "test@example.com"); + + // act & assert + assertThatThrownBy(() -> memberFacade.register( + "user1", "Password2!", "김철수", "1995-05-20", "other@example.com")) + .isInstanceOf(CoreException.class); + } + } + + @DisplayName("내 정보 조회") + @Nested + class FindByLoginId { + + @DisplayName("해당 ID의 회원이 존재할 경우 회원 정보가 반환된다") + @Test + void findByLoginId_whenExists_returnsMember() { + // arrange + memberFacade.register( + "user1", "Password1!", "홍길동", "1990-01-15", "test@example.com"); + + // act + Optional result = memberFacade.findByLoginId("user1"); + + // assert + assertThat(result).isPresent(); + assertThat(result.get().getLoginId().value()).isEqualTo("user1"); + } + + @DisplayName("해당 ID의 회원이 존재하지 않을 경우 null이 반환된다") + @Test + void findByLoginId_whenNotExists_returnsEmpty() { + // act + Optional result = memberFacade.findByLoginId("nobody"); + + // assert + assertThat(result).isEmpty(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java new file mode 100644 index 000000000..140f7037f --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java @@ -0,0 +1,367 @@ +package com.loopers.application.order; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderItem; +import com.loopers.domain.order.OrderStatus; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.vo.Price; +import com.loopers.domain.product.vo.Stock; +import com.loopers.fake.FakeBrandRepository; +import com.loopers.fake.FakeOrderRepository; +import com.loopers.fake.FakeProductRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.ZonedDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class OrderFacadeTest { + + private OrderFacade orderFacade; + private FakeOrderRepository orderRepository; + private FakeProductRepository productRepository; + private FakeBrandRepository brandRepository; + + @BeforeEach + void setUp() { + orderRepository = new FakeOrderRepository(); + productRepository = new FakeProductRepository(); + brandRepository = new FakeBrandRepository(); + orderFacade = new OrderFacade(orderRepository, productRepository, brandRepository); + } + + @Nested + @DisplayName("주문 생성") + class CreateOrder { + + @DisplayName("주문을 생성하면 상품 정보가 스냅샷되고 재고가 차감된다") + @Test + void createOrder_snapshotsProductInfoAndDecreasesStock() { + // arrange + Brand brand = brandRepository.save(new Brand("나이키", "스포츠 브랜드")); + Product product = productRepository.save( + new Product(brand.getId(), "에어맥스", new Price(150000), new Stock(10))); + + List requests = List.of( + new OrderFacade.OrderItemRequest(product.getId(), 2) + ); + + // act + Order result = orderFacade.createOrder(1L, requests); + + // assert + assertThat(result.getId()).isNotNull(); + assertThat(result.getId()).isGreaterThan(0L); + assertThat(result.getMemberId()).isEqualTo(1L); + assertThat(result.getStatus()).isEqualTo(OrderStatus.CREATED); + assertThat(result.getTotalPrice()).isEqualTo(300000); + assertThat(result.getItems()).hasSize(1); + + OrderItem item = result.getItems().get(0); + assertThat(item.getProductId()).isEqualTo(product.getId()); + assertThat(item.getProductName()).isEqualTo("에어맥스"); + assertThat(item.getProductPrice()).isEqualTo(150000); + assertThat(item.getBrandName()).isEqualTo("나이키"); + assertThat(item.getQuantity()).isEqualTo(2); + + // 재고 차감 검증 + assertThat(product.getStock().getQuantity()).isEqualTo(8); + } + + @DisplayName("여러 상품을 주문하면 각 상품의 재고가 차감되고 총 가격이 계산된다") + @Test + void createOrder_withMultipleItems_decreasesStocksAndCalculatesTotal() { + // arrange + Brand brand = brandRepository.save(new Brand("나이키", "스포츠 브랜드")); + Product product1 = productRepository.save( + new Product(brand.getId(), "에어맥스", new Price(150000), new Stock(10))); + Product product2 = productRepository.save( + new Product(brand.getId(), "에어포스", new Price(120000), new Stock(20))); + + List requests = List.of( + new OrderFacade.OrderItemRequest(product1.getId(), 1), + new OrderFacade.OrderItemRequest(product2.getId(), 3) + ); + + // act + Order result = orderFacade.createOrder(1L, requests); + + // assert + assertThat(result.getItems()).hasSize(2); + assertThat(result.getTotalPrice()).isEqualTo(150000 + 120000 * 3); + assertThat(product1.getStock().getQuantity()).isEqualTo(9); + assertThat(product2.getStock().getQuantity()).isEqualTo(17); + } + + @DisplayName("재고가 부족하면 예외가 발생한다") + @Test + void createOrder_whenInsufficientStock_throwsException() { + // arrange + Brand brand = brandRepository.save(new Brand("나이키", "스포츠 브랜드")); + productRepository.save( + new Product(brand.getId(), "에어맥스", new Price(150000), new Stock(2))); + + List requests = List.of( + new OrderFacade.OrderItemRequest(1L, 5) + ); + + // act & assert + assertThatThrownBy(() -> orderFacade.createOrder(1L, requests)) + .isInstanceOf(CoreException.class) + .extracting(e -> ((CoreException) e).getErrorType()) + .isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("존재하지 않는 상품을 주문하면 예외가 발생한다") + @Test + void createOrder_whenProductNotExists_throwsCoreException() { + // arrange + List requests = List.of( + new OrderFacade.OrderItemRequest(999L, 1) + ); + + // act & assert + assertThatThrownBy(() -> orderFacade.createOrder(1L, requests)) + .isInstanceOf(CoreException.class) + .extracting(e -> ((CoreException) e).getErrorType()) + .isEqualTo(ErrorType.NOT_FOUND); + } + + @DisplayName("브랜드가 없는 상품을 주문하면 브랜드 이름이 null로 스냅샷된다") + @Test + void createOrder_whenBrandNotExists_snapshotsNullBrandName() { + // arrange + Product product = productRepository.save( + new Product(999L, "에어맥스", new Price(150000), new Stock(10))); + + List requests = List.of( + new OrderFacade.OrderItemRequest(product.getId(), 1) + ); + + // act + Order result = orderFacade.createOrder(1L, requests); + + // assert + assertThat(result.getItems().get(0).getBrandName()).isNull(); + } + } + + @Nested + @DisplayName("주문 단건 조회") + class GetOrder { + + @DisplayName("본인의 주문을 조회하면 주문이 반환된다") + @Test + void getOrder_whenOwner_returnsOrder() { + // arrange + Brand brand = brandRepository.save(new Brand("나이키", "스포츠 브랜드")); + Product product = productRepository.save( + new Product(brand.getId(), "에어맥스", new Price(150000), new Stock(10))); + Order order = orderFacade.createOrder(1L, List.of( + new OrderFacade.OrderItemRequest(product.getId(), 1))); + + // act + Order result = orderFacade.getOrder(order.getId(), 1L); + + // assert + assertThat(result.getId()).isEqualTo(order.getId()); + assertThat(result.getMemberId()).isEqualTo(1L); + } + + @DisplayName("타인의 주문을 조회하면 예외가 발생한다") + @Test + void getOrder_whenNotOwner_throwsForbidden() { + // arrange + Brand brand = brandRepository.save(new Brand("나이키", "스포츠 브랜드")); + Product product = productRepository.save( + new Product(brand.getId(), "에어맥스", new Price(150000), new Stock(10))); + Order order = orderFacade.createOrder(1L, List.of( + new OrderFacade.OrderItemRequest(product.getId(), 1))); + + // act & assert + assertThatThrownBy(() -> orderFacade.getOrder(order.getId(), 2L)) + .isInstanceOf(CoreException.class) + .extracting(e -> ((CoreException) e).getErrorType()) + .isEqualTo(ErrorType.FORBIDDEN); + } + + @DisplayName("존재하지 않는 주문을 조회하면 예외가 발생한다") + @Test + void getOrder_whenNotExists_throwsCoreException() { + assertThatThrownBy(() -> orderFacade.getOrder(999L, 1L)) + .isInstanceOf(CoreException.class) + .extracting(e -> ((CoreException) e).getErrorType()) + .isEqualTo(ErrorType.NOT_FOUND); + } + } + + @Nested + @DisplayName("주문 취소") + class CancelOrder { + + @DisplayName("주문을 취소하면 상태가 CANCELLED로 변경되고 재고가 복원된다") + @Test + void cancelOrder_cancelsAndRestoresStock() { + // arrange + Brand brand = brandRepository.save(new Brand("나이키", "스포츠 브랜드")); + Product product = productRepository.save( + new Product(brand.getId(), "에어맥스", new Price(150000), new Stock(10))); + Order order = orderFacade.createOrder(1L, List.of( + new OrderFacade.OrderItemRequest(product.getId(), 3))); + assertThat(product.getStock().getQuantity()).isEqualTo(7); + + // act + orderFacade.cancelOrder(order.getId(), 1L); + + // assert + assertThat(order.getStatus()).isEqualTo(OrderStatus.CANCELLED); + assertThat(product.getStock().getQuantity()).isEqualTo(10); + } + + @DisplayName("타인의 주문을 취소하면 예외가 발생한다") + @Test + void cancelOrder_whenNotOwner_throwsForbidden() { + // arrange + Brand brand = brandRepository.save(new Brand("나이키", "스포츠 브랜드")); + Product product = productRepository.save( + new Product(brand.getId(), "에어맥스", new Price(150000), new Stock(10))); + Order order = orderFacade.createOrder(1L, List.of( + new OrderFacade.OrderItemRequest(product.getId(), 1))); + + // act & assert + assertThatThrownBy(() -> orderFacade.cancelOrder(order.getId(), 2L)) + .isInstanceOf(CoreException.class) + .extracting(e -> ((CoreException) e).getErrorType()) + .isEqualTo(ErrorType.FORBIDDEN); + } + + @DisplayName("이미 취소된 주문을 다시 취소하면 예외가 발생한다") + @Test + void cancelOrder_whenAlreadyCancelled_throwsException() { + // arrange + Brand brand = brandRepository.save(new Brand("나이키", "스포츠 브랜드")); + Product product = productRepository.save( + new Product(brand.getId(), "에어맥스", new Price(150000), new Stock(10))); + Order order = orderFacade.createOrder(1L, List.of( + new OrderFacade.OrderItemRequest(product.getId(), 1))); + orderFacade.cancelOrder(order.getId(), 1L); + + // act & assert + assertThatThrownBy(() -> orderFacade.cancelOrder(order.getId(), 1L)) + .isInstanceOf(CoreException.class) + .extracting(e -> ((CoreException) e).getErrorType()) + .isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("존재하지 않는 주문을 취소하면 예외가 발생한다") + @Test + void cancelOrder_whenNotExists_throwsCoreException() { + assertThatThrownBy(() -> orderFacade.cancelOrder(999L, 1L)) + .isInstanceOf(CoreException.class) + .extracting(e -> ((CoreException) e).getErrorType()) + .isEqualTo(ErrorType.NOT_FOUND); + } + } + + @Nested + @DisplayName("회원별 주문 목록 조회") + class GetOrdersByMemberId { + + @DisplayName("기간 조건 없이 조회하면 회원의 전체 주문이 반환된다") + @Test + void getOrdersByMemberId_withoutDateRange_returnsAll() { + // arrange + Brand brand = brandRepository.save(new Brand("나이키", "스포츠 브랜드")); + Product product = productRepository.save( + new Product(brand.getId(), "에어맥스", new Price(150000), new Stock(100))); + orderFacade.createOrder(1L, List.of( + new OrderFacade.OrderItemRequest(product.getId(), 1))); + orderFacade.createOrder(1L, List.of( + new OrderFacade.OrderItemRequest(product.getId(), 2))); + orderFacade.createOrder(2L, List.of( + new OrderFacade.OrderItemRequest(product.getId(), 1))); + + // act + List result = orderFacade.getOrdersByMemberId(1L, null, null); + + // assert + assertThat(result).hasSize(2); + assertThat(result).allSatisfy(order -> + assertThat(order.getMemberId()).isEqualTo(1L) + ); + } + + @DisplayName("기간 조건으로 조회하면 해당 기간의 주문만 반환된다") + @Test + void getOrdersByMemberId_withDateRange_returnsFiltered() { + // arrange + Brand brand = brandRepository.save(new Brand("나이키", "스포츠 브랜드")); + Product product = productRepository.save( + new Product(brand.getId(), "에어맥스", new Price(150000), new Stock(100))); + orderFacade.createOrder(1L, List.of( + new OrderFacade.OrderItemRequest(product.getId(), 1))); + + ZonedDateTime now = ZonedDateTime.now(); + ZonedDateTime startAt = now.minusHours(1); + ZonedDateTime endAt = now.plusHours(1); + + // act + List result = orderFacade.getOrdersByMemberId(1L, startAt, endAt); + + // assert + assertThat(result).hasSize(1); + } + + @DisplayName("주문이 없는 회원을 조회하면 빈 리스트가 반환된다") + @Test + void getOrdersByMemberId_whenNoOrders_returnsEmptyList() { + // act + List result = orderFacade.getOrdersByMemberId(999L, null, null); + + // assert + assertThat(result).isEmpty(); + } + } + + @Nested + @DisplayName("전체 주문 조회") + class GetAllOrders { + + @DisplayName("모든 주문이 반환된다") + @Test + void getAllOrders_returnsAll() { + // arrange + Brand brand = brandRepository.save(new Brand("나이키", "스포츠 브랜드")); + Product product = productRepository.save( + new Product(brand.getId(), "에어맥스", new Price(150000), new Stock(100))); + orderFacade.createOrder(1L, List.of( + new OrderFacade.OrderItemRequest(product.getId(), 1))); + orderFacade.createOrder(2L, List.of( + new OrderFacade.OrderItemRequest(product.getId(), 1))); + + // act + List result = orderFacade.getAllOrders(); + + // assert + assertThat(result).hasSize(2); + } + + @DisplayName("주문이 없으면 빈 리스트가 반환된다") + @Test + void getAllOrders_whenEmpty_returnsEmptyList() { + // act + List result = orderFacade.getAllOrders(); + + // assert + assertThat(result).isEmpty(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java new file mode 100644 index 000000000..f4f3e8e32 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java @@ -0,0 +1,241 @@ +package com.loopers.application.product; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.like.Like; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductWithBrand; +import com.loopers.domain.product.vo.Price; +import com.loopers.domain.product.vo.Stock; +import com.loopers.fake.FakeBrandRepository; +import com.loopers.fake.FakeLikeRepository; +import com.loopers.fake.FakeProductRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ProductFacadeTest { + + private ProductFacade productFacade; + private FakeProductRepository productRepository; + private FakeBrandRepository brandRepository; + private FakeLikeRepository likeRepository; + + @BeforeEach + void setUp() { + productRepository = new FakeProductRepository(); + brandRepository = new FakeBrandRepository(); + likeRepository = new FakeLikeRepository(); + productRepository.setBrandRepository(brandRepository); + productFacade = new ProductFacade(productRepository, brandRepository, likeRepository); + } + + @Nested + @DisplayName("상품 상세 조회") + class GetProductDetail { + + @DisplayName("상품을 조회하면 브랜드 정보가 함께 반환된다") + @Test + void getProductDetail_returnsProductWithBrand() { + // arrange + Brand brand = brandRepository.save(new Brand("나이키", "스포츠 브랜드")); + Product product = productRepository.save( + new Product(brand.getId(), "에어맥스", new Price(150000), new Stock(10))); + + // act + ProductWithBrand result = productFacade.getProductDetail(product.getId()); + + // assert + assertThat(result.product().getId()).isEqualTo(product.getId()); + assertThat(result.product().getName()).isEqualTo("에어맥스"); + assertThat(result.brandName()).isEqualTo("나이키"); + } + + @DisplayName("존재하지 않는 상품을 조회하면 예외가 발생한다") + @Test + void getProductDetail_whenProductNotExists_throwsCoreException() { + assertThatThrownBy(() -> productFacade.getProductDetail(999L)) + .isInstanceOf(CoreException.class) + .extracting(e -> ((CoreException) e).getErrorType()) + .isEqualTo(ErrorType.NOT_FOUND); + } + + @DisplayName("브랜드가 삭제된 상품은 브랜드 이름이 null로 반환된다") + @Test + void getProductDetail_whenBrandDeleted_returnsNullBrandName() { + // arrange + Product product = productRepository.save( + new Product(999L, "에어맥스", new Price(150000), new Stock(10))); + + // act + ProductWithBrand result = productFacade.getProductDetail(product.getId()); + + // assert + assertThat(result.brandName()).isNull(); + } + } + + @Nested + @DisplayName("상품 전체 조회") + class GetAllProducts { + + @DisplayName("모든 상품이 브랜드 정보와 함께 반환된다") + @Test + void getAllProducts_returnsAllWithBrandInfo() { + // arrange + Brand brand = brandRepository.save(new Brand("나이키", "스포츠 브랜드")); + productRepository.save(new Product(brand.getId(), "에어맥스", new Price(150000), new Stock(10))); + productRepository.save(new Product(brand.getId(), "에어포스", new Price(120000), new Stock(20))); + + // act + List result = productFacade.getAllProducts(); + + // assert + assertThat(result).hasSize(2); + assertThat(result).allSatisfy(info -> + assertThat(info.brandName()).isEqualTo("나이키") + ); + } + + @DisplayName("상품이 없으면 빈 리스트가 반환된다") + @Test + void getAllProducts_whenEmpty_returnsEmptyList() { + // act + List result = productFacade.getAllProducts(); + + // assert + assertThat(result).isEmpty(); + } + + @DisplayName("브랜드가 삭제된 상품은 브랜드 이름이 null로 반환된다") + @Test + void getAllProducts_whenBrandDeleted_returnsNullBrandName() { + // arrange + productRepository.save(new Product(999L, "에어맥스", new Price(150000), new Stock(10))); + + // act + List result = productFacade.getAllProducts(); + + // assert + assertThat(result).hasSize(1); + assertThat(result.get(0).brandName()).isNull(); + } + } + + @Nested + @DisplayName("상품 생성") + class CreateProduct { + + @DisplayName("유효한 브랜드로 상품을 생성하면 ID가 부여되어 반환된다") + @Test + void createProduct_withValidBrand_returnsWithId() { + // arrange + Brand brand = brandRepository.save(new Brand("나이키", "스포츠 브랜드")); + + // act + Product result = productFacade.createProduct(brand.getId(), "에어맥스", 150000, 10); + + // assert + assertThat(result.getId()).isNotNull(); + assertThat(result.getId()).isGreaterThan(0L); + assertThat(result.getName()).isEqualTo("에어맥스"); + assertThat(result.getPrice().getValue()).isEqualTo(150000); + assertThat(result.getStock().getQuantity()).isEqualTo(10); + assertThat(result.getBrandId()).isEqualTo(brand.getId()); + } + + @DisplayName("존재하지 않는 브랜드로 상품을 생성하면 예외가 발생한다") + @Test + void createProduct_withInvalidBrand_throwsCoreException() { + assertThatThrownBy(() -> productFacade.createProduct(999L, "에어맥스", 150000, 10)) + .isInstanceOf(CoreException.class) + .extracting(e -> ((CoreException) e).getErrorType()) + .isEqualTo(ErrorType.NOT_FOUND); + } + } + + @Nested + @DisplayName("상품 수정") + class UpdateProduct { + + @DisplayName("존재하는 상품을 수정하면 변경된 정보가 반환된다") + @Test + void updateProduct_whenExists_returnsUpdated() { + // arrange + Brand brand = brandRepository.save(new Brand("나이키", "스포츠 브랜드")); + Product product = productRepository.save( + new Product(brand.getId(), "에어맥스", new Price(150000), new Stock(10))); + + // act + Product result = productFacade.updateProduct(product.getId(), "에어맥스 97", 180000, 5); + + // assert + assertThat(result.getName()).isEqualTo("에어맥스 97"); + assertThat(result.getPrice().getValue()).isEqualTo(180000); + assertThat(result.getStock().getQuantity()).isEqualTo(5); + } + + @DisplayName("존재하지 않는 상품을 수정하면 예외가 발생한다") + @Test + void updateProduct_whenNotExists_throwsCoreException() { + assertThatThrownBy(() -> productFacade.updateProduct(999L, "에어맥스", 150000, 10)) + .isInstanceOf(CoreException.class) + .extracting(e -> ((CoreException) e).getErrorType()) + .isEqualTo(ErrorType.NOT_FOUND); + } + } + + @Nested + @DisplayName("상품 삭제") + class DeleteProduct { + + @DisplayName("상품을 삭제하면 소프트 삭제된다") + @Test + void deleteProduct_softDeletesProduct() { + // arrange + Brand brand = brandRepository.save(new Brand("나이키", "스포츠 브랜드")); + Product product = productRepository.save( + new Product(brand.getId(), "에어맥스", new Price(150000), new Stock(10))); + + // act + productFacade.deleteProduct(product.getId()); + + // assert + assertThat(productRepository.findById(product.getId())).isEmpty(); + } + + @DisplayName("존재하지 않는 상품을 삭제하면 예외가 발생한다") + @Test + void deleteProduct_whenNotExists_throwsCoreException() { + assertThatThrownBy(() -> productFacade.deleteProduct(999L)) + .isInstanceOf(CoreException.class) + .extracting(e -> ((CoreException) e).getErrorType()) + .isEqualTo(ErrorType.NOT_FOUND); + } + + @DisplayName("상품 삭제 시 해당 상품의 좋아요가 hard delete 된다") + @Test + void deleteProduct_hardDeletesLikes() { + // arrange + Brand brand = brandRepository.save(new Brand("나이키", "스포츠 브랜드")); + Product product = productRepository.save( + new Product(brand.getId(), "에어맥스", new Price(150000), new Stock(10))); + likeRepository.save(new Like(1L, product.getId())); + likeRepository.save(new Like(2L, product.getId())); + + // act + productFacade.deleteProduct(product.getId()); + + // assert + assertThat(likeRepository.findByMemberIdAndProductId(1L, product.getId())).isEmpty(); + assertThat(likeRepository.findByMemberIdAndProductId(2L, product.getId())).isEmpty(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java new file mode 100644 index 000000000..58fe05bfb --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java @@ -0,0 +1,49 @@ +package com.loopers.domain.brand; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class BrandTest { + + @Nested + @DisplayName("Brand 생성") + class Create { + + @DisplayName("유효한 정보로 Brand를 생성할 수 있다") + @Test + void create_withValidInfo_succeeds() { + Brand brand = new Brand("나이키", "스포츠 브랜드"); + + assertThat(brand.getName()).isEqualTo("나이키"); + assertThat(brand.getDescription()).isEqualTo("스포츠 브랜드"); + } + } + + @Nested + @DisplayName("Brand 수정") + class Update { + + @DisplayName("브랜드 이름을 변경할 수 있다") + @Test + void changeName_withNewName_updatesName() { + Brand brand = new Brand("나이키", "스포츠 브랜드"); + + brand.changeName("아디다스"); + + assertThat(brand.getName()).isEqualTo("아디다스"); + } + + @DisplayName("브랜드 설명을 변경할 수 있다") + @Test + void changeDescription_withNewDescription_updatesDescription() { + Brand brand = new Brand("나이키", "스포츠 브랜드"); + + brand.changeDescription("글로벌 스포츠 브랜드"); + + assertThat(brand.getDescription()).isEqualTo("글로벌 스포츠 브랜드"); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java deleted file mode 100644 index 44ca7576e..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java +++ /dev/null @@ -1,65 +0,0 @@ -package com.loopers.domain.example; - -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; -import static org.junit.jupiter.api.Assertions.assertThrows; - -class ExampleModelTest { - @DisplayName("예시 모델을 생성할 때, ") - @Nested - class Create { - @DisplayName("제목과 설명이 모두 주어지면, 정상적으로 생성된다.") - @Test - void createsExampleModel_whenNameAndDescriptionAreProvided() { - // arrange - String name = "제목"; - String description = "설명"; - - // act - ExampleModel exampleModel = new ExampleModel(name, description); - - // assert - assertAll( - () -> assertThat(exampleModel.getId()).isNotNull(), - () -> assertThat(exampleModel.getName()).isEqualTo(name), - () -> assertThat(exampleModel.getDescription()).isEqualTo(description) - ); - } - - @DisplayName("제목이 빈칸으로만 이루어져 있으면, BAD_REQUEST 예외가 발생한다.") - @Test - void throwsBadRequestException_whenTitleIsBlank() { - // arrange - String name = " "; - - // act - CoreException result = assertThrows(CoreException.class, () -> { - new ExampleModel(name, "설명"); - }); - - // assert - assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - - @DisplayName("설명이 비어있으면, BAD_REQUEST 예외가 발생한다.") - @Test - void throwsBadRequestException_whenDescriptionIsEmpty() { - // arrange - String description = ""; - - // act - CoreException result = assertThrows(CoreException.class, () -> { - new ExampleModel("제목", description); - }); - - // assert - assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java deleted file mode 100644 index bbd5fdbe1..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java +++ /dev/null @@ -1,72 +0,0 @@ -package com.loopers.domain.example; - -import com.loopers.infrastructure.example.ExampleJpaRepository; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import com.loopers.utils.DatabaseCleanUp; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; -import static org.junit.jupiter.api.Assertions.assertThrows; - -@SpringBootTest -class ExampleServiceIntegrationTest { - @Autowired - private ExampleService exampleService; - - @Autowired - private ExampleJpaRepository exampleJpaRepository; - - @Autowired - private DatabaseCleanUp databaseCleanUp; - - @AfterEach - void tearDown() { - databaseCleanUp.truncateAllTables(); - } - - @DisplayName("예시를 조회할 때,") - @Nested - class Get { - @DisplayName("존재하는 예시 ID를 주면, 해당 예시 정보를 반환한다.") - @Test - void returnsExampleInfo_whenValidIdIsProvided() { - // arrange - ExampleModel exampleModel = exampleJpaRepository.save( - new ExampleModel("예시 제목", "예시 설명") - ); - - // act - ExampleModel result = exampleService.getExample(exampleModel.getId()); - - // assert - assertAll( - () -> assertThat(result).isNotNull(), - () -> assertThat(result.getId()).isEqualTo(exampleModel.getId()), - () -> assertThat(result.getName()).isEqualTo(exampleModel.getName()), - () -> assertThat(result.getDescription()).isEqualTo(exampleModel.getDescription()) - ); - } - - @DisplayName("존재하지 않는 예시 ID를 주면, NOT_FOUND 예외가 발생한다.") - @Test - void throwsException_whenInvalidIdIsProvided() { - // arrange - Long invalidId = 999L; // Assuming this ID does not exist - - // act - CoreException exception = assertThrows(CoreException.class, () -> { - exampleService.getExample(invalidId); - }); - - // assert - assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeTest.java new file mode 100644 index 000000000..02d73d79a --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeTest.java @@ -0,0 +1,19 @@ +package com.loopers.domain.like; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class LikeTest { + + @DisplayName("Like 생성 시 memberId, productId, createdAt이 설정된다") + @Test + void create_withMemberAndProduct_setsFields() { + Like like = new Like(1L, 100L); + + assertThat(like.getMemberId()).isEqualTo(1L); + assertThat(like.getProductId()).isEqualTo(100L); + assertThat(like.getCreatedAt()).isNotNull(); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java new file mode 100644 index 000000000..c3e94a14e --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java @@ -0,0 +1,69 @@ +package com.loopers.domain.member; + +import com.loopers.domain.member.vo.BirthDate; +import com.loopers.domain.member.vo.Email; +import com.loopers.domain.member.vo.LoginId; +import com.loopers.domain.member.vo.Password; +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class MemberTest { + + @DisplayName("유효한 정보로 Member를 생성할 수 있다") + @Test + void create_withValidInfo_succeeds() { + Member member = new Member( + new LoginId("user1"), + new Password("encodedPw"), + "홍길동", + BirthDate.from("1990-01-15"), + new Email("test@example.com") + ); + + assertThat(member.getLoginId().value()).isEqualTo("user1"); + assertThat(member.getName()).isEqualTo("홍길동"); + } + + @DisplayName("이름이 null이면 생성에 실패한다") + @Test + void create_withNullName_throwsException() { + assertThatThrownBy(() -> new Member( + new LoginId("user1"), + new Password("encodedPw"), + null, + BirthDate.from("1990-01-15"), + new Email("test@example.com") + )).isInstanceOf(CoreException.class); + } + + @DisplayName("이름이 빈 문자열이면 생성에 실패한다") + @Test + void create_withBlankName_throwsException() { + assertThatThrownBy(() -> new Member( + new LoginId("user1"), + new Password("encodedPw"), + " ", + BirthDate.from("1990-01-15"), + new Email("test@example.com") + )).isInstanceOf(CoreException.class); + } + + @DisplayName("비밀번호를 변경할 수 있다") + @Test + void changePassword_updatesPassword() { + Member member = new Member( + new LoginId("user1"), + new Password("oldEncodedPw"), + "홍길동", + BirthDate.from("1990-01-15"), + new Email("test@example.com") + ); + + member.changePassword(new Password("newEncodedPw")); + assertThat(member.getPassword().encoded()).isEqualTo("newEncodedPw"); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/policy/PasswordPolicyTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/policy/PasswordPolicyTest.java new file mode 100644 index 000000000..f94b787f3 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/policy/PasswordPolicyTest.java @@ -0,0 +1,76 @@ +package com.loopers.domain.member.policy; + +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class PasswordPolicyTest { + + @DisplayName("유효한 비밀번호는 검증을 통과한다") + @Test + void validate_withValidPassword_succeeds() { + assertThatNoException().isThrownBy(() -> + PasswordPolicy.validate("Password1!", LocalDate.of(1990, 1, 15))); + } + + @DisplayName("8자 미만 비밀번호는 실패한다") + @Test + void validateFormat_withShortPassword_throwsException() { + assertThatThrownBy(() -> PasswordPolicy.validateFormat("Pass1!")) + .isInstanceOf(CoreException.class); + } + + @DisplayName("16자 초과 비밀번호는 실패한다") + @Test + void validateFormat_withLongPassword_throwsException() { + assertThatThrownBy(() -> PasswordPolicy.validateFormat("A".repeat(17))) + .isInstanceOf(CoreException.class); + } + + @DisplayName("null 비밀번호는 실패한다") + @Test + void validateFormat_withNull_throwsException() { + assertThatThrownBy(() -> PasswordPolicy.validateFormat(null)) + .isInstanceOf(CoreException.class); + } + + @DisplayName("생년월일(yyyyMMdd) 포함 비밀번호는 실패한다") + @Test + void validate_withBirthDateYYYYMMDD_throwsException() { + assertThatThrownBy(() -> + PasswordPolicy.validate("Pass19900115!", LocalDate.of(1990, 1, 15))) + .isInstanceOf(CoreException.class); + } + + @DisplayName("생년월일(yyMMdd) 포함 비밀번호는 실패한다") + @Test + void validate_withBirthDateYYMMDD_throwsException() { + assertThatThrownBy(() -> + PasswordPolicy.validate("Pass900115!!", LocalDate.of(1990, 1, 15))) + .isInstanceOf(CoreException.class); + } + + @DisplayName("금지 문자열이 포함되면 실패한다") + @Test + void validateNotContainsSubstrings_withForbidden_throwsException() { + assertThatThrownBy(() -> + PasswordPolicy.validateNotContainsSubstrings( + "hello_forbidden_world", + List.of("forbidden"), + "금지 문자열 포함")) + .isInstanceOf(CoreException.class); + } + + @DisplayName("extractBirthDateStrings는 yyyyMMdd와 yyMMdd를 반환한다") + @Test + void extractBirthDateStrings_returnsTwoFormats() { + List result = PasswordPolicy.extractBirthDateStrings(LocalDate.of(1990, 1, 15)); + org.assertj.core.api.Assertions.assertThat(result).containsExactly("19900115", "900115"); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/vo/BirthDateTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/vo/BirthDateTest.java new file mode 100644 index 000000000..424288640 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/vo/BirthDateTest.java @@ -0,0 +1,48 @@ +package com.loopers.domain.member.vo; + +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class BirthDateTest { + + @DisplayName("yyyy-MM-dd 형식의 문자열로 BirthDate를 생성할 수 있다") + @Test + void from_withValidFormat_succeeds() { + BirthDate birthDate = BirthDate.from("1990-01-15"); + assertThat(birthDate.value()).isEqualTo(LocalDate.of(1990, 1, 15)); + } + + @DisplayName("생년월일이 yyyy-MM-dd 형식에 맞지 않으면 User 객체 생성에 실패한다") + @Test + void from_withInvalidFormat_throwsException() { + assertThatThrownBy(() -> BirthDate.from("19900115")) + .isInstanceOf(CoreException.class); + } + + @DisplayName("null이면 생성에 실패한다") + @Test + void from_withNull_throwsException() { + assertThatThrownBy(() -> BirthDate.from(null)) + .isInstanceOf(CoreException.class); + } + + @DisplayName("슬래시 형식이면 생성에 실패한다") + @Test + void from_withSlashFormat_throwsException() { + assertThatThrownBy(() -> BirthDate.from("1990/01/15")) + .isInstanceOf(CoreException.class); + } + + @DisplayName("빈 문자열이면 생성에 실패한다") + @Test + void from_withEmpty_throwsException() { + assertThatThrownBy(() -> BirthDate.from("")) + .isInstanceOf(CoreException.class); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/vo/EmailTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/vo/EmailTest.java new file mode 100644 index 000000000..5976980fc --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/vo/EmailTest.java @@ -0,0 +1,46 @@ +package com.loopers.domain.member.vo; + +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class EmailTest { + + @DisplayName("유효한 이메일 형식으로 Email을 생성할 수 있다") + @Test + void create_withValidFormat_succeeds() { + Email email = new Email("test@example.com"); + assertThat(email.value()).isEqualTo("test@example.com"); + } + + @DisplayName("이메일이 xx@yy.zz 형식에 맞지 않으면 User 객체 생성에 실패한다") + @Test + void create_withInvalidFormat_throwsException() { + assertThatThrownBy(() -> new Email("invalid-email")) + .isInstanceOf(CoreException.class); + } + + @DisplayName("@가 없으면 생성에 실패한다") + @Test + void create_withoutAtSign_throwsException() { + assertThatThrownBy(() -> new Email("testexample.com")) + .isInstanceOf(CoreException.class); + } + + @DisplayName("null이면 생성에 실패한다") + @Test + void create_withNull_throwsException() { + assertThatThrownBy(() -> new Email(null)) + .isInstanceOf(CoreException.class); + } + + @DisplayName("도메인 부분이 없으면 생성에 실패한다") + @Test + void create_withoutDomain_throwsException() { + assertThatThrownBy(() -> new Email("test@")) + .isInstanceOf(CoreException.class); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/vo/LoginIdTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/vo/LoginIdTest.java new file mode 100644 index 000000000..382e5bb19 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/vo/LoginIdTest.java @@ -0,0 +1,53 @@ +package com.loopers.domain.member.vo; + +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class LoginIdTest { + + @DisplayName("유효한 영문 및 숫자 조합으로 LoginId를 생성할 수 있다") + @Test + void create_withValidFormat_succeeds() { + LoginId loginId = new LoginId("user1234"); + assertThat(loginId.value()).isEqualTo("user1234"); + } + + @DisplayName("ID가 영문 및 숫자 10자 이내 형식에 맞지 않으면 User 객체 생성에 실패한다") + @Test + void create_withInvalidFormat_throwsException() { + assertThatThrownBy(() -> new LoginId("한글아이디")) + .isInstanceOf(CoreException.class); + } + + @DisplayName("10자를 초과하면 생성에 실패한다") + @Test + void create_withTooLong_throwsException() { + assertThatThrownBy(() -> new LoginId("abcdefghijk")) + .isInstanceOf(CoreException.class); + } + + @DisplayName("특수문자가 포함되면 생성에 실패한다") + @Test + void create_withSpecialChars_throwsException() { + assertThatThrownBy(() -> new LoginId("user!@#")) + .isInstanceOf(CoreException.class); + } + + @DisplayName("null이면 생성에 실패한다") + @Test + void create_withNull_throwsException() { + assertThatThrownBy(() -> new LoginId(null)) + .isInstanceOf(CoreException.class); + } + + @DisplayName("빈 문자열이면 생성에 실패한다") + @Test + void create_withEmpty_throwsException() { + assertThatThrownBy(() -> new LoginId("")) + .isInstanceOf(CoreException.class); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/vo/PasswordTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/vo/PasswordTest.java new file mode 100644 index 000000000..e47be0ad5 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/vo/PasswordTest.java @@ -0,0 +1,59 @@ +package com.loopers.domain.member.vo; + +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class PasswordTest { + + private final PasswordEncoder encoder = new BCryptPasswordEncoder(); + + @DisplayName("유효한 비밀번호로 Password를 생성할 수 있다") + @Test + void create_withValidPassword_succeeds() { + Password password = Password.create("Password1!", LocalDate.of(1990, 1, 15), encoder); + assertThat(password.encoded()).isNotBlank(); + } + + @DisplayName("비밀번호가 형식에 맞지 않으면 생성에 실패한다") + @Test + void create_withInvalidFormat_throwsException() { + assertThatThrownBy(() -> Password.create("short", LocalDate.of(1990, 1, 15), encoder)) + .isInstanceOf(CoreException.class); + } + + @DisplayName("비밀번호에 생년월일이 포함되면 생성에 실패한다") + @Test + void create_withBirthDate_throwsException() { + assertThatThrownBy(() -> Password.create("Pass19900115!", LocalDate.of(1990, 1, 15), encoder)) + .isInstanceOf(CoreException.class); + } + + @DisplayName("matches로 평문 비밀번호를 검증할 수 있다") + @Test + void matches_withCorrectPassword_returnsTrue() { + Password password = Password.create("Password1!", LocalDate.of(1990, 1, 15), encoder); + assertThat(password.matches("Password1!", encoder)).isTrue(); + } + + @DisplayName("matches로 틀린 비밀번호를 거부할 수 있다") + @Test + void matches_withWrongPassword_returnsFalse() { + Password password = Password.create("Password1!", LocalDate.of(1990, 1, 15), encoder); + assertThat(password.matches("WrongPass1!", encoder)).isFalse(); + } + + @DisplayName("encoded가 null이면 생성에 실패한다") + @Test + void constructor_withNull_throwsException() { + assertThatThrownBy(() -> new Password(null)) + .isInstanceOf(CoreException.class); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemTest.java new file mode 100644 index 000000000..492e793d5 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemTest.java @@ -0,0 +1,17 @@ +package com.loopers.domain.order; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class OrderItemTest { + + @DisplayName("getSubtotal은 상품 가격과 수량의 곱을 반환한다") + @Test + void getSubtotal_calculatesCorrectly() { + OrderItem orderItem = new OrderItem(1L, "테스트 상품", 15000, "테스트 브랜드", 3); + + assertThat(orderItem.getSubtotal()).isEqualTo(45000); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java new file mode 100644 index 000000000..5eca5219d --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java @@ -0,0 +1,98 @@ +package com.loopers.domain.order; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class OrderTest { + + @Nested + @DisplayName("Order 생성") + class Create { + + @DisplayName("주문 항목이 비어있으면 예외가 발생한다") + @Test + void create_withEmptyItems_throwsException() { + assertThatThrownBy(() -> Order.create(1L, List.of())) + .isInstanceOf(CoreException.class) + .extracting(e -> ((CoreException) e).getErrorType()) + .isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("주문 항목이 null이면 예외가 발생한다") + @Test + void create_withNullItems_throwsException() { + assertThatThrownBy(() -> Order.create(1L, null)) + .isInstanceOf(CoreException.class) + .extracting(e -> ((CoreException) e).getErrorType()) + .isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("주문 항목들의 소계 합산으로 totalPrice가 계산된다") + @Test + void create_withItems_calculatesTotalPrice() { + Order.ItemSnapshot snap1 = new Order.ItemSnapshot(1L, "상품A", 10000, "브랜드A", 2); + Order.ItemSnapshot snap2 = new Order.ItemSnapshot(2L, "상품B", 5000, "브랜드B", 3); + + Order order = Order.create(1L, List.of(snap1, snap2)); + + assertThat(order.getMemberId()).isEqualTo(1L); + assertThat(order.getStatus()).isEqualTo(OrderStatus.CREATED); + assertThat(order.getTotalPrice()).isEqualTo(35000); + assertThat(order.getItems()).hasSize(2); + } + } + + @Nested + @DisplayName("주문 취소") + class Cancel { + + @DisplayName("주문을 취소하면 상태가 CANCELLED로 변경된다") + @Test + void cancel_changesStatusToCancelled() { + Order order = Order.create(1L, List.of( + new Order.ItemSnapshot(1L, "상품A", 10000, "브랜드A", 1))); + + order.cancel(); + + assertThat(order.getStatus()).isEqualTo(OrderStatus.CANCELLED); + } + + @DisplayName("이미 취소된 주문을 다시 취소하면 예외가 발생한다") + @Test + void cancel_whenAlreadyCancelled_throwsException() { + Order order = Order.create(1L, List.of( + new Order.ItemSnapshot(1L, "상품A", 10000, "브랜드A", 1))); + order.cancel(); + + assertThatThrownBy(order::cancel) + .isInstanceOf(CoreException.class) + .extracting(e -> ((CoreException) e).getErrorType()) + .isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @Nested + @DisplayName("주문 항목 조회") + class GetItems { + + @DisplayName("getItems는 수정 불가능한 리스트를 반환한다") + @Test + void getItems_returnsUnmodifiableList() { + Order order = Order.create(1L, List.of( + new Order.ItemSnapshot(1L, "상품A", 10000, "브랜드A", 1))); + + List items = order.getItems(); + + assertThatThrownBy(() -> items.add(new OrderItem(2L, "상품B", 5000, "브랜드B", 1))) + .isInstanceOf(UnsupportedOperationException.class); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java new file mode 100644 index 000000000..3707c6b21 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java @@ -0,0 +1,96 @@ +package com.loopers.domain.product; + +import com.loopers.domain.product.vo.Price; +import com.loopers.domain.product.vo.Stock; +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ProductTest { + + private Product createProduct() { + return new Product(1L, "테스트 상품", new Price(10000), new Stock(10)); + } + + @Nested + @DisplayName("Product 생성") + class Create { + + @DisplayName("유효한 정보로 Product를 생성하면 likeCount가 0으로 초기화된다") + @Test + void create_withValidInfo_likeCountIsZero() { + Product product = createProduct(); + + assertThat(product.getBrandId()).isEqualTo(1L); + assertThat(product.getName()).isEqualTo("테스트 상품"); + assertThat(product.getPrice().getValue()).isEqualTo(10000); + assertThat(product.getStock().getQuantity()).isEqualTo(10); + assertThat(product.getLikeCount()).isEqualTo(0); + } + } + + @Nested + @DisplayName("재고 차감") + class DecreaseStock { + + @DisplayName("재고가 충분하면 차감에 성공한다") + @Test + void decreaseStock_withSufficientStock_succeeds() { + Product product = createProduct(); + + product.decreaseStock(3); + + assertThat(product.getStock().getQuantity()).isEqualTo(7); + } + + @DisplayName("재고가 부족하면 CoreException이 발생한다") + @Test + void decreaseStock_withInsufficientStock_throwsException() { + Product product = createProduct(); + + assertThatThrownBy(() -> product.decreaseStock(11)) + .isInstanceOf(CoreException.class); + } + } + + @Nested + @DisplayName("좋아요 수") + class LikeCount { + + @DisplayName("좋아요를 증가시키면 likeCount가 1 증가한다") + @Test + void incrementLikeCount_increases() { + Product product = createProduct(); + + product.incrementLikeCount(); + + assertThat(product.getLikeCount()).isEqualTo(1); + } + + @DisplayName("좋아요를 감소시키면 likeCount가 1 감소한다") + @Test + void decrementLikeCount_withPositiveCount_decreases() { + Product product = createProduct(); + product.incrementLikeCount(); + product.incrementLikeCount(); + + product.decrementLikeCount(); + + assertThat(product.getLikeCount()).isEqualTo(1); + } + + @DisplayName("likeCount가 0일 때 감소시키면 0을 유지한다") + @Test + void decrementLikeCount_withZeroCount_staysZero() { + Product product = createProduct(); + + product.decrementLikeCount(); + + assertThat(product.getLikeCount()).isEqualTo(0); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/vo/PriceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/vo/PriceTest.java new file mode 100644 index 000000000..c2cf0f953 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/vo/PriceTest.java @@ -0,0 +1,38 @@ +package com.loopers.domain.product.vo; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class PriceTest { + + @Nested + @DisplayName("Price 생성") + class Create { + + @DisplayName("0으로 Price를 생성할 수 있다") + @Test + void create_withZero_succeeds() { + Price price = new Price(0); + assertThat(price.getValue()).isEqualTo(0); + } + + @DisplayName("양수로 Price를 생성할 수 있다") + @Test + void create_withPositiveValue_succeeds() { + Price price = new Price(10000); + assertThat(price.getValue()).isEqualTo(10000); + } + + @DisplayName("음수로 Price를 생성하면 예외가 발생한다") + @Test + void create_withNegativeValue_throwsException() { + assertThatThrownBy(() -> new Price(-1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("가격은 0 이상이어야 합니다."); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/vo/StockTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/vo/StockTest.java new file mode 100644 index 000000000..4c2f3ede7 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/vo/StockTest.java @@ -0,0 +1,85 @@ +package com.loopers.domain.product.vo; + +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class StockTest { + + @Nested + @DisplayName("Stock 생성") + class Create { + + @DisplayName("0 이상의 수량으로 Stock을 생성할 수 있다") + @Test + void create_withValidQuantity_succeeds() { + Stock stock = new Stock(10); + assertThat(stock.getQuantity()).isEqualTo(10); + } + + @DisplayName("음수로 Stock을 생성하면 예외가 발생한다") + @Test + void create_withNegativeQuantity_throwsException() { + assertThatThrownBy(() -> new Stock(-1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("재고는 0 이상이어야 합니다."); + } + } + + @Nested + @DisplayName("hasEnough") + class HasEnough { + + @DisplayName("재고가 요청 수량 이상이면 true를 반환한다") + @Test + void hasEnough_withSufficientStock_returnsTrue() { + Stock stock = new Stock(10); + assertThat(stock.hasEnough(10)).isTrue(); + } + + @DisplayName("재고가 요청 수량 미만이면 false를 반환한다") + @Test + void hasEnough_withInsufficientStock_returnsFalse() { + Stock stock = new Stock(5); + assertThat(stock.hasEnough(6)).isFalse(); + } + } + + @Nested + @DisplayName("decrease") + class Decrease { + + @DisplayName("재고가 충분하면 차감된 Stock을 반환한다") + @Test + void decrease_withSufficientStock_returnsDecreasedStock() { + Stock stock = new Stock(10); + Stock decreased = stock.decrease(3); + assertThat(decreased.getQuantity()).isEqualTo(7); + } + + @DisplayName("재고가 부족하면 CoreException이 발생한다") + @Test + void decrease_withInsufficientStock_throwsException() { + Stock stock = new Stock(2); + assertThatThrownBy(() -> stock.decrease(3)) + .isInstanceOf(CoreException.class); + } + } + + @Nested + @DisplayName("increase") + class Increase { + + @DisplayName("수량을 증가시킨 Stock을 반환한다") + @Test + void increase_withValidAmount_returnsIncreasedStock() { + Stock stock = new Stock(5); + Stock increased = stock.increase(3); + assertThat(increased.getQuantity()).isEqualTo(8); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/fake/FakeBrandRepository.java b/apps/commerce-api/src/test/java/com/loopers/fake/FakeBrandRepository.java new file mode 100644 index 000000000..a171c74f6 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/fake/FakeBrandRepository.java @@ -0,0 +1,60 @@ +package com.loopers.fake; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +public class FakeBrandRepository implements BrandRepository { + + private final Map store = new ConcurrentHashMap<>(); + private long sequence = 1L; + + @Override + public Brand save(Brand brand) { + if (brand.getId() == null || brand.getId() == 0L) { + long id = sequence++; + setBaseEntityId(brand, id); + } + store.put(brand.getId(), brand); + return brand; + } + + @Override + public Optional findById(Long id) { + return Optional.ofNullable(store.get(id)) + .filter(brand -> brand.getDeletedAt() == null); + } + + @Override + public List findAll() { + return store.values().stream() + .filter(brand -> brand.getDeletedAt() == null) + .toList(); + } + + @Override + public List findAllByIds(Set ids) { + return store.values().stream() + .filter(brand -> brand.getDeletedAt() == null) + .filter(brand -> ids.contains(brand.getId())) + .toList(); + } + + private void setBaseEntityId(Object entity, long id) { + try { + Field idField = BaseEntity.class.getDeclaredField("id"); + idField.setAccessible(true); + idField.set(entity, id); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/fake/FakeLikeRepository.java b/apps/commerce-api/src/test/java/com/loopers/fake/FakeLikeRepository.java new file mode 100644 index 000000000..7db51d80b --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/fake/FakeLikeRepository.java @@ -0,0 +1,61 @@ +package com.loopers.fake; + +import com.loopers.domain.like.Like; +import com.loopers.domain.like.LikeRepository; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +public class FakeLikeRepository implements LikeRepository { + + private final Map store = new ConcurrentHashMap<>(); + private long sequence = 1L; + + @Override + public Like save(Like like) { + if (like.getId() == null) { + long id = sequence++; + ReflectionTestUtils.setField(like, "id", id); + } + store.put(like.getId(), like); + return like; + } + + @Override + public void delete(Like like) { + store.remove(like.getId()); + } + + @Override + public Optional findByMemberIdAndProductId(Long memberId, Long productId) { + return store.values().stream() + .filter(like -> like.getMemberId().equals(memberId) && like.getProductId().equals(productId)) + .findFirst(); + } + + @Override + public boolean existsByMemberIdAndProductId(Long memberId, Long productId) { + return store.values().stream() + .anyMatch(like -> like.getMemberId().equals(memberId) && like.getProductId().equals(productId)); + } + + @Override + public List findAllByMemberId(Long memberId) { + return store.values().stream() + .filter(like -> like.getMemberId().equals(memberId)) + .toList(); + } + + @Override + public void deleteAllByProductId(Long productId) { + List keysToRemove = store.entrySet().stream() + .filter(entry -> entry.getValue().getProductId().equals(productId)) + .map(Map.Entry::getKey) + .toList(); + keysToRemove.forEach(store::remove); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/fake/FakeOrderRepository.java b/apps/commerce-api/src/test/java/com/loopers/fake/FakeOrderRepository.java new file mode 100644 index 000000000..b2a5aa26e --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/fake/FakeOrderRepository.java @@ -0,0 +1,82 @@ +package com.loopers.fake; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderRepository; + +import java.lang.reflect.Field; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +public class FakeOrderRepository implements OrderRepository { + + private final Map store = new ConcurrentHashMap<>(); + private long sequence = 1L; + + @Override + public Order save(Order order) { + if (order.getId() == null || order.getId() == 0L) { + long id = sequence++; + setBaseEntityId(order, id); + } + setCreatedAtIfAbsent(order); + store.put(order.getId(), order); + return order; + } + + @Override + public Optional findById(Long id) { + return Optional.ofNullable(store.get(id)); + } + + @Override + public List findAllByMemberId(Long memberId) { + return store.values().stream() + .filter(order -> order.getMemberId().equals(memberId)) + .toList(); + } + + @Override + public List findAllByMemberIdAndCreatedAtBetween(Long memberId, ZonedDateTime startAt, ZonedDateTime endAt) { + return store.values().stream() + .filter(order -> order.getMemberId().equals(memberId)) + .filter(order -> { + ZonedDateTime createdAt = order.getCreatedAt(); + return createdAt != null + && !createdAt.isBefore(startAt) + && !createdAt.isAfter(endAt); + }) + .toList(); + } + + @Override + public List findAll() { + return new ArrayList<>(store.values()); + } + + private void setBaseEntityId(Object entity, long id) { + try { + Field idField = BaseEntity.class.getDeclaredField("id"); + idField.setAccessible(true); + idField.set(entity, id); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private void setCreatedAtIfAbsent(Order order) { + if (order.getCreatedAt() == null) { + try { + Field createdAtField = BaseEntity.class.getDeclaredField("createdAt"); + createdAtField.setAccessible(true); + createdAtField.set(order, ZonedDateTime.now()); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/fake/FakeProductRepository.java b/apps/commerce-api/src/test/java/com/loopers/fake/FakeProductRepository.java new file mode 100644 index 000000000..cc68dc0f8 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/fake/FakeProductRepository.java @@ -0,0 +1,113 @@ +package com.loopers.fake; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.ProductWithBrand; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +public class FakeProductRepository implements ProductRepository { + + private final Map store = new ConcurrentHashMap<>(); + private long sequence = 1L; + private BrandRepository brandRepository; + + @Override + public Product save(Product product) { + if (product.getId() == null || product.getId() == 0L) { + long id = sequence++; + setBaseEntityId(product, id); + } + store.put(product.getId(), product); + return product; + } + + @Override + public Optional findById(Long id) { + return Optional.ofNullable(store.get(id)) + .filter(product -> product.getDeletedAt() == null); + } + + @Override + public List findAll() { + return store.values().stream() + .filter(product -> product.getDeletedAt() == null) + .toList(); + } + + @Override + public List findAllByBrandId(Long brandId) { + return store.values().stream() + .filter(product -> product.getDeletedAt() == null) + .filter(product -> product.getBrandId().equals(brandId)) + .toList(); + } + + @Override + public List findAllWithBrand() { + return store.values().stream() + .filter(product -> product.getDeletedAt() == null) + .map(product -> new ProductWithBrand(product, resolveBrandName(product.getBrandId()))) + .toList(); + } + + @Override + public List findAllWithBrand(String sort) { + Comparator comparator = toComparator(sort); + return store.values().stream() + .filter(product -> product.getDeletedAt() == null) + .sorted(comparator) + .map(product -> new ProductWithBrand(product, resolveBrandName(product.getBrandId()))) + .toList(); + } + + @Override + public List findAllByBrandIdWithBrand(Long brandId) { + return store.values().stream() + .filter(product -> product.getDeletedAt() == null) + .filter(product -> product.getBrandId().equals(brandId)) + .map(product -> new ProductWithBrand(product, resolveBrandName(product.getBrandId()))) + .toList(); + } + + public void setBrandRepository(BrandRepository brandRepository) { + this.brandRepository = brandRepository; + } + + private String resolveBrandName(Long brandId) { + if (brandRepository == null) return null; + return brandRepository.findById(brandId) + .map(Brand::getName) + .orElse(null); + } + + private Comparator toComparator(String sort) { + if (sort == null) { + return Comparator.comparing(Product::getId).reversed(); + } + return switch (sort) { + case "price_asc" -> Comparator.comparingInt(p -> p.getPrice().getValue()); + case "likes_desc" -> Comparator.comparingInt(Product::getLikeCount).reversed(); + default -> Comparator.comparing(Product::getId).reversed(); + }; + } + + private void setBaseEntityId(Object entity, long id) { + try { + Field idField = BaseEntity.class.getDeclaredField("id"); + idField.setAccessible(true); + idField.set(entity, id); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java deleted file mode 100644 index 1bb3dba65..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java +++ /dev/null @@ -1,114 +0,0 @@ -package com.loopers.interfaces.api; - -import com.loopers.domain.example.ExampleModel; -import com.loopers.infrastructure.example.ExampleJpaRepository; -import com.loopers.interfaces.api.example.ExampleV1Dto; -import com.loopers.utils.DatabaseCleanUp; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.web.client.TestRestTemplate; -import org.springframework.core.ParameterizedTypeReference; -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpMethod; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; - -import java.util.function.Function; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; -import static org.junit.jupiter.api.Assertions.assertTrue; - -@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -class ExampleV1ApiE2ETest { - - private static final Function ENDPOINT_GET = id -> "/api/v1/examples/" + id; - - private final TestRestTemplate testRestTemplate; - private final ExampleJpaRepository exampleJpaRepository; - private final DatabaseCleanUp databaseCleanUp; - - @Autowired - public ExampleV1ApiE2ETest( - TestRestTemplate testRestTemplate, - ExampleJpaRepository exampleJpaRepository, - DatabaseCleanUp databaseCleanUp - ) { - this.testRestTemplate = testRestTemplate; - this.exampleJpaRepository = exampleJpaRepository; - this.databaseCleanUp = databaseCleanUp; - } - - @AfterEach - void tearDown() { - databaseCleanUp.truncateAllTables(); - } - - @DisplayName("GET /api/v1/examples/{id}") - @Nested - class Get { - @DisplayName("존재하는 예시 ID를 주면, 해당 예시 정보를 반환한다.") - @Test - void returnsExampleInfo_whenValidIdIsProvided() { - // arrange - ExampleModel exampleModel = exampleJpaRepository.save( - new ExampleModel("예시 제목", "예시 설명") - ); - String requestUrl = ENDPOINT_GET.apply(exampleModel.getId()); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = - testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType); - - // assert - assertAll( - () -> assertTrue(response.getStatusCode().is2xxSuccessful()), - () -> assertThat(response.getBody().data().id()).isEqualTo(exampleModel.getId()), - () -> assertThat(response.getBody().data().name()).isEqualTo(exampleModel.getName()), - () -> assertThat(response.getBody().data().description()).isEqualTo(exampleModel.getDescription()) - ); - } - - @DisplayName("숫자가 아닌 ID 로 요청하면, 400 BAD_REQUEST 응답을 받는다.") - @Test - void throwsBadRequest_whenIdIsNotProvided() { - // arrange - String requestUrl = "/api/v1/examples/나나"; - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = - testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType); - - // assert - assertAll( - () -> assertTrue(response.getStatusCode().is4xxClientError()), - () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST) - ); - } - - @DisplayName("존재하지 않는 예시 ID를 주면, 404 NOT_FOUND 응답을 받는다.") - @Test - void throwsException_whenInvalidIdIsProvided() { - // arrange - Long invalidId = -1L; - String requestUrl = ENDPOINT_GET.apply(invalidId); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = - testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType); - - // assert - assertAll( - () -> assertTrue(response.getStatusCode().is4xxClientError()), - () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND) - ); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/member/MemberV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/member/MemberV1ApiE2ETest.java new file mode 100644 index 000000000..b861baeae --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/member/MemberV1ApiE2ETest.java @@ -0,0 +1,133 @@ +package com.loopers.interfaces.api.member; + +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class MemberV1ApiE2ETest { + + @Autowired + private TestRestTemplate testRestTemplate; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private ResponseEntity> signUp(Map body) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity> request = new HttpEntity<>(body, headers); + return testRestTemplate.exchange( + "/api/v1/members", + HttpMethod.POST, + request, + new ParameterizedTypeReference<>() {} + ); + } + + private Map validSignUpBody() { + return Map.of( + "loginId", "user1", + "password", "Password1!", + "name", "홍길동", + "birthDate", "1990-01-15", + "email", "test@example.com" + ); + } + + @DisplayName("POST /api/v1/members (회원 가입)") + @Nested + class SignUp { + + @DisplayName("회원 가입이 성공할 경우, 생성된 유저 정보를 응답으로 반환한다") + @Test + void signUp_withValidRequest_returnsCreatedWithUserInfo() { + // act + ResponseEntity> response = signUp(validSignUpBody()); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().data().loginId()).isEqualTo("user1"), + () -> assertThat(response.getBody().data().name()).isEqualTo("홍길동"), + () -> assertThat(response.getBody().data().email()).isEqualTo("test@example.com") + ); + } + } + + @DisplayName("GET /api/v1/members/me (내 정보 조회)") + @Nested + class GetMyInfo { + + @DisplayName("내 정보 조회에 성공할 경우, 해당하는 유저 정보를 응답으로 반환한다") + @Test + void getMyInfo_withValidAuth_returnsUserInfo() { + // arrange + signUp(validSignUpBody()); + + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "user1"); + headers.set("X-Loopers-LoginPw", "Password1!"); + + // act + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/members/me", + HttpMethod.GET, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().data().loginId()).isEqualTo("user1"), + () -> assertThat(response.getBody().data().email()).isEqualTo("test@example.com") + ); + } + + @DisplayName("존재하지 않는 ID로 조회할 경우, 401 Unauthorized") + @Test + void getMyInfo_withNonExistentId_returnsUnauthorized() { + // arrange + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "nobody"); + headers.set("X-Loopers-LoginPw", "Password1!"); + + // act + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/members/me", + HttpMethod.GET, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + } +} diff --git a/docs/design/01-requirements.md b/docs/design/01-requirements.md new file mode 100644 index 000000000..ca9278ffd --- /dev/null +++ b/docs/design/01-requirements.md @@ -0,0 +1,427 @@ +# 요구사항 명세서 + +## 1. 개요 + +이커머스 플랫폼의 핵심 도메인(브랜드, 상품, 좋아요, 주문)에 대한 요구사항을 정의한다. + +--- + +## 2. 액터 정의 + +| 액터 | 설명 | 인증 방식 | +|------|------|----------| +| **User (회원)** | 상품을 조회하고 좋아요, 주문을 수행하는 일반 사용자 | `X-Loopers-LoginId`, `X-Loopers-LoginPw` 헤더 | +| **Admin (관리자)** | 브랜드/상품을 등록·수정·삭제하고, 전체 주문을 조회하는 운영자 | `X-Loopers-Ldap: loopers.admin` 헤더 | +| **System** | 배치 작업, 정합성 보정 등 내부 시스템 프로세스 | 내부 호출 (인증 없음) | + +--- + +## 3. 유비쿼터스 언어 (Ubiquitous Language) + +| 용어 | 정의 | +|------|------| +| **브랜드 (Brand)** | 상품을 판매하는 판매자/제조사 단위 | +| **상품 (Product)** | 판매되는 개별 품목. 하나의 브랜드에 속함 | +| **재고 (Stock)** | 상품의 판매 가능 수량 | +| **좋아요 (Like)** | 회원이 상품에 표시한 관심 표시 | +| **주문 (Order)** | 회원이 상품을 구매하기 위해 생성한 거래 단위 | +| **주문 항목 (OrderItem)** | 주문에 포함된 개별 상품 정보 (스냅샷 포함) | +| **스냅샷 (Snapshot)** | 주문 시점의 상품 정보를 보존한 데이터 | + +--- + +## 4. 도메인별 기능 요구사항 + +### 4.1 브랜드 (Brand) + +#### US-B01: 브랜드 등록 +``` +As a 관리자 +I want to 새로운 브랜드를 등록하고 싶다 +So that 해당 브랜드의 상품을 등록할 수 있다 +``` + +| 흐름 | 설명 | +|------|------| +| Main | 브랜드명, 설명을 입력하여 브랜드를 생성한다 | +| Alternate | - | +| Exception | 브랜드명이 비어있으면 등록 실패 | + +#### US-B02: 브랜드 목록 조회 +``` +As a 사용자 +I want to 브랜드 목록을 조회하고 싶다 +So that 원하는 브랜드의 상품을 찾을 수 있다 +``` + +| 흐름 | 설명 | +|------|------| +| Main | 등록된 브랜드 목록을 조회한다 (삭제되지 않은 것만) | +| Alternate | - | +| Exception | - | + +#### US-B03: 브랜드 삭제 +``` +As a 관리자 +I want to 브랜드를 삭제하고 싶다 +So that 더 이상 해당 브랜드의 상품이 노출되지 않는다 +``` + +| 흐름 | 설명 | +|------|------| +| Main | 브랜드를 soft delete 처리한다 | +| Alternate | 해당 브랜드의 모든 상품도 soft delete 처리된다 | +| Alternate | 해당 상품들의 좋아요는 hard delete 된다 | +| Exception | 존재하지 않는 브랜드이면 삭제 실패 | + +--- + +### 4.2 상품 (Product) + +#### US-P01: 상품 등록 +``` +As a 관리자 +I want to 새로운 상품을 등록하고 싶다 +So that 회원들이 해당 상품을 구매할 수 있다 +``` + +| 흐름 | 설명 | +|------|------| +| Main | 상품명, 가격, 재고, 브랜드를 입력하여 상품을 생성한다 | +| Alternate | - | +| Exception | 가격이 0 이하이면 등록 실패 | +| Exception | 재고가 음수이면 등록 실패 | +| Exception | 존재하지 않는 브랜드이면 등록 실패 | + +#### US-P02: 상품 목록 조회 +``` +As a 사용자 +I want to 상품 목록을 조회하고 싶다 +So that 구매할 상품을 선택할 수 있다 +``` + +| 흐름 | 설명 | +|------|------| +| Main | 상품 목록을 조회한다 (삭제되지 않은 것만) | +| Alternate | 좋아요 순 정렬 가능 | +| Alternate | 브랜드별 필터링 가능 | +| Exception | - | + +#### US-P03: 상품 상세 조회 +``` +As a 사용자 +I want to 상품 상세 정보를 조회하고 싶다 +So that 상품 정보를 확인하고 구매를 결정할 수 있다 +``` + +| 흐름 | 설명 | +|------|------| +| Main | 상품의 상세 정보(이름, 가격, 재고, 브랜드, 좋아요 수)를 조회한다 | +| Alternate | - | +| Exception | 존재하지 않거나 삭제된 상품이면 조회 실패 | + +#### US-P04: 상품 수정 +``` +As a 관리자 +I want to 상품 정보를 수정하고 싶다 +So that 변경된 정보를 반영할 수 있다 +``` + +| 흐름 | 설명 | +|------|------| +| Main | 상품명, 가격, 재고를 수정한다 | +| Alternate | - | +| Exception | 가격이 0 이하이면 수정 실패 | +| Exception | 재고가 음수이면 수정 실패 | + +#### US-P05: 상품 삭제 +``` +As a 관리자 +I want to 상품을 삭제하고 싶다 +So that 더 이상 해당 상품이 노출되지 않는다 +``` + +| 흐름 | 설명 | +|------|------| +| Main | 상품을 soft delete 처리한다 | +| Alternate | 해당 상품의 좋아요는 hard delete 된다 | +| Exception | 존재하지 않는 상품이면 삭제 실패 | + +--- + +### 4.3 좋아요 (Like) + +#### US-L01: 좋아요 등록 +``` +As a 회원 +I want to 상품에 좋아요를 등록하고 싶다 +So that 관심 상품을 표시할 수 있다 +``` + +| 흐름 | 설명 | +|------|------| +| Main | POST `/api/v1/products/{productId}/likes` - 상품에 좋아요를 추가하고, 상품의 좋아요 수를 증가시킨다 | +| Alternate | 이미 좋아요한 상품이면 아무 동작 없음 (멱등성 보장) | +| Exception | 존재하지 않거나 삭제된 상품이면 실패 | + +#### US-L02: 좋아요 취소 +``` +As a 회원 +I want to 좋아요를 취소하고 싶다 +So that 관심 상품에서 제외할 수 있다 +``` + +| 흐름 | 설명 | +|------|------| +| Main | DELETE `/api/v1/products/{productId}/likes` - 좋아요를 삭제하고, 상품의 좋아요 수를 감소시킨다 | +| Alternate | 좋아요하지 않은 상품이면 아무 동작 없음 (멱등성 보장) | +| Exception | - | + +#### US-L03: 내가 좋아요한 상품 목록 조회 +``` +As a 회원 +I want to 내가 좋아요한 상품 목록을 조회하고 싶다 +So that 관심 상품을 한눈에 확인할 수 있다 +``` + +| 흐름 | 설명 | +|------|------| +| Main | GET `/api/v1/users/{userId}/likes` - 해당 회원이 좋아요한 상품 목록을 조회한다 | +| Alternate | 좋아요한 상품이 없으면 빈 목록 반환 | +| Exception | 다른 회원의 좋아요 목록 조회 시 권한 검증 (본인만 조회 가능) | + +--- + +### 4.4 주문 (Order) + +#### US-O01: 주문 생성 +``` +As a 회원 +I want to 상품을 주문하고 싶다 +So that 상품을 구매할 수 있다 +``` + +| 흐름 | 설명 | +|------|------| +| Main | 1. 주문할 상품과 수량을 선택한다 | +| Main | 2. 재고를 확인하고 차감한다 | +| Main | 3. 주문을 생성하고 주문 항목에 스냅샷을 저장한다 | +| Alternate | 여러 상품을 한 번에 주문할 수 있다 | +| Exception | 재고가 부족하면 주문 실패 | +| Exception | 존재하지 않거나 삭제된 상품이면 주문 실패 | + +#### US-O02: 주문 목록 조회 +``` +As a 회원 +I want to 내 주문 목록을 조회하고 싶다 +So that 주문 이력을 확인할 수 있다 +``` + +| 흐름 | 설명 | +|------|------| +| Main | GET `/api/v1/orders?startAt=&endAt=` - 해당 회원의 주문 목록을 조회한다 | +| Alternate | `startAt`, `endAt` 파라미터로 날짜 범위 필터링 가능 | +| Exception | - | + +#### US-O03: 주문 상세 조회 +``` +As a 회원 +I want to 주문 상세 내역을 조회하고 싶다 +So that 주문한 상품과 금액을 확인할 수 있다 +``` + +| 흐름 | 설명 | +|------|------| +| Main | 주문 항목의 스냅샷 정보를 포함하여 조회한다 | +| Alternate | - | +| Exception | 다른 회원의 주문이면 조회 실패 | + +--- + +## 5. 설계 결정 사항 + +### 5.1 재고 차감 시점 + +| 결정 | 주문 생성 시 즉시 차감 (단일 트랜잭션) | +|------|------| +| **이유** | 현재 과제는 모노리스 + 결제 미구현 상태 | +| **방식** | 단일 트랜잭션으로 `재고 확인 → 주문 저장 → 재고 차감`을 원자적으로 처리 | +| **확장** | 결제가 추가되면 보상 트랜잭션(Saga) 고려 | + +``` +현재: Order 생성 시 Stock 차감 (같은 트랜잭션) +미래: Order 생성 → Payment 요청 → [실패 시] Stock 복원 +``` + +### 5.2 상품 스냅샷 범위 + +**판단 기준**: "주문 상세 화면을 독립적으로 렌더링할 수 있는가?" + +원본 데이터가 변경되거나 삭제되어도, 주문 상세 페이지가 깨지지 않고 온전하게 보여야 한다. +(예: 쿠팡에서 3년 전 주문 내역을 열면 단종된 상품이라도 당시 상품명, 가격이 다 보임) + +| 분류 | 항목 | 저장 여부 | 이유 | +|------|------|----------|------| +| **필수** | product_name | O | 없으면 주문 상세 화면 성립 불가. 변경 시 "내가 주문한 게 이게 아닌데" 클레임 | +| **필수** | product_price | O | 정산/환불 기준. 변경되면 금액 증빙 불가 | +| **필수** | quantity | O | 주문 수량 | +| **권장** | brand_name | O | 주문 내역 UI에 거의 항상 표시 | +| **제외** | image_url | X | 현재 상품 스펙에 이미지 필드 없음. 요구사항에 없는 필드를 미리 스냅샷에 넣는 건 오버엔지니어링 | +| **제외** | description | X | 주문 상세에서 보여줄 필요 없음. 상품 상세 페이지 영역 | +| **제외** | like_count | X | 주문 내역과 무관 | +| **제외** | stock_quantity | X | 주문 내역과 무관 | + +**트레이드오프**: 스냅샷 컬럼이 늘어날수록 저장 비용 증가, 원본과의 동기화 불일치 가능성 증가, 스키마 변경 시 마이그레이션 영향 범위 확대 + +### 5.3 주문 상태 + +```java +public enum OrderStatus { + CREATED, // 주문 생성됨 (현재는 이게 곧 완료) + PAID, // 결제 완료 (미래 확장용) + CANCELLED // 취소됨 +} +``` + +- 결제가 없는 현재: `CREATED` = 주문 완료 상태로 사용 +- 결제가 추가되면: `CREATED` → `PAID` 전이 추가 +- YAGNI 원칙에 따라 현재 로직에서는 PAID를 사용하지 않음 + +### 5.4 좋아요 수 관리 + +| 결정 | 별도 컬럼 (like_count) + 동기화 | +|------|------| +| **이유** | `likes_desc` 정렬 요구사항 → 매 조회 시 COUNT는 비효율 | +| **허용 오차** | 좋아요 수는 1~2개 오차 허용 가능 (재고와 달리 "틀리면 큰일나는" 데이터 아님) | +| **정합성** | 같은 트랜잭션 처리, 필요시 배치로 보정 | + +```java +@Transactional +public void addLike(Long memberId, Long productId) { + likeRepository.save(new Like(memberId, productId)); + productRepository.incrementLikeCount(productId); // UPDATE +1 +} +``` + +### 5.5 삭제 정책 + +| 테이블 | 삭제 방식 | 이유 | +|--------|-----------|------| +| brands | Soft Delete | 상품이 참조, 주문 이력 보존 | +| products | Soft Delete | 주문이 참조 (스냅샷 있어도 조회 가능해야) | +| likes | Hard Delete | 삭제된 상품 좋아요는 의미 없음 | +| orders | Soft Delete | 주문 이력은 절대 삭제 안 함 | +| order_items | 삭제 없음 | Order와 생명주기 공유 (Order 취소 시에도 보존) | + +**브랜드 삭제 시 연쇄 처리**: +```java +@Transactional +public void deleteBrand(Long brandId) { + // 1. 해당 브랜드의 모든 상품 soft delete + productRepository.softDeleteByBrandId(brandId); + // 2. 해당 상품들의 좋아요 hard delete + likeRepository.deleteByBrandId(brandId); + // 3. 브랜드 soft delete + brandRepository.softDelete(brandId); +} +``` + +--- + +## 6. API 명세 + +### 6.1 인증 방식 + +| 구분 | Prefix | 인증 헤더 | +|------|--------|----------| +| 대고객 API | `/api/v1` | `X-Loopers-LoginId`, `X-Loopers-LoginPw` | +| 어드민 API | `/api-admin/v1` | `X-Loopers-Ldap: loopers.admin` | + +### 6.2 브랜드 & 상품 (대고객) + +| METHOD | URI | 설명 | +|--------|-----|------| +| GET | `/api/v1/brands/{brandId}` | 브랜드 정보 조회 | +| GET | `/api/v1/products` | 상품 목록 조회 | +| GET | `/api/v1/products/{productId}` | 상품 정보 조회 | + +**상품 목록 조회 쿼리 파라미터:** +- `brandId`: 브랜드별 필터링 +- `sort`: 정렬 (latest/price_asc/likes_desc) +- `page`, `size`: 페이징 + +### 6.3 브랜드 & 상품 (Admin) + +| METHOD | URI | 설명 | +|--------|-----|------| +| GET | `/api-admin/v1/brands` | 브랜드 목록 조회 | +| GET | `/api-admin/v1/brands/{brandId}` | 브랜드 상세 조회 | +| POST | `/api-admin/v1/brands` | 브랜드 등록 | +| PUT | `/api-admin/v1/brands/{brandId}` | 브랜드 수정 | +| DELETE | `/api-admin/v1/brands/{brandId}` | 브랜드 삭제 (상품도 삭제) | +| GET | `/api-admin/v1/products` | 상품 목록 조회 | +| GET | `/api-admin/v1/products/{productId}` | 상품 상세 조회 | +| POST | `/api-admin/v1/products` | 상품 등록 | +| PUT | `/api-admin/v1/products/{productId}` | 상품 수정 (브랜드 변경 불가) | +| DELETE | `/api-admin/v1/products/{productId}` | 상품 삭제 | + +### 6.4 좋아요 (Likes) + +| METHOD | URI | 설명 | +|--------|-----|------| +| POST | `/api/v1/products/{productId}/likes` | 좋아요 등록 | +| DELETE | `/api/v1/products/{productId}/likes` | 좋아요 취소 | +| GET | `/api/v1/users/{userId}/likes` | 내가 좋아요 한 상품 목록 | + +### 6.5 주문 (Orders) + +| METHOD | URI | 설명 | +|--------|-----|------| +| POST | `/api/v1/orders` | 주문 요청 | +| GET | `/api/v1/orders?startAt=&endAt=` | 주문 목록 조회 (날짜 필터) | +| GET | `/api/v1/orders/{orderId}` | 주문 상세 조회 | + +**주문 요청 Body 예시:** +```json +{ + "items": [ + { "productId": 1, "quantity": 2 } + ] +} +``` + +**주문 시 필수 처리:** +- 스냅샷 저장 (상품명, 가격, 브랜드명) +- 재고 확인 및 차감 + +### 6.6 주문 (Admin) + +| METHOD | URI | 설명 | +|--------|-----|------| +| GET | `/api-admin/v1/orders` | 주문 목록 조회 | +| GET | `/api-admin/v1/orders/{orderId}` | 주문 상세 조회 | + +--- + +## 7. 비기능 요구사항 + +| 항목 | 요구사항 | +|------|----------| +| 트랜잭션 | 주문 생성 시 재고 차감은 원자적으로 처리 | +| 멱등성 | 좋아요 등록/취소는 멱등하게 동작 | +| 정합성 | 주문 취소 시 재고 복원 보장 | +| 데이터 보존 | 주문 관련 데이터는 soft delete로 보존 | + +--- + +## 8. 미결정 사항 (추후 결정 필요) + +| 항목 | 현재 상태 | 추후 결정 시점 | +|------|----------|--------------| +| **결제 연동** | 미구현 (주문 생성 = 완료) | 결제 시스템 도입 시 | +| **동시성 제어** | 고려하지 않음 | 트래픽 증가 시 낙관적/비관적 락 선택 | +| **멱등성 키** | 미구현 | 중복 주문 방지 필요 시 | +| **일관성 보장** | 단일 트랜잭션 | MSA 전환 시 Saga 패턴 고려 | +| **느린 조회 최적화** | 기본 인덱스만 | 대량 데이터 시 캐시/검색엔진 도입 | +| **주문 상태 확장** | CREATED/PAID/CANCELLED | 배송 상태 추가 시 확장 | +| **Admin 인증 강화** | 단순 LDAP 헤더 | 실서비스 시 JWT/OAuth 전환 | diff --git a/docs/design/02-sequence-diagrams.md b/docs/design/02-sequence-diagrams.md new file mode 100644 index 000000000..45a53799d --- /dev/null +++ b/docs/design/02-sequence-diagrams.md @@ -0,0 +1,480 @@ +# 시퀀스 다이어그램 + +## 1. 개요 + +핵심 유스케이스의 객체 간 상호작용을 시퀀스 다이어그램으로 표현한다. + +### 다이어그램 읽는 법 + +| 표기 | 의미 | +|------|------| +| 실선 화살표 (`->>`) | 동기 메시지 (호출) | +| 점선 화살표 (`-->>`) | 응답 (반환) | +| 세로 막대 (activate/deactivate) | 액티베이션 바 - 객체가 일하고 있는 시간 | +| `rect` 블록 | 트랜잭션 경계 등 논리적 그룹 | +| `alt` 블록 | 조건 분기 (if-else) | +| `loop` 블록 | 반복문 | +| `Note over` | 설명 노트 | + +--- + +## 2. 주문 생성 (Order Creation) + +### 왜 이 다이어그램이 필요한가? + +주문 생성은 시스템에서 가장 복잡한 흐름이다. 다음을 검증하기 위해 필요: +- 재고 확인 → 차감 → 주문 저장이 **단일 트랜잭션**으로 처리되는지 +- 스냅샷 생성 시점이 올바른지 +- 예외 상황(재고 부족, 상품 없음)에서 롤백이 보장되는지 + +```mermaid +sequenceDiagram + autonumber + participant Client + participant Controller as OrderController + participant Service as OrderService + participant ProductRepo as ProductRepository + participant OrderRepo as OrderRepository + participant DB as Database + + Client->>Controller: POST /orders (상품ID, 수량 목록) + activate Controller + Controller->>Service: createOrder(memberId, orderItems) + activate Service + + rect rgb(240, 248, 255) + Note over Service,DB: 트랜잭션 경계 + + loop 각 주문 항목에 대해 + Service->>ProductRepo: findById(productId) + activate ProductRepo + ProductRepo->>DB: SELECT product WHERE id = ? + DB-->>ProductRepo: Product + ProductRepo-->>Service: Product + deactivate ProductRepo + + alt 상품이 존재하지 않거나 삭제됨 + Service-->>Controller: NotFound 예외 + Controller-->>Client: 404 Not Found + else 재고 부족 + Service-->>Controller: BadRequest 예외 (재고 부족) + Controller-->>Client: 400 Bad Request + else 정상 + Note over Service: 스냅샷 생성 (상품명, 가격, 브랜드명) + Note over Service: Stock.decrease(quantity) 호출 + end + end + + Service->>ProductRepo: saveAll(products) + activate ProductRepo + ProductRepo->>DB: UPDATE stock_quantity (재고 차감) + DB-->>ProductRepo: OK + ProductRepo-->>Service: OK + deactivate ProductRepo + + Note over Service: Order 생성 (totalPrice 계산) + + Service->>OrderRepo: save(order) + activate OrderRepo + OrderRepo->>DB: INSERT orders, order_items + DB-->>OrderRepo: Order (with ID) + OrderRepo-->>Service: Order + deactivate OrderRepo + end + + Service-->>Controller: Order + deactivate Service + Controller-->>Client: 201 Created (orderId) + deactivate Controller +``` + +### 읽는 법 + +1. **rect 블록**이 트랜잭션 경계 - 이 안의 모든 작업이 성공하거나 모두 롤백 +2. **loop 블록**에서 각 상품마다 재고 확인 및 스냅샷 생성 +3. **alt 블록**의 예외 케이스는 트랜잭션 롤백 후 에러 응답 + +### 핵심 설계 포인트 + +| 포인트 | 설명 | +|--------|------| +| **단일 트랜잭션** | 재고 확인, 차감, 주문 저장이 원자적으로 처리 | +| **스냅샷 생성** | 주문 시점의 상품명, 가격, 브랜드명 저장 | +| **Stock VO** | 재고 차감 로직이 VO 내부에 캡슐화 | +| **예외 처리** | 상품 미존재, 재고 부족 시 즉시 롤백 | + +--- + +## 3. 좋아요 등록 (Like - POST) + +### 왜 이 다이어그램이 필요한가? + +좋아요 등록은 다음을 검증하기 위해 필요: +- POST/DELETE 분리 방식의 RESTful 설계가 올바른지 +- **멱등성**이 어떻게 보장되는지 (이미 좋아요 시 무시) +- `like_count` 동기화가 트랜잭션 내에서 처리되는지 + +```mermaid +sequenceDiagram + autonumber + participant Client + participant Controller as LikeController + participant Service as LikeService + participant ProductRepo as ProductRepository + participant LikeRepo as LikeRepository + participant DB as Database + + Client->>Controller: POST /products/{productId}/likes + activate Controller + Controller->>Service: addLike(memberId, productId) + activate Service + + rect rgb(240, 248, 255) + Note over Service,DB: 트랜잭션 경계 + + Service->>ProductRepo: findById(productId) + activate ProductRepo + ProductRepo->>DB: SELECT product WHERE id = ? + DB-->>ProductRepo: Product + ProductRepo-->>Service: Product + deactivate ProductRepo + + alt 상품이 존재하지 않거나 삭제됨 + Service-->>Controller: NotFound 예외 + Controller-->>Client: 404 Not Found + end + + Service->>LikeRepo: existsByMemberIdAndProductId(memberId, productId) + activate LikeRepo + LikeRepo->>DB: SELECT EXISTS(...) + DB-->>LikeRepo: true/false + LikeRepo-->>Service: boolean + deactivate LikeRepo + + alt 이미 좋아요 존재 (멱등성 보장) + Note over Service: 변경 없이 성공 반환 + Service-->>Controller: OK (이미 좋아요 상태) + else 좋아요 없음 → 좋아요 추가 + Service->>LikeRepo: save(new Like) + activate LikeRepo + LikeRepo->>DB: INSERT INTO likes + DB-->>LikeRepo: Like + LikeRepo-->>Service: Like + deactivate LikeRepo + + Service->>ProductRepo: incrementLikeCount(productId) + activate ProductRepo + ProductRepo->>DB: UPDATE like_count = like_count + 1 + DB-->>ProductRepo: OK + ProductRepo-->>Service: OK + deactivate ProductRepo + + Service-->>Controller: OK + end + end + + deactivate Service + Controller-->>Client: 200 OK + deactivate Controller +``` + +### 읽는 법 + +1. **rect 블록** 안에서 Like 저장과 like_count 증가가 같은 트랜잭션 +2. **alt "이미 좋아요 존재"** 분기에서 아무것도 하지 않고 성공 반환 → 멱등성 보장 +3. 상품 조회 → 중복 확인 → 저장 순서로 진행 + +### 핵심 설계 포인트 + +| 포인트 | 설명 | +|--------|------| +| **RESTful** | POST로 리소스 생성 의도 명확 | +| **멱등성** | 이미 좋아요 존재 시 무시 (에러 아님) | +| **like_count 동기화** | Like 저장과 같은 트랜잭션에서 처리 | + +--- + +## 4. 좋아요 취소 (Like - DELETE) + +### 왜 이 다이어그램이 필요한가? + +좋아요 취소 흐름에서 다음을 검증: +- DELETE 요청으로 리소스 삭제 의도가 명확한지 +- **멱등성** - 이미 취소된 상태에서 다시 취소해도 에러 없이 성공 + +```mermaid +sequenceDiagram + autonumber + participant Client + participant Controller as LikeController + participant Service as LikeService + participant ProductRepo as ProductRepository + participant LikeRepo as LikeRepository + participant DB as Database + + Client->>Controller: DELETE /products/{productId}/likes + activate Controller + Controller->>Service: removeLike(memberId, productId) + activate Service + + rect rgb(240, 248, 255) + Note over Service,DB: 트랜잭션 경계 + + Service->>LikeRepo: findByMemberIdAndProductId(memberId, productId) + activate LikeRepo + LikeRepo->>DB: SELECT like WHERE member_id = ? AND product_id = ? + DB-->>LikeRepo: Like or null + LikeRepo-->>Service: Optional~Like~ + deactivate LikeRepo + + alt 좋아요 없음 (멱등성 보장) + Note over Service: 변경 없이 성공 반환 + Service-->>Controller: OK (이미 취소 상태) + else 좋아요 존재 → 삭제 + Service->>LikeRepo: delete(like) + activate LikeRepo + LikeRepo->>DB: DELETE FROM likes + DB-->>LikeRepo: OK + LikeRepo-->>Service: OK + deactivate LikeRepo + + Service->>ProductRepo: decrementLikeCount(productId) + activate ProductRepo + ProductRepo->>DB: UPDATE like_count = like_count - 1 + DB-->>ProductRepo: OK + ProductRepo-->>Service: OK + deactivate ProductRepo + + Service-->>Controller: OK + end + end + + deactivate Service + Controller-->>Client: 200 OK + deactivate Controller +``` + +### 읽는 법 + +1. **alt "좋아요 없음"** 분기 - 이미 취소 상태면 아무것도 안 하고 성공 +2. Like 삭제와 like_count 감소가 같은 트랜잭션 내 처리 + +### 핵심 설계 포인트 + +| 포인트 | 설명 | +|--------|------| +| **RESTful** | DELETE로 리소스 삭제 의도 명확 | +| **멱등성** | 좋아요 없을 때도 에러 아닌 성공 응답 | +| **비정규화** | 정렬 성능을 위해 like_count 별도 관리 | + +--- + +## 5. 좋아요 목록 조회 (My Likes) + +```mermaid +sequenceDiagram + autonumber + participant Client + participant Controller as LikeController + participant Service as LikeService + participant LikeRepo as LikeRepository + participant ProductRepo as ProductRepository + participant DB as Database + + Client->>Controller: GET /users/{userId}/likes + activate Controller + Controller->>Service: getMyLikes(memberId, userId) + activate Service + + alt userId != memberId (다른 회원의 좋아요 목록 조회 시도) + Service-->>Controller: Forbidden 예외 + Controller-->>Client: 403 Forbidden + end + + Service->>LikeRepo: findAllByMemberId(memberId) + activate LikeRepo + LikeRepo->>DB: SELECT * FROM likes WHERE member_id = ? + DB-->>LikeRepo: List~Like~ + LikeRepo-->>Service: List~Like~ + deactivate LikeRepo + + Note over Service: ⚠️ N+1 쿼리 발생 지점 - 향후 IN 쿼리로 최적화 + + loop 각 Like에 대해 + Service->>ProductRepo: findById(productId) + activate ProductRepo + ProductRepo->>DB: SELECT product WHERE id = ? + DB-->>ProductRepo: Product + ProductRepo-->>Service: Product + deactivate ProductRepo + end + + Note over Service: 삭제된 상품 필터링 또는 표시 + + Service-->>Controller: List~LikedProductResponse~ + deactivate Service + Controller-->>Client: 200 OK (상품 목록) + deactivate Controller +``` + +### 핵심 설계 포인트 + +| 포인트 | 설명 | +|--------|------| +| **권한 검증** | 본인의 좋아요 목록만 조회 가능 | +| **상품 정보 포함** | 좋아요한 상품의 상세 정보 반환 | +| **삭제 상품 처리** | 삭제된 상품은 필터링 또는 "삭제됨" 표시 | + +--- + +## 6. 상품 등록 (Admin) + +관리자가 새 상품을 등록하는 흐름이다. + +```mermaid +sequenceDiagram + autonumber + participant Admin + participant Controller as ProductController + participant Service as ProductService + participant BrandRepo as BrandRepository + participant ProductRepo as ProductRepository + participant DB as Database + + Admin->>Controller: POST /api-admin/v1/products + activate Controller + Controller->>Service: createProduct(brandId, name, price, stockQuantity) + activate Service + + Note over Service: 트랜잭션 시작 + + Service->>BrandRepo: findById(brandId) + activate BrandRepo + BrandRepo->>DB: SELECT brand WHERE id = ? + DB-->>BrandRepo: Brand + BrandRepo-->>Service: Brand + deactivate BrandRepo + + alt 브랜드가 존재하지 않거나 삭제됨 + Service-->>Controller: NotFound 예외 + Controller-->>Admin: 404 Not Found + end + + Note over Service: Price VO 생성 (가격 검증) + Note over Service: Stock VO 생성 (재고 검증) + Note over Service: Product 생성 + + Service->>ProductRepo: save(product) + activate ProductRepo + ProductRepo->>DB: INSERT INTO products + DB-->>ProductRepo: Product (with ID) + ProductRepo-->>Service: Product + deactivate ProductRepo + + Note over Service: 트랜잭션 커밋 + + Service-->>Controller: Product + deactivate Service + Controller-->>Admin: 201 Created (productId) + deactivate Controller +``` + +### 핵심 설계 포인트 + +| 포인트 | 설명 | +|--------|------| +| **브랜드 검증** | 상품 등록 전 브랜드 존재 여부 확인 | +| **VO 검증** | Price, Stock 생성 시 유효성 검증 | +| **ID 참조** | Product는 brandId만 저장 (Brand 객체 참조 X) | + +--- + +## 7. 브랜드 삭제 (연쇄 삭제) + +브랜드 삭제 시 연관 데이터를 함께 처리하는 흐름이다. + +```mermaid +sequenceDiagram + autonumber + participant Admin + participant Controller as BrandController + participant Service as BrandService + participant BrandRepo as BrandRepository + participant ProductRepo as ProductRepository + participant LikeRepo as LikeRepository + participant DB as Database + + Admin->>Controller: DELETE /api-admin/v1/brands/{brandId} + activate Controller + Controller->>Service: deleteBrand(brandId) + activate Service + + Note over Service: 트랜잭션 시작 + + Service->>BrandRepo: findById(brandId) + activate BrandRepo + BrandRepo->>DB: SELECT brand WHERE id = ? + DB-->>BrandRepo: Brand + BrandRepo-->>Service: Brand + deactivate BrandRepo + + alt 브랜드가 존재하지 않거나 이미 삭제됨 + Service-->>Controller: NotFound 예외 + Controller-->>Admin: 404 Not Found + end + + Note over Service: 1단계: 해당 브랜드 상품들의 좋아요 삭제 + + Service->>LikeRepo: deleteByBrandId(brandId) + activate LikeRepo + LikeRepo->>DB: DELETE FROM likes WHERE product_id IN (SELECT id FROM products WHERE brand_id = ?) + DB-->>LikeRepo: OK + LikeRepo-->>Service: OK + deactivate LikeRepo + + Note over Service: 2단계: 해당 브랜드 상품들 soft delete + + Service->>ProductRepo: softDeleteByBrandId(brandId) + activate ProductRepo + ProductRepo->>DB: UPDATE products SET deleted_at = NOW() WHERE brand_id = ? + DB-->>ProductRepo: OK + ProductRepo-->>Service: OK + deactivate ProductRepo + + Note over Service: 3단계: 브랜드 soft delete + + Service->>BrandRepo: softDelete(brandId) + activate BrandRepo + BrandRepo->>DB: UPDATE brands SET deleted_at = NOW() WHERE id = ? + DB-->>BrandRepo: OK + BrandRepo-->>Service: OK + deactivate BrandRepo + + Note over Service: 트랜잭션 커밋 + + Service-->>Controller: OK + deactivate Service + Controller-->>Admin: 204 No Content + deactivate Controller +``` + +### 핵심 설계 포인트 + +| 포인트 | 설명 | +|--------|------| +| **Soft Delete** | 브랜드, 상품은 deleted_at 설정 (주문 이력 보존) | +| **Hard Delete** | 좋아요는 의미 없어 완전 삭제 | +| **삭제 순서** | 좋아요 → 상품 → 브랜드 (의존 순서 역순) | +| **단일 트랜잭션** | 연쇄 삭제가 원자적으로 처리 | + +--- + +## 8. 잠재 리스크 + +| 리스크 | 현재 상태 | 대응 방안 | +|--------|----------|----------| +| **동시 주문 시 재고 이슈** | 락 없이 단순 조회 후 차감 | 비관적 락(`SELECT FOR UPDATE`) 또는 낙관적 락(버전 필드) 도입 | +| **트랜잭션 비대화** | 주문 생성 시 여러 상품 처리 | 상품 수 제한 또는 배치 처리 고려 | +| **like_count 정합성** | 트랜잭션 내 동기화 | 오차 허용, 야간 배치로 보정 가능 | +| **브랜드 삭제 시 대량 처리** | 동기 방식 연쇄 삭제 | 상품이 많으면 비동기 이벤트 처리 고려 | +| **N+1 쿼리** | 좋아요 목록에서 상품 개별 조회 | `IN` 쿼리로 일괄 조회 또는 Join Fetch | diff --git a/docs/design/03-class-diagram.md b/docs/design/03-class-diagram.md new file mode 100644 index 000000000..14b579795 --- /dev/null +++ b/docs/design/03-class-diagram.md @@ -0,0 +1,356 @@ +# 클래스 다이어그램 + +## 1. 개요 + +이커머스 플랫폼의 도메인 모델을 DDD 관점에서 설계한다. + +### 다이어그램 읽는 법 + +| 표기 | 의미 | +|------|------| +| `<>` | 해당 Aggregate의 진입점. 외부에서는 이 객체를 통해서만 접근 | +| `<>` | 고유 식별자를 가지는 객체. Aggregate 내부에서만 존재 | +| `<>` | 불변 객체. 값으로만 비교하며 식별자 없음 | +| `<>` | 열거형. 미리 정의된 상수 집합 | +| `*--` (컴포지션) | 생명주기를 함께하는 강한 포함 관계 | +| `..>` (점선 화살표) | ID 참조. Aggregate 간 느슨한 결합 | + +--- + +## 2. 레이어드 아키텍처 + +```mermaid +graph TB + subgraph Interfaces ["Interfaces Layer — Controller, DTO"] + BC["BrandController\nBrandAdminController"] + PC["ProductController\nProductAdminController"] + OC["OrderController\nOrderAdminController"] + LC["LikeController"] + MC["MemberV1Controller"] + end + + subgraph Application ["Application Layer — Facade (유스케이스 조율, 트랜잭션)"] + BF["BrandFacade\n· 브랜드 CRUD\n· 삭제 시 상품+좋아요 연쇄 처리"] + PF["ProductFacade\n· 상품 CRUD + 정렬 조회\n· 삭제 시 좋아요 연쇄 처리"] + OF["OrderFacade\n· 주문 생성 (재고 차감, 스냅샷)\n· 주문 취소 (재고 복원)\n· 권한 검증"] + LF["LikeFacade\n· 좋아요 추가 (멱등)\n· 좋아요 취소 (멱등)\n· likeCount 동기화"] + MF["MemberFacade\n· 회원가입\n· 비밀번호 변경"] + end + + subgraph Domain ["Domain Layer — Entity, VO, Repository Interface"] + direction LR + BR["«interface»\nBrandRepository"] + PR["«interface»\nProductRepository"] + OR["«interface»\nOrderRepository"] + LR2["«interface»\nLikeRepository"] + MR["«interface»\nMemberRepository"] + end + + subgraph Infrastructure ["Infrastructure Layer — Repository 구현체 (JPA)"] + BRI["BrandRepositoryImpl\nBrandJpaRepository"] + PRI["ProductRepositoryImpl\nProductJpaRepository"] + ORI["OrderRepositoryImpl\nOrderJpaRepository"] + LRI["LikeRepositoryImpl\nLikeJpaRepository"] + MRI["MemberRepositoryImpl\nMemberJpaRepository"] + end + + BC --> BF + PC --> PF + OC --> OF + LC --> LF + MC --> MF + + BF --> BR + BF --> PR + BF --> LR2 + PF --> PR + PF --> BR + PF --> LR2 + OF --> OR + OF --> PR + OF --> BR + LF --> LR2 + LF --> PR + MF --> MR + + BRI -.->|implements| BR + PRI -.->|implements| PR + ORI -.->|implements| OR + LRI -.->|implements| LR2 + MRI -.->|implements| MR +``` + +### 의존 방향 + +``` +Interfaces → Application → Domain ← Infrastructure +``` + +- Domain은 다른 레이어에 의존하지 않는다 +- Infrastructure가 Domain의 Repository 인터페이스를 구현한다 (DIP) + +### Facade별 책임 + +| Facade | 주요 책임 | 의존하는 Repository | +|--------|----------|-------------------| +| BrandFacade | 브랜드 CRUD, 삭제 시 상품+좋아요 연쇄 처리 | Brand, Product, Like | +| ProductFacade | 상품 CRUD, 정렬 조회, 삭제 시 좋아요 연쇄 처리 | Product, Brand, Like | +| OrderFacade | 주문 생성(재고 차감+스냅샷), 취소(재고 복원), 권한 검증 | Order, Product, Brand | +| LikeFacade | 좋아요 추가/취소(멱등), likeCount 동기화 | Like, Product | +| MemberFacade | 회원가입, 비밀번호 변경 | Member | + +--- + +## 3. Aggregate 구조 개요 + +``` +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ Brand Agg │ │ Product Agg │ │ Order Agg │ │ Like Agg │ │ Member Agg │ +├─────────────────┤ ├─────────────────┤ ├─────────────────┤ ├─────────────────┤ ├─────────────────┤ +│ Brand (Root) │ │ Product (Root) │ │ Order (Root) │ │ Like (Root) │ │ Member (Root) │ +│ │ │ ├ Price (VO) │ │ ├ OrderItem │ │ │ │ ├ LoginId (VO) │ +│ │ │ └ Stock (VO) │ │ ├ ItemSnapshot │ │ │ │ ├ Password (VO) │ +│ │ │ │ │ └ OrderStatus │ │ │ │ ├ Email (VO) │ +│ │ │ │ │ │ │ │ │ └ BirthDate(VO) │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ └─────────────────┘ └─────────────────┘ + │ │ │ │ + └─────────────────────┼─────────────────────┼─────────────────────┘ + │ │ + brandId (ID 참조) memberId, productId (ID 참조) +``` + +--- + +## 4. 전체 클래스 다이어그램 + +```mermaid +classDiagram + direction TB + + %% ===== Brand Aggregate ===== + class Brand { + <> + -Long id + -String name + -String description + +Brand(name, description) + +changeName(name) + +changeDescription(description) + +delete() + } + + %% ===== Product Aggregate ===== + class Product { + <> + -Long id + -Long brandId + -String name + -Price price + -Stock stock + -int likeCount + +Product(brandId, name, price, stock) + +changeName(name) + +changePrice(price) + +changeStock(stock) + +decreaseStock(quantity) + +increaseStock(quantity) + +incrementLikeCount() + +decrementLikeCount() + +delete() + } + + class Price { + <> + -int value + +Price(value) + } + + class Stock { + <> + -int quantity + +Stock(quantity) + +decrease(amount) Stock + +increase(amount) Stock + +hasEnough(amount) boolean + } + + Product *-- Price : contains + Product *-- Stock : contains + + %% ===== Order Aggregate ===== + class Order { + <> + -Long id + -Long memberId + -OrderStatus status + -int totalPrice + -List~OrderItem~ items + +create(memberId, List~ItemSnapshot~)$ Order + +cancel() + +getItems() List~OrderItem~ + } + + class ItemSnapshot { + <> + +Long productId + +String productName + +int productPrice + +String brandName + +int quantity + } + + class OrderItem { + <> + -Long id + -Long productId + -String productName + -int productPrice + -String brandName + -int quantity + ~OrderItem(productId, productName, productPrice, brandName, quantity) + +getSubtotal() int + } + + class OrderStatus { + <> + CREATED + PAID + CANCELLED + } + + Order *-- OrderItem : creates internally + Order -- ItemSnapshot : receives as input + Order --> OrderStatus : has + + %% ===== Like Aggregate ===== + class Like { + <> + -Long id + -Long memberId + -Long productId + +Like(memberId, productId) + } + + %% ===== Member Aggregate ===== + class Member { + <> + -Long id + -LoginId loginId + -Password password + -String name + -BirthDate birthDate + -Email email + +Member(loginId, password, name, birthDate, email) + +changePassword(newPassword) + } + + class LoginId { + <> + -String value + +LoginId(value) + } + + class Password { + <> + -String encoded + +create(plain, birthDate, encoder)$ Password + +matches(plain, encoder) boolean + } + + class Email { + <> + -String value + +Email(value) + } + + class BirthDate { + <> + -LocalDate value + +from(dateString)$ BirthDate + } + + Member *-- LoginId : contains + Member *-- Password : contains + Member *-- Email : contains + Member *-- BirthDate : contains + + %% ===== Aggregate 간 ID 참조 ===== + Product ..> Brand : brandId + Order ..> Member : memberId + OrderItem ..> Product : productId + Like ..> Member : memberId + Like ..> Product : productId +``` + +--- + +## 5. Aggregate 라이프사이클 통제 + +### 원칙 + +> Aggregate Root가 자식의 생성/삭제를 통제한다. +> 외부에서 자식 Entity를 직접 생성할 수 없어야 한다. + +### 점검 결과 + +| Aggregate Root | 자식 | 관계 | 통제 방식 | 판정 | +|---|---|---|---|---| +| **Order** | OrderItem | `@OneToMany` Entity | `Order.create(ItemSnapshot)` + package-private 생성자 | **완벽** | +| **Product** | Price, Stock | `@Embedded` VO | 불변 VO, 생성자 자기검증 | **정상** (VO는 통제 대상 아님) | +| **Member** | LoginId 등 | `@Embedded` VO | 불변 VO, 생성자 자기검증 | **정상** (VO는 통제 대상 아님) | + +### Order Aggregate 상세 + +``` +외부 (OrderFacade) Order Aggregate 내부 +┌────────────────────┐ ┌─────────────────────────────────┐ +│ │ │ │ +│ ItemSnapshot ─────┼────▶ Order.create(snapshots) │ +│ (데이터만 전달) │ │ └─▶ new OrderItem(...) │ +│ │ │ (package-private) │ +│ new OrderItem() ──┼──✕──▶ │ │ +│ (컴파일 에러) │ │ │ +└────────────────────┘ └─────────────────────────────────┘ +``` + +- Facade는 `Order.ItemSnapshot`(데이터)만 전달 +- OrderItem 생성은 `Order.create()` 내부에서만 발생 +- OrderItem 생성자가 package-private이라 외부 패키지에서 직접 생성 불가 + +### VO는 왜 통제 대상이 아닌가 + +| 구분 | Entity (OrderItem) | Value Object (Price, Stock) | +|------|-------------------|---------------------------| +| 식별자 | 있음 (ID) | 없음 (값 동등성) | +| 가변성 | 상태 변경 가능 | 불변 | +| 라이프사이클 | 부모와 함께 | 없음 (값일 뿐) | +| 통제 필요성 | **필수** — 부모 없이 존재하면 안 됨 | **불필요** — 어디서 만들든 같은 값 | + +--- + +## 6. 연관관계 방향 + +| 관계 | 방향 | 참조 방식 | +|------|------|----------| +| Product → Brand | 단방향 | `brandId` (ID 참조) | +| Order → Member | 단방향 | `memberId` (ID 참조) | +| Order → OrderItem | Aggregate 내부 | 객체 참조 (`@OneToMany`) | +| OrderItem → Product | 단방향 | `productId` (ID 참조, 스냅샷) | +| Like → Member | 단방향 | `memberId` (ID 참조) | +| Like → Product | 단방향 | `productId` (ID 참조) | + +**원칙**: +- **Aggregate 간 참조는 ID로**: 다른 Aggregate의 Root Entity를 직접 참조하지 않음 +- **Aggregate 내부는 객체 참조**: Order와 OrderItem은 같은 Aggregate + +--- + +## 7. 잠재 리스크 + +| 리스크 | 현재 상태 | 대응 방안 | +|--------|----------|----------| +| **Stock VO 동시성** | 단순 decrease 메서드 | 락이 없으면 동시 주문 시 재고 불일치. DB 레벨 락 필요 | +| **Aggregate 경계 넘는 참조** | ID로만 참조 | 성능을 위해 Join이 필요하면 읽기 전용 Query 모델 분리 고려 | +| **OrderItem 목록 크기** | 제한 없음 | 한 주문에 너무 많은 상품 시 트랜잭션 비대화. 최대 개수 제한 권장 | +| **likeCount와 실제 Like 수 불일치** | 트랜잭션 동기화 | 장애 상황에서 불일치 가능. 주기적 배치 보정 필요 | +| **Order 상태 전이** | 단순 enum + cancel() 검증 | 복잡해지면 상태 머신 패턴 또는 이벤트 소싱 고려 | diff --git a/docs/design/04-erd.md b/docs/design/04-erd.md new file mode 100644 index 000000000..675a7cc23 --- /dev/null +++ b/docs/design/04-erd.md @@ -0,0 +1,334 @@ +# ERD (Entity Relationship Diagram) + +## 1. 개요 + +이커머스 플랫폼의 핵심 도메인 테이블 구조를 정의한다. + +--- + +## 2. ERD 다이어그램 + +```mermaid +erDiagram + member ||--o{ orders : "places" + member ||--o{ likes : "has" + brand ||--o{ product : "has" + product ||--o{ likes : "has" + product ||--o{ order_item : "referenced by" + orders ||--|{ order_item : "contains" + + member { + bigint id PK + varchar login_id UK "로그인 ID" + varchar password "암호화된 비밀번호" + varchar name "이름" + date birth_date "생년월일" + varchar email "이메일" + timestamp created_at + timestamp updated_at + timestamp deleted_at "soft delete" + } + + brand { + bigint id PK + varchar name "브랜드명" + varchar description "브랜드 설명" + timestamp created_at + timestamp updated_at + timestamp deleted_at "soft delete" + } + + product { + bigint id PK + bigint brand_id FK "브랜드 참조" + varchar name "상품명" + int price "가격 (원)" + int stock_quantity "재고 수량" + int like_count "좋아요 수 (비정규화)" + timestamp created_at + timestamp updated_at + timestamp deleted_at "soft delete" + } + + likes { + bigint id PK + bigint member_id FK "회원 참조" + bigint product_id FK "상품 참조" + timestamp created_at + } + + orders { + bigint id PK + bigint member_id FK "주문자 참조" + varchar status "주문 상태 (CREATED/PAID/CANCELLED)" + int total_price "총 주문 금액" + timestamp created_at + timestamp updated_at + timestamp deleted_at "soft delete" + } + + order_item { + bigint id PK + bigint order_id FK "주문 참조" + bigint product_id FK "상품 참조 (원본)" + varchar product_name "상품명 스냅샷" + int product_price "상품 가격 스냅샷" + varchar brand_name "브랜드명 스냅샷" + int quantity "주문 수량" + timestamp created_at + } +``` + +--- + +## 3. 테이블 상세 명세 + +### 3.1 member (회원) + +> 1주차에 구현 완료. 참고용으로 포함. + +| 컬럼명 | 타입 | 제약조건 | 설명 | +|--------|------|----------|------| +| id | BIGINT | PK, AUTO_INCREMENT | 회원 고유 ID | +| login_id | VARCHAR(50) | UK, NOT NULL | 로그인 ID | +| password | VARCHAR(255) | NOT NULL | 암호화된 비밀번호 | +| name | VARCHAR(50) | NOT NULL | 이름 | +| birth_date | DATE | NOT NULL | 생년월일 | +| email | VARCHAR(100) | NOT NULL | 이메일 | +| created_at | TIMESTAMP | NOT NULL | 생성 일시 | +| updated_at | TIMESTAMP | NOT NULL | 수정 일시 | +| deleted_at | TIMESTAMP | NULL | 삭제 일시 (soft delete) | + +--- + +### 3.2 brand (브랜드) + +| 컬럼명 | 타입 | 제약조건 | 설명 | +|--------|------|----------|------| +| id | BIGINT | PK, AUTO_INCREMENT | 브랜드 고유 ID | +| name | VARCHAR(100) | NOT NULL | 브랜드명 | +| description | VARCHAR(500) | NULL | 브랜드 설명 | +| created_at | TIMESTAMP | NOT NULL | 생성 일시 | +| updated_at | TIMESTAMP | NOT NULL | 수정 일시 | +| deleted_at | TIMESTAMP | NULL | 삭제 일시 (soft delete) | + +**인덱스**: +- `idx_brand_deleted_at`: deleted_at (목록 조회 시 필터링) + +--- + +### 3.3 product (상품) + +| 컬럼명 | 타입 | 제약조건 | 설명 | +|--------|------|----------|------| +| id | BIGINT | PK, AUTO_INCREMENT | 상품 고유 ID | +| brand_id | BIGINT | FK (논리적) | 브랜드 참조 | +| name | VARCHAR(200) | NOT NULL | 상품명 | +| price | INT | NOT NULL, CHECK(price > 0) | 가격 (원) | +| stock_quantity | INT | NOT NULL, DEFAULT 0, CHECK(stock_quantity >= 0) | 재고 수량 | +| like_count | INT | NOT NULL, DEFAULT 0 | 좋아요 수 (비정규화) | +| created_at | TIMESTAMP | NOT NULL | 생성 일시 | +| updated_at | TIMESTAMP | NOT NULL | 수정 일시 | +| deleted_at | TIMESTAMP | NULL | 삭제 일시 (soft delete) | + +**인덱스**: +- `idx_product_brand_id`: brand_id (브랜드별 상품 조회) +- `idx_product_like_count`: like_count DESC (인기순 정렬) +- `idx_product_deleted_at`: deleted_at (목록 조회 시 필터링) + +**설계 결정**: +- `like_count`: 비정규화 컬럼. 목록 조회 시 COUNT 쿼리 대신 정렬에 사용 +- 좋아요 추가/삭제 시 동기화. 오차 허용 가능 (배치로 보정 가능) + +--- + +### 3.4 likes (좋아요) + +| 컬럼명 | 타입 | 제약조건 | 설명 | +|--------|------|----------|------| +| id | BIGINT | PK, AUTO_INCREMENT | 좋아요 고유 ID | +| member_id | BIGINT | FK (논리적), NOT NULL | 회원 참조 | +| product_id | BIGINT | FK (논리적), NOT NULL | 상품 참조 | +| created_at | TIMESTAMP | NOT NULL | 생성 일시 | + +**인덱스**: +- `uk_likes_member_product`: (member_id, product_id) UNIQUE - 중복 좋아요 방지 +- `idx_likes_member_id`: member_id (회원별 좋아요 목록 조회) +- `idx_likes_product_id`: product_id (상품별 좋아요 조회) + +**설계 결정**: +- Hard Delete 사용 (soft delete 불필요) +- 상품/브랜드 삭제 시 연쇄 삭제 + +--- + +### 3.5 orders (주문) + +| 컬럼명 | 타입 | 제약조건 | 설명 | +|--------|------|----------|------| +| id | BIGINT | PK, AUTO_INCREMENT | 주문 고유 ID | +| member_id | BIGINT | FK (논리적), NOT NULL | 주문자 참조 | +| status | VARCHAR(20) | NOT NULL | 주문 상태 | +| total_price | INT | NOT NULL, CHECK(total_price >= 0) | 총 주문 금액 | +| created_at | TIMESTAMP | NOT NULL | 생성 일시 | +| updated_at | TIMESTAMP | NOT NULL | 수정 일시 | +| deleted_at | TIMESTAMP | NULL | 삭제 일시 (soft delete) | + +**인덱스**: +- `idx_orders_member_id`: member_id (회원별 주문 조회) +- `idx_orders_status`: status (상태별 필터링) +- `idx_orders_member_created_at`: (member_id, created_at) (회원별 날짜 범위 조회) + +**주문 상태 값**: +| 상태 | 설명 | +|------|------| +| CREATED | 주문 생성됨 (현재는 이게 곧 완료) | +| PAID | 결제 완료 (미래 확장용) | +| CANCELLED | 취소됨 | + +--- + +### 3.6 order_item (주문 항목) + +| 컬럼명 | 타입 | 제약조건 | 설명 | +|--------|------|----------|------| +| id | BIGINT | PK, AUTO_INCREMENT | 주문 항목 고유 ID | +| order_id | BIGINT | FK (논리적), NOT NULL | 주문 참조 | +| product_id | BIGINT | FK (논리적), NOT NULL | 상품 참조 (원본) | +| product_name | VARCHAR(200) | NOT NULL | 상품명 **스냅샷** | +| product_price | INT | NOT NULL | 상품 가격 **스냅샷** | +| brand_name | VARCHAR(100) | NOT NULL | 브랜드명 **스냅샷** | +| quantity | INT | NOT NULL, CHECK(quantity > 0) | 주문 수량 | +| created_at | TIMESTAMP | NOT NULL | 생성 일시 | + +**인덱스**: +- `idx_order_item_order_id`: order_id (주문별 항목 조회) + +**설계 결정 (스냅샷)**: + +스냅샷 범위 판단 기준: **"주문 상세 화면을 독립적으로 렌더링할 수 있는가?"** + +| 컬럼 | 스냅샷 이유 | +|------|------------| +| `product_name` | 필수. 없으면 주문 상세 화면 성립 불가 | +| `product_price` | 필수. 정산/환불 기준, 금액 증빙 | +| `brand_name` | 권장. 주문 내역 UI에 거의 항상 표시 | + +- `image_url` 제외: 현재 상품 스펙에 이미지 필드 없음 (요구사항에 없는 필드를 미리 넣는 건 오버엔지니어링) +- `description` 제외: 주문 상세가 아닌 상품 상세 페이지 영역 +- `product_id`는 원본 참조용으로 유지 (상품 페이지 이동, 재주문 기능용. 삭제 시 404 반환은 허용) + +--- + +## 4. 관계 요약 + +| 관계 | 카디널리티 | 설명 | +|------|-----------|------| +| member - orders | 1:N | 회원은 여러 주문 가능 | +| member - likes | 1:N | 회원은 여러 좋아요 가능 | +| brand - product | 1:N | 브랜드는 여러 상품 보유 | +| product - likes | 1:N | 상품은 여러 좋아요 받음 | +| product - order_item | 1:N | 상품은 여러 주문에 포함 | +| orders - order_item | 1:N | 주문은 여러 항목 포함 | + +--- + +## 5. FK 제약 정책 + +| 관계 | FK 제약 | 이유 | +|------|---------|------| +| product → brand | 논리적 (제약 없음) | 브랜드 삭제 시 soft delete, 애플리케이션에서 검증 | +| likes → member/product | 논리적 | 상품 삭제 시 좋아요 연쇄 삭제, 애플리케이션 처리 | +| orders → member | 논리적 | 회원 삭제 시에도 주문 이력 보존 | +| order_item → orders | 논리적 | 주문과 항목은 항상 함께 관리 | +| order_item → product | 논리적 | 스냅샷이 있어 원본 삭제 가능 | + +**참고**: 대규모 트래픽에서 FK 제약은 데드락, Cascading 이슈를 유발할 수 있어 논리적 관계로 설계. 데이터 정합성은 애플리케이션 레벨에서 보장. + +--- + +## 6. DDL 예시 + +```sql +-- 브랜드 테이블 +CREATE TABLE brand ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) NOT NULL, + description VARCHAR(500), + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at TIMESTAMP NULL, + INDEX idx_brand_deleted_at (deleted_at) +); + +-- 상품 테이블 +CREATE TABLE product ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + brand_id BIGINT NOT NULL, + name VARCHAR(200) NOT NULL, + price INT NOT NULL, + stock_quantity INT NOT NULL DEFAULT 0, + like_count INT NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at TIMESTAMP NULL, + INDEX idx_product_brand_id (brand_id), + INDEX idx_product_like_count (like_count DESC), + INDEX idx_product_deleted_at (deleted_at), + CONSTRAINT chk_product_price CHECK (price > 0), + CONSTRAINT chk_product_stock CHECK (stock_quantity >= 0) +); + +-- 좋아요 테이블 +CREATE TABLE likes ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + member_id BIGINT NOT NULL, + product_id BIGINT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_likes_member_product (member_id, product_id), + INDEX idx_likes_member_id (member_id), + INDEX idx_likes_product_id (product_id) +); + +-- 주문 테이블 +CREATE TABLE orders ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + member_id BIGINT NOT NULL, + status VARCHAR(20) NOT NULL, + total_price INT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at TIMESTAMP NULL, + INDEX idx_orders_member_id (member_id), + INDEX idx_orders_status (status), + INDEX idx_orders_member_created_at (member_id, created_at), + CONSTRAINT chk_orders_total_price CHECK (total_price >= 0) +); + +-- 주문 항목 테이블 +CREATE TABLE order_item ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + order_id BIGINT NOT NULL, + product_id BIGINT NOT NULL, + product_name VARCHAR(200) NOT NULL, + product_price INT NOT NULL, + brand_name VARCHAR(100) NOT NULL, + quantity INT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_order_item_order_id (order_id), + CONSTRAINT chk_order_item_quantity CHECK (quantity > 0) +); +``` + +--- + +## 7. 잠재 리스크 + +| 리스크 | 현재 상태 | 대응 방안 | +|--------|----------|----------| +| **FK 제약 없음** | 논리적 관계만 정의 | 데이터 정합성은 애플리케이션에서 보장. 정기적 정합성 체크 배치 필요 | +| **like_count 비정규화** | product 테이블에 저장 | 정합성 오차 가능. COUNT 쿼리와 주기적 비교/보정 필요 | +| **soft delete 쿼리 복잡도** | WHERE deleted_at IS NULL 필수 | 조회 쿼리마다 조건 누락 위험. 기본 스코프 또는 뷰 활용 권장 | +| **order_item 스냅샷 중복** | 같은 상품 여러 주문 시 반복 저장 | 데이터 증가. 스냅샷 테이블 분리 또는 압축 고려 (대량 트래픽 시) | +| **인덱스 과다** | 정렬/필터용 여러 인덱스 | 쓰기 성능 저하 가능. 실제 쿼리 패턴 분석 후 최적화 | +| **orders.status VARCHAR** | 문자열 저장 | ENUM 타입으로 변경하거나 코드 테이블 분리 고려 | diff --git a/http/commerce-api/example-v1.http b/http/commerce-api/example-v1.http deleted file mode 100644 index 2a924d265..000000000 --- a/http/commerce-api/example-v1.http +++ /dev/null @@ -1,2 +0,0 @@ -### 예시 조회 -GET {{commerce-api}}/api/v1/examples/1 \ No newline at end of file diff --git a/plans/week1.md b/plans/week1.md new file mode 100644 index 000000000..49cf0c8cc --- /dev/null +++ b/plans/week1.md @@ -0,0 +1,448 @@ +# Week 1 - 회원 기능 + +## 요구사항 + +### 1. 회원가입 + +| 항목 | 내용 | +|------|------| +| 필요 정보 | 로그인 ID, 비밀번호, 이름, 생년월일, 이메일 | +| 로그인 ID | 영문 + 숫자만 허용, 중복 불가 | +| 포맷 검증 | 이름, 이메일, 생년월일 | +| 비밀번호 규칙 | 8~16자, 영문 대소문자 + 숫자 + 특수문자만 허용 | +| 비밀번호 제약 | 생년월일 포함 불가 | +| 저장 방식 | 비밀번호 암호화 저장 | + +### 2. 내 정보 조회 + +| 항목 | 내용 | +|------|------| +| 인증 방식 | HTTP 헤더 (`X-Loopers-LoginId`, `X-Loopers-LoginPw`) | +| 반환 정보 | 로그인 ID, 이름, 생년월일, 이메일 | +| 이름 마스킹 | 마지막 글자를 `*`로 마스킹 (예: 홍길동 → 홍길*) | + +### 3. 비밀번호 수정 + +| 항목 | 내용 | +|------|------| +| 인증 방식 | HTTP 헤더 (`X-Loopers-LoginId`, `X-Loopers-LoginPw`) | +| 필요 정보 | 기존 비밀번호, 새 비밀번호 | +| 비밀번호 규칙 | 8~16자, 영문 대소문자 + 숫자 + 특수문자만 허용 | +| 비밀번호 제약 | 생년월일 포함 불가, 현재 비밀번호 사용 불가 | + +--- + +## 기술 결정 사항 + +| 항목 | 결정 | 근거 | +|------|------|------| +| 비밀번호 암호화 | `spring-security-crypto` | 전체 Spring Security는 과한 의존성, crypto만 사용 | +| 인증 처리 | `HandlerMethodArgumentResolver` | 대중적, 확장성 좋음, 컨트롤러 코드 깔끔 | +| 엔티티 네이밍 | `MemberModel` | 기존 프로젝트 패턴(`ExampleModel`) 유지 | +| DTO | Record 사용 | Java 21 기본 기능, boilerplate 감소 | +| 검증 | `@Valid` 기본 어노테이션 + 서비스 레벨 검증 | 오버엔지니어링 방지 | + +--- + +## 구현 계획 (소스 레벨) + +### Phase 1: 공통 기반 구축 + +#### 1-1. 의존성 추가 (`apps/commerce-api/build.gradle.kts`) + +```kotlin +// 비밀번호 암호화 (spring-security-crypto만 사용) +implementation("org.springframework.security:spring-security-crypto") +``` + +#### 1-2. MemberModel 엔티티 + +**파일**: `domain/member/MemberModel.java` + +```java +@Entity +@Table(name = "member") +public class MemberModel extends BaseEntity { + + @Column(nullable = false, unique = true, length = 20) + private String loginId; + + @Column(nullable = false) + private String password; + + @Column(nullable = false, length = 50) + private String name; + + @Column(nullable = false) + private LocalDate birthDate; + + @Column(nullable = false, length = 100) + private String email; + + // 생성자, getter, 비밀번호 변경 메서드 +} +``` + +**설계 근거**: +- `loginId`: unique 제약, 영문+숫자만 허용 +- `password`: BCrypt 해시값 저장 (길이 제한 없음) +- `birthDate`: `LocalDate` 타입으로 날짜만 저장 +- `BaseEntity` 상속으로 id, createdAt, updatedAt, deletedAt 자동 관리 + +#### 1-3. MemberRepository + +**파일**: `domain/member/MemberRepository.java` + +```java +public interface MemberRepository { + MemberModel save(MemberModel member); + Optional findByLoginId(String loginId); + boolean existsByLoginId(String loginId); +} +``` + +#### 1-4. MemberRepositoryImpl + +**파일**: `infrastructure/member/MemberRepositoryImpl.java` + +```java +@RequiredArgsConstructor +@Component +public class MemberRepositoryImpl implements MemberRepository { + private final MemberJpaRepository memberJpaRepository; + + // 구현 +} +``` + +#### 1-5. MemberJpaRepository + +**파일**: `infrastructure/member/MemberJpaRepository.java` + +```java +public interface MemberJpaRepository extends JpaRepository { + Optional findByLoginId(String loginId); + boolean existsByLoginId(String loginId); +} +``` + +#### 1-6. PasswordEncoder 설정 + +**파일**: `support/auth/PasswordEncoderConfig.java` + +```java +@Configuration +public class PasswordEncoderConfig { + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} +``` + +**설계 근거**: +- `spring-security-crypto`의 `PasswordEncoder` 인터페이스 사용 +- BCrypt 알고리즘 (업계 표준) + +#### 1-7. PasswordValidator + +**파일**: `domain/member/PasswordValidator.java` + +```java +@Component +public class PasswordValidator { + + // 8~16자, 영문 대소문자 + 숫자 + 특수문자만 허용 + private static final String PASSWORD_PATTERN = "^[A-Za-z0-9!@#$%^&*()_+=-]{8,16}$"; + + public void validate(String password, LocalDate birthDate) { + // 1. 길이 및 문자 규칙 검증 + // 2. 생년월일 포함 여부 검증 (yyyyMMdd, yyMMdd 등) + } +} +``` + +**검증 항목**: +- 길이: 8~16자 +- 허용 문자: 영문 대소문자, 숫자, 특수문자 +- 생년월일 포함 불가: `19900101`, `900101` 등 패턴 체크 + +#### 1-8. 인증 컴포넌트 (HandlerMethodArgumentResolver) + +**파일**: `support/auth/AuthMember.java` (어노테이션) + +```java +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface AuthMember { +} +``` + +**파일**: `support/auth/AuthMemberResolver.java` + +```java +@RequiredArgsConstructor +@Component +public class AuthMemberResolver implements HandlerMethodArgumentResolver { + + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(AuthMember.class); + } + + @Override + public Object resolveArgument(...) { + String loginId = request.getHeader("X-Loopers-LoginId"); + String password = request.getHeader("X-Loopers-LoginPw"); + + // 1. 헤더 존재 여부 검증 + // 2. 회원 조회 + // 3. 비밀번호 일치 검증 + // 4. MemberModel 반환 + } +} +``` + +**파일**: `support/auth/WebMvcConfig.java` + +```java +@RequiredArgsConstructor +@Configuration +public class WebMvcConfig implements WebMvcConfigurer { + + private final AuthMemberResolver authMemberResolver; + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(authMemberResolver); + } +} +``` + +--- + +### Phase 2: 회원가입 기능 + +#### 2-1. API 설계 + +| 항목 | 내용 | +|------|------| +| Method | `POST` | +| Path | `/api/v1/members` | +| Request | `{ loginId, password, name, birthDate, email }` | +| Response | `201 Created` + `{ id, loginId, name, email }` | + +#### 2-2. DTO + +**파일**: `interfaces/api/member/MemberV1Dto.java` + +```java +public class MemberV1Dto { + + public record SignUpRequest( + @NotBlank @Pattern(regexp = "^[A-Za-z0-9]+$") String loginId, + @NotBlank @Size(min = 8, max = 16) String password, + @NotBlank String name, + @NotNull @Past LocalDate birthDate, + @NotBlank @Email String email + ) {} + + public record SignUpResponse( + Long id, + String loginId, + String name, + String email + ) { + public static SignUpResponse from(MemberInfo info) { ... } + } +} +``` + +#### 2-3. 계층별 구현 + +**Controller** → **Facade** → **Service** → **Repository** + +``` +MemberV1Controller.signUp(SignUpRequest) + ↓ +MemberFacade.signUp(SignUpCommand) + ↓ +MemberService.register(SignUpCommand) + - 로그인 ID 중복 검증 + - 비밀번호 검증 (PasswordValidator) + - 비밀번호 암호화 + - 저장 + ↓ +MemberRepository.save(MemberModel) +``` + +#### 2-4. TDD 사이클 + +| 사이클 | 테스트 케이스 | 구현 내용 | +|--------|-------------|----------| +| 1 | 정상 가입 시 201 응답 | 기본 API 흐름 구현 | +| 2 | 로그인 ID 중복 시 409 응답 | 중복 검증 로직 추가 | +| 3 | 로그인 ID 포맷 오류 시 400 응답 | @Pattern 검증 | +| 4 | 비밀번호 규칙 위반 시 400 응답 | PasswordValidator 연동 | +| 5 | 비밀번호에 생년월일 포함 시 400 응답 | 생년월일 검증 로직 | +| 6 | 이메일 포맷 오류 시 400 응답 | @Email 검증 | + +--- + +### Phase 3: 내 정보 조회 기능 + +#### 3-1. API 설계 + +| 항목 | 내용 | +|------|------| +| Method | `GET` | +| Path | `/api/v1/members/me` | +| Headers | `X-Loopers-LoginId`, `X-Loopers-LoginPw` | +| Response | `200 OK` + `{ loginId, name, birthDate, email }` | + +#### 3-2. DTO + +```java +public record MyInfoResponse( + String loginId, + String name, // 마스킹 적용 + LocalDate birthDate, + String email +) { + public static MyInfoResponse from(MemberModel member) { + return new MyInfoResponse( + member.getLoginId(), + maskName(member.getName()), + member.getBirthDate(), + member.getEmail() + ); + } + + private static String maskName(String name) { + if (name == null || name.length() < 2) return name; + return name.substring(0, name.length() - 1) + "*"; + } +} +``` + +#### 3-3. 계층별 구현 + +``` +MemberV1Controller.getMyInfo(@AuthMember MemberModel member) + ↓ +MyInfoResponse.from(member) // 직접 변환 (Facade 생략 가능) +``` + +**설계 근거**: 단순 조회이므로 Facade 없이 Controller에서 직접 DTO 변환 + +#### 3-4. TDD 사이클 + +| 사이클 | 테스트 케이스 | 구현 내용 | +|--------|-------------|----------| +| 1 | 정상 조회 시 200 응답 | 기본 API 흐름 | +| 2 | 이름 마스킹 검증 | maskName 로직 | +| 3 | 인증 헤더 없음 시 401 응답 | AuthMemberResolver 예외 처리 | +| 4 | 잘못된 비밀번호 시 401 응답 | 비밀번호 검증 | + +--- + +### Phase 4: 비밀번호 수정 기능 + +#### 4-1. API 설계 + +| 항목 | 내용 | +|------|------| +| Method | `PATCH` | +| Path | `/api/v1/members/me/password` | +| Headers | `X-Loopers-LoginId`, `X-Loopers-LoginPw` | +| Request | `{ currentPassword, newPassword }` | +| Response | `200 OK` | + +#### 4-2. DTO + +```java +public record ChangePasswordRequest( + @NotBlank String currentPassword, + @NotBlank @Size(min = 8, max = 16) String newPassword +) {} +``` + +#### 4-3. 계층별 구현 + +``` +MemberV1Controller.changePassword(@AuthMember MemberModel member, ChangePasswordRequest) + ↓ +MemberFacade.changePassword(member, ChangePasswordCommand) + ↓ +MemberService.changePassword(member, currentPassword, newPassword) + - 현재 비밀번호 일치 검증 + - 새 비밀번호 규칙 검증 + - 새 비밀번호 ≠ 현재 비밀번호 검증 + - 비밀번호 암호화 후 업데이트 +``` + +#### 4-4. TDD 사이클 + +| 사이클 | 테스트 케이스 | 구현 내용 | +|--------|-------------|----------| +| 1 | 정상 수정 시 200 응답 | 기본 API 흐름 | +| 2 | 현재 비밀번호 불일치 시 400 응답 | 비밀번호 검증 | +| 3 | 새 비밀번호 규칙 위반 시 400 응답 | PasswordValidator | +| 4 | 새 비밀번호 = 현재 비밀번호 시 400 응답 | 동일 비밀번호 검증 | +| 5 | 새 비밀번호에 생년월일 포함 시 400 응답 | 생년월일 검증 | + +--- + +## 브랜치 전략 + +``` +main + └── week1 + ├── feature/sign-up (Phase 1 + Phase 2) + ├── feature/my-info (Phase 3) + └── feature/change-password (Phase 4) +``` + +--- + +## 패키지 구조 (최종) + +``` +com.loopers +├── application/member/ +│ ├── MemberFacade.java +│ └── MemberInfo.java +├── domain/member/ +│ ├── MemberModel.java +│ ├── MemberService.java +│ ├── MemberRepository.java +│ └── PasswordValidator.java +├── infrastructure/member/ +│ ├── MemberJpaRepository.java +│ └── MemberRepositoryImpl.java +├── interfaces/api/member/ +│ ├── MemberV1Controller.java +│ ├── MemberV1ApiSpec.java +│ └── MemberV1Dto.java +└── support/ + └── auth/ + ├── AuthMember.java + ├── AuthMemberResolver.java + ├── PasswordEncoderConfig.java + └── WebMvcConfig.java +``` + +--- + +## ErrorType 추가 (필요시) + +```java +// 기존 ErrorType에 추가 +UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "Unauthorized", "인증이 필요합니다."), +DUPLICATE_LOGIN_ID(HttpStatus.CONFLICT, "Duplicate Login ID", "이미 존재하는 로그인 ID입니다."), +INVALID_PASSWORD(HttpStatus.BAD_REQUEST, "Invalid Password", "비밀번호 규칙에 맞지 않습니다."), +PASSWORD_MISMATCH(HttpStatus.BAD_REQUEST, "Password Mismatch", "비밀번호가 일치하지 않습니다."), +```