diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 000000000..7a824d72d --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(./gradlew test:*)", + "Bash(./gradlew :apps:commerce-api:test:*)", + "Bash(./gradlew :apps:commerce-api:compileJava:*)" + ] + } +} diff --git a/.claude/skills/requirements-analysis/SKILL.md b/.claude/skills/requirements-analysis/SKILL.md new file mode 100644 index 000000000..3485a8af8 --- /dev/null +++ b/.claude/skills/requirements-analysis/SKILL.md @@ -0,0 +1,77 @@ +--- +name: requirements-analysis +description: + 제공된 요구사항을 분석하고, 개발자와의 질문/대답을 통해 애매한 요구사항을 명확히 하여 정리합니다. + 모든 정리가 끝나면, 시퀀스 다이어그램, 클래스 다이어그램, ERD 등을 Mermaid 문법으로 작성한다. + 요구사항이 제공되었을 때, 코드를 작성하기 전 이를 명확히 하는 데에 사용합니다. +--- +요구사항을 분석할 때 반드시 다음 흐름을 따른다. +### 1️⃣ 요구사항을 그대로 믿지 말고, 문제 상황으로 다시 설명한다. +- 요구사항 문장을 정리하는 데서 끝내지 않는다. +- "무엇을 만들까?"가 아니라 "지금 어떤 문제가 있고, 그걸 왜 해결하려는가?" 로 재해석한다. +- 다음 관점을 분리해서 정리한다: + - 사용자 관점 + - 비즈니스 관점 + - 시스템 관점 +> 예시 +> "주문 실패 시 결제를 취소한다" → "결제 성공/실패와 주문 상태가 어긋나지 않도록 일관성을 유지하려는 문제" + +### 2️⃣ 애매한 요구사항을 숨기지 말고 드러낸다 +- 추측하거나 알아서 결정하지 않는다. +- 요구사항에서 결정되지 않은 부분을 명시적으로 나열한다. + **다음 유형의 질문을 반드시 포함한다:** +- 정책 질문: 기준 시점, 성공/실패 조건, 예외 처리 규칙 +- 경계 질문: 어디까지가 한 책임인가, 어디서 분리되는가 +- 확장 질문: 나중에 바뀔 가능성이 있는가 + +### 3️⃣ 요구사항 명확화를 위한 질문을 개발자 답변이 쉬운 형태로 제시한다 +- 질문은 우선순위를 가진다 (중요한 것부터). +- 선택지가 있는 경우, 옵션 + 영향도를 함께 제시한다. +> 형식 예시: +- 선택지 A: 하나의 트랜잭션으로 처리 → 구현 단순, 확장성 낮음 +- 선택지 B: 단계별 분리 → 구조 복잡, 확장/보상 처리 유리 + +### 4️⃣ 합의된 내용을 바탕으로 개념 모델부터 잡는다 +- 바로 코드나 기술 얘기로 들어가지 않는다. +- 먼저 다음을 정의한다: + - 액터 (사용자, 외부 시스템) + - 핵심 도메인 + - 보조/외부 시스템 +- 이 단계는 “구현”이 아니라 설계 사고 정렬이 목적이다. + +### 5️⃣ 다이어그램은 항상 이유 → 다이어그램 → 해석 순서로 제시한다 +**다이어그램을 그리기 전에 반드시 설명한다** +- 왜 이 다이어그램이 필요한지 +- 이 다이어그램으로 무엇을 검증하려는지 + +**다이어그램은 Mermaid 문법으로 작성한다** +사용 기준: +- **시퀀스 다이어그램** + - 책임 분리 + - 호출 순서 + - 트랜잭션 경계 확인 +- **클래스 다이어그램** + - 도메인 책임 + - 의존 방향 + - 응집도 확인 +- **ERD** + - 영속성 구조 + - 관계의 주인 + - 정규화 여부 + +### 6️⃣ 다이어그램을 던지고 끝내지 말고 읽는 법을 짚어준다 +- "이 구조에서 특히 봐야 할 포인트"를 2~3줄로 설명한다. +- 설계 의도가 드러나도록 해석을 붙인다. + +### 7️⃣ 설계의 잠재 리스크를 반드시 언급한다 +- 현재 설계가 가질 수 있는 위험을 숨기지 않는다. + - 트랜잭션 비대화 + - 도메인 간 결합도 증가 + - 정책 변경 시 영향 범위 확대 +- 해결책은 정답처럼 말하지 않고 선택지로 제시한다. + +### 톤 & 스타일 가이드 +- 강의처럼 설명하지 말고 설계 리뷰 톤을 유지한다 +- 정답이라고 제시하기보다, 다른 선택지가 있다면 이를 제공하도록 한다. +- 코드보다 의도, 책임, 경계를 더 중요하게 다룬다 +- 구현 전에 생각해야 할 것을 끌어내는 데 집중한다 \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..e57d39ca2 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,166 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Tech Stack & Versions + +| Category | Technology | Version | +|----------|------------|---------| +| Language | Java | 21 | +| Framework | Spring Boot | 3.4.4 | +| Dependency Management | Spring Dependency Management | 1.1.7 | +| Cloud | Spring Cloud | 2024.0.1 | +| Build Tool | Gradle (Kotlin DSL) | 8.13+ | +| API Documentation | SpringDoc OpenAPI | 2.7.0 | +| ORM | Spring Data JPA + QueryDSL | (managed by Spring Boot) | +| Database | MySQL | 8.0 | +| Cache | Redis (Master-Replica) | - | +| Messaging | Kafka | 3.5.1 | +| Monitoring | Micrometer + Prometheus | (managed by Spring Boot) | +| Logging | Logback + Slack Appender | 1.6.1 | +| Testing | JUnit 5, Mockito 5.14.0, SpringMockk 4.0.2, Instancio 5.0.2 | - | +| Containers | TestContainers | (managed by Spring Boot) | + +## Build & Run Commands + +```bash +# Build all modules +./gradlew build + +# Run tests (profile: test, timezone: Asia/Seoul) +./gradlew test + +# Run specific app +./gradlew :apps:commerce-api:bootRun +./gradlew :apps:commerce-batch:bootRun --args='--job.name=jobName' +./gradlew :apps:commerce-streamer:bootRun + +# Build specific module +./gradlew :apps:commerce-api:build + +# Run single test class +./gradlew test --tests "com.loopers.ExampleServiceIntegrationTest" + +# Run single test method +./gradlew test --tests "com.loopers.ExampleServiceIntegrationTest.testMethodName" + +# Test with coverage report +./gradlew test jacocoTestReport +``` + +**Java version**: 21 (configured via Gradle toolchain) + +## Local Infrastructure + +```bash +# Start MySQL, Redis (master+replica), Kafka +docker-compose -f docker/infra-compose.yml up + +# Start Prometheus + Grafana monitoring +docker-compose -f docker/monitoring-compose.yml up +``` + +- MySQL: localhost:3307 (root/root, application/application) +- Redis Master: localhost:6379, Replica: localhost:6380 +- Kafka: localhost:19092, Kafka UI: localhost:9099 +- Grafana: localhost:3000 (admin/admin) + +## Architecture + +### Multi-Module Structure + +``` +loopers-java-spring-template/ +├── apps/ # Executable Spring Boot applications +│ ├── commerce-api # REST API (web, actuator, springdoc-openapi) +│ ├── commerce-batch # Batch jobs (spring-batch) +│ └── commerce-streamer # Event streaming (web, kafka) +├── modules/ # Reusable infrastructure configurations +│ ├── jpa # JPA, QueryDSL, MySQL connector +│ ├── redis # Spring Data Redis (master-replica) +│ └── kafka # Spring Kafka +└── supports/ # Cross-cutting add-on modules + ├── jackson # Jackson serialization (Kotlin module, JSR310) + ├── logging # Logback, Slack appender + └── monitoring # Micrometer, Prometheus registry +``` + +### Module Dependencies + +| App | modules | supports | +|-----|---------|----------| +| commerce-api | jpa, redis | jackson, logging, monitoring | +| commerce-batch | jpa, redis | jackson, logging, monitoring | +| commerce-streamer | jpa, redis, kafka | jackson, logging, monitoring | + +### Layer Architecture (commerce-api) +``` +interfaces/api/ → Controllers, DTOs, OpenAPI specs +application/ → Facades (use case orchestration) +domain/ → Entities, Services, Repository interfaces +infrastructure/ → Repository implementations, adapters +``` + +### Key Patterns +- **Controllers**: Implement `*ApiSpec` interfaces for OpenAPI documentation +- **Facades**: Orchestrate domain services, convert domain models to DTOs +- **Services**: `@Component` with `@Transactional`, contain business logic +- **Repositories**: Interface in `domain/`, implementation in `infrastructure/` +- **Entities**: Extend `BaseEntity` (provides id, createdAt, updatedAt, deletedAt) +- **Response wrapper**: All APIs return `ApiResponse` +- **Error handling**: `CoreException` with `ErrorType` enum, caught by `ApiControllerAdvice` + +### Soft Delete +Entities use `deletedAt` field via `BaseEntity`: +```java +entity.delete(); // marks as deleted +entity.restore(); // restores +``` + +## Configuration + +- Profile-based: local, test, dev, qa, prd +- Config imports in application.yml: jpa.yml, redis.yml, logging.yml, monitoring.yml +- Management endpoints on port 8081 (/health, /prometheus) + +## Testing + +- Framework: JUnit 5 + AssertJ + Mockito + SpringMockk + Instancio +- `DatabaseCleanUp` utility truncates tables between tests (from jpa test fixtures) +- `RedisCleanUp` available from redis test fixtures +- TestContainers support for MySQL, Redis, Kafka + +## 개발 규칙 + +### 진행 Workflow - 증강 코딩 +- **대원칙**: 방향성 및 주요 의사 결정은 개발자에게 제안만 할 수 있으며, 최종 승인된 사항을 기반으로 작업 수행 +- **중간 결과 보고**: AI가 반복적인 동작을 하거나, 요청하지 않은 기능을 구현, 테스트 삭제를 임의로 진행할 경우 개발자가 개입 +- **설계 주도권 유지**: AI가 임의판단을 하지 않고, 방향성에 대한 제안 등을 진행할 수 있으나 개발자의 승인을 받은 후 수행 + +### 개발 Workflow - TDD (Red → Green → Refactor) +- 모든 테스트는 3A 원칙으로 작성 (Arrange - Act - Assert) + +| Phase | 설명 | +|-------|------| +| **Red** | 요구사항을 만족하는 실패하는 테스트 케이스 먼저 작성 | +| **Green** | Red Phase의 테스트가 모두 통과할 수 있는 최소한의 코드 작성 (오버엔지니어링 금지) | +| **Refactor** | 불필요한 private 함수 지양, 객체지향적 코드 작성, unused import 제거, 성능 최적화. 모든 테스트 통과 필수 | + +### 주의사항 + +**Never Do:** +- 실제 동작하지 않는 코드, 불필요한 Mock 데이터를 이용한 구현 금지 +- null-safety 하지 않은 코드 작성 금지 (Java의 경우 Optional 활용) +- println 코드 남기지 말 것 + +**Recommendation:** +- 실제 API를 호출해 확인하는 E2E 테스트 코드 작성 +- 재사용 가능한 객체 설계 +- 성능 최적화에 대한 대안 및 제안 +- 개발 완료된 API는 `http/*.http` 파일에 분류해 작성 + +**Priority:** +1. 실제 동작하는 해결책만 고려 +2. null-safety, thread-safety 고려 +3. 테스트 가능한 구조로 설계 +4. 기존 코드 패턴 분석 후 일관성 유지 diff --git a/MERMAID.md b/MERMAID.md new file mode 100644 index 000000000..09f77cf36 --- /dev/null +++ b/MERMAID.md @@ -0,0 +1,167 @@ +# Flow Diagrams + +## 1. 회원가입 (POST /api/v1/members) + +```mermaid +sequenceDiagram + autonumber + participant Client + participant Controller + participant Facade + participant Service + participant DB + + Client->>Controller: POST /api/v1/members (SignUpRequest) + Controller->>Facade: signupMember(request) + Facade->>Facade: Request to MemberModel 변환 + Facade->>Service: saveMember(memberModel) + Service->>DB: findByLoginId (중복 체크) + DB-->>Service: Optional.empty() + Service->>Service: passwordEncoder.encode() + Service->>DB: save(memberModel) + DB-->>Service: savedMember + Service-->>Facade: MemberModel + Facade->>Facade: MemberModel to MemberInfo 변환 + Facade-->>Controller: MemberInfo + Controller-->>Client: 201 Created (SignUpResponse) +``` + +### 예외 흐름 + +```mermaid +sequenceDiagram + autonumber + participant Client + participant Controller + participant Facade + participant Service + participant DB + + Client->>Controller: POST /api/v1/members (중복 ID) + Controller->>Facade: signupMember(request) + Facade->>Service: saveMember(memberModel) + Service->>DB: findByLoginId + DB-->>Service: Optional.of(existingMember) + Service-->>Controller: CoreException (CONFLICT) + Controller-->>Client: 409 Conflict +``` + +--- + +## 2. 내 정보 조회 (GET /api/v1/members/me) + +```mermaid +sequenceDiagram + autonumber + participant Client + participant Controller + participant Facade + participant Service + participant DB + + Client->>Controller: GET /api/v1/members/me + Note over Client,Controller: Headers: X-Loopers-LoginId, X-Loopers-LoginPw + Controller->>Facade: getMyInfo(loginId, password) + Facade->>Service: authenticate(loginId, password) + Service->>DB: findByLoginId + DB-->>Service: MemberModel + Service->>Service: passwordEncoder.matches() + Service-->>Facade: MemberModel (인증 성공) + Facade->>Facade: MemberModel to MemberInfo 변환 (이름 마스킹) + Facade-->>Controller: MemberInfo + Controller-->>Client: 200 OK (MemberInfoResponse) +``` + +### 예외 흐름 - 인증 실패 + +```mermaid +sequenceDiagram + autonumber + participant Client + participant Controller + participant Facade + participant Service + participant DB + + Client->>Controller: GET /api/v1/members/me (틀린 비밀번호) + Controller->>Facade: getMyInfo(loginId, wrongPassword) + Facade->>Service: authenticate(loginId, wrongPassword) + Service->>DB: findByLoginId + DB-->>Service: MemberModel + Service->>Service: passwordEncoder.matches() = false + Service-->>Controller: CoreException (UNAUTHORIZED) + Controller-->>Client: 401 Unauthorized +``` + +--- + +## 3. 비밀번호 변경 (PATCH /api/v1/members/me/password) + +```mermaid +sequenceDiagram + autonumber + participant Client + participant Controller + participant Facade + participant Service + participant DB + + Client->>Controller: PATCH /api/v1/members/me/password + Note over Client,Controller: Headers: X-Loopers-LoginId, X-Loopers-LoginPw + Note over Client,Controller: Body: oldPassword, newPassword + Controller->>Facade: changePassword(loginId, headerPw, oldPw, newPw) + Facade->>Service: authenticate(loginId, headerPw) + Service->>DB: findByLoginId + DB-->>Service: MemberModel + Service->>Service: passwordEncoder.matches(headerPw) + Service-->>Facade: 인증 성공 + Facade->>Facade: new MemberModel(loginId, oldPw) + Facade->>Service: changePassword(memberModel, newPw) + Service->>Service: passwordEncoder.matches(oldPw) 검증 + Service->>Service: newPw != oldPw 검증 + Service->>Service: validatePassword(newPw) 규칙 검증 + Service->>Service: passwordEncoder.encode(newPw) + Service->>DB: Dirty Checking (자동 저장) + Service-->>Facade: void + Facade-->>Controller: void + Controller-->>Client: 200 OK +``` + +### 예외 흐름 - Body의 기존 비밀번호 불일치 + +```mermaid +sequenceDiagram + autonumber + participant Client + participant Controller + participant Facade + participant Service + + Client->>Controller: PATCH (헤더 인증 OK, Body oldPw 틀림) + Controller->>Facade: changePassword(...) + Facade->>Service: authenticate() 성공 + Facade->>Service: changePassword(wrongOldPw, newPw) + Service->>Service: passwordEncoder.matches(wrongOldPw) = false + Service-->>Controller: CoreException (UNAUTHORIZED) + Controller-->>Client: 401 Unauthorized +``` + +### 예외 흐름 - 새 비밀번호가 기존과 동일 + +```mermaid +sequenceDiagram + autonumber + participant Client + participant Controller + participant Facade + participant Service + + Client->>Controller: PATCH (newPw == oldPw) + Controller->>Facade: changePassword(...) + Facade->>Service: authenticate() 성공 + Facade->>Service: changePassword(oldPw, samePassword) + Service->>Service: oldPw 검증 성공 + Service->>Service: newPw == oldPw 체크 + Service-->>Controller: CoreException (BAD_REQUEST) + Controller-->>Client: 400 Bad Request +``` \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/AdminBrandFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/AdminBrandFacade.java new file mode 100644 index 000000000..a652bb145 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/AdminBrandFacade.java @@ -0,0 +1,61 @@ +package com.loopers.application.brand; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandDeactivatedEvent; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.brand.BrandStatus; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Component +public class AdminBrandFacade { + + private final BrandRepository brandRepository; + private final ApplicationEventPublisher eventPublisher; + + @Transactional + public AdminBrandInfo createBrand(String name, String description, String logoImageUrl) { + Brand brand = brandRepository.save(new Brand(name, description, logoImageUrl)); + return AdminBrandInfo.from(brand); + } + + @Transactional + public AdminBrandInfo updateBrand(Long brandId, String name, String description, String logoImageUrl) { + Brand brand = brandRepository.findById(brandId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 브랜드입니다.")); + brand.updateInfo(name, description, logoImageUrl); + return AdminBrandInfo.from(brandRepository.save(brand)); + } + + @Transactional + public void deactivateBrand(Long brandId) { + Brand brand = brandRepository.findById(brandId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 브랜드입니다.")); + brand.deactivate(); + brandRepository.save(brand); + // 브랜드 비활성화 이벤트 발행 → BrandEventListener가 비동기로 상품 연쇄 처리 + eventPublisher.publishEvent(new BrandDeactivatedEvent(brandId)); + } + + @Transactional(readOnly = true) + public AdminBrandInfo getBrandInfo(Long brandId) { + // 어드민은 INACTIVE 포함 모든 상태의 브랜드를 조회 가능 + Brand brand = brandRepository.findById(brandId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 브랜드입니다.")); + return AdminBrandInfo.from(brand); + } + + @Transactional(readOnly = true) + public Page getBrandList(BrandStatus status, int page, int size) { + // status가 null이면 전체, 아니면 해당 상태만 반환 + return brandRepository.findAll(status, PageRequest.of(page, size)) + .map(AdminBrandInfo::from); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/AdminBrandInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/AdminBrandInfo.java new file mode 100644 index 000000000..075c7205a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/AdminBrandInfo.java @@ -0,0 +1,26 @@ +package com.loopers.application.brand; + +import com.loopers.domain.brand.Brand; +import java.time.ZonedDateTime; + +public record AdminBrandInfo( + Long brandId, + String name, + String description, + String logoImageUrl, + String status, + ZonedDateTime createdAt, + ZonedDateTime updatedAt +) { + public static AdminBrandInfo from(Brand brand) { + return new AdminBrandInfo( + brand.getId(), + brand.getName(), + brand.getDescription(), + brand.getLogoImageUrl(), + brand.getStatus().name(), + brand.getCreatedAt(), + brand.getUpdatedAt() + ); + } +} 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..f4af314ab --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java @@ -0,0 +1,26 @@ +package com.loopers.application.brand; + +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.Component; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Component +public class BrandFacade { + + private final BrandRepository brandRepository; + + @Transactional(readOnly = true) + public BrandInfo getBrandInfo(Long brandId) { + Brand brand = brandRepository.findById(brandId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 브랜드입니다.")); + if (!brand.isActive()) { + throw new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 브랜드입니다."); + } + return BrandInfo.from(brand); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java new file mode 100644 index 000000000..a9bef26db --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java @@ -0,0 +1,16 @@ +package com.loopers.application.brand; + +import com.loopers.domain.brand.Brand; +import java.time.ZonedDateTime; + +public record BrandInfo(Long brandId, String name, String description, String logoImageUrl, ZonedDateTime createdAt) { + public static BrandInfo from(Brand model) { + return new BrandInfo( + model.getId(), + model.getName(), + model.getDescription(), + model.getLogoImageUrl(), + model.getCreatedAt() + ); + } +} 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..0ad24bd23 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java @@ -0,0 +1,44 @@ +package com.loopers.application.order; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.common.Quantity; +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderService; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductService; +import com.loopers.domain.users.UserService; +import com.loopers.domain.users.Users; +import com.loopers.interfaces.api.order.OrderV1Dto; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class OrderFacade { + + private final UserService userService; + private final ProductService productService; + private final OrderService orderService; + + public OrderInfo createOrder(String loginId, String password, List items) { + Users user = userService.authenticate(loginId, password); + + List productIds = items.stream().map(OrderV1Dto.OrderItemRequest::productId).toList(); + List products = productService.getProducts(productIds); + + List brandIds = products.stream().map(Product::getBrandId).distinct().toList(); + List brands = productService.getBrands(brandIds); + + Map brandMap = brands.stream() + .collect(Collectors.toMap(Brand::getId, b -> b)); + Map deductionMap = items.stream() + .collect(Collectors.toMap(OrderV1Dto.OrderItemRequest::productId, item -> new Quantity(item.quantity()))); + + Order order = orderService.createOrder(user.getId(), products, brandMap, deductionMap); + + return OrderInfo.from(order); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java new file mode 100644 index 000000000..659832bf8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java @@ -0,0 +1,17 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderStatus; +import java.time.ZonedDateTime; + +public record OrderInfo(Long orderId, OrderStatus status, Long totalAmount, ZonedDateTime createdAt) { + + public static OrderInfo from(Order order) { + return new OrderInfo( + order.getId(), + order.getStatus(), + order.getTotalAmount(), + order.getCreatedAt() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/AdminProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/AdminProductFacade.java new file mode 100644 index 000000000..ac6cc68c5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/AdminProductFacade.java @@ -0,0 +1,87 @@ +package com.loopers.application.product; + +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.common.Money; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductHistory; +import com.loopers.domain.product.ProductHistoryRepository; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.ProductSortType; +import com.loopers.domain.product.ProductStatus; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Component +public class AdminProductFacade { + + private final ProductRepository productRepository; + private final BrandRepository brandRepository; + private final ProductHistoryRepository productHistoryRepository; + + @Transactional + public AdminProductInfo createProduct(Long brandId, String name, Long price, String description, String thumbnailImageUrl) { + brandRepository.findById(brandId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 브랜드입니다.")); + Product product = productRepository.save( + new Product(brandId, name, new Money(price), description, thumbnailImageUrl) + ); + // 등록 시 버전 1의 스냅샷 자동 저장 + int version = productHistoryRepository.countByProductId(product.getId()) + 1; + productHistoryRepository.save(ProductHistory.snapshot(product, version, "admin")); + return AdminProductInfo.from(product); + } + + @Transactional + public AdminProductInfo updateProduct(Long productId, Long brandId, String name, Long price, String description, String thumbnailImageUrl) { + brandRepository.findById(brandId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 브랜드입니다.")); + Product product = productRepository.findById(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 상품입니다.")); + product.updateInfo(brandId, name, new Money(price), description, thumbnailImageUrl); + productRepository.save(product); + // 수정 시 버전이 1 증가한 스냅샷 저장 + int version = productHistoryRepository.countByProductId(productId) + 1; + productHistoryRepository.save(ProductHistory.snapshot(product, version, "admin")); + return AdminProductInfo.from(product); + } + + @Transactional + public void deactivateProduct(Long productId) { + Product product = productRepository.findById(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 상품입니다.")); + product.deactivate(); + productRepository.save(product); + } + + @Transactional(readOnly = true) + public AdminProductInfo getProductInfo(Long productId) { + // 어드민은 PENDING/INACTIVE 포함 모든 상태의 상품 조회 가능 + Product product = productRepository.findById(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 상품입니다.")); + return AdminProductInfo.from(product); + } + + @Transactional(readOnly = true) + public Page getProductList(Long brandId, ProductStatus status, int page, int size) { + // status가 null이면 전체 조회, 아니면 해당 상태만 반환 + if (status != null) { + return productRepository.findAll(brandId, ProductSortType.LATEST, List.of(status), PageRequest.of(page, size)) + .map(AdminProductInfo::from); + } + return productRepository.findAll(brandId, ProductSortType.LATEST, PageRequest.of(page, size)) + .map(AdminProductInfo::from); + } + + @Transactional(readOnly = true) + public Page getProductHistory(Long productId, int page, int size) { + return productHistoryRepository.findAllByProductId(productId, PageRequest.of(page, size)) + .map(ProductHistoryInfo::from); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/AdminProductInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/AdminProductInfo.java new file mode 100644 index 000000000..5a9b374c1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/AdminProductInfo.java @@ -0,0 +1,30 @@ +package com.loopers.application.product; + +import com.loopers.domain.product.Product; +import java.time.ZonedDateTime; + +public record AdminProductInfo( + Long productId, + String name, + String description, + Long brandId, + String thumbnailImageUrl, + Long price, + String status, + ZonedDateTime createdAt, + ZonedDateTime updatedAt +) { + public static AdminProductInfo from(Product product) { + return new AdminProductInfo( + product.getId(), + product.getName(), + product.getDescription(), + product.getBrandId(), + product.getThumbnailImageUrl(), + product.getPrice(), + product.getStatus().name(), + product.getCreatedAt(), + product.getUpdatedAt() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/OrderFacade.java new file mode 100644 index 000000000..9b78fdd29 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/OrderFacade.java @@ -0,0 +1,5 @@ +package com.loopers.application.product; + +public class OrderFacade { + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java new file mode 100644 index 000000000..058a37271 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java @@ -0,0 +1,43 @@ +package com.loopers.application.product; + +import com.loopers.application.brand.BrandInfo; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductImage; +import com.loopers.domain.product.ProductOption; +import java.time.ZonedDateTime; +import java.util.List; + +public record ProductDetailInfo( + Long productId, + String name, + String description, + BrandInfo brand, + List imageUrls, + List options, + long likeCount, + ZonedDateTime createdAt +) { + + public record OptionInfo(Long optionId, String name, Long price, Long stockQuantity, boolean isAvailable) {} + + public static ProductDetailInfo from( + Product product, + Brand brand, + List options, + List images + ) { + return new ProductDetailInfo( + product.getId(), + product.getName(), + product.getDescription(), + BrandInfo.from(brand), + images.stream().map(ProductImage::getImageUrl).toList(), + options.stream() + .map(o -> new OptionInfo(o.getId(), o.getName(), o.getPrice(), o.getStockQuantity(), o.isAvailable())) + .toList(), + 0L, + product.getCreatedAt() + ); + } +} \ No newline at end of file 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..42edb1f82 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -0,0 +1,32 @@ +package com.loopers.application.product; + +import com.loopers.domain.product.ProductService; +import com.loopers.domain.product.ProductService.ProductDetail; +import com.loopers.domain.product.ProductSortType; +import com.loopers.domain.product.ProductStatus; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class ProductFacade { + + private static final List CUSTOMER_VISIBLE_STATUSES = + List.of(ProductStatus.ACTIVE, ProductStatus.OUT_OF_STOCK); + + private final ProductService productService; + + public ProductDetailInfo getProductDetail(Long productId) { + ProductDetail detail = productService.getProductDetail(productId); + return ProductDetailInfo.from(detail.product(), detail.brand(), detail.options(), detail.images()); + } + + public Page getProductList(Long brandId, String sort, int page, int size) { + ProductSortType sortType = ProductSortType.from(sort); + return productService.getProductList(brandId, sortType, CUSTOMER_VISIBLE_STATUSES, PageRequest.of(page, size)) + .map(ProductListInfo::from); + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductHistoryInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductHistoryInfo.java new file mode 100644 index 000000000..f1313bde3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductHistoryInfo.java @@ -0,0 +1,26 @@ +package com.loopers.application.product; + +import com.loopers.domain.product.ProductHistory; +import java.time.ZonedDateTime; + +public record ProductHistoryInfo( + Long historyId, + Integer version, + String name, + Long price, + String status, + String changedBy, + ZonedDateTime changedAt +) { + public static ProductHistoryInfo from(ProductHistory history) { + return new ProductHistoryInfo( + history.getId(), + history.getVersion(), + history.getName(), + history.getPrice(), + history.getStatus(), + history.getChangedBy(), + history.getCreatedAt() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductListInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductListInfo.java new file mode 100644 index 000000000..1ee3792a9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductListInfo.java @@ -0,0 +1,29 @@ +package com.loopers.application.product; + +import com.loopers.domain.product.ProductService.ProductListItem; +import java.time.ZonedDateTime; + +public record ProductListInfo( + Long productId, + String name, + Long brandId, + String brandName, + String thumbnailImageUrl, + Long minPrice, + long likeCount, + ZonedDateTime createdAt +) { + + public static ProductListInfo from(ProductListItem item) { + return new ProductListInfo( + item.product().getId(), + item.product().getName(), + item.brand().getId(), + item.brand().getName(), + item.product().getThumbnailImageUrl(), + item.minPrice(), + 0L, + item.product().getCreatedAt() + ); + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/application/users/UserFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/users/UserFacade.java new file mode 100644 index 000000000..ef4c5c6e9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/users/UserFacade.java @@ -0,0 +1,33 @@ +package com.loopers.application.users; + +import com.loopers.domain.users.UserService; +import com.loopers.domain.users.Users; +import com.loopers.interfaces.api.users.UserV1Dto; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +// Facade는 “조합/변환”만 한다는 원칙에 맞게! +@RequiredArgsConstructor +@Component +public class UserFacade { + + private final UserService userService; + + public UserInfo signupUser(UserV1Dto.SignUpRequest request) { + Users saved = userService.register( + request.loginId(), request.password(), + request.name(), request.birthDate(), request.email() + ); + return UserInfo.from(saved); + } + + public UserInfo getMyInfo(String loginId, String password) { + Users users = userService.authenticate(loginId, password); + return UserInfo.from(users); + } + + public void changePassword(String loginId, String password, String prevPassword, String newPassword) { + userService.authenticate(loginId, password); + userService.changePassword(loginId, prevPassword, newPassword); + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/application/users/UserInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/users/UserInfo.java new file mode 100644 index 000000000..0864255f2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/users/UserInfo.java @@ -0,0 +1,15 @@ +package com.loopers.application.users; + +import com.loopers.domain.users.Users; + +// UserInfo는 Facade → Controller로 전달되는 데이터 +public record UserInfo(String loginId, String name, String birthDate, String email) { + public static UserInfo from(Users model) { + return new UserInfo( + model.getLoginId(), + model.getMaskedName(), + model.getBirthDate(), + model.getEmail() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/config/AsyncConfig.java b/apps/commerce-api/src/main/java/com/loopers/config/AsyncConfig.java new file mode 100644 index 000000000..e9461feef --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/config/AsyncConfig.java @@ -0,0 +1,9 @@ +package com.loopers.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; + +@EnableAsync +@Configuration +public class AsyncConfig { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/config/SecurityConfig.java b/apps/commerce-api/src/main/java/com/loopers/config/SecurityConfig.java new file mode 100644 index 000000000..7e23f737a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/config/SecurityConfig.java @@ -0,0 +1,15 @@ +package com.loopers.config; + +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 SecurityConfig { + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/config/WebMvcConfig.java b/apps/commerce-api/src/main/java/com/loopers/config/WebMvcConfig.java new file mode 100644 index 000000000..ccdfc67c3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/config/WebMvcConfig.java @@ -0,0 +1,20 @@ +package com.loopers.config; + +import com.loopers.interfaces.api.admin.AdminAuthInterceptor; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@RequiredArgsConstructor +@Configuration +public class WebMvcConfig implements WebMvcConfigurer { + + private final AdminAuthInterceptor adminAuthInterceptor; + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(adminAuthInterceptor) + .addPathPatterns("/api-admin/v1/**"); + } +} 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..a82a5c920 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java @@ -0,0 +1,80 @@ +package com.loopers.domain.brand; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Table; + +@Entity +@Table(name = "brand") +public class Brand extends BaseEntity { + + @Column(name = "name", nullable = false, length = 100) + private String name; + + @Column(name = "description", length = 500) + private String description; + + @Column(name = "logo_image_url", length = 500) + private String logoImageUrl; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + private BrandStatus status = BrandStatus.PENDING; + + protected Brand() {} + + public Brand(String name) { + this(name, null, null); + } + + public Brand(String name, String description, String logoImageUrl) { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "브랜드명은 비어있을 수 없습니다."); + } + this.name = name; + this.description = description; + this.logoImageUrl = logoImageUrl; + } + + public void activate() { + this.status = BrandStatus.ACTIVE; + } + + public void deactivate() { + this.status = BrandStatus.INACTIVE; + } + + public boolean isActive() { + return this.status == BrandStatus.ACTIVE; + } + + public void updateInfo(String name, String description, String logoImageUrl) { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "브랜드명은 비어있을 수 없습니다."); + } + this.name = name; + this.description = description; + this.logoImageUrl = logoImageUrl; + } + + public String getName() { + return name; + } + + public String getDescription() { + return description; + } + + public String getLogoImageUrl() { + return logoImageUrl; + } + + public BrandStatus getStatus() { + return status; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandDeactivatedEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandDeactivatedEvent.java new file mode 100644 index 000000000..f524b36b6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandDeactivatedEvent.java @@ -0,0 +1,3 @@ +package com.loopers.domain.brand; + +public record BrandDeactivatedEvent(Long brandId) {} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandEventListener.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandEventListener.java new file mode 100644 index 000000000..0c4eb3c25 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandEventListener.java @@ -0,0 +1,35 @@ +package com.loopers.domain.brand; + +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductHistory; +import com.loopers.domain.product.ProductHistoryRepository; +import com.loopers.domain.product.ProductRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@RequiredArgsConstructor +@Component +public class BrandEventListener { + + private final ProductRepository productRepository; + private final ProductHistoryRepository productHistoryRepository; + + @Async + @Transactional(propagation = Propagation.REQUIRES_NEW) + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleBrandDeactivated(BrandDeactivatedEvent event) { + List products = productRepository.findAllByBrandId(event.brandId()); + for (Product product : products) { + product.deactivate(); + productRepository.save(product); + int version = productHistoryRepository.countByProductId(product.getId()) + 1; + productHistoryRepository.save(ProductHistory.snapshot(product, version, "system")); + } + } +} 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..4e55d6e5b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java @@ -0,0 +1,18 @@ +package com.loopers.domain.brand; + +import java.util.List; +import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface BrandRepository { + + Brand save(Brand brand); + + Optional findById(Long id); + + List findAllByIds(List ids); + + // 어드민 브랜드 목록 조회: status가 null이면 전체, 아니면 해당 상태만 반환 + Page findAll(BrandStatus status, Pageable pageable); +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandStatus.java new file mode 100644 index 000000000..fa00677b3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandStatus.java @@ -0,0 +1,5 @@ +package com.loopers.domain.brand; + +public enum BrandStatus { + PENDING, ACTIVE, INACTIVE, SCHEDULED +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/common/Money.java b/apps/commerce-api/src/main/java/com/loopers/domain/common/Money.java new file mode 100644 index 000000000..3a9118b30 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/common/Money.java @@ -0,0 +1,44 @@ +package com.loopers.domain.common; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import java.util.Objects; + +@Embeddable +public class Money { + + @Column(name = "value", nullable = false) + private Long value; + + protected Money() {} + + public Money(Long value) { + if (value == null || value < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "금액은 0 이상이어야 합니다."); + } + this.value = value; + } + + public Long getValue() { + return value; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Money)) return false; + return Objects.equals(value, ((Money) o).value); + } + + @Override + public int hashCode() { + return Objects.hash(value); + } + + @Override + public String toString() { + return String.valueOf(value); + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/common/Quantity.java b/apps/commerce-api/src/main/java/com/loopers/domain/common/Quantity.java new file mode 100644 index 000000000..759b616dc --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/common/Quantity.java @@ -0,0 +1,44 @@ +package com.loopers.domain.common; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import java.util.Objects; + +@Embeddable +public class Quantity { + + @Column(name = "quantity", nullable = false) + private Long value; + + protected Quantity() {} + + public Quantity(Long value) { + if (value == null || value <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "수량은 1 이상이어야 합니다."); + } + this.value = value; + } + + public Long getValue() { + return value; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Quantity)) return false; + return Objects.equals(value, ((Quantity) o).value); + } + + @Override + public int hashCode() { + return Objects.hash(value); + } + + @Override + public String toString() { + return String.valueOf(value); + } +} \ No newline at end of file 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..8a2f7bb79 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java @@ -0,0 +1,5 @@ +package com.loopers.domain.like; + +public class Like { + +} 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..0f90d773c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java @@ -0,0 +1,5 @@ +package com.loopers.domain.like; + +public class LikeRepository { + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java new file mode 100644 index 000000000..cf751059d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java @@ -0,0 +1,5 @@ +package com.loopers.domain.like; + +public class LikeService { + +} 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..e791858db --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java @@ -0,0 +1,55 @@ +package com.loopers.domain.order; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.common.Money; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.AttributeOverride; +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Table; + +@Entity +@Table(name = "orders") +public class Order extends BaseEntity { + + @Column(name = "member_id", nullable = false) + private Long memberId; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + private OrderStatus status; + + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "total_amount", nullable = false)) + private Money totalAmount; + + protected Order() {} + + public Order(Long memberId, Money totalAmount, OrderStatus status) { + if (memberId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "회원 ID는 비어있을 수 없습니다."); + } + if (totalAmount == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "총 금액은 비어있을 수 없습니다."); + } + this.memberId = memberId; + this.totalAmount = totalAmount; + this.status = status; + } + + public Long getMemberId() { + return memberId; + } + + public OrderStatus getStatus() { + return status; + } + + public Long getTotalAmount() { + return totalAmount.getValue(); + } +} 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..3ff7415f9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java @@ -0,0 +1,81 @@ +package com.loopers.domain.order; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.common.Quantity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.AttributeOverride; +import jakarta.persistence.AttributeOverrides; +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Table; + +@Entity +@Table(name = "order_item") +public class OrderItem extends BaseEntity { + + @Column(name = "order_id", nullable = false) + private Long orderId; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + private OrderItemStatus status; + + @Embedded + @AttributeOverrides({ + @AttributeOverride(name = "productName", column = @Column(name = "product_name", nullable = false, length = 200)), + @AttributeOverride(name = "price.value", column = @Column(name = "product_price", nullable = false)), + @AttributeOverride(name = "brandName", column = @Column(name = "brand_name", nullable = false, length = 100)) + }) + private ProductSnapshot snapshot; + + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "quantity", nullable = false)) + private Quantity quantity; + + protected OrderItem() {} + + public OrderItem(Long orderId, Long productId, OrderItemStatus status, + ProductSnapshot snapshot, Quantity quantity) { + if (orderId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문 ID는 비어있을 수 없습니다."); + } + if (productId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 ID는 비어있을 수 없습니다."); + } + if (snapshot == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 스냅샷은 비어있을 수 없습니다."); + } + this.orderId = orderId; + this.productId = productId; + this.status = status; + this.snapshot = snapshot; + this.quantity = quantity; + } + + public Long getOrderId() { + return orderId; + } + + public Long getProductId() { + return productId; + } + + public OrderItemStatus getStatus() { + return status; + } + + public Long getQuantity() { + return quantity.getValue(); + } + + public ProductSnapshot getSnapshot() { + return snapshot; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemRepository.java new file mode 100644 index 000000000..6de57cb2a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemRepository.java @@ -0,0 +1,8 @@ +package com.loopers.domain.order; + +import java.util.List; + +public interface OrderItemRepository { + + List saveAll(List orderItems); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemStatus.java new file mode 100644 index 000000000..01e96eb76 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemStatus.java @@ -0,0 +1,6 @@ +package com.loopers.domain.order; + +public enum OrderItemStatus { + ORDERED, + CANCELLED +} 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..5a83a7f65 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java @@ -0,0 +1,6 @@ +package com.loopers.domain.order; + +public interface OrderRepository { + + Order save(Order order); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java new file mode 100644 index 000000000..06ebbe95f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java @@ -0,0 +1,53 @@ +package com.loopers.domain.order; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.common.Money; +import com.loopers.domain.common.Quantity; +import com.loopers.domain.product.Product; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Component +public class OrderService { + + private final StockDeductionService stockDeductionService; + private final OrderRepository orderRepository; + private final OrderItemRepository orderItemRepository; + + @Transactional + public Order createOrder( + Long memberId, + List products, + Map brandMap, + Map deductionMap + ) { + stockDeductionService.deductAll(deductionMap); + + long totalAmount = products.stream() + .mapToLong(p -> p.getPrice() * deductionMap.get(p.getId()).getValue()) + .sum(); + + Order order = orderRepository.save(new Order(memberId, new Money(totalAmount), OrderStatus.CREATED)); + + List orderItems = products.stream() + .map(product -> { + Brand brand = brandMap.get(product.getBrandId()); + Quantity quantity = deductionMap.get(product.getId()); + ProductSnapshot snapshot = new ProductSnapshot( + product.getName(), + new Money(product.getPrice()), + brand.getName() + ); + return new OrderItem(order.getId(), product.getId(), OrderItemStatus.ORDERED, snapshot, quantity); + }) + .toList(); + + orderItemRepository.saveAll(orderItems); + + return order; + } +} 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..19793ee74 --- /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, + CONFIRMED, + CANCELLED +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/ProductSnapshot.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/ProductSnapshot.java new file mode 100644 index 000000000..713a1c137 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/ProductSnapshot.java @@ -0,0 +1,50 @@ +package com.loopers.domain.order; + +import com.loopers.domain.common.Money; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import jakarta.persistence.Embedded; + +@Embeddable +public class ProductSnapshot { + + @Column(name = "product_name", nullable = false, length = 200) + private String productName; + + @Embedded + private Money price; + + @Column(name = "brand_name", nullable = false, length = 100) + private String brandName; + + protected ProductSnapshot() {} + + public ProductSnapshot(String productName, Money price, String brandName) { + if (productName == null || productName.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품명은 비어있을 수 없습니다."); + } + if (price == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "가격은 비어있을 수 없습니다."); + } + if (brandName == null || brandName.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "브랜드명은 비어있을 수 없습니다."); + } + this.productName = productName; + this.price = price; + this.brandName = brandName; + } + + public String getProductName() { + return productName; + } + + public Long getProductPrice() { + return price.getValue(); + } + + public String getBrandName() { + return brandName; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/StockDeductionService.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/StockDeductionService.java new file mode 100644 index 000000000..48bd32ac3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/StockDeductionService.java @@ -0,0 +1,34 @@ +package com.loopers.domain.order; + +import com.loopers.domain.common.Quantity; +import com.loopers.domain.stock.Stock; +import com.loopers.domain.stock.StockRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Component +public class StockDeductionService { + + private final StockRepository stockRepository; + + @Transactional + public void deductAll(Map deductionMap) { + deductionMap.entrySet().stream() + .sorted(Map.Entry.comparingByKey()) + .forEach(entry -> { + Long productId = entry.getKey(); + Quantity quantity = entry.getValue(); + + Stock stock = stockRepository.findByProductIdWithLock(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, + "재고를 찾을 수 없습니다. [productId=" + productId + "]")); + + stock.deduct(quantity); + }); + } +} 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..1d9e91c9c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java @@ -0,0 +1,114 @@ +package com.loopers.domain.product; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.common.Money; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.AttributeOverride; +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Table; + +@Entity +@Table(name = "product") +public class Product extends BaseEntity { + + @Column(name = "brand_id", nullable = false) + private Long brandId; + + @Column(name = "name", nullable = false, length = 200) + private String name; + + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "price", nullable = false)) + private Money price; + + @Column(name = "description") + private String description; + + @Column(name = "thumbnail_image_url", length = 500) + private String thumbnailImageUrl; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + private ProductStatus status = ProductStatus.PENDING; + + protected Product() {} + + public Product(Long brandId, String name, Money price, String description) { + this(brandId, name, price, description, null); + } + + public Product(Long brandId, String name, Money price, String description, String thumbnailImageUrl) { + if (brandId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "브랜드 ID는 비어있을 수 없습니다."); + } + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품명은 비어있을 수 없습니다."); + } + if (price == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "가격은 비어있을 수 없습니다."); + } + this.brandId = brandId; + this.name = name; + this.price = price; + this.description = description; + this.thumbnailImageUrl = thumbnailImageUrl; + } + + public void activate() { + this.status = ProductStatus.ACTIVE; + } + + public void deactivate() { + this.status = ProductStatus.INACTIVE; + } + + public void markOutOfStock() { + this.status = ProductStatus.OUT_OF_STOCK; + } + + public void updateInfo(Long brandId, String name, Money price, String description, String thumbnailImageUrl) { + if (brandId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "브랜드 ID는 비어있을 수 없습니다."); + } + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품명은 비어있을 수 없습니다."); + } + if (price == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "가격은 비어있을 수 없습니다."); + } + this.brandId = brandId; + this.name = name; + this.price = price; + this.description = description; + this.thumbnailImageUrl = thumbnailImageUrl; + } + + public Long getBrandId() { + return brandId; + } + + public String getName() { + return name; + } + + public Long getPrice() { + return price.getValue(); + } + + public String getDescription() { + return description; + } + + public String getThumbnailImageUrl() { + return thumbnailImageUrl; + } + + public ProductStatus getStatus() { + return status; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductHistory.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductHistory.java new file mode 100644 index 000000000..3b1cae7cf --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductHistory.java @@ -0,0 +1,94 @@ +package com.loopers.domain.product; + +import com.loopers.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +@Entity +@Table(name = "product_history") +public class ProductHistory extends BaseEntity { + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "version", nullable = false) + private Integer version; + + @Column(name = "brand_id", nullable = false) + private Long brandId; + + @Column(name = "name", nullable = false, length = 200) + private String name; + + @Column(name = "price", nullable = false) + private Long price; + + @Column(name = "status", nullable = false, length = 20) + private String status; + + @Column(name = "description") + private String description; + + @Column(name = "changed_by", length = 100) + private String changedBy; + + protected ProductHistory() {} + + private ProductHistory(Long productId, int version, Long brandId, String name, Long price, + String status, String description, String changedBy) { + this.productId = productId; + this.version = version; + this.brandId = brandId; + this.name = name; + this.price = price; + this.status = status; + this.description = description; + this.changedBy = changedBy; + } + + public static ProductHistory snapshot(Product product, int version, String changedBy) { + return new ProductHistory( + product.getId(), + version, + product.getBrandId(), + product.getName(), + product.getPrice(), + product.getStatus().name(), + product.getDescription(), + changedBy + ); + } + + public Long getProductId() { + return productId; + } + + public Integer getVersion() { + return version; + } + + public Long getBrandId() { + return brandId; + } + + public String getName() { + return name; + } + + public Long getPrice() { + return price; + } + + public String getStatus() { + return status; + } + + public String getDescription() { + return description; + } + + public String getChangedBy() { + return changedBy; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductHistoryRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductHistoryRepository.java new file mode 100644 index 000000000..4efa4cf01 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductHistoryRepository.java @@ -0,0 +1,13 @@ +package com.loopers.domain.product; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface ProductHistoryRepository { + + ProductHistory save(ProductHistory history); + + Page findAllByProductId(Long productId, Pageable pageable); + + int countByProductId(Long productId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductImage.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductImage.java new file mode 100644 index 000000000..bbb38542a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductImage.java @@ -0,0 +1,40 @@ +package com.loopers.domain.product; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +@Entity +@Table(name = "product_image") +public class ProductImage extends BaseEntity { + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "image_url", nullable = false, length = 500) + private String imageUrl; + + protected ProductImage() {} + + public ProductImage(Long productId, String imageUrl) { + if (productId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 ID는 비어있을 수 없습니다."); + } + if (imageUrl == null || imageUrl.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "이미지 URL은 비어있을 수 없습니다."); + } + this.productId = productId; + this.imageUrl = imageUrl; + } + + public Long getProductId() { + return productId; + } + + public String getImageUrl() { + return imageUrl; + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductImageRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductImageRepository.java new file mode 100644 index 000000000..8d78b1d5d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductImageRepository.java @@ -0,0 +1,10 @@ +package com.loopers.domain.product; + +import java.util.List; + +public interface ProductImageRepository { + + ProductImage save(ProductImage productImage); + + List findAllByProductId(Long productId); +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductImageService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductImageService.java new file mode 100644 index 000000000..03327f421 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductImageService.java @@ -0,0 +1,5 @@ +package com.loopers.domain.product; + +public class ProductImageService { + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductOption.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductOption.java new file mode 100644 index 000000000..3b29c9ce6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductOption.java @@ -0,0 +1,70 @@ +package com.loopers.domain.product; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.common.Money; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.AttributeOverride; +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +@Entity +@Table(name = "product_option") +public class ProductOption extends BaseEntity { + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "name", nullable = false, length = 200) + private String name; + + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "price", nullable = false)) + private Money price; + + @Column(name = "stock_quantity", nullable = false) + private Long stockQuantity; + + protected ProductOption() {} + + public ProductOption(Long productId, String name, Money price, Long stockQuantity) { + if (productId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 ID는 비어있을 수 없습니다."); + } + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "옵션명은 비어있을 수 없습니다."); + } + if (price == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "가격은 비어있을 수 없습니다."); + } + if (stockQuantity == null || stockQuantity < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "재고 수량은 0 이상이어야 합니다."); + } + this.productId = productId; + this.name = name; + this.price = price; + this.stockQuantity = stockQuantity; + } + + public boolean isAvailable() { + return stockQuantity > 0; + } + + public Long getProductId() { + return productId; + } + + public String getName() { + return name; + } + + public Long getPrice() { + return price.getValue(); + } + + public Long getStockQuantity() { + return stockQuantity; + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductOptionRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductOptionRepository.java new file mode 100644 index 000000000..17761c6c3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductOptionRepository.java @@ -0,0 +1,12 @@ +package com.loopers.domain.product; + +import java.util.List; + +public interface ProductOptionRepository { + + ProductOption save(ProductOption productOption); + + List findAllByProductId(Long productId); + + List findAllByProductIds(List productIds); +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductOptionService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductOptionService.java new file mode 100644 index 000000000..ed26fe1ae --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductOptionService.java @@ -0,0 +1,5 @@ +package com.loopers.domain.product; + +public class ProductOptionService { + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductOptionStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductOptionStatus.java new file mode 100644 index 000000000..e031b7f25 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductOptionStatus.java @@ -0,0 +1,4 @@ +package com.loopers.domain.product; + +public enum ProductOptionStatus { +} 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..9c1dd86e4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -0,0 +1,21 @@ +package com.loopers.domain.product; + +import java.util.List; +import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface ProductRepository { + + Product save(Product product); + + Optional findById(Long id); + + List findAllByIds(List ids); + + List findAllByBrandId(Long brandId); + + Page findAll(Long brandId, ProductSortType sortType, Pageable pageable); + + Page findAll(Long brandId, ProductSortType sortType, List statuses, Pageable pageable); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java new file mode 100644 index 000000000..64714996c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java @@ -0,0 +1,87 @@ +package com.loopers.domain.product; + +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 java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Component +public class ProductService { + + private final ProductRepository productRepository; + private final BrandRepository brandRepository; + private final ProductOptionRepository productOptionRepository; + private final ProductImageRepository productImageRepository; + + @Transactional(readOnly = true) + public List getProducts(List productIds) { + List products = productRepository.findAllByIds(productIds); + if (products.size() != productIds.size()) { + throw new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 상품이 포함되어 있습니다."); + } + return products; + } + + @Transactional(readOnly = true) + public Product getProduct(Long productId) { + return productRepository.findById(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 상품입니다.")); + } + + @Transactional(readOnly = true) + public List getBrands(List brandIds) { + List brands = brandRepository.findAllByIds(brandIds); + if (brands.size() != brandIds.size()) { + throw new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 브랜드가 포함되어 있습니다."); + } + return brands; + } + + @Transactional(readOnly = true) + public ProductDetail getProductDetail(Long productId) { + Product product = productRepository.findById(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 상품입니다.")); + Brand brand = brandRepository.findById(product.getBrandId()) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 브랜드입니다.")); + List options = productOptionRepository.findAllByProductId(productId); + List images = productImageRepository.findAllByProductId(productId); + return new ProductDetail(product, brand, options, images); + } + + @Transactional(readOnly = true) + public Page getProductList(Long brandId, ProductSortType sortType, List statuses, Pageable pageable) { + Page products = productRepository.findAll(brandId, sortType, statuses, pageable); + List productIds = products.getContent().stream().map(Product::getId).toList(); + List brandIds = products.getContent().stream().map(Product::getBrandId).distinct().toList(); + + Map brandMap = brandRepository.findAllByIds(brandIds).stream() + .collect(Collectors.toMap(Brand::getId, b -> b)); + Map minPriceMap = productOptionRepository.findAllByProductIds(productIds).stream() + .collect(Collectors.groupingBy( + ProductOption::getProductId, + Collectors.collectingAndThen( + Collectors.minBy(java.util.Comparator.comparingLong(ProductOption::getPrice)), + opt -> opt.map(ProductOption::getPrice).orElse(0L) + ) + )); + + return products.map(product -> new ProductListItem( + product, + brandMap.get(product.getBrandId()), + minPriceMap.getOrDefault(product.getId(), 0L) + )); + } + + public record ProductDetail(Product product, Brand brand, List options, List images) {} + + public record ProductListItem(Product product, Brand brand, Long minPrice) {} +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSortType.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSortType.java new file mode 100644 index 000000000..0fc76fa80 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSortType.java @@ -0,0 +1,18 @@ +package com.loopers.domain.product; + +public enum ProductSortType { + LATEST, + PRICE_ASC, + LIKES_DESC; + + public static ProductSortType from(String value) { + if (value == null) { + return LATEST; + } + return switch (value.toLowerCase()) { + case "price_asc" -> PRICE_ASC; + case "likes_desc" -> LIKES_DESC; + default -> LATEST; + }; + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductStatus.java new file mode 100644 index 000000000..4f8ef1693 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductStatus.java @@ -0,0 +1,5 @@ +package com.loopers.domain.product; + +public enum ProductStatus { + PENDING, ACTIVE, INACTIVE, SCHEDULED, OUT_OF_STOCK +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/stock/Stock.java b/apps/commerce-api/src/main/java/com/loopers/domain/stock/Stock.java new file mode 100644 index 000000000..66738a084 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/stock/Stock.java @@ -0,0 +1,56 @@ +package com.loopers.domain.stock; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.common.Quantity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; + +@Entity +@Table( + name = "stock", + uniqueConstraints = @UniqueConstraint(name = "uk_stock_product_id", columnNames = "product_id") +) +public class Stock extends BaseEntity { + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "quantity", nullable = false) + private Long quantity; + + protected Stock() {} + + public Stock(Long productId, Long quantity) { + if (productId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 ID는 비어있을 수 없습니다."); + } + if (quantity == null || quantity < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "재고 수량은 0 이상이어야 합니다."); + } + this.productId = productId; + this.quantity = quantity; + } + + public Long getProductId() { + return productId; + } + + public Long getQuantity() { + return quantity; + } + + public boolean hasEnoughStock(Quantity amount) { + return this.quantity >= amount.getValue(); + } + + public void deduct(Quantity amount) { + if (!hasEnoughStock(amount)) { + throw new CoreException(ErrorType.BAD_REQUEST, "재고가 부족합니다. [productId=" + productId + "]"); + } + this.quantity -= amount.getValue(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockRepository.java new file mode 100644 index 000000000..922bf84cb --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockRepository.java @@ -0,0 +1,10 @@ +package com.loopers.domain.stock; + +import java.util.Optional; + +public interface StockRepository { + + Stock save(Stock stock); + + Optional findByProductIdWithLock(Long productId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/users/UserRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/users/UserRepository.java new file mode 100644 index 000000000..67fd35833 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/users/UserRepository.java @@ -0,0 +1,9 @@ +package com.loopers.domain.users; + +import java.util.Optional; + +public interface UserRepository { + Users save(Users users); + Optional findByLoginId(String loginId); + boolean existsByLoginId(String loginId); +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/users/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/users/UserService.java new file mode 100644 index 000000000..5ac241cab --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/users/UserService.java @@ -0,0 +1,84 @@ +package com.loopers.domain.users; + +import com.loopers.domain.users.vo.Email; +import com.loopers.domain.users.vo.EncryptedPassword; +import com.loopers.domain.users.vo.LoginId; +import com.loopers.domain.users.vo.RawPassword; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + + +// 유즈케이스 흐름을 책임지도록!, Facade에서 엔티티를 만들지 않게 하려면, 서비스가 입력값을 받아서 엔티티를 생성 +@RequiredArgsConstructor +@Component +public class UserService { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + + @Transactional + public Users register(String loginId, String rawPassword, String name, String birthDate, String email) { + + // 1) 중복 체크 (최종 방어는 DB unique constraint) + if (userRepository.existsByLoginId(loginId)) { + throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 아이디입니다."); + } + + // 2) VO 생성 + 검증 (Service가 담당) + LoginId lid = LoginId.of(loginId); + Email em = Email.of(email); + RawPassword rp = RawPassword.of(rawPassword, birthDate); + EncryptedPassword ep = EncryptedPassword.of(passwordEncoder.encode(rp.value())); + + // 3) 엔티티 생성 — 이미 준비된 VO만 전달 + Users users = Users.create(lid, ep, name, birthDate, em); + + // 4) 저장 + 최종 중복 방어 + try { + return userRepository.save(users); + } catch (DataIntegrityViolationException e) { + throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 아이디입니다."); + } + } + + @Transactional(readOnly = true) + public Users authenticate(String loginId, String password) { + Users users = getMember(loginId); + + if (!passwordEncoder.matches(password, users.getPassword())) { + throw new CoreException(ErrorType.UNAUTHORIZED, "인증 실패"); + } + + return users; + } + + @Transactional(readOnly = true) + public Users getMember(String loginId) { + return userRepository.findByLoginId(loginId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, + "[id = " + loginId + "] 회원을 찾을 수 없습니다.")); + } + + @Transactional + public void changePassword(String loginId, String prevPassword, String newPassword) { + Users users = getMember(loginId); + + // 입력한 기존 비밀번호 vs DB 암호화 값 비교 + if (!passwordEncoder.matches(prevPassword, users.getPassword())) { + throw new CoreException(ErrorType.UNAUTHORIZED, "기존 비밀번호가 일치하지 않습니다."); + } + + // 새 비밀번호 = 기존 비밀번호 중복 방지 + if (passwordEncoder.matches(newPassword, users.getPassword())) { + throw new CoreException(ErrorType.BAD_REQUEST, "새 비밀번호는 기존 비밀번호와 달라야 합니다."); + } + + // VO 검증 + 암호화 + Dirty Checking으로 자동 저장 + users.changePassword(newPassword, raw -> passwordEncoder.encode(raw)); + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/users/Users.java b/apps/commerce-api/src/main/java/com/loopers/domain/users/Users.java new file mode 100644 index 000000000..6a43d8587 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/users/Users.java @@ -0,0 +1,102 @@ +package com.loopers.domain.users; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.users.vo.Email; +import com.loopers.domain.users.vo.EncryptedPassword; +import com.loopers.domain.users.vo.LoginId; +import com.loopers.domain.users.vo.RawPassword; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.AttributeOverride; +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.util.function.Function; + +@Entity +@Table( + name = "users", + uniqueConstraints = { + @UniqueConstraint(name = "uk_users_login_id", columnNames = "login_id"), + @UniqueConstraint(name = "uk_users_email", columnNames = "email") + } +) +public class Users extends BaseEntity { + + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "login_id", nullable = false, length = 50)) + private LoginId loginId; + + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "password", nullable = false, length = 255)) + private EncryptedPassword password; + + @Column(name = "name", nullable = false, length = 50) + private String name; + + @Column(name = "birth_date", nullable = false, length = 8) + private String birthDate; + + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "email", nullable = false, length = 255)) + private Email email; + + protected Users() {} + + private Users(LoginId loginId, EncryptedPassword password, String name, String birthDate, Email email) { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "이름은 비어있을 수 없습니다."); + } + if (birthDate == null || birthDate.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 비어있을 수 없습니다."); + } + this.loginId = loginId; + this.password = password; + this.name = name; + this.birthDate = birthDate; + this.email = email; + } + + /** + * 회원가입 팩토리 메서드 + * - 이미 검증·암호화된 VO를 받아서 엔티티를 생성 + * - 검증(RawPassword)과 암호화는 Service에서 담당 + */ + public static Users create(LoginId loginId, EncryptedPassword password, String name, String birthDate, Email email) { + return new Users(loginId, password, name, birthDate, email); + } + + public void changePassword(String newRawPassword, Function encoder) { + RawPassword rp = RawPassword.of(newRawPassword, this.birthDate); + this.password = EncryptedPassword.of(encoder.apply(rp.value())); + } + + public String getLoginId() { + return loginId.value(); + } + + public String getPassword() { + return password.value(); + } + + public String getName() { + return name; + } + + public String getBirthDate() { + return birthDate; + } + + public String getEmail() { + return email.value(); + } + + public String getMaskedName() { + if (name.length() == 1) { + return "*"; + } + return name.substring(0, name.length() - 1) + "*"; + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/users/vo/Email.java b/apps/commerce-api/src/main/java/com/loopers/domain/users/vo/Email.java new file mode 100644 index 000000000..6603fab48 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/users/vo/Email.java @@ -0,0 +1,47 @@ +package com.loopers.domain.users.vo; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Embeddable; +import java.util.Objects; + +@Embeddable +public class Email { + + private String value; + + protected Email() {} + + public Email(String value) { + if (value == null || value.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "이메일은 비어있을 수 없습니다."); + } + // 필요시 더 강한 정규식/라이브러리로 교체 + if (!value.contains("@")) { + throw new CoreException(ErrorType.BAD_REQUEST, "이메일 형식이 올바르지 않습니다."); + } + this.value = value; + } + + public static Email of(String value) { + return new Email(value); + } + + public String value() { + return value; + } + + @Override public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Email)) return false; + return Objects.equals(value, ((Email) o).value); + } + + @Override public int hashCode() { + return Objects.hash(value); + } + + @Override public String toString() { + return value; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/users/vo/EncryptedPassword.java b/apps/commerce-api/src/main/java/com/loopers/domain/users/vo/EncryptedPassword.java new file mode 100644 index 000000000..c9791bd08 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/users/vo/EncryptedPassword.java @@ -0,0 +1,47 @@ +package com.loopers.domain.users.vo; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Embeddable; +import java.util.Objects; + +// Password를 Raw/Encrypted로 분리하면 “평문이 엔티티/DB에 남는 실수”를 구조적으로 막을 수 있어요. +// @Embeddable을 쓰면 VO 내부 필드가 부모 테이블의 컬럼으로 펼쳐집니다. JPA가 객체를 재구성할 때 기본 생성자가 필요합니다. +@Embeddable +public class EncryptedPassword { + + private String value; + + protected EncryptedPassword() {} + + public EncryptedPassword(String value) { + if (value == null || value.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 비어있을 수 없습니다."); + } + this.value = value; + } + + public static EncryptedPassword of(String value) { + return new EncryptedPassword(value); + } + + public String value() { + return value; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof EncryptedPassword)) { + return false; + } + return Objects.equals(value, ((EncryptedPassword) o).value); + } + + @Override + public int hashCode() { + return Objects.hash(value); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/users/vo/LoginId.java b/apps/commerce-api/src/main/java/com/loopers/domain/users/vo/LoginId.java new file mode 100644 index 000000000..ce223e1ff --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/users/vo/LoginId.java @@ -0,0 +1,55 @@ +package com.loopers.domain.users.vo; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Embeddable; +import java.util.Objects; + +// VO는 도메인 규칙(형식/제약)을 “자기 책임”으로 갖게 되고, +@Embeddable +public class LoginId { + + private String value; + + protected LoginId() {} + + public LoginId(String value) { + if (value == null || value.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "아이디는 비어있을 수 없습니다."); + } + if (!value.matches("^[a-zA-Z0-9]+$")) { + throw new CoreException(ErrorType.BAD_REQUEST, "로그인 아이디는 영문자와 숫자만 사용할 수 있습니다."); + } + this.value = value; + } + + public static LoginId of(String value) { + return new LoginId(value); + } + + public String value() { + return value; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof LoginId)) { + return false; + } + return Objects.equals(value, ((LoginId) o).value); + } + + @Override + public int hashCode() { + return Objects.hash(value); + } + + @Override + public String toString() { + return value; + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/users/vo/RawPassword.java b/apps/commerce-api/src/main/java/com/loopers/domain/users/vo/RawPassword.java new file mode 100644 index 000000000..f0db5c713 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/users/vo/RawPassword.java @@ -0,0 +1,33 @@ +package com.loopers.domain.users.vo; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +public final class RawPassword { + + private final String value; + + private RawPassword(String value, String birthDate) { + if (value == null || value.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 비어있을 수 없습니다."); + } + if (value.length() < 8 || value.length() > 16) { + throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 8~16자여야 합니다."); + } + if (!value.matches("^[a-zA-Z0-9!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>/?]+$")) { + throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 영문 대소문자, 숫자, 특수문자만 가능합니다."); + } + if (birthDate != null && !birthDate.isBlank() && value.contains(birthDate)) { + throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호에 생년월일을 포함할 수 없습니다."); + } + this.value = value; + } + + public static RawPassword of(String value, String birthDate) { + return new RawPassword(value, birthDate); + } + + public String value() { + return value; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandEntity.java new file mode 100644 index 000000000..b79633333 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandEntity.java @@ -0,0 +1,5 @@ +package com.loopers.infrastructure.brand; + +public class BrandEntity { + +} 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..3cdf69f39 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java @@ -0,0 +1,19 @@ +package com.loopers.infrastructure.brand; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandStatus; +import java.util.List; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface BrandJpaRepository extends JpaRepository { + + List findAllByIdIn(List ids); + + // status가 null이면 전체, 아니면 해당 상태만 반환 + @Query("SELECT b FROM Brand b WHERE (:status IS NULL OR b.status = :status)") + Page findAllByStatusFilter(@Param("status") BrandStatus status, Pageable pageable); +} 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..d39c5f546 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java @@ -0,0 +1,45 @@ +package com.loopers.infrastructure.brand; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.brand.BrandStatus; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +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.findById(id); + } + + @Override + public List findAllByIds(List ids) { + return brandJpaRepository.findAllByIdIn(ids); + } + + @Override + public Page findAll(BrandStatus status, Pageable pageable) { + // 기본 정렬: createdAt 내림차순 + Pageable sortedPageable = PageRequest.of( + pageable.getPageNumber(), pageable.getPageSize(), + Sort.by(Sort.Direction.DESC, "createdAt") + ); + return brandJpaRepository.findAllByStatusFilter(status, sortedPageable); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeEntity.java new file mode 100644 index 000000000..a64b8d292 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeEntity.java @@ -0,0 +1,5 @@ +package com.loopers.infrastructure.like; + +public class LikeEntity { + +} 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..fa042b4d6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java @@ -0,0 +1,5 @@ +package com.loopers.infrastructure.like; + +public class LikeRepositoryImpl { + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderEntity.java new file mode 100644 index 000000000..e37103368 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderEntity.java @@ -0,0 +1,5 @@ +package com.loopers.infrastructure.order; + +public class OrderEntity { + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemEntity.java new file mode 100644 index 000000000..b4dc1348a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemEntity.java @@ -0,0 +1,5 @@ +package com.loopers.infrastructure.order; + +public class OrderItemEntity { + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemJpaRepository.java new file mode 100644 index 000000000..9d82b0259 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemJpaRepository.java @@ -0,0 +1,7 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.OrderItem; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface OrderItemJpaRepository extends JpaRepository { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemRepositoryImpl.java new file mode 100644 index 000000000..a1f9e17b2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemRepositoryImpl.java @@ -0,0 +1,19 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.OrderItem; +import com.loopers.domain.order.OrderItemRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class OrderItemRepositoryImpl implements OrderItemRepository { + + private final OrderItemJpaRepository orderItemJpaRepository; + + @Override + public List saveAll(List orderItems) { + return orderItemJpaRepository.saveAll(orderItems); + } +} 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..f2ee62050 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java @@ -0,0 +1,7 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.Order; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface OrderJpaRepository extends JpaRepository { +} 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..5ea7ca142 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java @@ -0,0 +1,18 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class OrderRepositoryImpl implements OrderRepository { + + private final OrderJpaRepository orderJpaRepository; + + @Override + public Order save(Order order) { + return orderJpaRepository.save(order); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java new file mode 100644 index 000000000..1d3fe4dbe --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java @@ -0,0 +1,5 @@ +package com.loopers.infrastructure.product; + +public class ProductEntity { + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductHistoryJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductHistoryJpaRepository.java new file mode 100644 index 000000000..c90ba432e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductHistoryJpaRepository.java @@ -0,0 +1,13 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.ProductHistory; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ProductHistoryJpaRepository extends JpaRepository { + + Page findAllByProductId(Long productId, Pageable pageable); + + int countByProductId(Long productId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductHistoryRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductHistoryRepositoryImpl.java new file mode 100644 index 000000000..0d6e2ac53 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductHistoryRepositoryImpl.java @@ -0,0 +1,30 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.ProductHistory; +import com.loopers.domain.product.ProductHistoryRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class ProductHistoryRepositoryImpl implements ProductHistoryRepository { + + private final ProductHistoryJpaRepository productHistoryJpaRepository; + + @Override + public ProductHistory save(ProductHistory history) { + return productHistoryJpaRepository.save(history); + } + + @Override + public Page findAllByProductId(Long productId, Pageable pageable) { + return productHistoryJpaRepository.findAllByProductId(productId, pageable); + } + + @Override + public int countByProductId(Long productId) { + return productHistoryJpaRepository.countByProductId(productId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductImageEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductImageEntity.java new file mode 100644 index 000000000..49c91b6ec --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductImageEntity.java @@ -0,0 +1,5 @@ +package com.loopers.infrastructure.product; + +public class ProductImageEntity { + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductImageJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductImageJpaRepository.java new file mode 100644 index 000000000..02a3dfcc5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductImageJpaRepository.java @@ -0,0 +1,10 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.ProductImage; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ProductImageJpaRepository extends JpaRepository { + + List findAllByProductId(Long productId); +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductImageRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductImageRepositoryImpl.java new file mode 100644 index 000000000..537010cce --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductImageRepositoryImpl.java @@ -0,0 +1,24 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.ProductImage; +import com.loopers.domain.product.ProductImageRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class ProductImageRepositoryImpl implements ProductImageRepository { + + private final ProductImageJpaRepository productImageJpaRepository; + + @Override + public ProductImage save(ProductImage productImage) { + return productImageJpaRepository.save(productImage); + } + + @Override + public List findAllByProductId(Long productId) { + return productImageJpaRepository.findAllByProductId(productId); + } +} \ No newline at end of file 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..61e3b5352 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java @@ -0,0 +1,27 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductStatus; +import java.util.List; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface ProductJpaRepository extends JpaRepository { + + List findAllByIdIn(List ids); + + List findAllByBrandId(Long brandId); + + @Query("SELECT p FROM Product p WHERE (:brandId IS NULL OR p.brandId = :brandId)") + Page findAllByBrandIdFilter(@Param("brandId") Long brandId, Pageable pageable); + + @Query("SELECT p FROM Product p WHERE (:brandId IS NULL OR p.brandId = :brandId) AND p.status IN :statuses") + Page findAllByStatusFilter( + @Param("brandId") Long brandId, + @Param("statuses") List statuses, + Pageable pageable + ); +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductOptionEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductOptionEntity.java new file mode 100644 index 000000000..cfed0518b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductOptionEntity.java @@ -0,0 +1,5 @@ +package com.loopers.infrastructure.product; + +public class ProductOptionEntity { + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductOptionJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductOptionJpaRepository.java new file mode 100644 index 000000000..7cec620b0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductOptionJpaRepository.java @@ -0,0 +1,12 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.ProductOption; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ProductOptionJpaRepository extends JpaRepository { + + List findAllByProductId(Long productId); + + List findAllByProductIdIn(List productIds); +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductOptionRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductOptionRepositoryImpl.java new file mode 100644 index 000000000..baa17c72f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductOptionRepositoryImpl.java @@ -0,0 +1,29 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.ProductOption; +import com.loopers.domain.product.ProductOptionRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class ProductOptionRepositoryImpl implements ProductOptionRepository { + + private final ProductOptionJpaRepository productOptionJpaRepository; + + @Override + public ProductOption save(ProductOption productOption) { + return productOptionJpaRepository.save(productOption); + } + + @Override + public List findAllByProductId(Long productId) { + return productOptionJpaRepository.findAllByProductId(productId); + } + + @Override + public List findAllByProductIds(List productIds) { + return productOptionJpaRepository.findAllByProductIdIn(productIds); + } +} \ No newline at end of file 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..7e5f6142d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -0,0 +1,60 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.ProductSortType; +import com.loopers.domain.product.ProductStatus; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +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.findById(id); + } + + @Override + public List findAllByIds(List ids) { + return productJpaRepository.findAllByIdIn(ids); + } + + @Override + public List findAllByBrandId(Long brandId) { + return productJpaRepository.findAllByBrandId(brandId); + } + + @Override + public Page findAll(Long brandId, ProductSortType sortType, Pageable pageable) { + Pageable sortedPageable = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), resolveSort(sortType)); + return productJpaRepository.findAllByBrandIdFilter(brandId, sortedPageable); + } + + @Override + public Page findAll(Long brandId, ProductSortType sortType, List statuses, Pageable pageable) { + Pageable sortedPageable = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), resolveSort(sortType)); + return productJpaRepository.findAllByStatusFilter(brandId, statuses, sortedPageable); + } + + private Sort resolveSort(ProductSortType sortType) { + return switch (sortType) { + case PRICE_ASC -> Sort.by(Sort.Direction.ASC, "price.value"); + default -> Sort.by(Sort.Direction.DESC, "createdAt"); + }; + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockJpaRepository.java new file mode 100644 index 000000000..b2b0d7788 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockJpaRepository.java @@ -0,0 +1,16 @@ +package com.loopers.infrastructure.stock; + +import com.loopers.domain.stock.Stock; +import jakarta.persistence.LockModeType; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface StockJpaRepository extends JpaRepository { + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT s FROM Stock s WHERE s.productId = :productId") + Optional findByProductIdWithLock(@Param("productId") Long productId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockRepositoryImpl.java new file mode 100644 index 000000000..9f9924730 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockRepositoryImpl.java @@ -0,0 +1,24 @@ +package com.loopers.infrastructure.stock; + +import com.loopers.domain.stock.Stock; +import com.loopers.domain.stock.StockRepository; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class StockRepositoryImpl implements StockRepository { + + private final StockJpaRepository stockJpaRepository; + + @Override + public Stock save(Stock stock) { + return stockJpaRepository.save(stock); + } + + @Override + public Optional findByProductIdWithLock(Long productId) { + return stockJpaRepository.findByProductIdWithLock(productId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/users/UserEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/users/UserEntity.java new file mode 100644 index 000000000..37aec8699 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/users/UserEntity.java @@ -0,0 +1,5 @@ +package com.loopers.infrastructure.users; + +public class UserEntity { + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/users/UserJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/users/UserJpaRepository.java new file mode 100644 index 000000000..1defe830c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/users/UserJpaRepository.java @@ -0,0 +1,14 @@ +package com.loopers.infrastructure.users; + +import com.loopers.domain.users.Users; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserJpaRepository extends JpaRepository { + + // loginId 필드는 @Embedded LoginId 타입 → "loginId.value" 경로로 조회 + Optional findByLoginIdValue(String value); + + boolean existsByLoginId(String loginId); + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/users/UserRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/users/UserRepositoryImpl.java new file mode 100644 index 000000000..1d18151b4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/users/UserRepositoryImpl.java @@ -0,0 +1,29 @@ +package com.loopers.infrastructure.users; + +import com.loopers.domain.users.UserRepository; +import com.loopers.domain.users.Users; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class UserRepositoryImpl implements UserRepository { + + private final UserJpaRepository userJpaRepository; + + @Override + public Users save(Users users) { + return userJpaRepository.save(users); + } + + @Override + public Optional findByLoginId(String loginId) { + return userJpaRepository.findByLoginIdValue(loginId); + } + + @Override + public boolean existsByLoginId(String loginId) { + return userJpaRepository.findByLoginIdValue(loginId).isPresent(); + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminAuthInterceptor.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminAuthInterceptor.java new file mode 100644 index 000000000..c91846292 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminAuthInterceptor.java @@ -0,0 +1,37 @@ +package com.loopers.interfaces.api.admin; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +/** + * 어드민 API LDAP 인증 인터셉터. + * Body 파싱 이전 단계에서 X-Loopers-Ldap 헤더를 검사하여, + * 유효하지 않은 요청에 대해 즉시 403을 반환하고 처리를 중단한다. + */ +@Component +public class AdminAuthInterceptor implements HandlerInterceptor { + + private static final String ADMIN_LDAP = "loopers.admin"; + private static final String LDAP_HEADER = "X-Loopers-Ldap"; + + // Map.of()는 null 값을 허용하지 않으므로 JSON 문자열을 직접 작성한다. + private static final String FORBIDDEN_BODY = + "{\"meta\":{\"code\":\"Forbidden\",\"message\":\"접근 권한이 없습니다.\"},\"data\":null}"; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { + String ldap = request.getHeader(LDAP_HEADER); + if (!ADMIN_LDAP.equals(ldap)) { + response.setStatus(HttpStatus.FORBIDDEN.value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + response.getWriter().write(FORBIDDEN_BODY); + return false; + } + return true; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminBrandV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminBrandV1Dto.java new file mode 100644 index 000000000..bcbe17b15 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminBrandV1Dto.java @@ -0,0 +1,33 @@ +package com.loopers.interfaces.api.admin; + +import com.loopers.application.brand.AdminBrandInfo; +import java.time.ZonedDateTime; + +public class AdminBrandV1Dto { + + public record CreateBrandRequest(String name, String description, String logoImageUrl) {} + + public record UpdateBrandRequest(String name, String description, String logoImageUrl) {} + + public record AdminBrandResponse( + Long brandId, + String name, + String description, + String logoImageUrl, + String status, + ZonedDateTime createdAt, + ZonedDateTime updatedAt + ) { + public static AdminBrandResponse from(AdminBrandInfo info) { + return new AdminBrandResponse( + info.brandId(), + info.name(), + info.description(), + info.logoImageUrl(), + info.status(), + info.createdAt(), + info.updatedAt() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminController.java new file mode 100644 index 000000000..6adb4ebf3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminController.java @@ -0,0 +1,170 @@ +package com.loopers.interfaces.api.admin; + +import com.loopers.application.brand.AdminBrandFacade; +import com.loopers.application.product.AdminProductFacade; +import com.loopers.domain.brand.BrandStatus; +import com.loopers.domain.product.ProductStatus; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.admin.AdminBrandV1Dto.AdminBrandResponse; +import com.loopers.interfaces.api.admin.AdminBrandV1Dto.CreateBrandRequest; +import com.loopers.interfaces.api.admin.AdminBrandV1Dto.UpdateBrandRequest; +import com.loopers.interfaces.api.admin.AdminProductV1Dto.AdminProductResponse; +import com.loopers.interfaces.api.admin.AdminProductV1Dto.CreateProductRequest; +import com.loopers.interfaces.api.admin.AdminProductV1Dto.PageResponse; +import com.loopers.interfaces.api.admin.AdminProductV1Dto.ProductHistoryResponse; +import com.loopers.interfaces.api.admin.AdminProductV1Dto.UpdateProductRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +/** + * 어드민 브랜드/상품 관리 API. + * LDAP 인증은 AdminAuthInterceptor에서 body 파싱 이전에 처리하므로 + * 이 컨트롤러는 인증이 완료된 요청만 처리한다. + */ +@RequiredArgsConstructor +@RestController +@RequestMapping("/api-admin/v1") +public class AdminController implements AdminV1ApiSpec { + + private final AdminBrandFacade adminBrandFacade; + private final AdminProductFacade adminProductFacade; + + // ───────────────────────────────────────────── + // 브랜드 관리 + // ───────────────────────────────────────────── + + @PostMapping("/brands") + @ResponseStatus(HttpStatus.CREATED) + @Override + public ApiResponse createBrand(@RequestBody CreateBrandRequest request) { + return ApiResponse.success(AdminBrandResponse.from( + adminBrandFacade.createBrand(request.name(), request.description(), request.logoImageUrl()) + )); + } + + @PutMapping("/brands/{brandId}") + @Override + public ApiResponse updateBrand(@PathVariable Long brandId, @RequestBody UpdateBrandRequest request) { + return ApiResponse.success(AdminBrandResponse.from( + adminBrandFacade.updateBrand(brandId, request.name(), request.description(), request.logoImageUrl()) + )); + } + + /** + * 브랜드를 비활성화(INACTIVE)한다. + * 연관 상품의 비활성화는 BrandDeactivatedEvent를 통해 비동기로 처리된다. + */ + @DeleteMapping("/brands/{brandId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + @Override + public void deactivateBrand(@PathVariable Long brandId) { + adminBrandFacade.deactivateBrand(brandId); + } + + @GetMapping("/brands/{brandId}") + @Override + public ApiResponse getBrand(@PathVariable Long brandId) { + // 어드민은 INACTIVE 포함 모든 상태의 브랜드 조회 가능 + return ApiResponse.success(AdminBrandResponse.from(adminBrandFacade.getBrandInfo(brandId))); + } + + @GetMapping("/brands") + @Override + public ApiResponse> getBrandList( + @RequestParam(required = false) String status, + @RequestParam(required = false, defaultValue = "0") int page, + @RequestParam(required = false, defaultValue = "20") int size + ) { + // status 문자열을 BrandStatus enum으로 변환 (null이면 전체 조회) + BrandStatus brandStatus = status != null ? BrandStatus.valueOf(status) : null; + return ApiResponse.success(PageResponse.from( + adminBrandFacade.getBrandList(brandStatus, page, size).map(AdminBrandResponse::from) + )); + } + + // ───────────────────────────────────────────── + // 상품 관리 + // ───────────────────────────────────────────── + + /** + * 상품을 등록한다. + * 등록 시 ProductHistory 스냅샷이 1건 자동 생성된다. + */ + @PostMapping("/products") + @ResponseStatus(HttpStatus.CREATED) + @Override + public ApiResponse createProduct(@RequestBody CreateProductRequest request) { + return ApiResponse.success(AdminProductResponse.from( + adminProductFacade.createProduct( + request.brandId(), request.name(), request.price(), + request.description(), request.thumbnailImageUrl() + ) + )); + } + + /** + * 상품 정보를 수정한다. + * 수정 시 ProductHistory 스냅샷이 1건 추가된다. + */ + @PutMapping("/products/{productId}") + @Override + public ApiResponse updateProduct(@PathVariable Long productId, @RequestBody UpdateProductRequest request) { + return ApiResponse.success(AdminProductResponse.from( + adminProductFacade.updateProduct( + productId, request.brandId(), request.name(), request.price(), + request.description(), request.thumbnailImageUrl() + ) + )); + } + + @DeleteMapping("/products/{productId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + @Override + public void deactivateProduct(@PathVariable Long productId) { + adminProductFacade.deactivateProduct(productId); + } + + @GetMapping("/products/{productId}") + @Override + public ApiResponse getProduct(@PathVariable Long productId) { + // 어드민은 PENDING/INACTIVE 포함 모든 상태의 상품 조회 가능 + return ApiResponse.success(AdminProductResponse.from(adminProductFacade.getProductInfo(productId))); + } + + @GetMapping("/products") + @Override + public ApiResponse> getProductList( + @RequestParam(required = false) Long brandId, + @RequestParam(required = false) String status, + @RequestParam(required = false, defaultValue = "0") int page, + @RequestParam(required = false, defaultValue = "20") int size + ) { + // status 문자열을 ProductStatus enum으로 변환 (null이면 전체 조회) + ProductStatus productStatus = status != null ? ProductStatus.valueOf(status) : null; + return ApiResponse.success(PageResponse.from( + adminProductFacade.getProductList(brandId, productStatus, page, size).map(AdminProductResponse::from) + )); + } + + @GetMapping("/products/{productId}/history") + @Override + public ApiResponse> getProductHistory( + @PathVariable Long productId, + @RequestParam(required = false, defaultValue = "0") int page, + @RequestParam(required = false, defaultValue = "20") int size + ) { + return ApiResponse.success(PageResponse.from( + adminProductFacade.getProductHistory(productId, page, size).map(ProductHistoryResponse::from) + )); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminProductV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminProductV1Dto.java new file mode 100644 index 000000000..89d446af2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminProductV1Dto.java @@ -0,0 +1,74 @@ +package com.loopers.interfaces.api.admin; + +import com.loopers.application.product.AdminProductInfo; +import com.loopers.application.product.ProductHistoryInfo; +import java.time.ZonedDateTime; +import java.util.List; +import org.springframework.data.domain.Page; + +public class AdminProductV1Dto { + + public record CreateProductRequest(Long brandId, String name, Long price, String description, String thumbnailImageUrl) {} + + public record UpdateProductRequest(Long brandId, String name, Long price, String description, String thumbnailImageUrl) {} + + public record AdminProductResponse( + Long productId, + String name, + String description, + Long brandId, + String thumbnailImageUrl, + Long price, + String status, + ZonedDateTime createdAt, + ZonedDateTime updatedAt + ) { + public static AdminProductResponse from(AdminProductInfo info) { + return new AdminProductResponse( + info.productId(), + info.name(), + info.description(), + info.brandId(), + info.thumbnailImageUrl(), + info.price(), + info.status(), + info.createdAt(), + info.updatedAt() + ); + } + } + + public record ProductHistoryResponse( + Long historyId, + Integer version, + String name, + Long price, + String status, + String changedBy, + ZonedDateTime changedAt + ) { + public static ProductHistoryResponse from(ProductHistoryInfo info) { + return new ProductHistoryResponse( + info.historyId(), + info.version(), + info.name(), + info.price(), + info.status(), + info.changedBy(), + info.changedAt() + ); + } + } + + public record PageResponse(List content, long totalElements, int totalPages, int page, int size) { + public static PageResponse from(Page page) { + return new PageResponse<>( + page.getContent(), + page.getTotalElements(), + page.getTotalPages(), + page.getNumber(), + page.getSize() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminV1ApiSpec.java new file mode 100644 index 000000000..82df036be --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminV1ApiSpec.java @@ -0,0 +1,83 @@ +package com.loopers.interfaces.api.admin; + +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.admin.AdminBrandV1Dto.AdminBrandResponse; +import com.loopers.interfaces.api.admin.AdminBrandV1Dto.CreateBrandRequest; +import com.loopers.interfaces.api.admin.AdminBrandV1Dto.UpdateBrandRequest; +import com.loopers.interfaces.api.admin.AdminProductV1Dto.AdminProductResponse; +import com.loopers.interfaces.api.admin.AdminProductV1Dto.CreateProductRequest; +import com.loopers.interfaces.api.admin.AdminProductV1Dto.PageResponse; +import com.loopers.interfaces.api.admin.AdminProductV1Dto.ProductHistoryResponse; +import com.loopers.interfaces.api.admin.AdminProductV1Dto.UpdateProductRequest; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; + +@Tag(name = "Admin", description = "어드민 브랜드/상품 API") +public interface AdminV1ApiSpec { + + // ───────────────────────────────────────────── + // 브랜드 관리 + // ───────────────────────────────────────────── + + @PostMapping("/brands") + @ResponseStatus(HttpStatus.CREATED) + ApiResponse createBrand(@RequestBody CreateBrandRequest request); + + @PutMapping("/brands/{brandId}") + ApiResponse updateBrand(@PathVariable Long brandId, @RequestBody UpdateBrandRequest request); + + @DeleteMapping("/brands/{brandId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + void deactivateBrand(@PathVariable Long brandId); + + @GetMapping("/brands/{brandId}") + ApiResponse getBrand(@PathVariable Long brandId); + + @GetMapping("/brands") + ApiResponse> getBrandList( + @RequestParam(required = false) String status, + @RequestParam(required = false, defaultValue = "0") int page, + @RequestParam(required = false, defaultValue = "20") int size + ); + + // ───────────────────────────────────────────── + // 상품 관리 + // ───────────────────────────────────────────── + + @PostMapping("/products") + @ResponseStatus(HttpStatus.CREATED) + ApiResponse createProduct(@RequestBody CreateProductRequest request); + + @PutMapping("/products/{productId}") + ApiResponse updateProduct(@PathVariable Long productId, @RequestBody UpdateProductRequest request); + + @DeleteMapping("/products/{productId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + void deactivateProduct(@PathVariable Long productId); + + @GetMapping("/products/{productId}") + ApiResponse getProduct(@PathVariable Long productId); + + @GetMapping("/products") + ApiResponse> getProductList( + @RequestParam(required = false) Long brandId, + @RequestParam(required = false) String status, + @RequestParam(required = false, defaultValue = "0") int page, + @RequestParam(required = false, defaultValue = "20") int size + ); + + @GetMapping("/products/{productId}/history") + ApiResponse> getProductHistory( + @PathVariable Long productId, + @RequestParam(required = false, defaultValue = "0") int page, + @RequestParam(required = false, defaultValue = "20") int size + ); +} 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..0506ac89a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandController.java @@ -0,0 +1,30 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.application.brand.BrandFacade; +import com.loopers.application.brand.BrandInfo; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.brand.BrandV1Dto.BrandResponse; +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/brands") +public class BrandController implements BrandV1ApiSpec { + + private final BrandFacade brandFacade; + + @GetMapping("/{brandId}") + @Override + public ApiResponse getBrands( + @PathVariable(value = "brandId") Long brandId + ) { + BrandInfo info = brandFacade.getBrandInfo(brandId); + BrandV1Dto.BrandResponse response = BrandV1Dto.BrandResponse.from(info); + return ApiResponse.success(response); + } + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1ApiSpec.java new file mode 100644 index 000000000..f338fd546 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1ApiSpec.java @@ -0,0 +1,14 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.brand.BrandV1Dto.BrandResponse; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; + +public interface BrandV1ApiSpec { + + @GetMapping("/{brandId}") + ApiResponse getBrands( + @PathVariable(value = "brandId") Long brandId + ); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Dto.java new file mode 100644 index 000000000..e6a883d0d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Dto.java @@ -0,0 +1,18 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.application.brand.BrandInfo; +import java.time.ZonedDateTime; + +public class BrandV1Dto { + public record BrandResponse(Long brandId, String name, String description, String logoImageUrl, ZonedDateTime createdAt) { + public static BrandResponse from(BrandInfo info) { + return new BrandResponse( + info.brandId(), + info.name(), + info.description(), + info.logoImageUrl(), + info.createdAt() + ); + } + } +} 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..81c5e0a09 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java @@ -0,0 +1,34 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderFacade; +import com.loopers.application.order.OrderInfo; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +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/orders") +public class OrderController implements OrderV1ApiSpec { + + private final OrderFacade orderFacade; + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + @Override + public ApiResponse createOrder( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password, + @RequestBody OrderV1Dto.CreateOrderRequest request + ) { + OrderInfo info = orderFacade.createOrder(loginId, password, request.items()); + + return ApiResponse.success(OrderV1Dto.CreateOrderResponse.from(info)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java new file mode 100644 index 000000000..f724ec314 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java @@ -0,0 +1,20 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.web.bind.annotation.RequestHeader; + +@Tag(name = "Order V1 API", description = "주문 API") +public interface OrderV1ApiSpec { + + @Operation( + summary = "주문 생성", + description = "상품 목록을 받아 주문을 생성한다." + ) + ApiResponse createOrder( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password, + OrderV1Dto.CreateOrderRequest request + ); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java new file mode 100644 index 000000000..0326961ff --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java @@ -0,0 +1,33 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderInfo; +import java.time.ZonedDateTime; +import java.util.List; + +public class OrderV1Dto { + + public record CreateOrderRequest( + List items + ) {} + + public record OrderItemRequest( + Long productId, + Long quantity + ) {} + + public record CreateOrderResponse( + Long orderId, + String status, + Long totalAmount, + ZonedDateTime createdAt + ) { + public static CreateOrderResponse from(OrderInfo info) { + return new CreateOrderResponse( + info.orderId(), + info.status().name(), + info.totalAmount(), + info.createdAt() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java new file mode 100644 index 000000000..2fbaa7c36 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java @@ -0,0 +1,80 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductDetailInfo; +import com.loopers.application.product.ProductListInfo; +import java.time.ZonedDateTime; +import java.util.List; +import org.springframework.data.domain.Page; + +public class ProductV1Dto { + + public record ProductDetailResponse( + Long productId, + String name, + String description, + BrandSummary brand, + List imageUrls, + List options, + long likeCount, + ZonedDateTime createdAt + ) { + public static ProductDetailResponse from(ProductDetailInfo info) { + return new ProductDetailResponse( + info.productId(), + info.name(), + info.description(), + new BrandSummary(info.brand().brandId(), info.brand().name()), + info.imageUrls(), + info.options().stream() + .map(o -> new OptionResponse(o.optionId(), o.name(), o.price(), o.stockQuantity(), o.isAvailable())) + .toList(), + info.likeCount(), + info.createdAt() + ); + } + } + + public record BrandSummary(Long brandId, String name) {} + + public record OptionResponse(Long optionId, String name, Long price, Long stockQuantity, boolean isAvailable) {} + + public record ProductListItemResponse( + Long productId, + String name, + BrandSummary brand, + String thumbnailImageUrl, + Long minPrice, + long likeCount, + ZonedDateTime createdAt + ) { + public static ProductListItemResponse from(ProductListInfo info) { + return new ProductListItemResponse( + info.productId(), + info.name(), + new BrandSummary(info.brandId(), info.brandName()), + info.thumbnailImageUrl(), + info.minPrice(), + info.likeCount(), + info.createdAt() + ); + } + } + + public record PageResponse( + List content, + int page, + int size, + long totalElements, + int totalPages + ) { + public static PageResponse from(Page page) { + return new PageResponse<>( + page.getContent(), + page.getNumber(), + page.getSize(), + page.getTotalElements(), + page.getTotalPages() + ); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductsV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductsV1ApiSpec.java new file mode 100644 index 000000000..0fe839996 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductsV1ApiSpec.java @@ -0,0 +1,27 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; + +@Tag(name = "Products", description = "상품 API") +public interface ProductsV1ApiSpec { + + @Operation(summary = "상품 목록 조회", description = "브랜드 필터, 정렬 조건으로 상품 목록을 페이지네이션으로 조회합니다.") + @GetMapping("") + ApiResponse> getProductList( + @RequestParam(required = false) Long brandId, + @RequestParam(required = false, defaultValue = "latest") String sort, + @RequestParam(required = false, defaultValue = "0") int page, + @RequestParam(required = false, defaultValue = "20") int size + ); + + @Operation(summary = "상품 상세 조회", description = "상품 ID로 상품 상세 정보를 조회합니다.") + @GetMapping("/{productId}") + ApiResponse getProduct( + @PathVariable(value = "productId") Long productId + ); +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductsV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductsV1Controller.java new file mode 100644 index 000000000..a3188a859 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductsV1Controller.java @@ -0,0 +1,42 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductFacade; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/products") +public class ProductsV1Controller implements ProductsV1ApiSpec { + + private final ProductFacade productFacade; + + @GetMapping("") + @Override + public ApiResponse> getProductList( + @RequestParam(required = false) Long brandId, + @RequestParam(required = false, defaultValue = "latest") String sort, + @RequestParam(required = false, defaultValue = "0") int page, + @RequestParam(required = false, defaultValue = "20") int size + ) { + Page responsePage = productFacade.getProductList(brandId, sort, page, size) + .map(ProductV1Dto.ProductListItemResponse::from); + return ApiResponse.success(ProductV1Dto.PageResponse.from(responsePage)); + } + + @GetMapping("/{productId}") + @Override + public ApiResponse getProduct( + @PathVariable(value = "productId") Long productId + ) { + return ApiResponse.success( + ProductV1Dto.ProductDetailResponse.from(productFacade.getProductDetail(productId)) + ); + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/users/UserV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/users/UserV1ApiSpec.java new file mode 100644 index 000000000..5df7f3e08 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/users/UserV1ApiSpec.java @@ -0,0 +1,49 @@ +package com.loopers.interfaces.api.users; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.web.bind.annotation.RequestHeader; + +@Tag(name = "Member V1 API", description = "회원 API") +public interface UserV1ApiSpec { + + @Operation( + summary = "회원 가입 요청", + description = "주어진 정보를 가지고 회원 가입을 실행한다" + ) + // @Schema는 Swagger API 문서에서 파라미터 설명을 보여주는 용도 + // 예제에서는 Long exampleId 같은 단일 파라미터에 붙였는데, 지금은 SignUpRequest로 통째로 받으니까 여기엔 필요 없음 + ApiResponse signUp( + UserV1Dto.SignUpRequest request + ); + + @Operation( + summary = "내 정보 조회", + description = "로그인 ID로 내 회원 정보를 조회한다" + ) + ApiResponse getMyInfo( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password + ); + + @Operation( + summary = "비밀번호 변경", + description = "기존 비밀번호와 새 비밀번호를 받아 비밀번호를 변경한다" + ) + ApiResponse changePassword( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password, + UserV1Dto.ChangePasswordRequest request + ); + + @Operation( + summary = "내 정보 조회", + description = "로그인 ID로 내 회원 정보를 조회한다" + ) + ApiResponse getMyLikes( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password + ); + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/users/UserV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/users/UserV1Controller.java new file mode 100644 index 000000000..83255a64f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/users/UserV1Controller.java @@ -0,0 +1,74 @@ +package com.loopers.interfaces.api.users; + +import com.loopers.application.users.UserFacade; +import com.loopers.application.users.UserInfo; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.users.UserV1Dto.ChangePasswordRequest; +import com.loopers.interfaces.api.users.UserV1Dto.UserInfoResponse; +import com.loopers.interfaces.api.users.UserV1Dto.SignUpRequest; +import com.loopers.interfaces.api.users.UserV1Dto.SignUpResponse; +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.RequestHeader; +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/users") +public class UserV1Controller implements UserV1ApiSpec { + + // 클라이언트 → [SignUpRequest (record)] → Controller → Facade → Service → [MemberModel (entity)] → DB + // 요청 데이터 전달용 DB에 저장되는 객체 + // DB → [MemberModel (entity)] → Facade → [SignUpResponse (record)] → Controller → 클라이언트 + // DB에서 꺼낸 객체 응답 데이터 전달용 + private final UserFacade userFacade; + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + @Override + public ApiResponse signUp(@RequestBody SignUpRequest request) { + UserInfo info = userFacade.signupUser(request); + UserV1Dto.SignUpResponse response = UserV1Dto.SignUpResponse.from(info); + return ApiResponse.success(response); + } + + @GetMapping("/me") + @Override + public ApiResponse getMyInfo( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password + ) { + UserInfo info = userFacade.getMyInfo(loginId, password); + UserInfoResponse response = UserInfoResponse.from(info); + return ApiResponse.success(response); + } + + @PatchMapping("/me/password") + @Override + public ApiResponse changePassword( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password, + @RequestBody ChangePasswordRequest request + ) { + userFacade.changePassword(loginId, password, request.oldPassword(), request.newPassword()); + return ApiResponse.success("비밀번호가 변경되었습니다."); + } + + @GetMapping("me/likes") + @Override + public ApiResponse getMyLikes( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password + ) { + UserInfo info = userFacade.getMyInfo(loginId, password); + UserInfoResponse response = UserInfoResponse.from(info); + return ApiResponse.success(response); + } + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/users/UserV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/users/UserV1Dto.java new file mode 100644 index 000000000..36a18ae6d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/users/UserV1Dto.java @@ -0,0 +1,48 @@ +package com.loopers.interfaces.api.users; + + +import com.loopers.application.users.UserInfo; + +public class UserV1Dto { + + // Request: POST방식으로 보낼때 데이터를 담는 그릇 (from 필요 없음) + public record SignUpRequest( + String loginId, + String password, + String name, + String birthDate, + String email + ) {} + + // Response: 변환 메서드(from)가 여기에! + public record SignUpResponse(String loginId) { + public static SignUpResponse from(UserInfo info) { + return new SignUpResponse(info.loginId()); + } + } + + public record UserInfoResponse( + String loginId, + String name, + String birthDate, + String email + ) { + public static UserInfoResponse from(UserInfo info) { + return new UserInfoResponse( + info.loginId(), + info.name(), + info.birthDate(), + info.email() + ); + } + } + + + // Request: 비밀번호 변경 요청 + public record ChangePasswordRequest( + String oldPassword, + String newPassword + ) {} + + +} 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..71b8e8e64 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 @@ -11,7 +11,9 @@ public enum ErrorType { INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase(), "일시적인 오류가 발생했습니다."), BAD_REQUEST(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.getReasonPhrase(), "잘못된 요청입니다."), NOT_FOUND(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.getReasonPhrase(), "존재하지 않는 요청입니다."), - CONFLICT(HttpStatus.CONFLICT, HttpStatus.CONFLICT.getReasonPhrase(), "이미 존재하는 리소스입니다."); + CONFLICT(HttpStatus.CONFLICT, HttpStatus.CONFLICT.getReasonPhrase(), "이미 존재하는 리소스입니다."), + UNAUTHORIZED(HttpStatus.UNAUTHORIZED, HttpStatus.UNAUTHORIZED.getReasonPhrase(), "유효하지 않은 인증 정보입니다."), + FORBIDDEN(HttpStatus.FORBIDDEN, HttpStatus.FORBIDDEN.getReasonPhrase(), "접근 권한이 없습니다."); private final HttpStatus status; private final String code; 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..df9ad452c --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java @@ -0,0 +1,64 @@ +package com.loopers.domain.brand; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +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; + +class BrandTest { + + @DisplayName("브랜드를 생성할 때, ") + @Nested + class Create { + + @DisplayName("유효한 이름이면, 정상적으로 생성된다.") + @Test + void createsBrand_whenNameIsValid() { + // arrange & act + Brand brand = new Brand("나이키"); + + // assert + assertThat(brand.getName()).isEqualTo("나이키"); + } + + @DisplayName("이름이 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenNameIsNull() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + new Brand(null) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("이름이 빈 문자열이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenNameIsEmpty() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + new Brand("") + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("이름이 공백으로만 이루어지면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenNameIsBlank() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + new Brand(" ") + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/common/MoneyTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/common/MoneyTest.java new file mode 100644 index 000000000..8be75a303 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/common/MoneyTest.java @@ -0,0 +1,62 @@ +package com.loopers.domain.common; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +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; + +class MoneyTest { + + @DisplayName("금액을 생성할 때, ") + @Nested + class Create { + + @DisplayName("양수 금액이면, 정상적으로 생성된다.") + @Test + void createsMoney_whenValueIsPositive() { + // arrange & act + Money money = new Money(1000L); + + // assert + assertThat(money.getValue()).isEqualTo(1000L); + } + + @DisplayName("0원이면, 정상적으로 생성된다.") + @Test + void createsMoney_whenValueIsZero() { + // arrange & act + Money money = new Money(0L); + + // assert + assertThat(money.getValue()).isEqualTo(0L); + } + + @DisplayName("음수이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenValueIsNegative() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + new Money(-1L) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenValueIsNull() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + new Money(null) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/common/QuantityTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/common/QuantityTest.java new file mode 100644 index 000000000..abfccead1 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/common/QuantityTest.java @@ -0,0 +1,64 @@ +package com.loopers.domain.common; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +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; + +class QuantityTest { + + @DisplayName("수량을 생성할 때, ") + @Nested + class Create { + + @DisplayName("양수이면, 정상적으로 생성된다.") + @Test + void createsQuantity_whenValueIsPositive() { + // arrange & act + Quantity quantity = new Quantity(5L); + + // assert + assertThat(quantity.getValue()).isEqualTo(5L); + } + + @DisplayName("0이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenValueIsZero() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + new Quantity(0L) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("음수이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenValueIsNegative() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + new Quantity(-1L) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenValueIsNull() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + new Quantity(null) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} \ No newline at end of file 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..6e4e78b57 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemTest.java @@ -0,0 +1,75 @@ +package com.loopers.domain.order; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.loopers.domain.common.Money; +import com.loopers.domain.common.Quantity; +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; + +class OrderItemTest { + + private static final ProductSnapshot VALID_SNAPSHOT = + new ProductSnapshot("나이키 신발", new Money(50000L), "나이키"); + + @DisplayName("주문 항목을 생성할 때, ") + @Nested + class Create { + + @DisplayName("유효한 정보가 주어지면, 정상적으로 생성된다.") + @Test + void createsOrderItem_whenValidInfoIsProvided() { + // arrange & act + OrderItem orderItem = new OrderItem(1L, 1L, OrderItemStatus.ORDERED, VALID_SNAPSHOT, new Quantity(2L)); + + // assert + assertAll( + () -> assertThat(orderItem.getOrderId()).isEqualTo(1L), + () -> assertThat(orderItem.getProductId()).isEqualTo(1L), + () -> assertThat(orderItem.getStatus()).isEqualTo(OrderItemStatus.ORDERED), + () -> assertThat(orderItem.getQuantity()).isEqualTo(2L) + ); + } + + @DisplayName("orderId가 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenOrderIdIsNull() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + new OrderItem(null, 1L, OrderItemStatus.ORDERED, VALID_SNAPSHOT, new Quantity(2L)) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("productId가 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenProductIdIsNull() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + new OrderItem(1L, null, OrderItemStatus.ORDERED, VALID_SNAPSHOT, new Quantity(2L)) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("snapshot이 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenSnapshotIsNull() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + new OrderItem(1L, 1L, OrderItemStatus.ORDERED, null, new Quantity(2L)) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java new file mode 100644 index 000000000..4518bb2c2 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java @@ -0,0 +1,156 @@ +package com.loopers.domain.order; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.common.Money; +import com.loopers.domain.common.Quantity; +import com.loopers.domain.product.Product; +import com.loopers.domain.stock.Stock; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.infrastructure.order.OrderItemJpaRepository; +import com.loopers.infrastructure.order.OrderJpaRepository; +import com.loopers.infrastructure.product.ProductJpaRepository; +import com.loopers.infrastructure.stock.StockJpaRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import com.loopers.utils.DatabaseCleanUp; +import java.util.List; +import java.util.Map; +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; + +@SpringBootTest +class OrderServiceIntegrationTest { + + @Autowired + private OrderService orderService; + + @Autowired + private BrandJpaRepository brandJpaRepository; + + @Autowired + private ProductJpaRepository productJpaRepository; + + @Autowired + private StockJpaRepository stockJpaRepository; + + @Autowired + private OrderJpaRepository orderJpaRepository; + + @Autowired + private OrderItemJpaRepository orderItemJpaRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("주문을 생성할 때, ") + @Nested + class CreateOrder { + + @DisplayName("모든 재고가 충분하면, Order와 OrderItem이 생성되고 재고가 차감된다.") + @Test + void createsOrderAndItems_whenAllStocksAreSufficient() { + // arrange + Brand brand = brandJpaRepository.save(new Brand("나이키")); + + Product productA = productJpaRepository.save(new Product(brand.getId(), "신발A", new Money(50000L), "설명A")); + Product productB = productJpaRepository.save(new Product(brand.getId(), "신발B", new Money(30000L), "설명B")); + + stockJpaRepository.save(new Stock(productA.getId(), 100L)); + stockJpaRepository.save(new Stock(productB.getId(), 50L)); + + Long memberId = 1L; + Map deductionMap = Map.of( + productA.getId(), new Quantity(2L), + productB.getId(), new Quantity(3L) + ); + Map brandMap = Map.of(brand.getId(), brand); + + // act + Order order = orderService.createOrder(memberId, List.of(productA, productB), brandMap, deductionMap); + + // assert — Order 검증 + assertAll( + () -> assertThat(order.getId()).isNotNull(), + () -> assertThat(order.getMemberId()).isEqualTo(memberId), + () -> assertThat(order.getStatus()).isEqualTo(OrderStatus.CREATED), + () -> assertThat(order.getTotalAmount()).isEqualTo(50000L * 2 + 30000L * 3) // 190000 + ); + + // assert — OrderItem 검증 + List orderItems = orderItemJpaRepository.findAll(); + assertThat(orderItems).hasSize(2); + assertThat(orderItems).allMatch(item -> item.getStatus() == OrderItemStatus.ORDERED); + + // assert — 재고 차감 검증 + assertThat(stockJpaRepository.findAll()) + .extracting(Stock::getQuantity) + .containsExactlyInAnyOrder(98L, 47L); // 100-2, 50-3 + } + + @DisplayName("하나라도 재고가 부족하면, BAD_REQUEST 예외가 발생하고 Order가 생성되지 않는다.") + @Test + void throwsBadRequest_whenAnyStockIsInsufficient() { + // arrange + Brand brand = brandJpaRepository.save(new Brand("나이키")); + + Product productA = productJpaRepository.save(new Product(brand.getId(), "신발A", new Money(50000L), "설명A")); + Product productB = productJpaRepository.save(new Product(brand.getId(), "신발B", new Money(30000L), "설명B")); + + stockJpaRepository.save(new Stock(productA.getId(), 100L)); + stockJpaRepository.save(new Stock(productB.getId(), 5L)); // productB 재고 부족 + + Long memberId = 1L; + Map deductionMap = Map.of( + productA.getId(), new Quantity(2L), + productB.getId(), new Quantity(10L) // 5 < 10 → 부족 + ); + Map brandMap = Map.of(brand.getId(), brand); + + // act + CoreException result = assertThrows(CoreException.class, () -> + orderService.createOrder(memberId, List.of(productA, productB), brandMap, deductionMap) + ); + + // assert — 예외 타입 + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + + // assert — Order 미생성, 재고 원상복구 + assertThat(orderJpaRepository.count()).isZero(); + assertThat(stockJpaRepository.findAll()) + .extracting(Stock::getQuantity) + .containsExactlyInAnyOrder(100L, 5L); + } + + @DisplayName("totalAmount는 각 상품의 가격 × 수량의 합산이다.") + @Test + void calculatesTotalAmount_asSum_ofPriceTimesQuantity() { + // arrange + Brand brand = brandJpaRepository.save(new Brand("나이키")); + + Product product = productJpaRepository.save(new Product(brand.getId(), "신발", new Money(25000L), "설명")); + stockJpaRepository.save(new Stock(product.getId(), 100L)); + + Map deductionMap = Map.of(product.getId(), new Quantity(4L)); + Map brandMap = Map.of(brand.getId(), brand); + + // act + Order order = orderService.createOrder(1L, List.of(product), brandMap, deductionMap); + + // assert — 25000 × 4 = 100000 + assertThat(order.getTotalAmount()).isEqualTo(100000L); + } + } +} 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..83f71f982 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java @@ -0,0 +1,58 @@ +package com.loopers.domain.order; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.loopers.domain.common.Money; +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; + +class OrderTest { + + @DisplayName("주문을 생성할 때, ") + @Nested + class Create { + + @DisplayName("유효한 정보가 주어지면, 정상적으로 생성된다.") + @Test + void createsOrder_whenValidInfoIsProvided() { + // arrange & act + Order order = new Order(1L, new Money(100000L), OrderStatus.CREATED); + + // assert + assertAll( + () -> assertThat(order.getMemberId()).isEqualTo(1L), + () -> assertThat(order.getTotalAmount()).isEqualTo(100000L), + () -> assertThat(order.getStatus()).isEqualTo(OrderStatus.CREATED) + ); + } + + @DisplayName("memberId가 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenMemberIdIsNull() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + new Order(null, new Money(100000L), OrderStatus.CREATED) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("totalAmount가 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenTotalAmountIsNull() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + new Order(1L, null, OrderStatus.CREATED) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/ProductSnapshotTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/ProductSnapshotTest.java new file mode 100644 index 000000000..e4a4ed380 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/ProductSnapshotTest.java @@ -0,0 +1,70 @@ +package com.loopers.domain.order; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.loopers.domain.common.Money; +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; + +class ProductSnapshotTest { + + @DisplayName("상품 스냅샷을 생성할 때, ") + @Nested + class Create { + + @DisplayName("유효한 정보가 주어지면, 정상적으로 생성된다.") + @Test + void createsProductSnapshot_whenValidInfoIsProvided() { + // arrange & act + ProductSnapshot snapshot = new ProductSnapshot("나이키 신발", new Money(50000L), "나이키"); + + // assert + assertAll( + () -> assertThat(snapshot.getProductName()).isEqualTo("나이키 신발"), + () -> assertThat(snapshot.getProductPrice()).isEqualTo(50000L), + () -> assertThat(snapshot.getBrandName()).isEqualTo("나이키") + ); + } + + @DisplayName("상품명이 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenProductNameIsNull() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + new ProductSnapshot(null, new Money(50000L), "나이키") + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("가격이 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenPriceIsNull() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + new ProductSnapshot("나이키 신발", null, "나이키") + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("브랜드명이 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenBrandNameIsNull() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + new ProductSnapshot("나이키 신발", new Money(50000L), null) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java new file mode 100644 index 000000000..92213a1f0 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java @@ -0,0 +1,109 @@ +package com.loopers.domain.product; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.common.Money; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.infrastructure.product.ProductJpaRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import com.loopers.utils.DatabaseCleanUp; +import java.util.List; +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; + +@SpringBootTest +class ProductServiceIntegrationTest { + + @Autowired + private ProductService productService; + + @Autowired + private ProductJpaRepository productJpaRepository; + + @Autowired + private BrandJpaRepository brandJpaRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("상품을 조회할 때, ") + @Nested + class GetProducts { + + @DisplayName("존재하는 상품 ID 목록을 주면, 해당 상품 목록을 반환한다.") + @Test + void returnsProducts_whenValidIdsAreProvided() { + // arrange + Brand brand = brandJpaRepository.save(new Brand("나이키")); + Product product1 = productJpaRepository.save(new Product(brand.getId(), "신발A", new Money(50000L), "설명A")); + Product product2 = productJpaRepository.save(new Product(brand.getId(), "신발B", new Money(60000L), "설명B")); + + // act + List result = productService.getProducts(List.of(product1.getId(), product2.getId())); + + // assert + assertThat(result).hasSize(2); + } + + @DisplayName("존재하지 않는 상품 ID가 포함되면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenAnyIdDoesNotExist() { + // arrange + Long nonExistentId = 999L; + + // act + CoreException result = assertThrows(CoreException.class, () -> + productService.getProducts(List.of(nonExistentId)) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("브랜드를 조회할 때, ") + @Nested + class GetBrands { + + @DisplayName("존재하는 브랜드 ID 목록을 주면, 해당 브랜드 목록을 반환한다.") + @Test + void returnsBrands_whenValidIdsAreProvided() { + // arrange + Brand brand = brandJpaRepository.save(new Brand("나이키")); + + // act + List result = productService.getBrands(List.of(brand.getId())); + + // assert + assertThat(result).hasSize(1); + assertThat(result.get(0).getName()).isEqualTo("나이키"); + } + + @DisplayName("존재하지 않는 브랜드 ID가 포함되면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenAnyBrandIdDoesNotExist() { + // arrange + Long nonExistentId = 999L; + + // act + CoreException result = assertThrows(CoreException.class, () -> + productService.getBrands(List.of(nonExistentId)) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } +} 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..fac21fac1 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java @@ -0,0 +1,83 @@ +package com.loopers.domain.product; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.loopers.domain.common.Money; +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; + +class ProductTest { + + @DisplayName("상품을 생성할 때, ") + @Nested + class Create { + + @DisplayName("유효한 정보가 주어지면, 정상적으로 생성된다.") + @Test + void createsProduct_whenValidInfoIsProvided() { + // arrange & act + Product product = new Product(1L, "나이키 신발", new Money(50000L), "편한 신발"); + + // assert + assertAll( + () -> assertThat(product.getBrandId()).isEqualTo(1L), + () -> assertThat(product.getName()).isEqualTo("나이키 신발"), + () -> assertThat(product.getPrice()).isEqualTo(50000L), + () -> assertThat(product.getDescription()).isEqualTo("편한 신발") + ); + } + + @DisplayName("brandId가 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenBrandIdIsNull() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + new Product(null, "나이키 신발", new Money(50000L), "편한 신발") + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("상품명이 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenNameIsNull() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + new Product(1L, null, new Money(50000L), "편한 신발") + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("상품명이 공백이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenNameIsBlank() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + new Product(1L, " ", new Money(50000L), "편한 신발") + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("가격이 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenPriceIsNull() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + new Product(1L, "나이키 신발", null, "편한 신발") + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/stock/StockDeductionServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/stock/StockDeductionServiceIntegrationTest.java new file mode 100644 index 000000000..dfcf39be6 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/stock/StockDeductionServiceIntegrationTest.java @@ -0,0 +1,98 @@ +package com.loopers.domain.stock; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.loopers.domain.common.Quantity; +import com.loopers.domain.order.StockDeductionService; +import com.loopers.infrastructure.stock.StockJpaRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import com.loopers.utils.DatabaseCleanUp; +import java.util.Map; +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; + +@SpringBootTest +class StockDeductionServiceIntegrationTest { + + @Autowired + private StockDeductionService stockDeductionService; + + @Autowired + private StockJpaRepository stockJpaRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("재고를 차감할 때, ") + @Nested + class DeductAll { + + @DisplayName("모든 상품의 재고가 충분하면, 정상적으로 모두 차감된다.") + @Test + void deductsAll_whenAllStocksAreSufficient() { + // arrange + Stock stockA = stockJpaRepository.save(new Stock(1L, 100L)); + Stock stockB = stockJpaRepository.save(new Stock(2L, 50L)); + + // act + stockDeductionService.deductAll(Map.of( + stockA.getProductId(), new Quantity(30L), + stockB.getProductId(), new Quantity(20L) + )); + + // assert + assertThat(stockJpaRepository.findById(stockA.getId()).get().getQuantity()).isEqualTo(70L); + assertThat(stockJpaRepository.findById(stockB.getId()).get().getQuantity()).isEqualTo(30L); + } + + @DisplayName("하나라도 재고가 부족하면, BAD_REQUEST 예외가 발생하고 모든 차감이 롤백된다.") + @Test + void rollsBackAll_whenAnyStockIsInsufficient() { + // arrange — stockA 충분, stockB 부족 + Stock stockA = stockJpaRepository.save(new Stock(1L, 100L)); + Stock stockB = stockJpaRepository.save(new Stock(2L, 50L)); + + // act + CoreException result = assertThrows(CoreException.class, () -> + stockDeductionService.deductAll(Map.of( + stockA.getProductId(), new Quantity(80L), // stockA: 충분 + stockB.getProductId(), new Quantity(60L) // stockB: 부족 + )) + ); + + // assert — 예외 타입 확인 + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + // assert — stockA도 롤백되어 원래대로 (All-or-Nothing) + assertThat(stockJpaRepository.findById(stockA.getId()).get().getQuantity()).isEqualTo(100L); + } + + @DisplayName("productId가 역순으로 요청되어도, 오름차순으로 정렬되어 처리된다.") + @Test + void deductsInAscendingOrder_whenProductIdsAreInReverseOrder() { + // arrange — productId가 큰 것(2)을 먼저 Map에 넣어도 + Stock stockA = stockJpaRepository.save(new Stock(1L, 100L)); + Stock stockB = stockJpaRepository.save(new Stock(2L, 50L)); + + // act — 순서와 무관하게 정상 차감되어야 함 + stockDeductionService.deductAll(Map.of( + stockB.getProductId(), new Quantity(10L), // productId=2 먼저 + stockA.getProductId(), new Quantity(30L) // productId=1 나중 + )); + + // assert — 순서와 무관하게 모두 차감됨 + assertThat(stockJpaRepository.findById(stockA.getId()).get().getQuantity()).isEqualTo(70L); + assertThat(stockJpaRepository.findById(stockB.getId()).get().getQuantity()).isEqualTo(40L); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/stock/StockTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/stock/StockTest.java new file mode 100644 index 000000000..fbf6d4c5d --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/stock/StockTest.java @@ -0,0 +1,134 @@ +package com.loopers.domain.stock; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.loopers.domain.common.Quantity; +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; + +class StockTest { + + @DisplayName("재고를 생성할 때, ") + @Nested + class Create { + + @DisplayName("유효한 정보가 주어지면, 정상적으로 생성된다.") + @Test + void createsStock_whenValidInfoIsProvided() { + // arrange & act + Stock stock = new Stock(1L, 100L); + + // assert + assertThat(stock.getQuantity()).isEqualTo(100L); + } + + @DisplayName("productId가 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenProductIdIsNull() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + new Stock(null, 100L) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("초기 수량이 음수이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenQuantityIsNegative() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + new Stock(1L, -1L) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("재고를 확인할 때, ") + @Nested + class HasEnoughStock { + + @DisplayName("요청 수량보다 재고가 충분하면, true를 반환한다.") + @Test + void returnsTrue_whenStockIsSufficient() { + // arrange + Stock stock = new Stock(1L, 100L); + + // act & assert + assertThat(stock.hasEnoughStock(new Quantity(50L))).isTrue(); + } + + @DisplayName("요청 수량과 재고가 같으면, true를 반환한다.") + @Test + void returnsTrue_whenStockEqualsRequestedQuantity() { + // arrange + Stock stock = new Stock(1L, 100L); + + // act & assert + assertThat(stock.hasEnoughStock(new Quantity(100L))).isTrue(); + } + + @DisplayName("요청 수량보다 재고가 부족하면, false를 반환한다.") + @Test + void returnsFalse_whenStockIsInsufficient() { + // arrange + Stock stock = new Stock(1L, 100L); + + // act & assert + assertThat(stock.hasEnoughStock(new Quantity(101L))).isFalse(); + } + } + + @DisplayName("재고를 차감할 때, ") + @Nested + class Deduct { + + @DisplayName("재고가 충분하면, 수량이 차감된다.") + @Test + void deductsQuantity_whenStockIsSufficient() { + // arrange + Stock stock = new Stock(1L, 100L); + + // act + stock.deduct(new Quantity(30L)); + + // assert + assertThat(stock.getQuantity()).isEqualTo(70L); + } + + @DisplayName("재고가 정확히 맞으면, 차감 후 0이 된다.") + @Test + void deductsToZero_whenStockMatchesRequestedQuantity() { + // arrange + Stock stock = new Stock(1L, 100L); + + // act + stock.deduct(new Quantity(100L)); + + // assert + assertThat(stock.getQuantity()).isEqualTo(0L); + } + + @DisplayName("재고가 부족하면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenStockIsInsufficient() { + // arrange + Stock stock = new Stock(1L, 100L); + + // act + CoreException result = assertThrows(CoreException.class, () -> + stock.deduct(new Quantity(101L)) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/users/UsersServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/users/UsersServiceIntegrationTest.java new file mode 100644 index 000000000..475ef63c8 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/users/UsersServiceIntegrationTest.java @@ -0,0 +1,175 @@ +package com.loopers.domain.users; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +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; + +@SpringBootTest +class UsersServiceIntegrationTest { + + @Autowired + private UserService userService; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("회원가입을 할 때, ") + @Nested + class SaveUsers { + + @DisplayName("필수 정보가 모두 주어지면, DB에 저장되고 조회할 수 있다.") + @Test + void returnsUserInfo_whenValidUserInfoIsProvided() { + // arrange + String loginId = "testuser"; + String password = "Test1234!"; + String name = "홍길동"; + String birthDate = "19900101"; + String email = "test@example.com"; + + // act + userService.register(loginId, password, name, birthDate, email); + Users result = userService.getMember(loginId); + + // assert + assertAll( + () -> assertThat(result).isNotNull(), + () -> assertThat(result.getLoginId()).isEqualTo(loginId), + () -> assertThat(result.getName()).isEqualTo(name), + () -> assertThat(result.getBirthDate()).isEqualTo(birthDate), + () -> assertThat(result.getEmail()).isEqualTo(email) + ); + } + + @DisplayName("중복 ID로 가입 시도하면, CONFLICT 예외가 발생한다.") + @Test + void throwsException_whenExistIdIsTryToSaveUser() { + // arrange — 먼저 한 명 가입시켜 DB에 저장 + String loginId = "testuser"; + userService.register(loginId, "Test1234!", "홍길동", "19900101", "test@example.com"); + + // act — 같은 ID로 또 가입 시도 + CoreException exception = assertThrows(CoreException.class, () -> + userService.register(loginId, "Test1234!", "홍길동", "19900101", "test@example.com") + ); + + // assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.CONFLICT); + } + } + + @DisplayName("회원을 조회할 때, ") + @Nested + class GetUsers { + + @DisplayName("존재하는 ID를 주면, 해당 유저 정보를 반환한다.") + @Test + void returnsUserInfo_whenValidIdIsProvided() { + // arrange + String loginId = "testuser"; + String password = "Test1234!"; + String name = "홍길동"; + String birthDate = "19900101"; + String email = "test@example.com"; + userService.register(loginId, password, name, birthDate, email); + + // act + Users result = userService.getMember(loginId); + + // assert + assertAll( + () -> assertThat(result).isNotNull(), + () -> assertThat(result.getLoginId()).isEqualTo(loginId), + () -> assertThat(result.getName()).isEqualTo(name), + () -> assertThat(result.getBirthDate()).isEqualTo(birthDate), + () -> assertThat(result.getEmail()).isEqualTo(email) + ); + } + + @DisplayName("존재하지 않는 ID로 조회하면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsException_whenMemberNotFound() { + // arrange + String loginId = "nonexistent"; + + // act + CoreException exception = assertThrows(CoreException.class, () -> + userService.getMember(loginId) + ); + + // assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("비밀번호 변경을 할 때, ") + @Nested + class ChangePassword { + + @DisplayName("기존 비밀번호와 새 비밀번호가 유효하면, 비밀번호가 변경된다.") + @Test + void changesPassword_whenOldAndNewPasswordsAreValid() { + // arrange + String loginId = "testuser"; + String prevPassword = "Test1234!"; + String newPassword = "NewPass123!"; + userService.register(loginId, prevPassword, "홍길동", "19900101", "test@test.co.kr"); + String beforeEncrypted = userService.getMember(loginId).getPassword(); + + // act + userService.changePassword(loginId, prevPassword, newPassword); + + // assert + Users after = userService.getMember(loginId); + assertThat(after.getPassword()).isNotEqualTo(beforeEncrypted); + } + + @DisplayName("기존 비밀번호가 일치하지 않으면, UNAUTHORIZED 예외가 발생한다.") + @Test + void throwsException_whenOldPasswordDoesNotMatch() { + // arrange + String loginId = "testuser"; + userService.register(loginId, "Test1234!", "홍길동", "19900101", "test@test.co.kr"); + + // act — 틀린 비밀번호로 시도 + CoreException exception = assertThrows(CoreException.class, () -> + userService.changePassword(loginId, "WrongPass123!", "NewPass456!") + ); + + // assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.UNAUTHORIZED); + } + + @DisplayName("새 비밀번호가 기존 비밀번호와 같으면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsException_whenNewPasswordIsSameAsOld() { + // arrange + String loginId = "testuser"; + String prevPassword = "Test1234!"; + userService.register(loginId, prevPassword, "홍길동", "19900101", "test@test.co.kr"); + + // act + CoreException exception = assertThrows(CoreException.class, () -> + userService.changePassword(loginId, prevPassword, prevPassword) + ); + + // assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/users/UsersTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/users/UsersTest.java new file mode 100644 index 000000000..81a7537c6 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/users/UsersTest.java @@ -0,0 +1,116 @@ +package com.loopers.domain.users; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.loopers.domain.users.vo.Email; +import com.loopers.domain.users.vo.EncryptedPassword; +import com.loopers.domain.users.vo.LoginId; +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; + +class UsersTest { + + // Users.create()에 전달할 이미 검증된 VO 헬퍼 + private Users createUser(String loginId, String password, String name, String birthDate, String email) { + return Users.create( + new LoginId(loginId), + new EncryptedPassword(password), + name, + birthDate, + new Email(email) + ); + } + + @DisplayName("회원을 생성할 때, ") + @Nested + class Create { + + @DisplayName("필수 정보가 모두 주어지면, 정상적으로 생성된다.") + @Test + void createsUser_whenAllFieldsAreProvided() { + // arrange + String loginId = "testuser"; + String password = "hashed_pw"; + String name = "홍길동"; + String birthDate = "19900101"; + String email = "test@example.com"; + + // act + Users users = createUser(loginId, password, name, birthDate, email); + + // assert + assertAll( + () -> assertThat(users.getLoginId()).isEqualTo(loginId), + () -> assertThat(users.getName()).isEqualTo(name), + () -> assertThat(users.getBirthDate()).isEqualTo(birthDate), + () -> assertThat(users.getEmail()).isEqualTo(email) + ); + } + + @DisplayName("이름이 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenNameIsNull() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + createUser("testuser", "hashed_pw", null, "19900101", "test@example.com") + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("이름이 빈 문자열이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenNameIsBlank() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + createUser("testuser", "hashed_pw", " ", "19900101", "test@example.com") + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("생년월일이 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenBirthDateIsNull() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + createUser("testuser", "hashed_pw", "홍길동", null, "test@example.com") + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("이름을 마스킹할 때, ") + @Nested + class MaskName { + + @DisplayName("2자 이상이면 마지막 글자만 마스킹된다.") + @Test + void masksLastCharacter_whenNameIsMultipleChars() { + // arrange + Users users = createUser("testuser", "hashed_pw", "홍길동", "19900101", "test@example.com"); + + // act & assert + assertThat(users.getMaskedName()).isEqualTo("홍길*"); + } + + @DisplayName("1자이면 전체가 마스킹된다.") + @Test + void masksAllCharacters_whenNameIsSingleChar() { + // arrange + Users users = createUser("testuser", "hashed_pw", "홍", "19900101", "test@example.com"); + + // act & assert + assertThat(users.getMaskedName()).isEqualTo("*"); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/users/vo/EmailTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/users/vo/EmailTest.java new file mode 100644 index 000000000..b284deed3 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/users/vo/EmailTest.java @@ -0,0 +1,64 @@ +package com.loopers.domain.users.vo; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +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; + +class EmailTest { + + @DisplayName("이메일을 생성할 때, ") + @Nested + class Create { + + @DisplayName("올바른 이메일 형식이면, 정상적으로 생성된다.") + @Test + void createsEmail_whenValueIsValid() { + // arrange & act + Email email = new Email("test@example.com"); + + // assert + assertThat(email.value()).isEqualTo("test@example.com"); + } + + @DisplayName("null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenValueIsNull() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + new Email(null) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("빈 문자열이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenValueIsBlank() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + new Email(" ") + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("@ 문자가 없으면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenValueHasNoAtSign() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + new Email("testexample.com") + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/users/vo/LoginIdTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/users/vo/LoginIdTest.java new file mode 100644 index 000000000..3fe3f981a --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/users/vo/LoginIdTest.java @@ -0,0 +1,64 @@ +package com.loopers.domain.users.vo; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +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; + +class LoginIdTest { + + @DisplayName("로그인 아이디를 생성할 때, ") + @Nested + class Create { + + @DisplayName("영문자와 숫자로만 이루어진 경우, 정상적으로 생성된다.") + @Test + void createsLoginId_whenValueIsAlphanumeric() { + // arrange & act + LoginId loginId = new LoginId("testuser123"); + + // assert + assertThat(loginId.value()).isEqualTo("testuser123"); + } + + @DisplayName("null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenValueIsNull() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + new LoginId(null) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("빈 문자열이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenValueIsBlank() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + new LoginId(" ") + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("영문자·숫자 외의 문자가 포함되면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenValueContainsSpecialChars() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + new LoginId("user!@#") + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/users/vo/RawPasswordTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/users/vo/RawPasswordTest.java new file mode 100644 index 000000000..283ab6ebf --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/users/vo/RawPasswordTest.java @@ -0,0 +1,88 @@ +package com.loopers.domain.users.vo; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +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; + +class RawPasswordTest { + + @DisplayName("비밀번호를 생성할 때, ") + @Nested + class Create { + + @DisplayName("규칙을 모두 만족하면, 정상적으로 생성된다.") + @Test + void createsRawPassword_whenValueIsValid() { + // arrange & act + RawPassword password = RawPassword.of("Test1234!", "19900101"); + + // assert + assertThat(password.value()).isEqualTo("Test1234!"); + } + + @DisplayName("null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenValueIsNull() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + RawPassword.of(null, "19900101") + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("7자 미만이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenValueIsTooShort() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + RawPassword.of("Test12!", "19900101") + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("17자 초과이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenValueIsTooLong() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + RawPassword.of("Test123456789012!", "19900101") + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("한글이 포함되면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenValueContainsKorean() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + RawPassword.of("Test홍길동!", "19900101") + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("생년월일이 포함되면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenValueContainsBirthDate() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + RawPassword.of("Test19900101!", "19900101") + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/AdminV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/AdminV1ApiE2ETest.java new file mode 100644 index 000000000..d174d5e52 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/AdminV1ApiE2ETest.java @@ -0,0 +1,681 @@ +package com.loopers.interfaces.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.common.Money; +import com.loopers.domain.product.Product; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.infrastructure.product.ProductHistoryJpaRepository; +import com.loopers.infrastructure.product.ProductJpaRepository; +import com.loopers.utils.DatabaseCleanUp; +import java.util.Map; +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; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class AdminV1ApiE2ETest { + + private static final String BRAND_ENDPOINT = "/api-admin/v1/brands"; + private static final String PRODUCT_ENDPOINT = "/api-admin/v1/products"; + private static final String LDAP_HEADER = "X-Loopers-Ldap"; + private static final String ADMIN_LDAP = "loopers.admin"; + + private final TestRestTemplate testRestTemplate; + private final BrandJpaRepository brandJpaRepository; + private final ProductJpaRepository productJpaRepository; + private final ProductHistoryJpaRepository productHistoryJpaRepository; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public AdminV1ApiE2ETest( + TestRestTemplate testRestTemplate, + BrandJpaRepository brandJpaRepository, + ProductJpaRepository productJpaRepository, + ProductHistoryJpaRepository productHistoryJpaRepository, + DatabaseCleanUp databaseCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.brandJpaRepository = brandJpaRepository; + this.productJpaRepository = productJpaRepository; + this.productHistoryJpaRepository = productHistoryJpaRepository; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private HttpHeaders adminHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set(LDAP_HEADER, ADMIN_LDAP); + headers.setContentType(MediaType.APPLICATION_JSON); + return headers; + } + + // ───────────────────────────────────────────── + // 브랜드 관리 + // ───────────────────────────────────────────── + + @DisplayName("POST /api-admin/v1/brands") + @Nested + class CreateBrand { + + @DisplayName("LDAP 헤더 없이 요청하면 403을 반환한다.") + @Test + void returnsForbidden_whenLdapHeaderIsMissing() { + // arrange + String body = """ + {"name": "나이키", "description": "스포츠 브랜드", "logoImageUrl": "https://example.com/logo.png"} + """; + + // act + ResponseEntity>> response = testRestTemplate.exchange( + BRAND_ENDPOINT, + HttpMethod.POST, + new HttpEntity<>(body, new HttpHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + } + + @DisplayName("유효한 요청으로 브랜드를 등록하면 201과 브랜드 정보를 반환한다.") + @Test + void returnsBrandInfo_whenValidRequestProvided() { + // arrange + String body = """ + {"name": "나이키", "description": "스포츠 브랜드", "logoImageUrl": "https://example.com/logo.png"} + """; + + // act + ResponseEntity>> response = testRestTemplate.exchange( + BRAND_ENDPOINT, + HttpMethod.POST, + new HttpEntity<>(body, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED), + () -> { + Map data = response.getBody().data(); + assertThat(data.get("name")).isEqualTo("나이키"); + assertThat(data.get("status")).isEqualTo("PENDING"); + assertThat(data).containsKey("brandId"); + assertThat(data).containsKey("createdAt"); + } + ); + } + } + + @DisplayName("PUT /api-admin/v1/brands/{brandId}") + @Nested + class UpdateBrand { + + @DisplayName("브랜드 정보를 수정하면 200과 수정된 정보를 반환한다.") + @Test + void returnsUpdatedBrand_whenValidRequestProvided() { + // arrange + Brand brand = brandJpaRepository.save(new Brand("나이키", "스포츠", "https://example.com/logo.png")); + String body = """ + {"name": "나이키 코리아", "description": "한국 나이키", "logoImageUrl": "https://example.com/new-logo.png"} + """; + + // act + ResponseEntity>> response = testRestTemplate.exchange( + BRAND_ENDPOINT + "/" + brand.getId(), + HttpMethod.PUT, + new HttpEntity<>(body, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().get("name")).isEqualTo("나이키 코리아") + ); + } + } + + @DisplayName("DELETE /api-admin/v1/brands/{brandId}") + @Nested + class DeactivateBrand { + + @DisplayName("브랜드를 비활성화하면 204를 반환하고, 해당 브랜드의 상품도 INACTIVE가 된다.") + @Test + void returnsNoContent_andDeactivatesProducts_whenBrandDeactivated() throws InterruptedException { + // arrange + Brand brand = brandJpaRepository.save(new Brand("나이키", null, null)); + brand.activate(); + brandJpaRepository.save(brand); + + Product product = productJpaRepository.save( + new Product(brand.getId(), "에어맥스", new Money(150000L), null) + ); + product.activate(); + productJpaRepository.save(product); + + // act + ResponseEntity response = testRestTemplate.exchange( + BRAND_ENDPOINT + "/" + brand.getId(), + HttpMethod.DELETE, + new HttpEntity<>(adminHeaders()), + Void.class + ); + + // assert - 브랜드 비활성화 응답 + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + + // assert - 상품 비동기 비활성화 (최대 3초 대기) + long deadline = System.currentTimeMillis() + 3000; + while (System.currentTimeMillis() < deadline) { + Product updated = productJpaRepository.findById(product.getId()).orElseThrow(); + if ("INACTIVE".equals(updated.getStatus().name())) { + break; + } + Thread.sleep(100); + } + Product updated = productJpaRepository.findById(product.getId()).orElseThrow(); + assertThat(updated.getStatus().name()).isEqualTo("INACTIVE"); + } + } + + // ───────────────────────────────────────────── + // 상품 관리 + // ───────────────────────────────────────────── + + @DisplayName("POST /api-admin/v1/products") + @Nested + class CreateProduct { + + @DisplayName("존재하지 않는 브랜드로 상품 등록 시 404를 반환한다.") + @Test + void returnsNotFound_whenBrandDoesNotExist() { + // arrange + String body = """ + {"brandId": 999999, "name": "에어맥스", "price": 150000, "description": "러닝화"} + """; + + // act + ResponseEntity>> response = testRestTemplate.exchange( + PRODUCT_ENDPOINT, + HttpMethod.POST, + new HttpEntity<>(body, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @DisplayName("유효한 요청으로 상품을 등록하면 201과 상품 정보를 반환하고 이력이 1건 저장된다.") + @Test + void returnsProductInfo_andSavesHistory_whenValidRequestProvided() { + // arrange + Brand brand = brandJpaRepository.save(new Brand("나이키", null, null)); + String body = String.format(""" + {"brandId": %d, "name": "에어맥스", "price": 150000, "description": "러닝화"} + """, brand.getId()); + + // act + ResponseEntity>> response = testRestTemplate.exchange( + PRODUCT_ENDPOINT, + HttpMethod.POST, + new HttpEntity<>(body, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED), + () -> { + Map data = response.getBody().data(); + assertThat(data.get("name")).isEqualTo("에어맥스"); + assertThat(data.get("status")).isEqualTo("PENDING"); + assertThat(data).containsKey("productId"); + }, + () -> { + Long productId = ((Number) response.getBody().data().get("productId")).longValue(); + assertThat(productHistoryJpaRepository.countByProductId(productId)).isEqualTo(1); + } + ); + } + } + + @DisplayName("PUT /api-admin/v1/products/{productId}") + @Nested + class UpdateProduct { + + @DisplayName("상품 수정 시 200을 반환하고 이력이 2건이 된다.") + @Test + void returnsUpdatedProduct_andHistoryCount2_whenProductUpdated() { + // arrange + Brand brand = brandJpaRepository.save(new Brand("나이키", null, null)); + // 상품 등록 (이력 1건 생성) + String createBody = String.format(""" + {"brandId": %d, "name": "에어맥스", "price": 150000, "description": "러닝화"} + """, brand.getId()); + ResponseEntity>> createResponse = testRestTemplate.exchange( + PRODUCT_ENDPOINT, + HttpMethod.POST, + new HttpEntity<>(createBody, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + Long productId = ((Number) createResponse.getBody().data().get("productId")).longValue(); + + // 상품 수정 + String updateBody = String.format(""" + {"brandId": %d, "name": "에어맥스 90", "price": 160000, "description": "클래식 러닝화"} + """, brand.getId()); + + // act + ResponseEntity>> response = testRestTemplate.exchange( + PRODUCT_ENDPOINT + "/" + productId, + HttpMethod.PUT, + new HttpEntity<>(updateBody, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().get("name")).isEqualTo("에어맥스 90"), + () -> assertThat(productHistoryJpaRepository.countByProductId(productId)).isEqualTo(2) + ); + } + } + + @DisplayName("GET /api-admin/v1/products/{productId}/history") + @Nested + class GetProductHistory { + + @DisplayName("상품 이력을 조회하면 200과 이력 목록을 반환한다.") + @Test + void returnsHistoryList_whenProductHistoryRequested() { + // arrange + Brand brand = brandJpaRepository.save(new Brand("나이키", null, null)); + String createBody = String.format(""" + {"brandId": %d, "name": "에어맥스", "price": 150000} + """, brand.getId()); + ResponseEntity>> createResponse = testRestTemplate.exchange( + PRODUCT_ENDPOINT, + HttpMethod.POST, + new HttpEntity<>(createBody, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + Long productId = ((Number) createResponse.getBody().data().get("productId")).longValue(); + + // act + ResponseEntity>> response = testRestTemplate.exchange( + PRODUCT_ENDPOINT + "/" + productId + "/history", + HttpMethod.GET, + new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> { + Map data = response.getBody().data(); + assertThat(data).containsKey("content"); + assertThat(data).containsKey("totalElements"); + } + ); + } + } + + // ───────────────────────────────────────────── + // 브랜드 조회 + // ───────────────────────────────────────────── + + @DisplayName("GET /api-admin/v1/brands/{brandId}") + @Nested + class GetAdminBrand { + + @DisplayName("존재하는 brandId를 조회하면 200과 브랜드 상세 정보를 반환한다.") + @Test + void returnsBrandDetail_whenBrandExists() { + // arrange + Brand brand = brandJpaRepository.save(new Brand("나이키", "스포츠 브랜드", "https://example.com/logo.png")); + + // act + ResponseEntity>> response = testRestTemplate.exchange( + BRAND_ENDPOINT + "/" + brand.getId(), + HttpMethod.GET, + new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> { + Map data = response.getBody().data(); + assertThat(((Number) data.get("brandId")).longValue()).isEqualTo(brand.getId()); + assertThat(data.get("name")).isEqualTo("나이키"); + assertThat(data.get("status")).isEqualTo("PENDING"); + assertThat(data).containsKey("createdAt"); + assertThat(data).containsKey("updatedAt"); + } + ); + } + + @DisplayName("존재하지 않는 brandId를 조회하면 404를 반환한다.") + @Test + void returnsNotFound_whenBrandDoesNotExist() { + // act + ResponseEntity>> response = testRestTemplate.exchange( + BRAND_ENDPOINT + "/999999", + HttpMethod.GET, + new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } + + @DisplayName("GET /api-admin/v1/brands") + @Nested + class GetAdminBrandList { + + @DisplayName("status=ACTIVE 필터를 주면 ACTIVE 브랜드만 반환한다.") + @Test + void returnsActiveBrandsOnly_whenStatusFilterProvided() { + // arrange + brandJpaRepository.save(new Brand("나이키", null, null)); // PENDING + + Brand active = brandJpaRepository.save(new Brand("아디다스", null, null)); + active.activate(); + brandJpaRepository.save(active); + + // act + ResponseEntity>> response = testRestTemplate.exchange( + BRAND_ENDPOINT + "?status=ACTIVE", + HttpMethod.GET, + new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> { + Map data = response.getBody().data(); + java.util.List> content = + (java.util.List>) data.get("content"); + assertThat(content).hasSize(1); + assertThat(content.get(0).get("name")).isEqualTo("아디다스"); + } + ); + } + + @DisplayName("필터 없이 조회하면 모든 상태의 브랜드 목록을 반환한다.") + @Test + void returnsBrandList_whenNoFilterProvided() { + // arrange + brandJpaRepository.save(new Brand("나이키", null, null)); // PENDING + Brand active = brandJpaRepository.save(new Brand("아디다스", null, null)); + active.activate(); + brandJpaRepository.save(active); + + // act + ResponseEntity>> response = testRestTemplate.exchange( + BRAND_ENDPOINT, + HttpMethod.GET, + new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> { + Map data = response.getBody().data(); + java.util.List> content = + (java.util.List>) data.get("content"); + assertThat(content).hasSize(2); + } + ); + } + } + + // ───────────────────────────────────────────── + // 상품 조회 / 비활성화 + // ───────────────────────────────────────────── + + @DisplayName("GET /api-admin/v1/products/{productId}") + @Nested + class GetAdminProduct { + + @DisplayName("존재하는 productId를 조회하면 200과 상품 상세 정보를 반환한다.") + @Test + void returnsProductDetail_whenProductExists() { + // arrange + Brand brand = brandJpaRepository.save(new Brand("나이키", null, null)); + Product product = productJpaRepository.save( + new Product(brand.getId(), "에어맥스", new Money(150000L), "러닝화") + ); + + // act + ResponseEntity>> response = testRestTemplate.exchange( + PRODUCT_ENDPOINT + "/" + product.getId(), + HttpMethod.GET, + new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> { + Map data = response.getBody().data(); + assertThat(((Number) data.get("productId")).longValue()).isEqualTo(product.getId()); + assertThat(data.get("name")).isEqualTo("에어맥스"); + assertThat(data.get("status")).isEqualTo("PENDING"); + assertThat(data.get("price")).isEqualTo(150000); + assertThat(data).containsKey("brandId"); + assertThat(data).containsKey("createdAt"); + } + ); + } + + @DisplayName("존재하지 않는 productId를 조회하면 404를 반환한다.") + @Test + void returnsNotFound_whenProductDoesNotExist() { + // act + ResponseEntity>> response = testRestTemplate.exchange( + PRODUCT_ENDPOINT + "/999999", + HttpMethod.GET, + new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } + + @DisplayName("GET /api-admin/v1/products") + @Nested + class GetAdminProductList { + + @DisplayName("brandId 필터를 주면 해당 브랜드의 상품만 반환한다.") + @Test + void returnsFilteredProducts_whenBrandIdProvided() { + // arrange + Brand nike = brandJpaRepository.save(new Brand("나이키", null, null)); + Brand adidas = brandJpaRepository.save(new Brand("아디다스", null, null)); + productJpaRepository.save(new Product(nike.getId(), "에어맥스", new Money(150000L), null)); + productJpaRepository.save(new Product(adidas.getId(), "울트라부스트", new Money(180000L), null)); + + // act + ResponseEntity>> response = testRestTemplate.exchange( + PRODUCT_ENDPOINT + "?brandId=" + nike.getId(), + HttpMethod.GET, + new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> { + Map data = response.getBody().data(); + java.util.List> content = + (java.util.List>) data.get("content"); + assertThat(content).hasSize(1); + assertThat(content.get(0).get("name")).isEqualTo("에어맥스"); + } + ); + } + + @DisplayName("status=ACTIVE 필터를 주면 ACTIVE 상품만 반환한다.") + @Test + void returnsActiveProductsOnly_whenStatusFilterProvided() { + // arrange + Brand brand = brandJpaRepository.save(new Brand("나이키", null, null)); + productJpaRepository.save(new Product(brand.getId(), "PENDING상품", new Money(100000L), null)); // PENDING + + Product activeProduct = productJpaRepository.save( + new Product(brand.getId(), "ACTIVE상품", new Money(150000L), null) + ); + activeProduct.activate(); + productJpaRepository.save(activeProduct); + + // act + ResponseEntity>> response = testRestTemplate.exchange( + PRODUCT_ENDPOINT + "?status=ACTIVE", + HttpMethod.GET, + new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> { + Map data = response.getBody().data(); + java.util.List> content = + (java.util.List>) data.get("content"); + assertThat(content).hasSize(1); + assertThat(content.get(0).get("name")).isEqualTo("ACTIVE상품"); + } + ); + } + } + + @DisplayName("DELETE /api-admin/v1/products/{productId}") + @Nested + class DeactivateProduct { + + @DisplayName("상품을 비활성화하면 204를 반환하고 상태가 INACTIVE가 된다.") + @Test + void returnsNoContent_andDeactivatesProduct_whenProductDeactivated() { + // arrange + Brand brand = brandJpaRepository.save(new Brand("나이키", null, null)); + Product product = productJpaRepository.save( + new Product(brand.getId(), "에어맥스", new Money(150000L), null) + ); + product.activate(); + productJpaRepository.save(product); + + // act + ResponseEntity response = testRestTemplate.exchange( + PRODUCT_ENDPOINT + "/" + product.getId(), + HttpMethod.DELETE, + new HttpEntity<>(adminHeaders()), + Void.class + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + Product updated = productJpaRepository.findById(product.getId()).orElseThrow(); + assertThat(updated.getStatus().name()).isEqualTo("INACTIVE"); + } + } + + // ───────────────────────────────────────────── + // 고객 API 상태 필터링 + // ───────────────────────────────────────────── + + @DisplayName("고객 API - INACTIVE 브랜드 조회 시 404를 반환한다.") + @Test + void returnsNotFound_whenBrandIsInactive() { + // arrange + Brand brand = brandJpaRepository.save(new Brand("나이키", null, null)); + brand.deactivate(); + brandJpaRepository.save(brand); + + // act + ResponseEntity>> response = testRestTemplate.exchange( + "/api/v1/brands/" + brand.getId(), + HttpMethod.GET, + new HttpEntity<>(null), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @DisplayName("고객 API - INACTIVE 상품은 상품 목록에 포함되지 않는다.") + @Test + void excludesInactiveProducts_fromCustomerProductList() { + // arrange + Brand brand = brandJpaRepository.save(new Brand("나이키", null, null)); + brand.activate(); + brandJpaRepository.save(brand); + + Product activeProduct = productJpaRepository.save( + new Product(brand.getId(), "에어맥스", new Money(150000L), null) + ); + activeProduct.activate(); + productJpaRepository.save(activeProduct); + + Product inactiveProduct = productJpaRepository.save( + new Product(brand.getId(), "단종 상품", new Money(50000L), null) + ); + inactiveProduct.deactivate(); + productJpaRepository.save(inactiveProduct); + + // act + ResponseEntity>> response = testRestTemplate.exchange( + "/api/v1/products", + HttpMethod.GET, + new HttpEntity<>(null), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> { + Map data = response.getBody().data(); + java.util.List> content = + (java.util.List>) data.get("content"); + assertThat(content).hasSize(1); + assertThat(content.get(0).get("name")).isEqualTo("에어맥스"); + } + ); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/BrandV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/BrandV1ApiE2ETest.java new file mode 100644 index 000000000..106698cc4 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/BrandV1ApiE2ETest.java @@ -0,0 +1,103 @@ +package com.loopers.interfaces.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.loopers.domain.brand.Brand; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.utils.DatabaseCleanUp; +import java.util.Map; +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; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class BrandV1ApiE2ETest { + + private static final String ENDPOINT = "/api/v1/brands"; + + private final TestRestTemplate testRestTemplate; + private final BrandJpaRepository brandJpaRepository; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public BrandV1ApiE2ETest( + TestRestTemplate testRestTemplate, + BrandJpaRepository brandJpaRepository, + DatabaseCleanUp databaseCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.brandJpaRepository = brandJpaRepository; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("GET /api/v1/brands/{brandId}") + @Nested + class GetBrand { + + @DisplayName("유효한 brandId를 주면, brandId, name, description, logoImageUrl, createdAt을 반환한다.") + @Test + void returnsBrandInfo_whenValidBrandIdIsProvided() { + // arrange - 고객 API는 ACTIVE 브랜드만 반환하므로 activate 필요 + Brand brand = brandJpaRepository.save( + new Brand("나이키", "스포츠 브랜드", "https://example.com/nike-logo.png") + ); + brand.activate(); + brandJpaRepository.save(brand); + Long brandId = brand.getId(); + + // act + ResponseEntity>> response = testRestTemplate.exchange( + ENDPOINT + "/" + brandId, + HttpMethod.GET, + new HttpEntity<>(null), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> { + Map data = response.getBody().data(); + assertThat(((Number) data.get("brandId")).longValue()).isEqualTo(brandId); + assertThat(data.get("name")).isEqualTo("나이키"); + assertThat(data).containsKey("description"); + assertThat(data).containsKey("logoImageUrl"); + assertThat(data).containsKey("createdAt"); + } + ); + } + + @DisplayName("존재하지 않는 brandId를 주면, 404 Not Found를 반환한다.") + @Test + void returnsNotFound_whenBrandDoesNotExist() { + // arrange — DB에 아무 브랜드도 없음 + Long nonExistentId = 999999L; + + // act + ResponseEntity>> response = testRestTemplate.exchange( + ENDPOINT + "/" + nonExistentId, + HttpMethod.GET, + new HttpEntity<>(null), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/OrderV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/OrderV1ApiE2ETest.java new file mode 100644 index 000000000..ad0d6841f --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/OrderV1ApiE2ETest.java @@ -0,0 +1,212 @@ +package com.loopers.interfaces.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.common.Money; +import com.loopers.domain.product.Product; +import com.loopers.domain.stock.Stock; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.infrastructure.product.ProductJpaRepository; +import com.loopers.infrastructure.stock.StockJpaRepository; +import com.loopers.utils.DatabaseCleanUp; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.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.ResponseEntity; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class OrderV1ApiE2ETest { + + private static final String ENDPOINT = "/api/v1/orders"; + private static final String USERS_ENDPOINT = "/api/v1/users"; + private static final String LOGIN_ID = "testuser"; + private static final String PASSWORD = "Test1234!"; + + private final TestRestTemplate testRestTemplate; + private final DatabaseCleanUp databaseCleanUp; + private final BrandJpaRepository brandJpaRepository; + private final ProductJpaRepository productJpaRepository; + private final StockJpaRepository stockJpaRepository; + + @Autowired + public OrderV1ApiE2ETest( + TestRestTemplate testRestTemplate, + DatabaseCleanUp databaseCleanUp, + BrandJpaRepository brandJpaRepository, + ProductJpaRepository productJpaRepository, + StockJpaRepository stockJpaRepository + ) { + this.testRestTemplate = testRestTemplate; + this.databaseCleanUp = databaseCleanUp; + this.brandJpaRepository = brandJpaRepository; + this.productJpaRepository = productJpaRepository; + this.stockJpaRepository = stockJpaRepository; + } + + @BeforeEach + void setUp() { + Map signUpRequest = Map.of( + "loginId", LOGIN_ID, + "password", PASSWORD, + "name", "홍길동", + "birthDate", "19900101", + "email", "test@example.com" + ); + testRestTemplate.exchange( + USERS_ENDPOINT, + HttpMethod.POST, + new HttpEntity<>(signUpRequest), + new ParameterizedTypeReference>>() {} + ); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("POST /api/v1/orders (주문 생성)") + @Nested + class CreateOrder { + + @DisplayName("모든 재고가 충분하면, 201 Created와 주문 정보를 반환한다.") + @Test + void returnsCreated_whenAllStocksAreSufficient() { + // arrange + Brand brand = brandJpaRepository.save(new Brand("나이키")); + Product productA = productJpaRepository.save(new Product(brand.getId(), "신발A", new Money(50000L), "설명A")); + Product productB = productJpaRepository.save(new Product(brand.getId(), "신발B", new Money(30000L), "설명B")); + stockJpaRepository.save(new Stock(productA.getId(), 100L)); + stockJpaRepository.save(new Stock(productB.getId(), 50L)); + + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", LOGIN_ID); + headers.set("X-Loopers-LoginPw", PASSWORD); + headers.set("Content-Type", "application/json"); + + Map request = Map.of( + "items", List.of( + Map.of("productId", productA.getId(), "quantity", 2), + Map.of("productId", productB.getId(), "quantity", 3) + ) + ); + + // act + ResponseEntity>> response = testRestTemplate.exchange( + ENDPOINT, + HttpMethod.POST, + new HttpEntity<>(request, headers), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED), + () -> assertThat(response.getBody()).isNotNull(), + () -> { + Map data = response.getBody().data(); + assertThat(data).isNotNull(); + assertThat(data.get("orderId")).isNotNull(); + assertThat(data.get("status")).isEqualTo("CREATED"); + assertThat(((Number) data.get("totalAmount")).longValue()) + .isEqualTo(50000L * 2 + 30000L * 3); // 190000 + } + ); + } + + @DisplayName("재고가 부족한 상품이 있으면, 400 Bad Request를 반환한다.") + @Test + void returnsBadRequest_whenAnyStockIsInsufficient() { + // arrange + Brand brand = brandJpaRepository.save(new Brand("나이키")); + Product product = productJpaRepository.save(new Product(brand.getId(), "신발", new Money(50000L), "설명")); + stockJpaRepository.save(new Stock(product.getId(), 5L)); // 재고 5개 + + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", LOGIN_ID); + headers.set("X-Loopers-LoginPw", PASSWORD); + headers.set("Content-Type", "application/json"); + + Map request = Map.of( + "items", List.of( + Map.of("productId", product.getId(), "quantity", 10) // 재고 초과 + ) + ); + + // act + ResponseEntity>> response = testRestTemplate.exchange( + ENDPOINT, + HttpMethod.POST, + new HttpEntity<>(request, headers), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("잘못된 비밀번호로 요청하면, 401 Unauthorized를 반환한다.") + @Test + void returnsUnauthorized_whenPasswordIsWrong() { + // arrange + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", LOGIN_ID); + headers.set("X-Loopers-LoginPw", "WrongPass1!"); + headers.set("Content-Type", "application/json"); + + Map request = Map.of( + "items", List.of(Map.of("productId", 1L, "quantity", 1)) + ); + + // act + ResponseEntity>> response = testRestTemplate.exchange( + ENDPOINT, + HttpMethod.POST, + new HttpEntity<>(request, headers), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @DisplayName("존재하지 않는 상품 ID로 주문하면, 404 Not Found를 반환한다.") + @Test + void returnsNotFound_whenProductDoesNotExist() { + // arrange + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", LOGIN_ID); + headers.set("X-Loopers-LoginPw", PASSWORD); + headers.set("Content-Type", "application/json"); + + Map request = Map.of( + "items", List.of(Map.of("productId", 999999L, "quantity", 1)) + ); + + // act + ResponseEntity>> response = testRestTemplate.exchange( + ENDPOINT, + HttpMethod.POST, + new HttpEntity<>(request, headers), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductsV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductsV1ApiE2ETest.java new file mode 100644 index 000000000..d42ab1d1d --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductsV1ApiE2ETest.java @@ -0,0 +1,273 @@ +package com.loopers.interfaces.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.common.Money; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductImage; +import com.loopers.domain.product.ProductOption; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.infrastructure.product.ProductImageJpaRepository; +import com.loopers.infrastructure.product.ProductJpaRepository; +import com.loopers.infrastructure.product.ProductOptionJpaRepository; +import com.loopers.utils.DatabaseCleanUp; +import java.util.List; +import java.util.Map; +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; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class ProductsV1ApiE2ETest { + + private static final String ENDPOINT = "/api/v1/products"; + + private final TestRestTemplate testRestTemplate; + private final BrandJpaRepository brandJpaRepository; + private final ProductJpaRepository productJpaRepository; + private final ProductOptionJpaRepository productOptionJpaRepository; + private final ProductImageJpaRepository productImageJpaRepository; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public ProductsV1ApiE2ETest( + TestRestTemplate testRestTemplate, + BrandJpaRepository brandJpaRepository, + ProductJpaRepository productJpaRepository, + ProductOptionJpaRepository productOptionJpaRepository, + ProductImageJpaRepository productImageJpaRepository, + DatabaseCleanUp databaseCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.brandJpaRepository = brandJpaRepository; + this.productJpaRepository = productJpaRepository; + this.productOptionJpaRepository = productOptionJpaRepository; + this.productImageJpaRepository = productImageJpaRepository; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("GET /api/v1/products/{productId}") + @Nested + class GetProduct { + + @DisplayName("유효한 productId를 주면, productId, name, description, brand, imageUrls, options, likeCount, createdAt을 반환한다.") + @Test + void returnsProductDetail_whenValidProductIdIsProvided() { + // arrange + Brand brand = brandJpaRepository.save( + new Brand("나이키", "스포츠 브랜드", "https://example.com/nike-logo.png") + ); + Product product = productJpaRepository.save( + new Product(brand.getId(), "에어맥스 90", new Money(150000L), "클래식 러닝화") + ); + productOptionJpaRepository.save( + new ProductOption(product.getId(), "265mm", new Money(150000L), 10L) + ); + productImageJpaRepository.save( + new ProductImage(product.getId(), "https://example.com/airmax-main.png") + ); + + // act + ResponseEntity>> response = testRestTemplate.exchange( + ENDPOINT + "/" + product.getId(), + HttpMethod.GET, + new HttpEntity<>(null), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> { + Map data = response.getBody().data(); + assertThat(((Number) data.get("productId")).longValue()).isEqualTo(product.getId()); + assertThat(data.get("name")).isEqualTo("에어맥스 90"); + assertThat(data).containsKey("description"); + assertThat(data).containsKey("brand"); + assertThat(data).containsKey("imageUrls"); + assertThat(data).containsKey("options"); + assertThat(data).containsKey("likeCount"); + assertThat(data).containsKey("createdAt"); + Map brandData = (Map) data.get("brand"); + assertThat(((Number) brandData.get("brandId")).longValue()).isEqualTo(brand.getId()); + assertThat(brandData.get("name")).isEqualTo("나이키"); + List> imageUrls = (List>) data.get("imageUrls"); + assertThat(imageUrls).hasSize(1); + List> options = (List>) data.get("options"); + assertThat(options).hasSize(1); + } + ); + } + + @DisplayName("존재하지 않는 productId를 주면, 404 Not Found를 반환한다.") + @Test + void returnsNotFound_whenProductDoesNotExist() { + // arrange — DB에 아무 상품도 없음 + Long nonExistentId = 999999L; + + // act + ResponseEntity>> response = testRestTemplate.exchange( + ENDPOINT + "/" + nonExistentId, + HttpMethod.GET, + new HttpEntity<>(null), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } + + @DisplayName("GET /api/v1/products") + @Nested + class GetProductList { + + @DisplayName("조건 없이 조회하면, 전체 상품 목록을 페이지네이션 구조로 반환한다.") + @Test + void returnsPagedProductList_whenNoFilterProvided() { + // arrange + Brand brand = brandJpaRepository.save(new Brand("나이키", "스포츠 브랜드", "https://example.com/logo.png")); + Product product1 = productJpaRepository.save(new Product(brand.getId(), "에어맥스 90", new Money(150000L), "러닝화")); + product1.activate(); + productJpaRepository.save(product1); + Product product2 = productJpaRepository.save(new Product(brand.getId(), "조던 1", new Money(200000L), "농구화")); + product2.activate(); + productJpaRepository.save(product2); + productOptionJpaRepository.save(new ProductOption(product1.getId(), "265mm", new Money(150000L), 5L)); + productOptionJpaRepository.save(new ProductOption(product2.getId(), "270mm", new Money(200000L), 3L)); + + // act + ResponseEntity>> response = testRestTemplate.exchange( + ENDPOINT, + HttpMethod.GET, + new HttpEntity<>(null), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> { + Map data = response.getBody().data(); + assertThat(data).containsKey("content"); + assertThat(data).containsKey("page"); + assertThat(data).containsKey("size"); + assertThat(data).containsKey("totalElements"); + assertThat(data).containsKey("totalPages"); + List> content = (List>) data.get("content"); + assertThat(content).hasSize(2); + } + ); + } + + @DisplayName("brandId 필터를 주면, 해당 브랜드의 상품만 반환한다.") + @Test + void returnsFilteredProducts_whenBrandIdProvided() { + // arrange + Brand nike = brandJpaRepository.save(new Brand("나이키", "스포츠 브랜드", "https://example.com/nike.png")); + Brand adidas = brandJpaRepository.save(new Brand("아디다스", "스포츠 브랜드", "https://example.com/adidas.png")); + Product nikeProduct = productJpaRepository.save(new Product(nike.getId(), "에어맥스 90", new Money(150000L), "러닝화")); + nikeProduct.activate(); + productJpaRepository.save(nikeProduct); + Product adidasProduct = productJpaRepository.save(new Product(adidas.getId(), "울트라부스트", new Money(180000L), "러닝화")); + adidasProduct.activate(); + productJpaRepository.save(adidasProduct); + productOptionJpaRepository.save(new ProductOption(nikeProduct.getId(), "265mm", new Money(150000L), 5L)); + + // act + ResponseEntity>> response = testRestTemplate.exchange( + ENDPOINT + "?brandId=" + nike.getId(), + HttpMethod.GET, + new HttpEntity<>(null), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> { + Map data = response.getBody().data(); + List> content = (List>) data.get("content"); + assertThat(content).hasSize(1); + assertThat(content.get(0).get("name")).isEqualTo("에어맥스 90"); + } + ); + } + + @DisplayName("sort=price_asc 파라미터를 주면, minPrice 오름차순으로 반환한다.") + @Test + void returnsProductsSortedByMinPriceAsc_whenSortParamProvided() { + // arrange + Brand brand = brandJpaRepository.save(new Brand("나이키", null, null)); + Product cheapProduct = productJpaRepository.save(new Product(brand.getId(), "저가 상품", new Money(50000L), null)); + cheapProduct.activate(); + productJpaRepository.save(cheapProduct); + Product expensiveProduct = productJpaRepository.save(new Product(brand.getId(), "고가 상품", new Money(200000L), null)); + expensiveProduct.activate(); + productJpaRepository.save(expensiveProduct); + productOptionJpaRepository.save(new ProductOption(cheapProduct.getId(), "S", new Money(50000L), 10L)); + productOptionJpaRepository.save(new ProductOption(expensiveProduct.getId(), "M", new Money(200000L), 10L)); + + // act + ResponseEntity>> response = testRestTemplate.exchange( + ENDPOINT + "?sort=price_asc", + HttpMethod.GET, + new HttpEntity<>(null), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> { + Map data = response.getBody().data(); + List> content = (List>) data.get("content"); + assertThat(content).hasSize(2); + long firstMinPrice = ((Number) content.get(0).get("minPrice")).longValue(); + long secondMinPrice = ((Number) content.get(1).get("minPrice")).longValue(); + assertThat(firstMinPrice).isLessThanOrEqualTo(secondMinPrice); + } + ); + } + + @DisplayName("상품이 없으면, 빈 content를 반환한다.") + @Test + void returnsEmptyContent_whenNoProductsExist() { + // arrange — DB에 아무 상품도 없음 + + // act + ResponseEntity>> response = testRestTemplate.exchange( + ENDPOINT, + HttpMethod.GET, + new HttpEntity<>(null), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> { + Map data = response.getBody().data(); + List> content = (List>) data.get("content"); + assertThat(content).isEmpty(); + } + ); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UsersV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UsersV1ApiE2ETest.java new file mode 100644 index 000000000..2b0de1982 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UsersV1ApiE2ETest.java @@ -0,0 +1,355 @@ +package com.loopers.interfaces.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.loopers.utils.DatabaseCleanUp; +import java.util.Map; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +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.ResponseEntity; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class UsersV1ApiE2ETest { + + private static final String ENDPOINT = "/api/v1/users"; + + private final TestRestTemplate testRestTemplate; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public UsersV1ApiE2ETest( + TestRestTemplate testRestTemplate, + DatabaseCleanUp databaseCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("POST /api/v1/members (회원가입)") + @Nested + class SignUp { + + @DisplayName("유효한 회원 정보를 보내면, 201 Created와 생성된 ID를 반환한다.") + @Test + void returnsCreated_whenValidMemberInfoIsProvided() { + // arrange + Map request = Map.of( + "loginId", "testuser", + "password", "Test1234!", + "name", "홍길동", + "birthDate", "19900101", + "email", "test@example.com" + ); + + // act + ResponseEntity>> response = testRestTemplate.exchange( + ENDPOINT, + HttpMethod.POST, + new HttpEntity<>(request), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED), + () -> assertThat(response.getBody()).isNotNull(), + () -> { + Assertions.assertNotNull(response.getBody()); + assertThat(response.getBody().data()).isNotNull(); + }, + () -> { + Assertions.assertNotNull(response.getBody()); + assertThat(response.getBody().data().get("loginId")).isNotNull(); + } + ); + } + + @DisplayName("중복된 loginId로 가입하면, 409 Conflict를 반환한다.") + @Test + void returnsConflict_whenDuplicateLoginIdIsProvided() { + // arrange - 먼저 한 명 가입 + Map request = Map.of( + "loginId", "testuser", + "password", "Test1234!", + "name", "홍길동", + "birthDate", "19900101", + "email", "test@example.com" + ); + testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), new ParameterizedTypeReference>>() {}); + + // act - 같은 ID로 다시 가입 + ResponseEntity>> response = testRestTemplate.exchange( + ENDPOINT, + HttpMethod.POST, + new HttpEntity<>(request), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + } + } + + @DisplayName("GET /api/v1/members/me (회원정보조회)") + @Nested + class GetUsersInfo { + + @DisplayName("헤더 인증 성공 시, 200 OK와 마스킹된 이름을 반환한다.") + @Test + void returnsMemberInfo_whenHeaderAuthIsValid() { + // arrange - 먼저 회원 가입 + Map signUpRequest = Map.of( + "loginId", "testuser", + "password", "Test1234!", + "name", "홍길동", + "birthDate", "19900101", + "email", "test@test.co.kr" + ); + testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, new HttpEntity<>(signUpRequest), new ParameterizedTypeReference>>() {}); + + // act - 헤더에 인증 정보 포함하여 조회 + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "testuser"); + headers.set("X-Loopers-LoginPw", "Test1234!"); + + ResponseEntity>> response = testRestTemplate.exchange( + ENDPOINT + "/me", + HttpMethod.GET, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody()).isNotNull(), + () -> { + Assertions.assertNotNull(response.getBody()); + assertThat(response.getBody().data()).isNotNull(); + }, + () -> { + Assertions.assertNotNull(response.getBody()); + assertThat(response.getBody().data().get("loginId")).isEqualTo("testuser"); + }, + () -> { + Assertions.assertNotNull(response.getBody()); + assertThat(response.getBody().data().get("name")).isEqualTo("홍길*"); + } + ); + } + + @DisplayName("헤더 인증 실패 시 (비밀번호 불일치), 401 Unauthorized를 반환한다.") + @Test + void returnsUnauthorized_whenHeaderPasswordIsWrong() { + // arrange - 먼저 회원 가입 + Map signUpRequest = Map.of( + "loginId", "testuser", + "password", "Test1234!", + "name", "홍길동", + "birthDate", "19900101", + "email", "test@test.co.kr" + ); + testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, new HttpEntity<>(signUpRequest), new ParameterizedTypeReference>>() {}); + + // act - 틀린 비밀번호로 조회 + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "testuser"); + headers.set("X-Loopers-LoginPw", "WrongPass1!"); + + ResponseEntity>> response = testRestTemplate.exchange( + ENDPOINT + "/me", + HttpMethod.GET, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @DisplayName("존재하지 않는 회원의 ID로 조회하면, 404 Not Found를 반환한다.") + @Test + void returnsNotFound_whenMemberDoesNotExist() { + // arrange - 아무 데이터 없음 + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "nonexistent"); + headers.set("X-Loopers-LoginPw", "Test1234!"); + + // act + ResponseEntity>> response = testRestTemplate.exchange( + ENDPOINT + "/me", + HttpMethod.GET, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } + + @DisplayName("PATCH /api/v1/members/me/password (비밀번호 변경)") + @Nested + class ChangePassword { + + @DisplayName("헤더 인증 성공 + 유효한 비밀번호 변경 요청이면, 200 OK를 반환한다.") + @Test + void returnsOk_whenHeaderAuthAndPasswordChangeAreValid() { + // arrange - 먼저 회원 가입 + Map signUpRequest = Map.of( + "loginId", "testuser", + "password", "Test1234!", + "name", "홍길동", + "birthDate", "19900101", + "email", "test@test.co.kr" + ); + testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, new HttpEntity<>(signUpRequest), new ParameterizedTypeReference>>() {}); + + // act - 헤더 인증 + 비밀번호 변경 요청 + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "testuser"); + headers.set("X-Loopers-LoginPw", "Test1234!"); + headers.set("Content-Type", "application/json"); + + Map changePasswordRequest = Map.of( + "oldPassword", "Test1234!", + "newPassword", "NewPass123!" + ); + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/me/password", + HttpMethod.PATCH, + new HttpEntity<>(changePasswordRequest, headers), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody()).isNotNull(), + () -> { + Assertions.assertNotNull(response.getBody()); + assertThat(response.getBody().data()).isEqualTo("비밀번호가 변경되었습니다."); + } + ); + } + + @DisplayName("헤더 인증 실패 시 (비밀번호 불일치), 401 Unauthorized를 반환한다.") + @Test + void returnsUnauthorized_whenHeaderPasswordIsWrong() { + // arrange - 먼저 회원 가입 + Map signUpRequest = Map.of( + "loginId", "testuser", + "password", "Test1234!", + "name", "홍길동", + "birthDate", "19900101", + "email", "test@test.co.kr" + ); + testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, new HttpEntity<>(signUpRequest), new ParameterizedTypeReference>>() {}); + + // act - 틀린 헤더 비밀번호로 요청 + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "testuser"); + headers.set("X-Loopers-LoginPw", "WrongPass1!"); + headers.set("Content-Type", "application/json"); + + Map changePasswordRequest = Map.of( + "oldPassword", "Test1234!", + "newPassword", "NewPass123!" + ); + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/me/password", + HttpMethod.PATCH, + new HttpEntity<>(changePasswordRequest, headers), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @DisplayName("Body의 기존 비밀번호가 일치하지 않으면, 401 Unauthorized를 반환한다.") + @Test + void returnsUnauthorized_whenOldPasswordInBodyDoesNotMatch() { + // arrange - 먼저 회원 가입 + Map signUpRequest = Map.of( + "loginId", "testuser", + "password", "Test1234!", + "name", "홍길동", + "birthDate", "19900101", + "email", "test@test.co.kr" + ); + testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, new HttpEntity<>(signUpRequest), new ParameterizedTypeReference>>() {}); + + // act - 헤더는 맞지만 Body의 oldPassword가 틀림 + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "testuser"); + headers.set("X-Loopers-LoginPw", "Test1234!"); + headers.set("Content-Type", "application/json"); + + Map changePasswordRequest = Map.of( + "oldPassword", "WrongPass1!", + "newPassword", "NewPass123!" + ); + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/me/password", + HttpMethod.PATCH, + new HttpEntity<>(changePasswordRequest, headers), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @DisplayName("새 비밀번호가 기존 비밀번호와 같으면, 400 Bad Request를 반환한다.") + @Test + void returnsBadRequest_whenNewPasswordIsSameAsOld() { + // arrange - 먼저 회원 가입 + Map signUpRequest = Map.of( + "loginId", "testuser", + "password", "Test1234!", + "name", "홍길동", + "birthDate", "19900101", + "email", "test@test.co.kr" + ); + testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, new HttpEntity<>(signUpRequest), new ParameterizedTypeReference>>() {}); + + // act - 기존과 동일한 비밀번호로 변경 요청 + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "testuser"); + headers.set("X-Loopers-LoginPw", "Test1234!"); + headers.set("Content-Type", "application/json"); + + Map changePasswordRequest = Map.of( + "oldPassword", "Test1234!", + "newPassword", "Test1234!" + ); + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/me/password", + HttpMethod.PATCH, + new HttpEntity<>(changePasswordRequest, headers), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } +} diff --git a/build.gradle.kts b/build.gradle.kts index 9c8490b8a..c5cba1df6 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -34,14 +34,20 @@ allprojects { } subprojects { + val containerProjects = setOf("apps", "modules", "supports") + apply(plugin = "java") apply(plugin = "org.springframework.boot") apply(plugin = "io.spring.dependency-management") - apply(plugin = "jacoco") + + if (name !in containerProjects) { + apply(plugin = "jacoco") + } dependencyManagement { imports { mavenBom("org.springframework.cloud:spring-cloud-dependencies:${project.properties["springCloudDependenciesVersion"]}") + mavenBom("org.testcontainers:testcontainers-bom:${project.properties["testcontainersVersion"]}") } } @@ -55,6 +61,8 @@ subprojects { // Lombok implementation("org.projectlombok:lombok") annotationProcessor("org.projectlombok:lombok") + // 암호화 + implementation ("org.springframework.security:spring-security-crypto") // Test testRuntimeOnly("org.junit.platform:junit-platform-launcher") // testcontainers:mysql 이 jdbc 사용함 @@ -85,24 +93,27 @@ subprojects { jvmArgs("-Xshare:off") } - tasks.withType { - mustRunAfter("test") - executionData(fileTree(layout.buildDirectory.asFile).include("jacoco/*.exec")) - reports { - xml.required = true - csv.required = false - html.required = false - } - afterEvaluate { - classDirectories.setFrom( - files( - classDirectories.files.map { - fileTree(it) - }, - ), - ) - } - } + // TODO: JDK 24 환경에서 JacocoReport 태스크 생성 오류 발생 - JDK 21로 전환 후 활성화 필요 + // if (name !in containerProjects) { + // tasks.withType { + // mustRunAfter("test") + // executionData(fileTree(layout.buildDirectory.asFile).include("jacoco/*.exec")) + // reports { + // xml.required = true + // csv.required = false + // html.required = false + // } + // afterEvaluate { + // classDirectories.setFrom( + // files( + // classDirectories.files.map { + // fileTree(it) + // }, + // ), + // ) + // } + // } + // } } // module-container 는 task 를 실행하지 않도록 한다. diff --git a/docker/infra-compose.yml b/docker/infra-compose.yml index 18e5fcf5f..6921cb397 100644 --- a/docker/infra-compose.yml +++ b/docker/infra-compose.yml @@ -3,7 +3,7 @@ services: mysql: image: mysql:8.0 ports: - - "3306:3306" + - "3307:3306" environment: - MYSQL_ROOT_PASSWORD=root - MYSQL_USER=application diff --git a/docs/design/01-requirements.md b/docs/design/01-requirements.md new file mode 100644 index 000000000..1ea070cdb --- /dev/null +++ b/docs/design/01-requirements.md @@ -0,0 +1,94 @@ +# 감성 이커머스 시스템 요구사항 명세서 (v1) + +## 1️⃣ 도메인 구조 + +### v1 핵심 도메인 +| 도메인 | 책임 | 주요 엔티티 | +|---------|------|------------| +| **브랜드** | 브랜드 정보 관리 | Brand | +| **상품** | 상품 카탈로그 관리 | Product, ProductOption, ProductImage | +| **좋아요** | 사용자 관심 상품 관리 | Like | +| **주문** | 주문 생성 및 재고 예약 관리 | Order, OrderItem, Stock | + +### 도메인 간 관계 +``` +Brand (브랜드) + └─→ Product (상품) : 1:N [brand_id로 참조] + +Product (상품) + ├─→ ProductOption (상품 옵션) : 1:N [product_id로 참조] + ├─→ ProductImage (상품 이미지) : 1:N [product_id로 참조] + └─→ Like (좋아요) : 1:N [product_id로 참조] + +ProductOption (상품 옵션) + └─→ Stock (재고) : 1:1 [product_option_id로 참조] + +User (사용자) + ├─→ Like (좋아요) : 1:N [user_id로 참조] + └─→ Order (주문) : 1:N [user_id로 참조] + +Order (주문) + └─→ OrderItem (주문 항목) : 1:N [order_id로 참조] + +``` + +### 핵심 개념 +- **Brand**: 상품을 제공하는 브랜드 +- **Product**: 브랜드가 판매하는 상품의 기본 정보 및 판매 상태 관리 + - 상태 기반 관리 (DRAFT / ACTIVE / INACTIVE) + - 삭제 불가 + - ACTIVE는 운영자 수동 전이 +- **ProductOption**: 상품의 판매 단위 + - 상태 보유 (ON_SALE / SOLD_OUT / STOPPED) + - 재고는 옵션 단위 + - 삭제 불가 + - 구매 조건: + - Product ACTIVE + - Option ON_SALE + - stock > 0 +- **Stock**: 옵션 단위 재고 관리 + - stock_quantity : 실제 보유 재고 + - reserved_quantity : 잠가 둔 재고 + - available : 현재 주문 가능한 수량 +- **ProductImage**: 상품의 이미지들 (여러 장 가능, product_id 보유) +- **Like**: 사용자의 상품 좋아요 + - Unique(user_id, product_id) + - Product ACTIVE일 때만 등록 가능 + - 취소는 멱등 + - Product INACTIVE 시 기존 Like는 유지 +- **Order**: 사용자의 주문 + - 상태 기반 관리 (CREATED / CONFIRMED / CANCELLED) + - 예약 재고 기반 처리 + - All-or-Nothing 정책 적용 + **OrderItem**: 주문 시점의 상품 정보 스냅샷 보존 + - productName + - brandName + - optionName + - optionAttributes + - thumbnailImageUrl + - orderPrice (Money) + - quantity (Quantity) + + +### 상품 및 주문 시스템 설계 원칙 +**1. 데이터 연결 및 무결성 (No Foreign Key)** +- **ID 기반 참조**: 데이터베이스 강제 연결(FK) 대신, 서비스 로직에서 상품과 옵션을 연결합니다. +- **유연한 관리**: 시스템이 멈추지 않고 유연하게 데이터를 처리하며, 데이터 간의 정합성은 애플리케이션 서비스 단계에서 꼼꼼히 체크합니다. + +**2. 상태 중심 운영 (State Control)** +- **상태로 판매 통제**: 상품의 판매 가능 여부는 '판매중', '품절', '중지' 등 상태값으로 관리합니다. +- **수동 제어 우선**: 시스템 자동 처리보다는 운영자가 직접 상황에 맞춰 판매를 제어할 수 있도록 설계하여 운영의 묘를 살립니다. + +**3. 데이터 보존 (Soft Delete)** +- **삭제 금지**: 브랜드, 상품, 옵션 데이터는 절대 삭제하지 않습니다. +- **히스토리 유지**: 상품이 품절되거나 사라져도 과거의 주문 내역이나 고객의 '좋아요' 기록은 그대로 보존하여 데이터 추적성을 확보합니다. + +**4. 데이터 일관성 전략 (Consistency)** +- **재고와 주문 (절대 일관성)**: 결제와 재고 차감은 **'모두 성공하거나 모두 실패'**해야 합니다. 수량 오차를 0으로 유지합니다. +- **좋아요 수 (점진적 반영)**: 수치는 실시간으로 아주 약간의 오차가 있을 수 있으나, 시스템 부하를 줄이기 위해 비동기 방식으로 빠르게 업데이트합니다. + +### 핵심 설계 의도 +- **옵션 중심 관리**: 같은 상품이라도 옵션(사이즈, 색상 등)에 따라 가격과 재고가 다를 수 있음 +- **판매의 실질 단위**: 고객이 구경하는 것은 '상품'이지만, 실제로 장바구니에 담고 **결제하는 단위는 '옵션'** +- 좋아요는 **상품 단위**로 관리 (옵션 단위 아님) +- **FK 제약 없음**: 애플리케이션 레벨에서 참조 무결성 관리 diff --git a/docs/design/03-class-diagram.md b/docs/design/03-class-diagram.md new file mode 100644 index 000000000..35c174d5a --- /dev/null +++ b/docs/design/03-class-diagram.md @@ -0,0 +1,763 @@ +# 클래스 다이어그램 (Class Diagram) + +## 1️⃣ 전체 아키텍처 개요 + +### 레이어 구조 +``` +┌─────────────────────────────────────────┐ +│ Presentation Layer │ +│ (Controller) │ +└─────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────┐ +│ Application Layer │ +│ (Facade - 도메인 서비스 조율) │ +└─────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────┐ +│ Domain Layer │ +│ (Service, Entity) │ +└─────────────────────────────────────────┘ +  ↑ +┌─────────────────────────────────────────┐ +│ Infrastructure Layer │ +│ (Repository) │ +└─────────────────────────────────────────┘ + + +상세 +Presentation Layer +├── ProductController +├── LikeController +└── OrderController + +Application Layer +├── ProductFacade +└── OrderFacade ← 주문 유스케이스 조율 + +Domain Layer +├── Brand, Product, ProductOption, Like +├── Order, OrderItem, Stock +├── OrderService +├── StockDeductionService +└── VO (Money, Quantity, Snapshot) + +Infrastructure Layer +├── ProductRepository +├── LikeRepository +├── OrderRepository +└── StockRepository +``` +--- + +## 2️⃣ 전체 클래스 다이어그램 + +```mermaid +classDiagram + %% ============================================ + %% Presentation Layer (Controllers) + %% ============================================ + class BrandController { + -BrandService brandService + +getBrand(brandId: Long) BrandResponse + } + + class ProductController { + -ProductFacade productFacade + +getProducts(brandId: Long, sort: String, page: int, size: int, headers) Page~ProductListResponse~ + +getProductDetail(productId: Long, headers) ProductDetailResponse + -extractUserId(headers) Long + } + + class LikeController { + -LikeService likeService + +createLike(productId: Long, headers) void + +deleteLike(productId: Long, headers) void + +getMyLikes(headers, page:int, size:int) Page~MyLikeResponse~ + -extractUserId(headers) Long + } + + %% ============================================ + %% Application Layer (Facades) + %% ============================================ + class ProductFacade { + -ProductService productService + -ProductOptionService productOptionService + -ProductImageService productImageService + -LikeService likeService + +getProducts(brandId: Long, sort: String, page:int, size:int, userId: Long) Page~ProductListResponse~ + +getProductDetail(productId: Long, userId: Long) ProductDetailResponse + } + + %% ============================================ + %% Domain Layer (Services) + %% ============================================ + class BrandService { + -BrandRepository brandRepository + +getBrand(brandId: Long) Brand + } + + class ProductService { + -ProductRepository productRepository + +findActiveProducts(brandId: Long, sort: String, page:int, size:int) Page~Product~ + +findActiveProduct(productId: Long) Product + +findLikeCounts(productIds: List~Long~) Map~Long, Integer~ + +getLikeCount(productId: Long) Integer + +validateProductActive(productId: Long) void + } + + class ProductOptionService { + -ProductOptionRepository productOptionRepository + +findOptions(productId: Long) List~ProductOption~ + +calculateMinPrices(productIds: List~Long~) Map~Long, Integer~ + } + + class ProductImageService { + -ProductImageRepository productImageRepository + +findImages(productId: Long) List~ProductImage~ + } + + class LikeService { + -LikeRepository likeRepository + -ProductService productService + -EventPublisher eventPublisher + +createLike(userId: Long, productId: Long) void + +deleteLike(userId: Long, productId: Long) void + +findMyLikes(userId: Long, page:int, size:int) Page~MyLikeResponse~ + +checkLikedByUser(userId: Long, productId: Long) boolean + +findLikedProductIds(userId: Long, productIds: List~Long~) Set~Long~ + } + + %% ============================================ + %% Domain Layer (Entities / Enums) + %% ============================================ + class Brand { + -Long id + -String name + -String description + -String logoImageUrl + -LocalDateTime createdAt + } + + class Product { + -Long id + -Long brandId + -String name + -String description + -String thumbnailImageUrl + -ProductStatus status + -int likeCount %% products.like_count (집계값) + -LocalDateTime createdAt + } + + class ProductStatus { + <> + DRAFT + ACTIVE + INACTIVE + } + + class ProductOption { + -Long id + -Long productId + -String name + -int price + -int stockQuantity + -ProductOptionStatus status + -LocalDateTime createdAt + } + + class ProductOptionStatus { + <> + ON_SALE + SOLD_OUT + STOPPED + } + + class ProductImage { + -Long id + -Long productId + -String imageUrl + -int displayOrder + } + + class Like { + -Long id + -Long userId + -Long productId + -LocalDateTime createdAt + %% Unique(userId, productId) + } + + class Money { + -long value + +Money(value: long) + %% value >= 0 (음수 방지) + } + + class Quantity { + -int value + +Quantity(value: int) + %% 주문 수량: value >= 1 / 재고 수량: value >= 0 + } + + %% ============================================ + %% Infrastructure Layer (Repositories) + %% ============================================ + class BrandRepository { + <> + +findById(brandId: Long) Optional~Brand~ + } + + class ProductRepository { + <> + +findActiveById(productId: Long) Optional~Product~ + +findAllActive(brandId: Long, sort: String, page:int, size:int) Page~Product~ + +findLikeCountsByIds(productIds: List~Long~) Map~Long, Integer~ %% products.like_count batch + +findLikeCountById(productId: Long) Integer + +incrementLikeCount(productId: Long) void + +decrementLikeCount(productId: Long) void + } + + class ProductOptionRepository { + <> + +findByProductId(productId: Long) List~ProductOption~ + +findMinPricesByProductIds(productIds: List~Long~) Map~Long, Integer~ + } + + class ProductImageRepository { + <> + +findByProductId(productId: Long) List~ProductImage~ + } + + class LikeRepository { + <> + +save(like: Like) Like + +findByUserIdAndProductId(userId: Long, productId: Long) Optional~Like~ + +deleteById(likeId: Long) void + +findLikedProductIds(userId: Long, productIds: List~Long~) Set~Long~ + +findByUserId(userId: Long, page:int, size:int) Page~Like~ + %% countByProductId(s)는 사용하지 않음 (like_count는 Product에 저장) + } + + %% ============================================ + %% Async / Batch (Eventual Consistency) + %% ============================================ + class EventPublisher { + <> + +publish(event) void + } + + class LikeCreatedEvent { + -Long userId + -Long productId + -LocalDateTime occurredAt + } + + class LikeDeletedEvent { + -Long userId + -Long productId + -LocalDateTime occurredAt + } + + class LikeCountConsumer { + -ProductRepository productRepository + +handle(event) void + } + + class LikeCountReconcileBatch { + -LikeRepository likeRepository + -ProductRepository productRepository + +reconcile() void + } + + %% ============================================ + %% Exceptions (minimal) + %% ============================================ + class BusinessException { + <> + -String errorCode + -String message + } + + class ProductNotFoundException + class BrandNotFoundException + class DuplicateLikeException + + BusinessException <|-- ProductNotFoundException + BusinessException <|-- BrandNotFoundException + BusinessException <|-- DuplicateLikeException + + %% ============================================ + %% Relationships + %% ============================================ + BrandController ..> BrandService : uses + ProductController ..> ProductFacade : uses + LikeController ..> LikeService : uses + + ProductFacade ..> ProductService : uses + ProductFacade ..> ProductOptionService : uses + ProductFacade ..> ProductImageService : uses + ProductFacade ..> LikeService : uses + + BrandService ..> BrandRepository : uses + ProductService ..> ProductRepository : uses + ProductOptionService ..> ProductOptionRepository : uses + ProductImageService ..> ProductImageRepository : uses + ProductOption ..> Money : price + ProductOption ..> Quantity : stockQuantity + OrderItem ..> Money : optionPrice + OrderItem ..> Quantity : quantity + Order ..> Money : totalAmount + LikeService ..> LikeRepository : uses + LikeService ..> ProductService : validates ACTIVE + LikeService ..> EventPublisher : publishes + + LikeCountConsumer ..> ProductRepository : updates like_count + LikeCountReconcileBatch ..> LikeRepository : reads likes + LikeCountReconcileBatch ..> ProductRepository : fixes like_count + + Brand "1" --> "N" Product : has + Product "1" --> "N" ProductOption : has + Product "1" --> "N" ProductImage : has + Product "1" --> "N" Like : receives +``` + +--- + +## 3️⃣ 레이어별 상세 설계 + +### Presentation Layer (Controller) + +#### BrandController + +**책임:** +- HTTP 요청 처리 +- Path Variable 추출 +- 응답 DTO 변환 +- HTTP 상태 코드 반환 + +**예외 처리:** +- BrandNotFoundException → 404 Not Found + +--- + +#### ProductController + +**책임:** +- HTTP 요청 처리 +- Query Parameter, Path Variable, Header 추출 +- 인증 정보 추출 (userId) +- Facade 호출 +- 응답 DTO 반환 + +--- + +### Application Layer (Facade) + +#### ProductFacade + +**책임:** +- 여러 도메인 서비스 조율 (orchestration) +- 병렬 처리 가능한 작업 조율 +- 데이터 조합 및 응답 DTO 구성 +- 로그인 여부에 따른 분기 처리 +- 비즈니스 규칙 검증 (옵션 존재 여부) + +**예외 처리:** +- ProductNotFoundException (ProductService에서 전파) +- ProductOptionNotFoundException (옵션이 없을 때) + + +#### OrderFacade + +**책임:** “주문 생성” 유스케이스를 끝까지 완주시키기 + +**하는 일:** +- 로그인 인증으로 memberId 확보 +- 요청에서 optionIds 추출 +- ProductService로 옵션/상품/브랜드 조회 +- ProductSnapshot 만들어서 OrderItem 재료 준비 +- OrderService 호출해서 “예약 주문 생성” 실행 +- 결과를 응답 DTO로 매핑 + +--- + +### Domain Layer (Services) + +#### BrandService + +**책임:** +- 브랜드 도메인 비즈니스 로직 +- 브랜드 조회 + +**예외:** +- BrandNotFoundException + +--- + +#### ProductService + +**책임:** +- 상품 도메인 비즈니스 로직 +- 상품 조회 (목록, 상세) + +**예외:** +- ProductNotFoundException + +--- + +#### ProductOptionService + +**책임:** +- 상품 옵션 도메인 비즈니스 로직 +- 옵션 조회 +- 최저가 계산 (집계 쿼리) +- 가격/재고 검증 + +**예외:** +- BusinessRuleViolationException (가격 음수, 재고 음수 등) + +--- + +#### ProductImageService + +**책임:** +- 상품 이미지 도메인 비즈니스 로직 +- 이미지 조회 +- 이미지 리소스 존재 검증 + +**예외:** +- ImageNotFoundException + +--- + +#### LikeService + +**책임:** +- 좋아요 도메인 비즈니스 로직 +- 좋아요 수 조회 (단일, 배치) +- 좋아요 여부 확인 (단일, 배치) +- 좋아요 등록/취소 + +**예외:** +- DuplicateLikeException + +#### OrderService + +**책임:** Order Aggregate 생성/상태 전이 같은 도메인 규칙 + +**하는 일:** +- OrderModel 생성 (status=CREATED) + +- OrderItemModel 생성 + +- StockDeductionService.reserveAll() 호출 + +- 저장 + +#### StockDeductionService + +**책임:** “여러 Stock에 걸친 원자적 예약/확정/해제” (크로스 엔티티 규칙) + + +--- + +### Domain Layer (Entities) + +#### Brand + +**설계 포인트:** +- 불변 객체 지향 (Setter 없음) +- 생성자를 통한 필수 값 주입 +- JPA 기본 생성자는 protected + +--- + +#### Product + +**설계 포인트:** +- brandId는 Long 타입 (FK 제약 없음) +- 생성자에서 비즈니스 규칙 검증 +- 도메인 무결성은 애플리케이션 레벨에서 관리 + +--- + +#### ProductOption + +**설계 포인트:** +- 가격, 재고 검증 로직 포함 +- `isAvailable()` 비즈니스 메서드 +- Unique 제약: 같은 상품 내 옵션명 중복 불가 + +--- + +#### ProductImage + +**설계 포인트:** +- displayOrder로 이미지 순서 관리 +- 실제 이미지 파일은 S3/CDN에 저장, URL만 DB에 보관 + +--- + +#### Like + +**설계 포인트:** +- Unique 제약: 사용자당 상품 1개만 좋아요 가능 +- 중복 좋아요는 DB 레벨에서 방지 +- v1에서는 userId를 임시 식별자로 사용 + +--- + +#### Money (VO) +- value: long +- 생성 규칙: value >= 0 (음수 방지) +- 사용처: ProductOption.price, Order.totalAmount, OrderItem.optionPrice + +#### Quantity (VO) +- value: int +- 생성 규칙: 주문 수량 value >= 1 / 재고 수량 value >= 0 +- 사용처: ProductOption.stockQuantity, OrderItem.quantity + +--- + +## 4️⃣ 예외 계층 구조 + +```mermaid +classDiagram + class RuntimeException { + <> + } + + class BusinessException { + <> + -String errorCode + -String message + +BusinessException(errorCode: String, message: String) + +getErrorCode() String + +getMessage() String + } + + %% ---- Catalog (Brand/Product/Like) ---- + class BrandNotFoundException { + +BrandNotFoundException(brandId: Long) + } + + class ProductNotFoundException { + +ProductNotFoundException(productId: Long) + } + + class DuplicateLikeException { + +DuplicateLikeException(userId: Long, productId: Long) + } + + class BusinessRuleViolationException { + +BusinessRuleViolationException(message: String) + } + + %% ---- Order/Stock (예약/확정/취소 정책 반영) ---- + class OrderNotFoundException { + +OrderNotFoundException(orderId: Long) + } + + class ProductOptionNotFoundException { + +ProductOptionNotFoundException(productOptionId: Long) + } + + class InsufficientStockException { + +InsufficientStockException(productOptionId: Long) + } + + class InvalidOrderStateException { + +InvalidOrderStateException(orderId: Long, fromStatus: String, action: String) + } + + class UnauthorizedOrderActionException { + +UnauthorizedOrderActionException(orderId: Long, action: String) + } + + RuntimeException <|-- BusinessException + BusinessException <|-- BrandNotFoundException + BusinessException <|-- ProductNotFoundException + BusinessException <|-- DuplicateLikeException + BusinessException <|-- BusinessRuleViolationException + BusinessException <|-- OrderNotFoundException + BusinessException <|-- ProductOptionNotFoundException + BusinessException <|-- InsufficientStockException + BusinessException <|-- InvalidOrderStateException + BusinessException <|-- UnauthorizedOrderActionException +``` + +### 예외 클래스 상세 + + +#### BrandNotFoundException + +**발생 시점:** 브랜드 조회 시 존재하지 않을 때 +**HTTP 상태:** 404 Not Found +**복구 전략:** 사용자에게 브랜드가 존재하지 않음을 알림 + +--- + +#### ProductNotFoundException + +**발생 시점:** +- 상품 조회 시 존재하지 않을 때 +- 타이밍 이슈로 조회 중 삭제되었을 때 (동시성) + +**HTTP 상태:** 404 Not Found +**복구 전략:** 사용자에게 상품이 존재하지 않음을 알림 + +--- + +#### ProductOptionNotFoundException + +**발생 시점:** 상품은 존재하는데 옵션이 하나도 없을 때 (데이터 무결성 위반) +**HTTP 상태:** 500 Internal Server Error +**복구 전략:** +- 시스템 관리자에게 알림 +- 데이터 정합성 복구 필요 +- 사용자에게는 일시적 오류 안내 + +--- + +#### ImageNotFoundException + +**발생 시점:** 이미지 URL은 DB에 있지만 실제 리소스(S3, CDN)가 없을 때 +**HTTP 상태:** 404 Not Found (또는 500으로 설정 가능) +**복구 전략:** +- 기본 이미지로 대체 +- 시스템 관리자에게 알림 (이미지 리소스 복구 필요) + +--- + +#### BusinessRuleViolationException + +**발생 시점:** +- 가격이 음수일 때 +- 재고가 음수일 때 +- 기타 비즈니스 규칙 위반 + +**HTTP 상태:** 500 Internal Server Error +**복구 전략:** +- 시스템 관리자에게 알림 +- 데이터 검증 강화 +- 사용자에게는 일시적 오류 안내 + +--- + +#### DuplicateLikeException + +**발생 시점:** 이미 좋아요를 누른 상품에 다시 좋아요 시도 +**HTTP 상태:** 409 Conflict +**복구 전략:** 사용자에게 이미 좋아요했음을 안내 + +--- + +## 5️⃣ 설계 원칙 및 고려사항 + +### 1. 레이어 분리 원칙 + +#### Controller 책임 +- HTTP 프로토콜 처리에만 집중 +- 비즈니스 로직 없음 +- 인증 정보 추출 (userId) +- 예외를 HTTP 상태 코드로 변환 + +#### Facade 책임 +- 여러 도메인 서비스 조율 +- 복잡한 흐름 관리 +- 데이터 조합 +- **비즈니스 규칙은 Service에 위임** + +#### Service 책임 +- 도메인별 비즈니스 로직 +- 단일 도메인에 집중 +- 트랜잭션 경계 +- Entity 검증 및 생성 + +#### Repository 책임 +- 데이터 접근만 +- 쿼리 최적화 +- 영속성 관리 + +--- + +### 2. Facade 사용 기준 + +**Facade가 필요한 경우:** +- 여러 도메인 서비스 협력이 필요한 경우 +- 복잡한 데이터 조합이 필요한 경우 +- 조건부 처리(로그인 여부 등)가 필요한 경우 + +**Facade가 불필요한 경우:** +- 단일 도메인만 다루는 경우 (예: 브랜드 조회) +- Controller → Service 직접 호출로 충분한 경우 + +--- + +### 3. 예외 처리 전략 + +#### 예외 계층 구조 +``` +RuntimeException + └─ BusinessException (추상) + ├─ BrandNotFoundException (404) + ├─ ProductNotFoundException (404) + ├─ ProductOptionNotFoundException (500) ← 치명적 + ├─ ImageNotFoundException (404/500) + ├─ BusinessRuleViolationException (500) ← 치명적 + └─ DuplicateLikeException (409) +``` + +#### 치명적 vs 일반 예외 + +| 예외 | 치명도 | HTTP | 복구 전략 | +|------|--------|------|----------| +| BrandNotFoundException | 일반 | 404 | 사용자 안내 | +| ProductNotFoundException | 일반 | 404 | 사용자 안내 | +| **ProductOptionNotFoundException** | **치명적** | **500** | **시스템 알림, 데이터 복구** | +| **ImageNotFoundException** | 치명적 | 404 | 기본 이미지 대체, 알림 | +| **BusinessRuleViolationException** | **치명적** | **500** | **시스템 알림, 데이터 검증** | +| DuplicateLikeException | 일반 | 409 | 사용자 안내 | + +--- + +### 4. FK 제약 없는 설계 + +**이유:** +- 애플리케이션 레벨에서 참조 무결성 관리 +- DB 레벨 제약으로 인한 성능 오버헤드 제거 +- 향후 샤딩, 마이크로서비스 전환 시 유연성 확보 + +**트레이드오프:** +- 데이터 정합성은 애플리케이션 책임 +- 고아 레코드(orphan records) 발생 가능성 +- 정기적인 데이터 정합성 체크 필요 + +**보완 전략:** +- Service 레벨에서 참조 검증 +- 배치 작업을 통한 정합성 체크 +- 모니터링 및 알림 + +--- + +### 5. 성능 고려사항 + +#### N+1 문제 방지 +- Fetch Join 활용 +- 배치 조회 메서드 제공 (예: `calculateMinPrices(List)`) +- Repository에서 IN 절 쿼리 사용 + +#### 병렬 처리 +- Facade에서 독립적인 조회는 병렬 실행 가능 +- CompletableFuture 또는 @Async 활용 고려 + +#### 캐싱 +- 자주 조회되는 브랜드 정보 캐싱 +- 상품 최저가 계산 결과 캐싱 (Redis) +- 좋아요 수 캐싱 (Eventual Consistency 허용) + +--- + +**문서 끝** \ No newline at end of file diff --git a/docs/design/04-erd.md b/docs/design/04-erd.md new file mode 100644 index 000000000..5be882d72 --- /dev/null +++ b/docs/design/04-erd.md @@ -0,0 +1,82 @@ +### ERD │ +```mermaid +erDiagram +brands ||--o{ products : "has" +products ||--o{ product_options : "has" +products ||--o{ product_images : "has" +products ||--o{ likes : "receives" +products ||--o{ order_items : "referenced by" +product_options ||--o{ order_items : "ordered as" +orders ||--o{ order_items : "contains" +users ||--o{ likes : "creates" +users ||--o{ orders : "places" + + brands { + BIGINT brand_id PK "AUTO_INCREMENT" + VARCHAR(100) name UK "NOT NULL, UNIQUE" + TEXT description "NULL" + VARCHAR(500) logo_image_url "NULL" + TIMESTAMP created_at "NOT NULL, DEFAULT CURRENT_TIMESTAMP" + } + + products { + BIGINT product_id PK "AUTO_INCREMENT" + BIGINT brand_id "NOT NULL (FK 없음)" + VARCHAR(200) name "NOT NULL" + TEXT description "NULL" + INT like_count "NOT NULL DEFAULT 0 (비정규화, 선택)" + TIMESTAMP created_at "NOT NULL, DEFAULT CURRENT_TIMESTAMP" + } + + product_options { + BIGINT option_id PK "AUTO_INCREMENT" + BIGINT product_id "NOT NULL (FK 없음)" + VARCHAR(100) name UK "NOT NULL (product 내 UNIQUE)" + INT price "NOT NULL" + INT stock_quantity "NOT NULL DEFAULT 0" + TIMESTAMP created_at "NOT NULL, DEFAULT CURRENT_TIMESTAMP" + } + + product_images { + BIGINT image_id PK "AUTO_INCREMENT" + BIGINT product_id "NOT NULL (FK 없음)" + VARCHAR(500) image_url "NOT NULL" + INT display_order "NOT NULL" + TIMESTAMP created_at "NOT NULL, DEFAULT CURRENT_TIMESTAMP" + } + + likes { + BIGINT like_id PK "AUTO_INCREMENT" + BIGINT user_id "NOT NULL (FK 없음)" + BIGINT product_id "NOT NULL (FK 없음)" + TIMESTAMP created_at "NOT NULL, DEFAULT CURRENT_TIMESTAMP" + } + + orders { + BIGINT order_id PK "AUTO_INCREMENT" + BIGINT user_id "NOT NULL (FK 없음)" + VARCHAR(20) status "NOT NULL (CREATED/CONFIRMED/CANCELLED)" + BIGINT total_amount "NOT NULL (Money VO -> BIGINT)" + TIMESTAMP created_at "NOT NULL, DEFAULT CURRENT_TIMESTAMP" + } + + order_items { + BIGINT order_item_id PK "AUTO_INCREMENT" + BIGINT order_id "NOT NULL (FK 없음)" + BIGINT product_id "NOT NULL (FK 없음)" + BIGINT option_id "NOT NULL (FK 없음)" + VARCHAR(20) status "NOT NULL (ORDERED/CANCELLED)" + + VARCHAR(200) product_name "NOT NULL (Snapshot)" + VARCHAR(100) brand_name "NOT NULL (Snapshot)" + VARCHAR(100) option_name "NOT NULL (Snapshot)" + BIGINT option_price "NOT NULL (Money Snapshot -> BIGINT)" + BIGINT quantity "NOT NULL (Quantity VO -> BIGINT)" + + TIMESTAMP created_at "NOT NULL, DEFAULT CURRENT_TIMESTAMP" + } + + users { + BIGINT user_id PK + } +``` \ No newline at end of file diff --git "a/docs/design/\353\270\214\353\236\234\353\223\234_\354\203\201\355\222\210/01-requirements.md" "b/docs/design/\353\270\214\353\236\234\353\223\234_\354\203\201\355\222\210/01-requirements.md" new file mode 100644 index 000000000..795a152a9 --- /dev/null +++ "b/docs/design/\353\270\214\353\236\234\353\223\234_\354\203\201\355\222\210/01-requirements.md" @@ -0,0 +1,412 @@ +# 브랜드 & 상품 관리 유저 시나리오 기반 기능 정의정의서 + +### US-1. 브랜드 탐색 +**시나리오:** +사용자는 여러 브랜드를 둘러보며 관심 있는 브랜드를 찾는다. + +**상세 흐름:** +1. 브랜드 목록 페이지 접속 +2. 브랜드 카드(썸네일, 이름) 확인 +3. 특정 브랜드 클릭 → 브랜드 상세 정보 조회 + +**제공 기능:** +- 브랜드 정보 조회 + +--- + +### US-2. 상품 탐색 +**시나리오:** +사용자는 브랜드의 상품 목록을 둘러보고, 정렬 기능을 활용하여 원하는 상품을 찾는다. 관심 있는 상품의 상세 정보를 확인한다. + +**주요 흐름:** +1. 전체 상품 목록 또는 특정 브랜드의 상품 목록 조회 +2. 정렬 옵션 선택 (최신순, 가격순, 인기순) +3. 상품 카드(이미지, 이름, 최저가, 좋아요 수) 확인 +4. 특정 상품 클릭 → 상품 상세 정보 조회 +5. 상품 옵션별 가격, 재고 확인 + +**제공 기능:** +- 상품 목록 조회 (브랜드 필터링, 정렬, 페이지네이션) +- 상품 상세 정보 조회 + +--- + +### US-3. 상품 좋아요 +**시나리오:** +사용자는 마음에 드는 상품에 좋아요를 누르고, 내가 좋아요한 상품 목록을 확인한다. + +**주요 흐름:** +1. 상품 목록 또는 상세 페이지에서 좋아요 버튼 클릭 +2. 좋아요 등록/취소 토글 +3. 좋아요 수 실시간 업데이트 (비동기) +4. 내 좋아요 목록 페이지에서 좋아요한 상품들 확인 + +**제공 기능:** +- 상품 좋아요 등록 +- 상품 좋아요 취소 +- 내 좋아요 목록 조회 + +**참고:** +- 좋아요 기능의 상세 요구사항(동기/비동기 처리, 카운트 업데이트 정책 등)은 별도 문서 참조 + +--- + +## 기능별 상세 요구사항 + +### 🔹 브랜드 (Brand) + +#### FR-B-01. 브랜드 정보 조회 +**API 명세:** +``` +POST GET /api/v1/brands/{brandId} +Authorization: Not Required +``` + +**Path Parameter:** +- `brandId` (Long): 브랜드 ID + +**반환 정보:** +```json +{ + "brandId": 1, + "name": "브랜드명", + "description": "브랜드 설명", + "logoImageUrl": "https://example.com/brand-logo.png", + "createdAt": "2025-01-01T00:00:00" +} +``` + +**에러 처리:** +- 존재하지 않는 브랜드 ID → 404 Not Found + +--- + +### 🔹 상품 (Product) + +#### FR-P-01. 상품 목록 조회 + +**API 명세:** +``` +POST GET GET /api/v1/products +Authorization: Not Required (로그인 시 좋아요 여부 추가 제공) +``` + +**Query Parameters:** + +| 파라미터 | 타입 | 필수 | 기본값 | 설명 | +|---------|------|------|--------|------| +| **brandId** | Long | X | - | 특정 브랜드의 상품만 필터링 | +| `sort` | String | X | `latest` | 정렬 기준 (`latest`, `price_asc`, `likes_desc`) | +| `page` | Integer | X | 0 | 페이지 번호 | +| `size` | Integer | X | 20 | 페이지당 상품 수 | + +**정렬 옵션:** +- `latest` (기본값): 최신 등록순 (createdAt DESC) +- `price_asc`: 최저가 낮은 순 (각 상품의 옵션 중 최저가 기준) +- `likes_desc`: 좋아요 많은 순 (likeCount DESC) + +**반환 정보 (비로그인 사용자):** +```json +{ + "content": [ + { + "productId": 1, + "name": "상품명", + "brand": { + "brandId": 1, + "name": "브랜드명" + }, + "thumbnailImageUrl": "https://example.com/product-thumbnail.png", + "minPrice": 10000, + "likeCount": 150, + "createdAt": "2025-01-01T00:00:00" + } + ], + "page": 0, + "size": 20, + "totalElements": 100, + "totalPages": 5 +} +``` + +**반환 정보 (로그인 사용자):** +```json +{ + "content": [ + { + "productId": 1, + "name": "상품명", + "brand": { + "brandId": 1, + "name": "브랜드명" + }, + "thumbnailImageUrl": "https://example.com/product-thumbnail.png", + "minPrice": 10000, + "likeCount": 150, + "isLikedByMe": true, + "createdAt": "2025-01-01T00:00:00" + } + ], + "page": 0, + "size": 20, + "totalElements": 100, + "totalPages": 5 +} +``` + +**비즈니스 규칙:** +- `minPrice`: 해당 상품의 모든 옵션 중 **최저가**를 표시 +- `likeCount`: 해당 상품의 좋아요 수 (비동기 업데이트, Eventual Consistency) +- `isLikedByMe`: 로그인한 사용자가 해당 상품을 좋아요 했는지 여부 (로그인 시에만 포함) + +**성능 고려사항:** +- 비로그인: 브랜드 정보 Fetch Join, 최저가는 서브쿼리 사용 +- 로그인: 추가로 좋아요 여부 확인 (EXISTS 서브쿼리 또는 LEFT JOIN) + +--- + +#### FR-P-02. 상품 상세 정보 조회 +**API 명세:** +``` +GET /api/v1/products/{productId} +Authorization: Not Required (로그인 시 좋아요 여부 추가 제공) +``` + +**Path Parameter:** +- `productId` (Long): 상품 ID + +**반환 정보 (비로그인 사용자):** +```json +{ + "productId": 1, + "name": "상품명", + "description": "상품 상세 설명", + "brand": { + "brandId": 1, + "name": "브랜드명" + }, + "imageUrls": [ + "https://example.com/product-image1.png", + "https://example.com/product-image2.png" + ], + "options": [ + { + "productOptionId": 1, + "name": "S 사이즈", + "price": 10000, + "stockQuantity": 50, + "isAvailable": true + }, + { + "productOptionId": 2, + "name": "M 사이즈", + "price": 10000, + "stockQuantity": 0, + "isAvailable": false + }, + { + "productOptionId": 3, + "name": "L 사이즈", + "price": 12000, + "stockQuantity": 30, + "isAvailable": true + } + ], + "likeCount": 150, + "createdAt": "2025-01-01T00:00:00" +} +``` + +[//]: # (**반환 정보 (로그인 사용자):**) +[//]: # (```json) +[//]: # ({) +[//]: # ( "productId": 1,) +[//]: # ( "name": "상품명",) +[//]: # ( "description": "상품 상세 설명",) +[//]: # ( "brand": {) +[//]: # ( "brandId": 1,) +[//]: # ( "name": "브랜드명") +[//]: # ( },) +[//]: # ( "imageUrls": [) +[//]: # ( "https://example.com/product-image1.png",) +[//]: # ( "https://example.com/product-image2.png") +[//]: # ( ],) +[//]: # ( "options": [) +[//]: # ( {) +[//]: # ( "productOptionId": 1,) +[//]: # ( "name": "S 사이즈",) +[//]: # ( "price": 10000,) +[//]: # ( "stockQuantity": 50,) +[//]: # ( "isAvailable": true) +[//]: # ( }) +[//]: # ( ],) +[//]: # ( "likeCount": 150,) +[//]: # ( "isLikedByMe": true,) +[//]: # ( "createdAt": "2025-01-01T00:00:00") +[//]: # (}) +[//]: # (```) + +**비즈니스 규칙:** +- **옵션별 가격**: 각 옵션은 독립적인 가격을 가짐 +- **재고 표시**: `stockQuantity`로 재고 수량 표시 +- **판매 가능 여부**: `isAvailable = stockQuantity > 0` +- 옵션이 없는 상품은 존재하지 않음 (최소 1개 옵션 필수) + +**에러 처리:** +- 존재하지 않는 상품 ID → 404 Not Found + +--- + +### 🔹 좋아요 (Like) + +> **참고:** 좋아요 기능의 상세 요구사항(동기/비동기 처리, 이벤트 발행, 배치 복구 등)은 별도 문서 참조 + + +## 설계 고려사항 + +### 확장 포인트 + +#### 1. 옵션 구조의 유연성 +**현재 (v1):** +- 단순 옵션명 + 가격 + 재고 +- 예: "S 사이즈", "M 사이즈", "L 사이즈" + +**향후 확장 가능성 (v2):** +- 다차원 옵션 지원 (예: 색상 × 사이즈) +- 예: "빨강/S", "빨강/M", "파랑/S", "파랑/M" +- 옵션 그룹 개념 도입 (색상 그룹, 사이즈 그룹) + +**설계 시 주의사항:** +- 현재 단순 구조로 시작하되, 다차원 옵션으로 확장 가능하도록 옵션명을 구조화 +- 옵션 ID는 불변으로 유지, 옵션 속성 변경 시 새 옵션 생성 + +--- + +#### 2. 재고 관리 +**현재 (v1):** +- 단순 수량 관리 (`stock_quantity`) +- 재고 조회만 가능 + +**향후 확장 가능성 (v2):** +- 예약 재고 개념 (주문 생성 시 차감) +- 안전 재고 (품절 임박 알림) +- 재고 히스토리 (입고/출고 이력) + +**설계 시 주의사항:** +- 재고 차감은 트랜잭션 내에서 원자적으로 처리 +- 동시성 제어 (낙관적 락 또는 비관적 락) + +--- + +#### 3. 가격 정책 +**현재 (v1):** +- 옵션별 단일 가격 +- 최저가 기준 정렬 + +**향후 확장 가능성 (v2):** +- 프로모션 가격 (기간 한정 할인) +- 회원 등급별 가격 +- 쿠폰 적용 후 가격 + +**설계 시 주의사항:** +- 가격 이력 관리 (가격 변경 추적) +- 주문 시점 가격 고정 (계약 개념) + +--- + + +### 성능 최적화 포인트 + +#### 1. 상품 목록 조회 +**N+1 문제 방지:** +``` +❌ 각 상품마다 브랜드 조회, 최저가 조회, 좋아요 수 조회 +✅ Fetch Join + 서브쿼리로 단일 쿼리 구성 +``` + +**쿼리 최적화 전략:** +- 브랜드 정보: Fetch Join +- 최저가: 서브쿼리 (SELECT MIN(price) FROM product_options WHERE ...) +- 좋아요 수: 서브쿼리 (SELECT COUNT(*) FROM likes WHERE ...) +- 좋아요 여부 (로그인 시): EXISTS 서브쿼리 또는 LEFT JOIN + +**인덱스 전략:** +- `products(brand_id)`: 브랜드별 상품 필터링 +- `products(created_at DESC)`: 최신순 정렬 +- `product_options(product_id, price)`: 최저가 계산 +- `likes(product_id)`: 좋아요 수 집계 +- `likes(user_id, product_id)`: 중복 방지 + 좋아요 여부 확인 + +--- + +#### 2. 상품 상세 조회 +**Fetch Join 활용:** +``` +✅ 상품 + 옵션 + 이미지 + 브랜드를 단일 쿼리로 조회 +``` + +**쿼리 최적화 전략:** +- 옵션 목록: Fetch Join (1:N) +- 이미지 목록: Fetch Join (1:N) +- 브랜드 정보: Fetch Join (N:1) + +**주의사항:** +- 1:N 관계가 여러 개면 카테시안 곱 발생 가능 +- 필요 시 배치 쿼리로 분리 + +--- + +#### 3. 정렬 성능 +**최신순 (`latest`):** +- 인덱스: `products(created_at DESC)` +- 추가 계산 없이 인덱스 스캔만으로 정렬 가능 + +**가격순 (`price_asc`):** +- 각 상품의 최저가 계산 필요 +- 서브쿼리 또는 집계 쿼리 사용 +- 대용량 데이터 시 성능 이슈 가능 → 캐싱 고려 + +**인기순 (`likes_desc`):** +- 좋아요 수 집계 필요 +- 비동기 업데이트로 인한 지연 허용 +- 대용량 데이터 시 Redis 캐시 활용 고려 + +--- + +### 잠재 리스크 및 해결 방안 + +#### 1. 대용량 상품 데이터 처리 +**리스크:** +- 상품 수 10만 개 이상 시 목록 조회 성능 저하 +- 특히 가격순, 인기순 정렬 시 계산 비용 증가 + +**해결 방안:** +- 적절한 인덱스 설계 +- 페이지네이션 필수 (Offset 방식 → Cursor 방식 고려) +- 자주 조회되는 정렬 결과 캐싱 (Redis) + +--- + +#### 2. 좋아요 동시성 이슈 +**리스크:** +- 같은 사용자가 동시에 좋아요 등록 요청 +- 여러 사용자가 동시에 같은 상품 좋아요 + +**해결 방안:** +- DB Unique 제약으로 중복 방지 +- 애플리케이션 레벨에서 멱등성 보장 +- 낙관적 락 사용 (버전 관리) + +--- + +#### 3. 이미지 로딩 성능 +**리스크:** +- 상품 목록에서 이미지 다수 로딩 시 초기 로딩 지연 + +**해결 방안:** +- CDN 활용 +- Lazy Loading (스크롤 시 순차 로딩) +- 썸네일 이미지 최적화 (리사이징, WebP 포맷) +- 이미지 URL은 DB에 저장, 실제 파일은 Object Storage (S3 등) + +**문서 끝** \ No newline at end of file diff --git "a/docs/design/\353\270\214\353\236\234\353\223\234_\354\203\201\355\222\210/02-sequence-diagrams.md" "b/docs/design/\353\270\214\353\236\234\353\223\234_\354\203\201\355\222\210/02-sequence-diagrams.md" new file mode 100644 index 000000000..846b2f21c --- /dev/null +++ "b/docs/design/\353\270\214\353\236\234\353\223\234_\354\203\201\355\222\210/02-sequence-diagrams.md" @@ -0,0 +1,509 @@ +# 시퀀스 다이어그램 (Sequence Diagrams) + +## 1️⃣ 브랜드 조회 + +### 개요 +사용자가 특정 브랜드의 상세 정보를 조회하는 시나리오. + +### 참여 객체 +- **Client**: 사용자 클라이언트 (웹/앱) +- **BrandController**: 브랜드 API 컨트롤러 +- **BrandService**: 브랜드 도메인 서비스 +- **BrandRepository**: 브랜드 데이터 접근 +- **Database**: 데이터베이스 + +### 주요 흐름 +1. 클라이언트가 브랜드 ID로 조회 요청 +2. 컨트롤러가 요청을 받아 서비스로 전달 +3. 서비스가 리포지토리를 통해 브랜드 조회 +4. 브랜드가 존재하면 정보 반환, 없으면 404 에러 + +### Mermaid 다이어그램 + +```mermaid +sequenceDiagram + participant Client + participant BrandController + participant BrandService + participant BrandRepository + participant Database + + Client->>BrandController: GET /api/v1/brands/{brandId} + BrandController->>BrandService: getBrand(brandId) + BrandService->>BrandRepository: findById(brandId) + BrandRepository->>Database: SELECT * FROM brands WHERE brand_id = ? + + alt 브랜드 존재 + Database-->>BrandRepository: Brand 데이터 + BrandRepository-->>BrandService: Brand + BrandService-->>BrandController: BrandResponse + BrandController-->>Client: 200 OK (BrandResponse) + else 브랜드 없음 + Database-->>BrandRepository: null + BrandRepository-->>BrandService: null + BrandService-->>BrandController: throw BrandNotFoundException + BrandController-->>Client: 404 Not Found + end +``` + +### 설계 포인트 +- **단순한 조회 흐름**: 브랜드 조회는 다른 도메인과의 협력이 필요 없는 단순 조회 +- **Facade 불필요**: 단일 도메인만 다루므로 Facade 레이어 없이 Controller → Service 직접 호출 +- **예외 처리**: 브랜드가 존재하지 않을 경우 404 응답 + +--- + +## 2️⃣ 상품 목록 조회 + +### 개요 +사용자가 상품 목록을 조회하는 시나리오. 브랜드 필터링, 정렬, 페이지네이션을 지원하며, 로그인 사용자의 경우 좋아요 여부도 포함. + +### 참여 객체 +- **Client**: 사용자 클라이언트 +- **ProductController**: 상품 API 컨트롤러 +- **ProductFacade**: 여러 도메인 서비스 조율 +- **ProductService**: 상품 도메인 서비스 +- **ProductOptionService**: 상품 옵션 도메인 서비스 +- **LikeService**: 좋아요 도메인 서비스 +- **각 Repository**: 데이터 접근 계층 +- **Database**: 데이터베이스 + +### 주요 흐름 (로그인 사용자) +1. 클라이언트가 상품 목록 조회 요청 (헤더에 로그인 정보 포함) +2. 컨트롤러가 헤더에서 userId 추출 +3. Facade가 여러 서비스를 조율: + - 상품 목록 조회 + - 각 상품의 최저가 계산 + - 각 상품의 좋아요 수 조회 + - 사용자의 좋아요 여부 확인 +4. Facade가 데이터를 조합하여 응답 구성 + +### Mermaid 다이어그램 (로그인 사용자) + +```mermaid +ssequenceDiagram + participant Client + participant ProductController + participant ProductFacade + participant ProductService + participant ProductOptionService + participant LikeService + participant ProductRepository + participant ProductOptionRepository + participant LikeRepository + participant Database + + Client->>ProductController: GET /api/v1/products?brandId=1&sort=latest
(Headers: X-Loopers-LoginId, X-Loopers-LoginPw) + + ProductController->>ProductController: extractUserId(headers) + Note over ProductController: userId 추출 성공 + + ProductController->>ProductFacade: getProducts(brandId, sort, page, size, userId) + + par 상품 목록 조회 (ACTIVE만) + ProductFacade->>ProductService: findProducts(brandId, sort, page, size) + ProductService->>ProductRepository: findAllActive(brandId, sort, pageable) + ProductRepository->>Database: SELECT * FROM products
WHERE brand_id = ? AND status = 'ACTIVE'
ORDER BY created_at DESC + Database-->>ProductRepository: List + ProductRepository-->>ProductService: List + ProductService-->>ProductFacade: List + end + + Note over ProductFacade: productIds 추출: [1, 2, 3, ...] + + par 최저가 계산 + ProductFacade->>ProductOptionService: calculateMinPrices(productIds) + ProductOptionService->>ProductOptionRepository: findMinPricesByProductIds(productIds) + ProductOptionRepository->>Database: SELECT product_id, MIN(price)
FROM product_options
WHERE product_id IN (?) GROUP BY product_id + Database-->>ProductOptionRepository: Map + ProductOptionRepository-->>ProductOptionService: Map + ProductOptionService-->>ProductFacade: Map + and 좋아요 수 조회 (Product.like_count 사용) + ProductFacade->>ProductService: findLikeCounts(productIds) + ProductService->>ProductRepository: findLikeCountsByIds(productIds) + ProductRepository->>Database: SELECT id AS product_id, like_count
FROM products WHERE id IN (?) + Database-->>ProductRepository: Map + ProductRepository-->>ProductService: Map + ProductService-->>ProductFacade: Map + and 좋아요 여부 확인 + ProductFacade->>LikeService: checkLikedByUser(userId, productIds) + LikeService->>LikeRepository: findLikedProductIds(userId, productIds) + LikeRepository->>Database: SELECT product_id FROM likes
WHERE user_id = ? AND product_id IN (?) + Database-->>LikeRepository: Set + LikeRepository-->>LikeService: Set + LikeService-->>ProductFacade: Set (좋아요한 상품들) + end + + Note over ProductFacade: 데이터 조합:
Product + minPrice + likeCount + isLikedByMe + + ProductFacade-->>ProductController: Page + ProductController-->>Client: 200 OK (상품 목록 + 최저가 + 좋아요 수 + 좋아요 여부) +``` + +### Mermaid 다이어그램 (비로그인 사용자) + +```mermaid +sequenceDiagram + participant Client + participant ProductController + participant ProductFacade + participant ProductService + participant ProductOptionService + participant LikeService + participant ProductRepository + participant ProductOptionRepository + participant LikeRepository + participant Database + + Client->>ProductController: GET /api/v1/products?brandId=1&sort=latest + + ProductController->>ProductController: extractUserId(headers) + Note over ProductController: userId 없음 (비로그인) + + ProductController->>ProductFacade: getProducts(brandId, sort, page, size, null) + + par 상품 목록 조회 + ProductFacade->>ProductService: findProducts(brandId, sort, page, size) + ProductService->>ProductRepository: findAll(brandId, sort, pageable) + ProductRepository->>Database: SELECT * FROM products + Database-->>ProductRepository: List + ProductRepository-->>ProductService: List + ProductService-->>ProductFacade: List + end + + Note over ProductFacade: productIds 추출 + + par 최저가 계산 + ProductFacade->>ProductOptionService: calculateMinPrices(productIds) + ProductOptionService->>ProductOptionRepository: findMinPricesByProductIds(productIds) + ProductOptionRepository->>Database: SELECT product_id, MIN(price)
FROM product_options + Database-->>ProductOptionRepository: Map + ProductOptionRepository-->>ProductOptionService: Map + ProductOptionService-->>ProductFacade: Map + and 좋아요 수 조회 + ProductFacade->>LikeService: countLikes(productIds) + LikeService->>LikeRepository: countByProductIds(productIds) + LikeRepository->>Database: SELECT product_id, COUNT(*)
FROM likes + Database-->>LikeRepository: Map + LikeRepository-->>LikeService: Map + LikeService-->>ProductFacade: Map + end + + Note over ProductFacade: userId가 null이므로
좋아요 여부 조회 생략 + + Note over ProductFacade: 데이터 조합:
Product + minPrice + likeCount
(isLikedByMe 제외) + + ProductFacade-->>ProductController: Page + ProductController-->>Client: 200 OK (상품 목록 + 최저가 + 좋아요 수) +sequenceDiagram + participant Client + participant ProductController + participant ProductFacade + participant ProductService + participant ProductOptionService + participant ProductRepository + participant ProductOptionRepository + participant Database + + Client->>ProductController: GET /api/v1/products?brandId=1&sort=latest + + ProductController->>ProductController: extractUserId(headers) + Note over ProductController: userId 없음 (비로그인) + + ProductController->>ProductFacade: getProducts(brandId, sort, page, size, null) + + par 상품 목록 조회 (ACTIVE만) + ProductFacade->>ProductService: findProducts(brandId, sort, page, size) + ProductService->>ProductRepository: findAllActive(brandId, sort, pageable) + ProductRepository->>Database: SELECT * FROM products
WHERE brand_id = ? AND status = 'ACTIVE'
ORDER BY created_at DESC + Database-->>ProductRepository: List + ProductRepository-->>ProductService: List + ProductService-->>ProductFacade: List + end + + Note over ProductFacade: productIds 추출 + + par 최저가 계산 + ProductFacade->>ProductOptionService: calculateMinPrices(productIds) + ProductOptionService->>ProductOptionRepository: findMinPricesByProductIds(productIds) + ProductOptionRepository->>Database: SELECT product_id, MIN(price)
FROM product_options
WHERE product_id IN (?) GROUP BY product_id + Database-->>ProductOptionRepository: Map + ProductOptionRepository-->>ProductOptionService: Map + ProductOptionService-->>ProductFacade: Map + and 좋아요 수 조회 (Product.like_count 사용) + ProductFacade->>ProductService: findLikeCounts(productIds) + ProductService->>ProductRepository: findLikeCountsByIds(productIds) + ProductRepository->>Database: SELECT id AS product_id, like_count
FROM products WHERE id IN (?) + Database-->>ProductRepository: Map + ProductRepository-->>ProductService: Map + ProductService-->>ProductFacade: Map + end + + Note over ProductFacade: userId가 null이므로
좋아요 여부 조회 생략 + + Note over ProductFacade: 데이터 조합:
Product + minPrice + likeCount
(isLikedByMe 제외) + + ProductFacade-->>ProductController: Page + ProductController-->>Client: 200 OK (상품 목록 + 최저가 + 좋아요 수) +``` + +### 설계 포인트 + +#### 1. Facade의 역할 +- **여러 도메인 서비스 조율**: Product, ProductOption, Like 서비스를 조율 +- **병렬 처리 가능**: 최저가, 좋아요 수, 좋아요 여부 조회는 독립적이므로 병렬 실행 가능 +- **데이터 조합**: 각 서비스에서 받은 데이터를 하나의 응답 DTO로 조합 + +#### 2. 로그인 여부에 따른 분기 +- **Controller에서 userId 추출**: 헤더 존재 여부로 로그인 판단 +- **Facade에서 조건부 처리**: userId가 null이면 좋아요 여부 조회 생략 +- **응답 DTO 차이**: 로그인 시 `isLikedByMe` 필드 포함, 비로그인 시 제외 + +#### 3. 성능 최적화 +- **배치 조회**: productIds를 일괄 전달하여 N+1 문제 방지 +- **병렬 처리**: 최저가, 좋아요 수, 좋아요 여부를 동시에 조회 가능 (par 블록) +- **인덱스 활용**: 각 쿼리는 적절한 인덱스 사용 전제 + +#### 4. 트랜잭션 경계 +- **읽기 전용**: 모든 조회는 읽기 전용 트랜잭션 +- **일관성**: 각 조회는 독립적이므로 최종 일관성(Eventual Consistency) 허용 + +--- + +## 3️⃣ 상품 상세 조회 + +### 개요 +사용자가 특정 상품의 상세 정보를 조회하는 시나리오. 상품 기본 정보, 옵션 목록, 이미지 목록, 좋아요 수를 포함하며, 로그인 사용자의 경우 좋아요 여부도 포함. + +### 참여 객체 +- **Client**: 사용자 클라이언트 +- **ProductController**: 상품 API 컨트롤러 +- **ProductFacade**: 여러 도메인 서비스 조율 +- **ProductService**: 상품 도메인 서비스 +- **ProductOptionService**: 상품 옵션 도메인 서비스 +- **ProductImageService**: 상품 이미지 도메인 서비스 +- **LikeService**: 좋아요 도메인 서비스 +- **각 Repository**: 데이터 접근 계층 +- **Database**: 데이터베이스 + +### 주요 흐름 (로그인 사용자) +1. 클라이언트가 상품 상세 조회 요청 (헤더에 로그인 정보 포함) +2. 컨트롤러가 헤더에서 userId 추출 +3. Facade가 여러 서비스를 조율: + - 상품 기본 정보 조회 + - 상품 옵션 목록 조회 + - 상품 이미지 목록 조회 + - 좋아요 수 조회 + - 사용자의 좋아요 여부 확인 +4. Facade가 데이터를 조합하여 응답 구성 + +### Mermaid 다이어그램 (로그인 사용자) + +```mermaid +sequenceDiagram + participant Client + participant ProductController + participant ProductFacade + participant ProductService + participant ProductOptionService + participant ProductImageService + participant LikeService + participant ProductRepository + participant ProductOptionRepository + participant ProductImageRepository + participant LikeRepository + participant Database + + Client->>ProductController: GET /api/v1/products/{productId}
(Headers: X-Loopers-LoginId, X-Loopers-LoginPw) + + ProductController->>ProductController: extractUserId(headers) + Note over ProductController: userId 추출 성공 + + ProductController->>ProductFacade: getProductDetail(productId, userId) + + ProductFacade->>ProductService: findActiveProduct(productId) + ProductService->>ProductRepository: findActiveById(productId) + ProductRepository->>Database: SELECT * FROM products WHERE product_id = ? AND status = 'ACTIVE' + + alt 상품 존재 (ACTIVE) + Database-->>ProductRepository: Product + ProductRepository-->>ProductService: Product + ProductService-->>ProductFacade: Product + + par 옵션 목록 조회 + ProductFacade->>ProductOptionService: findOptions(productId) + ProductOptionService->>ProductOptionRepository: findByProductId(productId) + ProductOptionRepository->>Database: SELECT * FROM product_options
WHERE product_id = ? ORDER BY created_at + Database-->>ProductOptionRepository: List + ProductOptionRepository-->>ProductOptionService: List + ProductOptionService-->>ProductFacade: List + and 이미지 목록 조회 + ProductFacade->>ProductImageService: findImages(productId) + ProductImageService->>ProductImageRepository: findByProductId(productId) + ProductImageRepository->>Database: SELECT * FROM product_images
WHERE product_id = ? ORDER BY display_order + Database-->>ProductImageRepository: List + ProductImageRepository-->>ProductImageService: List + ProductImageService-->>ProductFacade: List + and 좋아요 수 조회 (Product.like_count) + ProductFacade->>ProductService: getLikeCount(productId) + ProductService->>ProductRepository: findLikeCountById(productId) + ProductRepository->>Database: SELECT like_count FROM products WHERE product_id = ? + Database-->>ProductRepository: likeCount + ProductRepository-->>ProductService: likeCount + ProductService-->>ProductFacade: likeCount + and 좋아요 여부 확인 + ProductFacade->>LikeService: checkLikedByUser(userId, productId) + LikeService->>LikeRepository: existsByUserIdAndProductId(userId, productId) + LikeRepository->>Database: SELECT EXISTS(SELECT 1 FROM likes
WHERE user_id = ? AND product_id = ?) + Database-->>LikeRepository: boolean + LikeRepository-->>LikeService: boolean + LikeService-->>ProductFacade: isLiked + end + + Note over ProductFacade: 데이터 조합:
Product + Options + Images + likeCount + isLikedByMe + + ProductFacade-->>ProductController: ProductDetailResponse + ProductController-->>Client: 200 OK (상품 상세 정보) + + else 상품 없음 or 비활성 (DRAFT/INACTIVE) + Database-->>ProductRepository: null + ProductRepository-->>ProductService: null + ProductService-->>ProductFacade: throw ProductNotFoundException + ProductFacade-->>ProductController: throw ProductNotFoundException + ProductController-->>Client: 404 Not Found + end +``` + +### Mermaid 다이어그램 (비로그인 사용자) + +```mermaid +sequenceDiagram + participant Client + participant ProductController + participant ProductFacade + participant ProductService + participant ProductOptionService + participant ProductImageService + participant ProductRepository + participant ProductOptionRepository + participant ProductImageRepository + participant Database + + Client->>ProductController: GET /api/v1/products/{productId} + + ProductController->>ProductController: extractUserId(headers) + Note over ProductController: userId 없음 (비로그인) + + ProductController->>ProductFacade: getProductDetail(productId, null) + + ProductFacade->>ProductService: findActiveProduct(productId) + ProductService->>ProductRepository: findActiveById(productId) + ProductRepository->>Database: SELECT * FROM products WHERE product_id = ? AND status = 'ACTIVE' + + alt 상품 존재 (ACTIVE) + Database-->>ProductRepository: Product + ProductRepository-->>ProductService: Product + ProductService-->>ProductFacade: Product + + par 옵션 목록 조회 + ProductFacade->>ProductOptionService: findOptions(productId) + ProductOptionService->>ProductOptionRepository: findByProductId(productId) + ProductOptionRepository->>Database: SELECT * FROM product_options
WHERE product_id = ? ORDER BY created_at + Database-->>ProductOptionRepository: List + ProductOptionRepository-->>ProductOptionService: List + ProductOptionService-->>ProductFacade: List + and 이미지 목록 조회 + ProductFacade->>ProductImageService: findImages(productId) + ProductImageService->>ProductImageRepository: findByProductId(productId) + ProductImageRepository->>Database: SELECT * FROM product_images
WHERE product_id = ? ORDER BY display_order + Database-->>ProductImageRepository: List + ProductImageRepository-->>ProductImageService: List + ProductImageService-->>ProductFacade: List + and 좋아요 수 조회 (Product.like_count) + ProductFacade->>ProductService: getLikeCount(productId) + ProductService->>ProductRepository: findLikeCountById(productId) + ProductRepository->>Database: SELECT like_count FROM products WHERE product_id = ? + Database-->>ProductRepository: likeCount + ProductRepository-->>ProductService: likeCount + ProductService-->>ProductFacade: likeCount + end + + Note over ProductFacade: userId가 null이므로
좋아요 여부 조회 생략 + + Note over ProductFacade: 데이터 조합:
Product + Options + Images + likeCount
(isLikedByMe 제외) + + ProductFacade-->>ProductController: ProductDetailResponse + ProductController-->>Client: 200 OK (상품 상세 정보) + + else 상품 없음 or 비활성 (DRAFT/INACTIVE) + Database-->>ProductRepository: null + ProductRepository-->>ProductService: null + ProductService-->>ProductFacade: throw ProductNotFoundException + ProductFacade-->>ProductController: throw ProductNotFoundException + ProductController-->>Client: 404 Not Found + end +``` + +### 설계 포인트 + +#### 1. Facade의 역할 +- **다중 도메인 조율**: Product, ProductOption, ProductImage, Like 서비스 조율 +- **병렬 처리**: 옵션, 이미지, 좋아요 수, 좋아요 여부 조회는 독립적이므로 병렬 실행 가능 +- **예외 처리 위임**: 상품이 없으면 ProductService에서 예외 발생, Facade는 그대로 전파 + +#### 2. 로그인 여부에 따른 분기 +- **Controller에서 userId 추출**: 상품 목록 조회와 동일한 패턴 +- **Facade에서 조건부 처리**: userId가 null이면 좋아요 여부 조회 생략 +- **응답 일관성**: 로그인 여부에 따라 응답 구조 달라짐 + +#### 3. 데이터 조회 전략 +- **상품 기본 정보 먼저**: 상품이 존재하지 않으면 즉시 404 반환 +- **나머지 데이터 병렬**: 상품 존재 확인 후 옵션/이미지/좋아요 정보를 병렬로 조회 +- **Fetch Join 고려**: ProductOption, ProductImage는 Fetch Join으로 단일 쿼리 가능 (N+1 방지) + +#### 4. 성능 최적화 +- **병렬 처리**: par 블록으로 여러 조회를 동시에 수행 +- **Fetch Join**: 옵션과 이미지를 상품과 함께 조회하는 것도 가능 (트레이드오프 고려) +- **인덱스**: product_options(product_id), product_images(product_id, display_order) + +--- + +## 📊 전체 설계 요약 + +### 레이어별 책임 + +| 레이어 | 책임 | +|--------|------| +| **Controller** | - HTTP 요청/응답 처리
- 인증 정보 추출 (헤더에서 userId)
- 입력 검증
- 예외를 HTTP 상태 코드로 변환 | +| **Facade** | - 여러 도메인 서비스 조율 (orchestration)
- 데이터 조합 및 응답 DTO 구성
- 병렬 처리 최적화
- 로그인 여부에 따른 분기 처리 | +| **Service** | - 도메인별 비즈니스 로직
- 단일 도메인 책임
- 리포지토리 호출 | +| **Repository** | - 데이터 접근
- 쿼리 최적화 (Fetch Join, 배치 조회)
- 영속성 관리 | + +### Facade 사용 기준 + +| 시나리오 | Facade 사용 여부 | 이유 | +|---------|----------------|------| +| **브랜드 조회** | ❌ 불필요 | 단일 도메인만 다룸 | +| **상품 목록 조회** | ✅ 필요 | Product + ProductOption + Like 조율 | +| **상품 상세 조회** | ✅ 필요 | Product + ProductOption + ProductImage + Like 조율 | + +### 병렬 처리 가능 구간 + +**상품 목록 조회:** +``` +최저가 계산 ∥ 좋아요 수 조회 ∥ 좋아요 여부 확인 +``` + +**상품 상세 조회:** +``` +옵션 조회 ∥ 이미지 조회 ∥ 좋아요 수 조회 ∥ 좋아요 여부 확인 +``` + +### 트랜잭션 전략 +- 모든 조회는 **읽기 전용 트랜잭션** +- Facade 레벨에서 `@Transactional(readOnly = true)` 적용 +- 각 서비스 메서드도 독립적으로 읽기 전용 트랜잭션 가능 + +--- + +**문서 끝** \ No newline at end of file diff --git "a/docs/design/\354\226\264\353\223\234\353\257\274/\353\270\214\353\236\234\353\223\234_\354\203\201\355\222\210/01-requirements.md" "b/docs/design/\354\226\264\353\223\234\353\257\274/\353\270\214\353\236\234\353\223\234_\354\203\201\355\222\210/01-requirements.md" new file mode 100644 index 000000000..bc21d286f --- /dev/null +++ "b/docs/design/\354\226\264\353\223\234\353\257\274/\353\270\214\353\236\234\353\223\234_\354\203\201\355\222\210/01-requirements.md" @@ -0,0 +1,335 @@ + +--- + +## 3. 유저 시나리오 기반 기능 정의 + +### 3.1 어드민 사용자 시나리오 + +#### 시나리오 1: 브랜드 관리 +``` +AS AN 어드민 사용자 +I WANT TO 브랜드를 등록/수정/조회/비활성화할 수 있다 +SO THAT 판매 가능한 브랜드 카탈로그를 관리할 수 있다 + +Given: LDAP 인증된 어드민 사용자가 로그인한 상태 +When: 브랜드 등록/수정 요청 시 +Then: 브랜드 정보가 저장되고, 어드민에게 결과가 반환된다 + +Given: 활성화된 브랜드에 활성 상품이 10개 있는 상태 +When: 브랜드 비활성화 요청 시 +Then: + - 브랜드 상태가 INACTIVE로 변경된다 + - BrandDeactivatedEvent가 발행된다 + - 이벤트 리스너가 해당 브랜드의 모든 상품을 INACTIVE로 일괄 업데이트한다 + - 각 상품의 변경 이력이 ProductHistory에 스냅샷으로 저장된다 +``` + +**기능 요구사항**: +- `POST /api-admin/v1/brands` - 브랜드 등록 +- `PUT /api-admin/v1/brands/{brandId}` - 브랜드 정보 수정 +- `GET /api-admin/v1/brands?page=0&size=20&status=ACTIVE&sort=createdAt,desc` - 브랜드 목록 조회 (페이징, 필터링, 정렬) +- `GET /api-admin/v1/brands/{brandId}` - 브랜드 상세 조회 +- `DELETE /api-admin/v1/brands/{brandId}` - 브랜드 비활성화 (상태 변경) + +**비기능 요구사항**: +- LDAP 헤더 필수 검증 (`X-Loopers-Ldap: loopers.admin`) +- 브랜드 비활성화 시 트랜잭션 분리 (브랜드 상태 변경 → 이벤트 → 비동기 상품 처리) +- 상품 수가 많아도 응답 시간 2초 이내 (브랜드 상태만 먼저 변경 후 응답) + +--- + +#### 시나리오 2: 상품 관리 +``` +AS AN 어드민 사용자 +I WANT TO 상품을 등록/수정/조회/비활성화할 수 있다 +SO THAT 판매 가능한 상품 카탈로그를 관리할 수 있다 + +Given: 활성화된 브랜드 A가 존재하는 상태 +When: 브랜드 A에 속한 상품을 등록 요청 시 +Then: + - 브랜드 A의 존재 여부를 확인한다 + - 상품이 저장되고, 초기 스냅샷이 ProductHistory에 저장된다 + +Given: 상품 P가 브랜드 A에 속한 상태 +When: 상품 P의 브랜드를 B로 변경 요청 시 +Then: + - 브랜드 B의 존재 여부를 확인한다 + - 상품 P의 브랜드가 B로 변경된다 + - 변경 시점의 전체 상품 정보가 ProductHistory에 스냅샷으로 저장된다 + +Given: 재고가 0인 상품이 있는 상태 +When: 재고 소진 처리 요청 시 +Then: + - 상품 상태가 OUT_OF_STOCK으로 변경된다 + - 고객 API에서 해당 상품은 "품절" 표시와 함께 조회된다 +``` + +**기능 요구사항**: +- `POST /api-admin/v1/products` - 상품 등록 + - 브랜드 존재 여부 검증 + - 초기 스냅샷 자동 저장 +- `PUT /api-admin/v1/products/{productId}` - 상품 정보 수정 + - 브랜드 변경 가능 + - 변경 시점 스냅샷 자동 저장 +- `GET /api-admin/v1/products?page=0&size=20&brandId={brandId}&status=ACTIVE&sort=name,asc` - 상품 목록 조회 +- `GET /api-admin/v1/products/{productId}` - 상품 상세 조회 +- `DELETE /api-admin/v1/products/{productId}` - 상품 비활성화 +- `GET /api-admin/v1/products/{productId}/history` - 상품 변경 이력 조회 + +**비기능 요구사항**: +- 상품 등록/수정 시 브랜드 검증은 Application Layer에서 수행 +- 스냅샷 저장은 동일 트랜잭션 내에서 처리 +- 이력 조회는 페이징 지원 (변경 시점 역순 정렬) + +--- + +### 3.2 고객 사용자 시나리오 + +#### 시나리오 3: 브랜드/상품 조회 +``` +AS A 고객 +I WANT TO 활성화된 브랜드와 상품을 조회할 수 있다 +SO THAT 구매할 상품을 찾을 수 있다 + +Given: 브랜드 목록에 ACTIVE 브랜드 5개, INACTIVE 브랜드 3개가 있는 상태 +When: 고객이 브랜드 목록 조회 시 +Then: ACTIVE 상태인 5개 브랜드만 반환된다 + +Given: 상품 목록에 ACTIVE 상품 10개, OUT_OF_STOCK 상품 2개, INACTIVE 상품 3개가 있는 상태 +When: 고객이 상품 목록 조회 시 +Then: + - ACTIVE 상품 10개는 "구매 가능"으로 표시 + - OUT_OF_STOCK 상품 2개는 "품절"로 표시 + - INACTIVE 상품 3개는 반환되지 않음 +``` + +**기능 요구사항**: +- `GET /api/v1/brands?page=0&size=20` - 활성 브랜드 목록 조회 +- `GET /api/v1/brands/{brandId}` - 브랜드 상세 조회 (ACTIVE만) +- `GET /api/v1/products?page=0&size=20&brandId={brandId}` - 활성/품절 상품 목록 조회 +- `GET /api/v1/products/{productId}` - 상품 상세 조회 (ACTIVE, OUT_OF_STOCK만) + +**비기능 요구사항**: +- LDAP 인증 불필요 +- 조회 성능 최적화 (인덱스: status, brand_id) +- 품절 상품도 노출하되, 구매 불가 표시 + +--- + +#### 시나리오 4: 상품 좋아요 +``` +AS A 고객 +I WANT TO 관심 있는 상품에 좋아요를 누를 수 있다 +SO THAT 나중에 다시 찾아볼 수 있다 + +Given: 고객 C가 로그인한 상태 +When: 상품 P에 좋아요 클릭 시 +Then: + - ProductLike 레코드가 생성된다 + - 상품 P의 좋아요 수가 1 증가한다 + +Given: 고객 C가 상품 P에 이미 좋아요를 누른 상태 +When: 좋아요 다시 클릭 시 +Then: + - ProductLike 레코드가 삭제된다 + - 상품 P의 좋아요 수가 1 감소한다 +``` + +**기능 요구사항**: +- `POST /api/v1/products/{productId}/likes` - 좋아요 추가 +- `DELETE /api/v1/products/{productId}/likes` - 좋아요 취소 +- `GET /api/v1/products/{productId}` 응답에 좋아요 수 포함 + +**비기능 요구사항**: +- 동일 사용자의 중복 좋아요 방지 (Unique 제약) +- 좋아요 수는 집계 테이블 또는 캐시 활용 (성능) + +--- + +## 4. 상태 전이도 + +### 4.1 브랜드 상태 +``` +[등록 시] → PENDING + ↓ (승인) + ACTIVE ←→ SCHEDULED (예약 판매) + ↓ (비활성화) + INACTIVE +``` + +### 4.2 상품 상태 +``` +[등록 시] → PENDING + ↓ (승인) + ACTIVE ←→ SCHEDULED (예약 판매) + ↓ (재고 소진) ↓ (비활성화) + OUT_OF_STOCK INACTIVE + ↓ (재입고) + ACTIVE +``` + +**상태별 의미**: +- `PENDING`: 등록 완료, 승인 대기 (향후 확장) +- `ACTIVE`: 활성 상태, 고객에게 노출 +- `INACTIVE`: 비활성 상태, 고객에게 미노출 +- `SCHEDULED`: 예약 판매 (특정 시점부터 활성화, 향후 확장) +- `OUT_OF_STOCK`: 품절 (고객에게 노출되지만 구매 불가) + +--- + +## 5. 데이터 일관성 정책 + +### 5.1 브랜드-상품 연쇄 비활성화 +- **트리거**: 브랜드 상태가 ACTIVE → INACTIVE 변경 +- **처리 방식**: + 1. 브랜드 상태 변경 (트랜잭션 A) + 2. `BrandDeactivatedEvent` 발행 + 3. 이벤트 리스너가 비동기로 상품 일괄 업데이트 (트랜잭션 B) + ```sql + UPDATE products + SET status = 'INACTIVE', updated_at = NOW() + WHERE brand_id = ? AND status = 'ACTIVE' + ``` + 4. 각 상품의 스냅샷을 ProductHistory에 저장 + +- **일시적 불일치 허용**: + - 브랜드는 즉시 비활성화되지만, 상품은 수 초 뒤 비활성화 + - 고객 조회 시 브랜드가 INACTIVE면 해당 상품도 필터링 + +### 5.2 상품 변경 이력 스냅샷 +- **저장 시점**: 상품 등록/수정 시 +- **저장 내용**: 상품의 모든 필드 + 버전 정보 +- **구조**: + ``` + ProductHistory { + id: Long + productId: Long (FK) + version: Integer + brandId: Long + name: String + price: BigDecimal + status: String + stockQuantity: Integer + ... (모든 필드) + changedAt: LocalDateTime + changedBy: String + } + ``` + +### 5.3 재고 관리 +- **재고 소진 조건**: `stockQuantity <= 0` +- **자동 상태 변경**: 재고가 0이 되면 상태를 OUT_OF_STOCK으로 변경 +- **재입고 처리**: 재고가 다시 증가하면 ACTIVE로 복원 + +--- + +## 6. API 모듈 분리 전략 + +### 6.1 모듈 구조 +``` +project-root/ +├── admin-api/ # 어드민 API 모듈 +│ ├── controller/ # Admin*Controller +│ ├── dto/ # Admin*Request, Admin*Response +│ └── service/ # AdminBrandService, AdminProductService +├── customer-api/ # 고객 API 모듈 +│ ├── controller/ # Customer*Controller +│ ├── dto/ # *Response (조회용) +│ └── service/ # CustomerBrandService, CustomerProductService +├── domain/ # 공통 도메인 모듈 +│ ├── model/ # Brand, Product, ProductHistory, ProductLike +│ ├── repository/ # *Repository 인터페이스 +│ └── event/ # BrandDeactivatedEvent +└── infrastructure/ # 공통 인프라 모듈 + ├── persistence/ # JPA 구현 + └── config/ # DB, Event 설정 +``` + +### 6.2 DTO 분리 전략 +- **어드민 API**: + - `CreateBrandRequest`, `UpdateBrandRequest` + - `BrandAdminResponse` (등록일, 수정일, 상태, 등록자 포함) + - `ProductAdminResponse` (원가, 재고, 상태, 이력 링크 포함) + +- **고객 API**: + - `BrandResponse` (브랜드명, 로고, 설명만) + - `ProductResponse` (상품명, 가격, 이미지, 좋아요 수, 재고 상태) + +### 6.3 책임 분리 +- **AdminBrandService**: 브랜드 등록/수정/비활성화, 이벤트 발행 +- **AdminProductService**: 상품 CRUD, 스냅샷 저장, 브랜드 검증 +- **CustomerBrandService**: ACTIVE 브랜드만 조회 +- **CustomerProductService**: ACTIVE/OUT_OF_STOCK 상품만 조회, 좋아요 처리 + +--- + +## 7. 비기능 요구사항 + +### 7.1 성능 +- 브랜드 목록 조회: 500ms 이내 +- 상품 목록 조회: 1초 이내 +- 브랜드 비활성화: 2초 이내 (응답 기준, 상품 처리는 비동기) +- 좋아요 처리: 300ms 이내 + +### 7.2 확장성 +- 페이징: Pageable 인터페이스 사용 +- 필터링/정렬: Specification 패턴 또는 QueryDSL 고려 +- 검색: 나중에 Elasticsearch 연동 가능하도록 Repository 인터페이스 추상화 + +### 7.3 보안 +- 어드민 API: LDAP 헤더 필수 검증 +- 고객 API: 좋아요 기능은 인증 필요, 조회는 비인증 허용 + +### 7.4 데이터 정합성 +- 브랜드-상품 참조 무결성: FK 제약 + Application Layer 검증 +- 상품 변경 이력: 트랜잭션 내 스냅샷 저장 보장 +- 재고-상태 동기화: 재고 변경 시 상태 자동 업데이트 + +--- + +## 8. 잠재 리스크 및 고려사항 + +### 8.1 브랜드 비활성화 시 대량 상품 처리 +- **문제**: 브랜드 하나에 상품 10,000개가 있을 때 일괄 업데이트 시 DB 락 증가 +- **완화 방안**: + - 배치 사이즈 제한 (1000개씩 분할 처리) + - 실패 시 재시도 메커니즘 + - 어드민에게 진행 상태 노출 (옵션) + +### 8.2 스냅샷 테이블 증가 +- **문제**: 상품이 자주 수정되면 ProductHistory 테이블 크기 급증 +- **완화 방안**: + - 파티셔닝 (월별, 연도별) + - 오래된 이력 아카이빙 정책 (예: 2년 이상 된 이력은 별도 저장소) + +### 8.3 일시적 데이터 불일치 +- **문제**: 브랜드 비활성화 후 상품 비활성화까지 수 초 간 불일치 +- **완화 방안**: + - 고객 조회 시 브랜드 상태로 필터링 (브랜드가 INACTIVE면 상품도 제외) + - 모니터링: 이벤트 처리 실패 시 알림 + +### 8.4 좋아요 동시성 +- **문제**: 동일 상품에 동시 좋아요 시 카운트 불일치 +- **완화 방안**: + - Unique 제약으로 중복 방지 + - 좋아요 수는 집계 쿼리로 실시간 계산 또는 캐시 활용 + +--- + +## 9. 다음 단계 + +1. **시퀀스 다이어그램**: 브랜드 비활성화 시 상품 연쇄 비활성화 흐름 +2. **클래스 다이어그램**: Brand, Product, ProductHistory, ProductLike 도메인 책임 +3. **ERD**: 테이블 구조, 관계, 인덱스 설계 + +--- + +## 10. 용어 정리 + +| 용어 | 설명 | +|------|------| +| LDAP | Lightweight Directory Access Protocol, 사내 사용자 인증 | +| 스냅샷 | 특정 시점의 데이터 전체 상태 복사본 | +| 연쇄 비활성화 | 브랜드 비활성화 시 모든 상품도 함께 비활성화 | +| Specification 패턴 | 동적 쿼리 조건 조합을 위한 디자인 패턴 | +| Pageable | Spring Data의 페이징/정렬 추상화 인터페이스 | \ No newline at end of file diff --git "a/docs/design/\354\226\264\353\223\234\353\257\274/\353\270\214\353\236\234\353\223\234_\354\203\201\355\222\210/02-sequence-diagrams.md" "b/docs/design/\354\226\264\353\223\234\353\257\274/\353\270\214\353\236\234\353\223\234_\354\203\201\355\222\210/02-sequence-diagrams.md" new file mode 100644 index 000000000..5c010ffd0 --- /dev/null +++ "b/docs/design/\354\226\264\353\223\234\353\257\274/\353\270\214\353\236\234\353\223\234_\354\203\201\355\222\210/02-sequence-diagrams.md" @@ -0,0 +1,354 @@ +# 시퀀스 다이어그램 + +## 1. 브랜드 비활성화 시 상품 연쇄 비활성화 + +### 설계 의도 +- **트랜잭션 분리**: 브랜드 상태 변경은 즉시 완료하고 응답, 상품 처리는 비동기로 수행 +- **일시적 불일치 허용**: 브랜드 비활성화 후 수 초간 상품은 아직 활성 상태일 수 있음 +- **보상 로직**: 이벤트 처리 실패 시 재시도 가능 (EventListener의 책임) + +### 특히 봐야 할 포인트 +1. `@Transactional` 경계가 AdminBrandService.deactivateBrand()에만 있음 +2. 이벤트 발행은 트랜잭션 커밋 후 발생 (`@TransactionalEventListener`) +3. ProductEventListener는 별도 트랜잭션으로 상품을 처리 +4. 각 상품 변경 시 스냅샷이 ProductHistoryRepository에 저장됨 + +```mermaid +sequenceDiagram + actor Admin as 어드민 사용자 + participant AC as AdminBrandController + participant ABS as AdminBrandService + participant BR as BrandRepository + participant EP as EventPublisher + participant PEL as ProductEventListener + participant PR as ProductRepository + participant PHR as ProductHistoryRepository + + Note over Admin,PHR: 트랜잭션 A: 브랜드 비활성화 + Admin->>AC: DELETE /api-admin/v1/brands/{brandId} + AC->>AC: LDAP 헤더 검증 + AC->>ABS: deactivateBrand(brandId) + + activate ABS + Note over ABS: @Transactional 시작 + ABS->>BR: findById(brandId) + BR-->>ABS: Brand(status=ACTIVE) + + ABS->>ABS: brand.deactivate() + Note over ABS: Brand 도메인 내부에서
상태를 INACTIVE로 변경 + + ABS->>BR: save(brand) + BR-->>ABS: Brand(status=INACTIVE) + + ABS->>EP: publish(BrandDeactivatedEvent) + Note over EP: 이벤트는 트랜잭션 커밋 후 발행됨
(TransactionalEventPublisher) + + ABS-->>AC: BrandAdminResponse + Note over ABS: @Transactional 커밋 + deactivate ABS + + AC-->>Admin: 200 OK (즉시 응답) + + Note over Admin,PHR: 트랜잭션 B: 비동기 상품 처리 + EP->>PEL: onBrandDeactivated(event) + + activate PEL + Note over PEL: @TransactionalEventListener
새로운 트랜잭션 시작 + + PEL->>PR: updateStatusByBrandId(brandId, INACTIVE) + Note over PR: UPDATE products
SET status = 'INACTIVE'
WHERE brand_id = ?
AND status = 'ACTIVE' + PR-->>PEL: updatedCount + + PEL->>PR: findAllByBrandId(brandId) + PR-->>PEL: List + + loop 각 상품마다 + PEL->>PHR: save(ProductHistory.from(product)) + Note over PHR: 스냅샷 저장
(변경 전 상태도 보관) + PHR-->>PEL: ProductHistory + end + + Note over PEL: @Transactional 커밋 + deactivate PEL + + Note over Admin,PHR: 고객 조회 시 필터링 보정 + actor Customer as 고객 + Customer->>PR: findAllByStatus(ACTIVE) + Note over PR: WHERE status = 'ACTIVE'
AND brand.status = 'ACTIVE'
(JOIN 조건으로 필터링) + PR-->>Customer: List (비활성 브랜드 상품 제외) +``` + +### 이 설계의 장단점 + +**장점**: +- 어드민 응답 속도 빠름 (2초 이내) +- 대량 상품 처리 시 브랜드 락이 오래 유지되지 않음 +- 이벤트 실패 시 재시도 가능 (이벤트 소싱/메시지 큐 도입 가능) + +**단점**: +- 일시적 불일치 (수 초간 브랜드는 비활성인데 상품은 활성) +- 고객 조회 쿼리에 브랜드 상태 조인 필요 (약간의 성능 오버헤드) + +**위험 시나리오**: +- 이벤트 리스너 실패 시 상품이 영구히 활성 상태로 남을 수 있음 +- 완화: 데드레터 큐 + 모니터링 + 재처리 배치 + +--- + +## 2. 상품 등록 (브랜드 검증 포함) + +### 설계 의도 +- **브랜드 존재 검증**: Application Layer(Service)에서 수행 +- **초기 스냅샷 저장**: 상품 등록과 동일 트랜잭션에서 처리 +- **도메인 순수성**: Product는 Brand 존재 여부를 몰라도 됨 + +### 특히 봐야 할 포인트 +1. 브랜드 검증이 ProductService에서 일어남 (Product 도메인은 관여 안 함) +2. 스냅샷 저장이 동일 트랜잭션 내에서 원자적으로 처리됨 +3. 브랜드가 없으면 예외 발생 → 트랜잭션 롤백 + +```mermaid +sequenceDiagram + actor Admin as 어드민 사용자 + participant APC as AdminProductController + participant APS as AdminProductService + participant BR as BrandRepository + participant PR as ProductRepository + participant PHR as ProductHistoryRepository + + Admin->>APC: POST /api-admin/v1/products + Note over Admin,APC: Body: CreateProductRequest
{brandId, name, price, ...} + + APC->>APC: LDAP 헤더 검증 + APC->>APS: createProduct(request) + + activate APS + Note over APS: @Transactional 시작 + + APS->>BR: existsById(request.brandId) + BR-->>APS: true/false + + alt 브랜드가 없으면 + APS-->>APC: throw BrandNotFoundException + APC-->>Admin: 404 Not Found + end + + APS->>APS: Product.create(request) + Note over APS: 정적 팩토리 메서드로 생성
초기 상태는 PENDING + + APS->>PR: save(product) + PR-->>APS: Product (id 생성됨) + + APS->>PHR: save(ProductHistory.from(product, version=1)) + Note over PHR: 초기 스냅샷 저장
version 1, changedBy='admin' + PHR-->>APS: ProductHistory + + Note over APS: @Transactional 커밋 + deactivate APS + + APS-->>APC: ProductAdminResponse + APC-->>Admin: 201 Created +``` + +--- + +## 3. 상품 브랜드 변경 (이력 추적) + +### 설계 의도 +- **브랜드 변경 허용**: 요구사항에 따라 가능하도록 설계 +- **전체 스냅샷 저장**: 브랜드만이 아니라 상품의 모든 상태를 저장 +- **버전 관리**: ProductHistory의 version을 자동 증가 + +### 특히 봐야 할 포인트 +1. 변경 전 상태를 스냅샷으로 저장하는가, 변경 후 상태를 저장하는가? + - → **변경 후 상태**를 저장 (최신 상태가 ProductHistory에 누적) +2. 트랜잭션 실패 시 스냅샷도 저장 안 됨 (원자성 보장) + +```mermaid +sequenceDiagram + actor Admin as 어드민 사용자 + participant APC as AdminProductController + participant APS as AdminProductService + participant BR as BrandRepository + participant PR as ProductRepository + participant PHR as ProductHistoryRepository + + Admin->>APC: PUT /api-admin/v1/products/{productId} + Note over Admin,APC: Body: UpdateProductRequest
{brandId, name, price, ...} + + APC->>APS: updateProduct(productId, request) + + activate APS + Note over APS: @Transactional 시작 + + APS->>PR: findById(productId) + PR-->>APS: Product (현재 상태) + + alt 브랜드를 변경하려는 경우 + APS->>BR: existsById(request.brandId) + BR-->>APS: true/false + + alt 새 브랜드가 없으면 + APS-->>APC: throw BrandNotFoundException + APC-->>Admin: 404 Not Found + end + end + + APS->>APS: product.update(request) + Note over APS: Product 도메인 메서드로 변경
brandId, name, price 등 업데이트 + + APS->>PR: save(product) + PR-->>APS: Product (변경 후 상태) + + APS->>PHR: getLatestVersion(productId) + PHR-->>APS: latestVersion (예: 5) + + APS->>PHR: save(ProductHistory.from(product, version=6)) + Note over PHR: 변경 후 스냅샷 저장
version 증가, changedAt=NOW() + PHR-->>APS: ProductHistory + + Note over APS: @Transactional 커밋 + deactivate APS + + APS-->>APC: ProductAdminResponse + APC-->>Admin: 200 OK +``` + +--- + +## 4. 고객의 상품 좋아요 + +### 설계 의도 +- **토글 방식**: 좋아요 누르면 추가, 다시 누르면 취소 +- **동시성 제어**: Unique 제약으로 중복 방지 +- **좋아요 수 집계**: 실시간 COUNT 쿼리 또는 캐시 활용 + +### 특히 봐야 할 포인트 +1. 좋아요 수는 Product 엔티티에 비정규화할 것인가, 매번 COUNT 할 것인가? + - → 현재는 **매번 COUNT** (나중에 캐시로 최적화 가능) +2. 중복 좋아요 시도 시 예외 처리 + +```mermaid +sequenceDiagram + actor Customer as 고객 + participant CPC as CustomerProductController + participant CPS as CustomerProductService + participant PR as ProductRepository + participant PLR as ProductLikeRepository + + Note over Customer,PLR: 좋아요 추가 + Customer->>CPC: POST /api/v1/products/{productId}/likes + CPC->>CPC: 사용자 인증 확인 + CPC->>CPS: likeProduct(customerId, productId) + + activate CPS + Note over CPS: @Transactional 시작 + + CPS->>PR: existsById(productId) + PR-->>CPS: true/false + + alt 상품이 없거나 INACTIVE면 + CPS-->>CPC: throw ProductNotFoundException + CPC-->>Customer: 404 Not Found + end + + CPS->>PLR: existsByCustomerIdAndProductId(customerId, productId) + PLR-->>CPS: false + + CPS->>CPS: ProductLike.create(customerId, productId) + CPS->>PLR: save(productLike) + + alt Unique 제약 위반 (동시 요청) + PLR-->>CPS: throw DataIntegrityViolationException + CPS-->>CPC: 이미 좋아요 누름 + CPC-->>Customer: 409 Conflict + end + + PLR-->>CPS: ProductLike + + Note over CPS: @Transactional 커밋 + deactivate CPS + + CPS-->>CPC: success + CPC-->>Customer: 200 OK + + Note over Customer,PLR: 좋아요 취소 + Customer->>CPC: DELETE /api/v1/products/{productId}/likes + CPC->>CPS: unlikeProduct(customerId, productId) + + activate CPS + Note over CPS: @Transactional 시작 + + CPS->>PLR: deleteByCustomerIdAndProductId(customerId, productId) + PLR-->>CPS: deletedCount (0 or 1) + + alt 좋아요가 없었으면 + CPS-->>CPC: 좋아요 안 한 상태 + CPC-->>Customer: 404 Not Found + end + + Note over CPS: @Transactional 커밋 + deactivate CPS + + CPS-->>CPC: success + CPC-->>Customer: 204 No Content +``` + +--- + +## 5. 고객의 상품 목록 조회 (필터링 포함) + +### 설계 의도 +- **상태 기반 필터링**: ACTIVE, OUT_OF_STOCK만 조회 (INACTIVE 제외) +- **브랜드 상태 조인**: 브랜드가 비활성이면 상품도 제외 +- **좋아요 수 포함**: LEFT JOIN으로 집계 + +### 특히 봐야 할 포인트 +1. 브랜드 비활성화 후 상품 비활성화 전 일시적 불일치 보정 +2. N+1 문제 방지 (fetch join 또는 batch size 설정) + +```mermaid +sequenceDiagram + actor Customer as 고객 + participant CPC as CustomerProductController + participant CPS as CustomerProductService + participant PR as ProductRepository + + Customer->>CPC: GET /api/v1/products?page=0&size=20&brandId=1 + CPC->>CPS: getProducts(brandId, pageable) + + activate CPS + + CPS->>PR: findAllWithBrandAndLikes(brandId, pageable) + Note over PR: SELECT p.*, b.status, COUNT(pl.id)
FROM products p
JOIN brands b ON p.brand_id = b.id
LEFT JOIN product_likes pl ON p.id = pl.product_id
WHERE b.id = ?
AND p.status IN ('ACTIVE', 'OUT_OF_STOCK')
AND b.status = 'ACTIVE'
GROUP BY p.id
LIMIT 20 OFFSET 0 + + PR-->>CPS: Page (좋아요 수 포함) + + CPS->>CPS: 각 Product를 ProductResponse로 변환 + Note over CPS: DTO 변환 시 좋아요 수,
재고 상태 (품절 여부) 포함 + + deactivate CPS + + CPS-->>CPC: Page + CPC-->>Customer: 200 OK + JSON +``` + +--- + +## 시퀀스 다이어그램 해석 가이드 + +### 핵심 설계 원칙 +1. **트랜잭션 분리**: 브랜드 비활성화(빠른 응답) vs 상품 비활성화(비동기) +2. **Application Layer 검증**: 브랜드 존재 여부는 Service에서 확인, Domain은 순수 유지 +3. **스냅샷 원자성**: 상품 변경과 이력 저장을 동일 트랜잭션으로 묶어 일관성 보장 +4. **조회 최적화**: JOIN + 집계 쿼리로 N+1 방지 + +### 이 구조에서 특히 봐야 할 포인트 +- **이벤트 발행 시점**: 트랜잭션 커밋 후 (`@TransactionalEventListener`) +- **비동기 처리의 일관성**: 고객 조회 시 브랜드 상태로 보정 +- **스냅샷 저장 전략**: 변경 후 상태를 저장하여 이력 추적 + +### 잠재 리스크 +- 이벤트 리스너 실패 시 상품이 활성 상태로 남음 → **모니터링 + 재처리 필요** +- 대량 상품 스냅샷 저장 시 DB 부하 → **배치 insert 최적화** +- 좋아요 수 COUNT 쿼리 비용 → **Redis 캐시 도입 검토** \ No newline at end of file diff --git "a/docs/design/\354\226\264\353\223\234\353\257\274/\353\270\214\353\236\234\353\223\234_\354\203\201\355\222\210/03-class-diagram.md" "b/docs/design/\354\226\264\353\223\234\353\257\274/\353\270\214\353\236\234\353\223\234_\354\203\201\355\222\210/03-class-diagram.md" new file mode 100644 index 000000000..df5c8ab08 --- /dev/null +++ "b/docs/design/\354\226\264\353\223\234\353\257\274/\353\270\214\353\236\234\353\223\234_\354\203\201\355\222\210/03-class-diagram.md" @@ -0,0 +1,599 @@ +# 클래스 다이어그램 + +## 1. 도메인 모델 전체 구조 + +### 설계 의도 +- **도메인 중심 설계**: Entity는 비즈니스 로직을 포함하고, Repository는 영속성만 담당 +- **VO(Value Object) 활용**: Money, ProductStatus, BrandStatus 등 개념을 타입으로 표현 +- **정적 팩토리 메서드**: 생성 로직을 명확히 하고 불변성 유지 + +### 특히 봐야 할 포인트 +1. Brand와 Product는 양방향 연관관계를 맺지 않음 (Product → Brand 단방향) +2. ProductHistory는 Product의 스냅샷이지만, Product와 직접 연관관계 없음 (느슨한 결합) +3. ProductLike는 Customer-Product 다대다 관계를 풀어낸 중간 엔티티 + +```mermaid +classDiagram + class Brand { + -Long id + -String name + -String description + -String logoUrl + -BrandStatus status + -LocalDateTime createdAt + -LocalDateTime updatedAt + -String createdBy + + +create(name, description, logoUrl, createdBy) Brand$ + +deactivate() void + +activate() void + +isActive() boolean + } + + class BrandStatus { + <> + ACTIVE + INACTIVE + PENDING + SCHEDULED + } + + class Product { + -Long id + -Long brandId + -String name + -String description + -Money price + -Integer stockQuantity + -ProductStatus status + -String imageUrl + -LocalDateTime createdAt + -LocalDateTime updatedAt + -String createdBy + + +create(brandId, name, price, stockQuantity, createdBy) Product$ + +update(name, description, price, stockQuantity) void + +changeBrand(newBrandId) void + +deactivate() void + +activate() void + +decreaseStock(quantity) void + +increaseStock(quantity) void + +isOutOfStock() boolean + +checkAndUpdateStockStatus() void + } + + class ProductStatus { + <> + ACTIVE + INACTIVE + PENDING + SCHEDULED + OUT_OF_STOCK + } + + class Money { + <> + -BigDecimal amount + -Currency currency + + +of(amount, currency) Money$ + +add(Money) Money + +multiply(int) Money + +isGreaterThan(Money) boolean + } + + class ProductHistory { + -Long id + -Long productId + -Integer version + -Long brandId + -String name + -String description + -Money price + -Integer stockQuantity + -ProductStatus status + -String imageUrl + -LocalDateTime changedAt + -String changedBy + + +from(product, version, changedBy) ProductHistory$ + } + + class ProductLike { + -Long id + -Long customerId + -Long productId + -LocalDateTime createdAt + + +create(customerId, productId) ProductLike$ + } + + Brand "1" -- "*" Product : brandId + Product "1" -- "*" ProductHistory : productId + Product "1" -- "*" ProductLike : productId + Product *-- Money : price + Product *-- ProductStatus : status + Brand *-- BrandStatus : status + ProductHistory *-- Money : price + ProductHistory *-- ProductStatus : status +``` + +--- + +## 2. 레이어별 클래스 구조 + +### 설계 의도 +- **Layered Architecture**: Presentation → Application → Domain → Infrastructure +- **의존성 역전**: Repository는 인터페이스(Domain)에 의존, 구현은 Infrastructure +- **DTO 분리**: Admin용, Customer용 DTO를 명확히 구분 + +### 특히 봐야 할 포인트 +1. Service는 Repository 인터페이스에만 의존 (구현체 모름) +2. Domain 레이어는 다른 레이어에 의존하지 않음 (순수 자바) +3. Controller는 DTO만 다루고, Domain은 Service 레이어에서만 다룸 + +```mermaid +classDiagram + %% Presentation Layer - Admin API + class AdminBrandController { + -AdminBrandService adminBrandService + +createBrand(CreateBrandRequest) ResponseEntity~BrandAdminResponse~ + +updateBrand(Long, UpdateBrandRequest) ResponseEntity~BrandAdminResponse~ + +getBrand(Long) ResponseEntity~BrandAdminResponse~ + +getBrands(Pageable) ResponseEntity~Page~BrandAdminResponse~~ + +deactivateBrand(Long) ResponseEntity~Void~ + } + + class AdminProductController { + -AdminProductService adminProductService + +createProduct(CreateProductRequest) ResponseEntity~ProductAdminResponse~ + +updateProduct(Long, UpdateProductRequest) ResponseEntity~ProductAdminResponse~ + +getProduct(Long) ResponseEntity~ProductAdminResponse~ + +getProducts(Long, Pageable) ResponseEntity~Page~ProductAdminResponse~~ + +deactivateProduct(Long) ResponseEntity~Void~ + +getProductHistory(Long, Pageable) ResponseEntity~Page~ProductHistoryResponse~~ + } + + %% Presentation Layer - Customer API + class CustomerBrandController { + -CustomerBrandService customerBrandService + +getBrand(Long) ResponseEntity~BrandResponse~ + +getBrands(Pageable) ResponseEntity~Page~BrandResponse~~ + } + + class CustomerProductController { + -CustomerProductService customerProductService + +getProduct(Long) ResponseEntity~ProductResponse~ + +getProducts(Long, Pageable) ResponseEntity~Page~ProductResponse~~ + +likeProduct(Long) ResponseEntity~Void~ + +unlikeProduct(Long) ResponseEntity~Void~ + } + + %% Application Layer - Admin + class AdminBrandService { + -BrandRepository brandRepository + -EventPublisher eventPublisher + +createBrand(CreateBrandRequest) BrandAdminResponse + +updateBrand(Long, UpdateBrandRequest) BrandAdminResponse + +getBrand(Long) BrandAdminResponse + +getBrands(Pageable) Page~BrandAdminResponse~ + +deactivateBrand(Long) void + } + + class AdminProductService { + -ProductRepository productRepository + -BrandRepository brandRepository + -ProductHistoryRepository productHistoryRepository + +createProduct(CreateProductRequest) ProductAdminResponse + +updateProduct(Long, UpdateProductRequest) ProductAdminResponse + +getProduct(Long) ProductAdminResponse + +getProducts(Long, Pageable) Page~ProductAdminResponse~ + +deactivateProduct(Long) void + +getProductHistory(Long, Pageable) Page~ProductHistoryResponse~ + } + + %% Application Layer - Customer + class CustomerBrandService { + -BrandRepository brandRepository + +getBrand(Long) BrandResponse + +getBrands(Pageable) Page~BrandResponse~ + } + + class CustomerProductService { + -ProductRepository productRepository + -ProductLikeRepository productLikeRepository + +getProduct(Long) ProductResponse + +getProducts(Long, Pageable) Page~ProductResponse~ + +likeProduct(Long, Long) void + +unlikeProduct(Long, Long) void + } + + %% Event Handling + class ProductEventListener { + -ProductRepository productRepository + -ProductHistoryRepository productHistoryRepository + +onBrandDeactivated(BrandDeactivatedEvent) void + } + + class BrandDeactivatedEvent { + -Long brandId + -LocalDateTime occurredAt + } + + %% Domain Layer - Repository Interfaces + class BrandRepository { + <> + +findById(Long) Optional~Brand~ + +save(Brand) Brand + +existsById(Long) boolean + +findAllByStatus(BrandStatus, Pageable) Page~Brand~ + +updateStatus(Long, BrandStatus) void + } + + class ProductRepository { + <> + +findById(Long) Optional~Product~ + +save(Product) Product + +existsById(Long) boolean + +findAllByBrandIdAndStatusIn(Long, List~ProductStatus~, Pageable) Page~Product~ + +updateStatusByBrandId(Long, ProductStatus) int + +findAllByBrandId(Long) List~Product~ + } + + class ProductHistoryRepository { + <> + +save(ProductHistory) ProductHistory + +findAllByProductId(Long, Pageable) Page~ProductHistory~ + +getLatestVersion(Long) Integer + } + + class ProductLikeRepository { + <> + +save(ProductLike) ProductLike + +existsByCustomerIdAndProductId(Long, Long) boolean + +deleteByCustomerIdAndProductId(Long, Long) int + +countByProductId(Long) long + } + + %% Domain Layer - Entities (이미 위에서 정의됨) + + %% Relationships + AdminBrandController --> AdminBrandService + AdminProductController --> AdminProductService + CustomerBrandController --> CustomerBrandService + CustomerProductController --> CustomerProductService + + AdminBrandService --> BrandRepository + AdminBrandService --> EventPublisher + AdminProductService --> ProductRepository + AdminProductService --> BrandRepository + AdminProductService --> ProductHistoryRepository + + CustomerBrandService --> BrandRepository + CustomerProductService --> ProductRepository + CustomerProductService --> ProductLikeRepository + + ProductEventListener --> ProductRepository + ProductEventListener --> ProductHistoryRepository + ProductEventListener ..> BrandDeactivatedEvent : listens + + AdminBrandService ..> Brand : uses + AdminProductService ..> Product : uses + AdminProductService ..> ProductHistory : uses + CustomerProductService ..> ProductLike : uses +``` + +--- + +## 3. DTO 클래스 구조 + +### 설계 의도 +- **역할별 분리**: Admin과 Customer가 보는 정보가 다름 +- **불변성**: 모든 DTO는 record로 정의하여 불변 유지 +- **변환 책임**: DTO ↔ Entity 변환은 DTO 자신이 담당 (정적 팩토리 메서드) + +### 특히 봐야 할 포인트 +1. Admin DTO는 관리 정보 포함 (생성일, 상태, 이력 링크) +2. Customer DTO는 고객 필요 정보만 (가격, 좋아요 수, 품절 여부) +3. Request DTO는 검증 로직 포함 (Bean Validation) + +```mermaid +classDiagram + %% Admin DTOs + class CreateBrandRequest { + <> + +String name + +String description + +String logoUrl + +validate() void + } + + class UpdateBrandRequest { + <> + +String name + +String description + +String logoUrl + +validate() void + } + + class BrandAdminResponse { + <> + +Long id + +String name + +String description + +String logoUrl + +BrandStatus status + +LocalDateTime createdAt + +LocalDateTime updatedAt + +String createdBy + + +from(Brand) BrandAdminResponse$ + } + + class CreateProductRequest { + <> + +Long brandId + +String name + +String description + +BigDecimal price + +String currency + +Integer stockQuantity + +String imageUrl + +validate() void + } + + class UpdateProductRequest { + <> + +Long brandId + +String name + +String description + +BigDecimal price + +Integer stockQuantity + +String imageUrl + +validate() void + } + + class ProductAdminResponse { + <> + +Long id + +Long brandId + +String brandName + +String name + +String description + +BigDecimal price + +String currency + +Integer stockQuantity + +ProductStatus status + +String imageUrl + +LocalDateTime createdAt + +LocalDateTime updatedAt + +String createdBy + +String historyUrl + + +from(Product, Brand) ProductAdminResponse$ + } + + class ProductHistoryResponse { + <> + +Long id + +Integer version + +Long brandId + +String name + +BigDecimal price + +ProductStatus status + +LocalDateTime changedAt + +String changedBy + + +from(ProductHistory) ProductHistoryResponse$ + } + + %% Customer DTOs + class BrandResponse { + <> + +Long id + +String name + +String description + +String logoUrl + + +from(Brand) BrandResponse$ + } + + class ProductResponse { + <> + +Long id + +Long brandId + +String brandName + +String name + +String description + +BigDecimal price + +String currency + +boolean outOfStock + +String imageUrl + +long likeCount + +boolean likedByMe + + +from(Product, Brand, long, boolean) ProductResponse$ + } + + AdminBrandController ..> CreateBrandRequest : uses + AdminBrandController ..> UpdateBrandRequest : uses + AdminBrandController ..> BrandAdminResponse : uses + + AdminProductController ..> CreateProductRequest : uses + AdminProductController ..> UpdateProductRequest : uses + AdminProductController ..> ProductAdminResponse : uses + AdminProductController ..> ProductHistoryResponse : uses + + CustomerBrandController ..> BrandResponse : uses + CustomerProductController ..> ProductResponse : uses +``` + +--- + +## 4. Value Object 상세 설계 + +### 설계 의도 +- **도메인 개념 표현**: 금액, 상태 같은 개념을 타입으로 명확히 +- **불변성**: VO는 생성 후 변경 불가 +- **비즈니스 로직 응집**: Money는 금액 계산 로직을 포함 + +### 특히 봐야 할 포인트 +1. Money는 `BigDecimal`을 감싸서 통화 단위 강제 +2. Status는 enum으로 허용된 상태만 표현 +3. VO는 Entity가 아니므로 식별자(id) 없음 + +```mermaid +classDiagram + class Money { + <> + -BigDecimal amount + -Currency currency + + +of(BigDecimal, Currency) Money$ + +krw(BigDecimal) Money$ + +usd(BigDecimal) Money$ + +add(Money) Money + +subtract(Money) Money + +multiply(int) Money + +divide(int) Money + +isGreaterThan(Money) boolean + +isLessThan(Money) boolean + +equals(Object) boolean + +hashCode() int + +toString() String + } + + class Currency { + <> + KRW + USD + EUR + JPY + } + + class BrandStatus { + <> + ACTIVE + INACTIVE + PENDING + SCHEDULED + + +isActive() boolean + +canTransitionTo(BrandStatus) boolean + } + + class ProductStatus { + <> + ACTIVE + INACTIVE + PENDING + SCHEDULED + OUT_OF_STOCK + + +isActive() boolean + +isAvailableForPurchase() boolean + +canTransitionTo(ProductStatus) boolean + } + + Money *-- Currency +``` + +--- + +## 5. 파사드 레이어 적용 여부 검토 + +### 파사드 패턴이 필요한 경우 +- 여러 Service를 조합하는 복잡한 비즈니스 로직이 있을 때 +- Controller가 여러 Service를 직접 호출하면 복잡도가 높아질 때 + +### 현재 시스템에서의 판단 +**불필요함**. 이유: +1. 각 Controller는 단일 Service만 사용 (AdminBrandController → AdminBrandService) +2. 복잡한 오케스트레이션 없음 (브랜드 비활성화 → 이벤트 발행만) +3. 파사드 도입 시 불필요한 레이어 추가 + +**예외 케이스**: 나중에 "주문" 기능 추가 시 +- OrderFacade: ProductService + InventoryService + PaymentService 조합 +- 이때는 파사드 도입 고려 + +--- + +## 6. 정적 팩토리 메서드 사용 전략 + +### 왜 사용하는가? +1. **생성 의도 명확화**: `Brand.create()` vs `new Brand()` +2. **검증 로직 캡슐화**: 생성자는 단순히 값만 할당, 팩토리는 검증 후 생성 +3. **불변성 강제**: VO는 정적 팩토리로만 생성 가능 + +### 적용 예시 +```java +// Brand.java +public class Brand { + private Brand(String name, String description, BrandStatus status, String createdBy) { + this.name = name; + this.description = description; + this.status = status; + this.createdBy = createdBy; + this.createdAt = LocalDateTime.now(); + } + + public static Brand create(String name, String description, String logoUrl, String createdBy) { + validateName(name); + validateCreatedBy(createdBy); + return new Brand(name, description, BrandStatus.PENDING, createdBy); + } +} + +// Money.java +public record Money(BigDecimal amount, Currency currency) { + public static Money of(BigDecimal amount, Currency currency) { + if (amount.compareTo(BigDecimal.ZERO) < 0) { + throw new IllegalArgumentException("Amount cannot be negative"); + } + return new Money(amount, currency); + } + + public static Money krw(long amount) { + return of(BigDecimal.valueOf(amount), Currency.KRW); + } +} +``` + +--- + +## 7. VO 사용 권장 사항 + +### 어디에 사용하는가? +| 개념 | VO 사용 여부 | 이유 | +|------|-------------|------| +| 금액 (price) | ✅ Money | 통화 단위 강제, 계산 로직 응집 | +| 상태 (status) | ✅ Enum | 허용된 값만 표현, 전이 규칙 포함 | +| 이메일 | ✅ Email | 형식 검증 로직 캡슐화 | +| 이름 (name) | ❌ String | 단순 문자열, VO 과잉 설계 | +| ID | ❌ Long | JPA 식별자, 원시 타입 유지 | + +### 현재 설계에 적용 +- ✅ **Money**: 가격은 금액+통화 조합 +- ✅ **BrandStatus, ProductStatus**: 상태는 enum +- ❌ **ProductName**: 과잉 설계 (검증만 필요하면 Bean Validation) +- ❌ **BrandId, ProductId**: JPA 식별자는 Long 유지 + +--- + +## 클래스 다이어그램 해석 가이드 + +### 핵심 설계 원칙 +1. **단일 책임**: 각 클래스는 하나의 책임만 (Brand는 브랜드 정보, Product는 상품 정보) +2. **의존성 역전**: Service → Repository Interface ← JPA Implementation +3. **도메인 순수성**: Entity는 JPA 어노테이션만, 비즈니스 로직은 메서드로 + +### 이 구조에서 특히 봐야 할 포인트 +- **Product는 Brand를 참조하지만, Brand는 Product를 모름** (단방향) +- **ProductHistory는 Product와 별도 테이블** (느슨한 결합, 스냅샷) +- **VO는 Entity가 아님** (식별자 없음, 값으로만 비교) + +### 잠재 리스크 +- Product가 Brand 정보를 필요로 할 때마다 조인 발생 → **N+1 문제 가능성** + - 완화: `@EntityGraph`, `fetch join` 사용 +- ProductHistory 증가 시 테이블 크기 급증 → **파티셔닝 필요** +- Money 계산 시 통화 불일치 → **예외 처리 필수** \ No newline at end of file diff --git "a/docs/design/\354\226\264\353\223\234\353\257\274/\354\243\274\353\254\270/01-requirements.md" "b/docs/design/\354\226\264\353\223\234\353\257\274/\354\243\274\353\254\270/01-requirements.md" new file mode 100644 index 000000000..e69de29bb diff --git "a/docs/design/\354\242\213\354\225\204\354\232\224/01-requirements.md" "b/docs/design/\354\242\213\354\225\204\354\232\224/01-requirements.md" new file mode 100644 index 000000000..979eb89f5 --- /dev/null +++ "b/docs/design/\354\242\213\354\225\204\354\232\224/01-requirements.md" @@ -0,0 +1,189 @@ +## 좋아요 기능 상세 정의 + +### FR-1. 좋아요 등록 +**사용자 스토리:** +사용자는 상품 목록 또는 상세 페이지에서 좋아요 버튼을 눌러 관심 상품을 표시할 수 있다. + +**API 명세:** +``` +POST /api/v1/products/{productId}/likes +Authorization: Required +``` + +**상세 동작:** +1. 인증된 사용자만 좋아요 등록 가능 +2. 동일 사용자가 동일 상품에 대해 중복 좋아요 시도 시: + - DB Unique 제약에 의해 실패 + - 클라이언트에 적절한 에러 응답 (409 Conflict 또는 400 Bad Request) +3. 좋아요 등록 성공 시: + - `likes` 테이블에 데이터 저장 + - 이벤트 발행 (`LikeCreated`) + - 비동기로 상품의 `like_count` 증가 + +**정책:** +- 좋아요 등록은 동기 처리 (즉시 응답) +- 카운트 업데이트는 비동기 처리 (Eventual Consistency) +- 이벤트 실패 시 재시도 없음, 배치로 정합성 복구 +- 좋아요 카운트전략은 "비정규화"로 만들어보기 + +**에러 처리:** +- 이미 좋아요한 상품 → 409 Conflict + +--- + +### FR-2. 좋아요 취소 +**사용자 스토리:** +사용자는 이미 누른 좋아요를 취소할 수 있다. + +**API 명세:** +``` +DELETE /api/v1/products/{productId}/likes +Authorization: Required +``` + +**상세 동작:** +1. 인증된 사용자만 좋아요 취소 가능 +2. 존재하지 않는 좋아요에 대한 취소 요청 시: + - 비정상적인 접근으로 간주 + - 에러 로그 기록 + - 클라이언트에는 성공 응답 (멱등성 보장) +3. 좋아요 취소 성공 시: + - `likes` 테이블에서 데이터 삭제 + - 이벤트 발행 (`LikeDeleted`) + - 비동기로 상품의 `like_count` 감소 +4. 멱등성 보장 (이미 취소된 좋아요 재요청 시 성공 응답) + +**정책:** +- 좋아요 취소는 멱등성을 가짐 (여러 번 호출해도 결과 동일) +- 카운트 업데이트는 비동기 처리 +- 이벤트 실패 시 재시도 없음, 배치로 정합성 복구 + +--- + +### FR-3. 내 좋아요 목록 조회 +**사용자 스토리:** +사용자는 자신이 좋아요한 상품 목록을 확인할 수 있다. + +**API 명세:** +``` +GET /api/v1/users/{userId}/likes +Authorization: Required +``` + +**상세 동작:** +1. 인증된 사용자만 조회 가능 +2. URL의 `{userId}` 파라미터는 무시 +3. 항상 로그인한 사용자의 좋아요 목록만 조회 +4. 페이지네이션 지원 (추후 구체화 필요) + +**Query Parameters:** +- `page` (선택, Integer, 기본값: 0): 페이지 번호 +- `size` (선택, Integer, 기본값: 20): 페이지 크기 + +**반환 정보:** +```json +{ + "content": [ + { + "productId": 1, + "name": "상품명", + "brand": { + "brandId": 1, + "name": "브랜드명" + }, + "thumbnailImageUrl": "https://example.com/product-thumbnail.png", + "minPrice": 10000, + "likeCount": 150, + "likedAt": "2025-01-15T10:30:00" + } + ], + "page": 0, + "size": 20, + "totalElements": 10, + "totalPages": 1 +} +``` + +**정책:** +- 다른 사용자의 좋아요 목록은 조회 불가 +- 권한 에러(403) 없음, URL userId는 참고용으로만 사용 +- 향후 확장 가능성을 위해 URL 구조는 유지 + +**참고:** +- 현재는 본인 것만 조회하지만, URL 구조는 향후 "친구 좋아요 목록 보기" 등의 기능 확장을 염두 + +--- + +### FR-4. 좋아요 수 노출 +**사용자 스토리:** +사용자는 상품의 인기도를 파악하기 위해 좋아요 수를 확인할 수 있다. + +**노출 위치:** +- 상품 목록 페이지 +- 상품 상세 페이지 + +**상세 동작:** +1. 상품 조회 API 응답에 `like_count` 포함 +2. 좋아요 등록/취소 직후에는 카운트가 바로 반영되지 않을 수 있음 (Eventual Consistency) +3. 향후 트래픽 증가 시 Redis로 전환 고려 + +**정책:** +- 좋아요 수는 정확하지 않아도 됨 (몇 초~몇 분 지연 허용) +- 대략적인 인기도 파악이 목적 + +--- + +## 비기능 요구사항 + +### NFR-1. 성능 +- 좋아요 등록/취소 API는 200ms 이내 응답 목표 +- 동시 좋아요 시 DB 락 경합 최소화 (비동기 카운트 업데이트) + +### NFR-2. 확장성 +- 향후 Redis를 통한 카운트 캐싱 구조로 전환 가능해야 함 +- 이벤트 기반 아키텍처로 다른 시스템과의 결합도 최소화 + +### NFR-3. 데이터 일관성 +- 좋아요 데이터는 강한 일관성 (DB 제약) +- 좋아요 카운트는 최종 일관성 (Eventual Consistency) +- 배치를 통한 정합성 복구 메커니즘 필요 + +### NFR-4. 보안 +- 모든 API는 인증 필수 +- SQL Injection, XSS 등 기본 보안 위협 방어 + +--- + +## 결정된 제약사항 및 전제조건 + +### 데이터 제약 +- 사용자 1명당 상품 1개에 대해 좋아요 1개만 가능 + - DB Unique Index: `(user_id, product_id)` + +### 아키텍처 결정 +- **동기 처리:** 좋아요 등록/취소 (즉시 응답) +- **비동기 처리:** 카운트 업데이트 (이벤트 기반) +- **재시도 없음:** 이벤트 실패 시 로그만 남기고 배치로 복구 + +### 향후 확장 고려사항 +- Redis 도입 (카운트 캐싱) +- 친구/타인의 좋아요 목록 조회 기능 +- 좋아요 기반 추천 시스템 + + +#### 좋아요 카운트 정합성 +**현재 (v1):** +- 좋아요 등록/취소: 동기 +- 카운트 업데이트: 비동기 (Eventual Consistency) + +**잠재 리스크:** +- 이벤트 발행 실패 시 카운트 불일치 +- 대량 좋아요 발생 시 카운트 업데이트 지연 + +**해결 방안:** +- 배치 작업을 통한 정합성 복구 +- 향후 Redis 캐시 도입 (실시간 카운트) + +**설계 시 주의사항:** +- 좋아요 수는 "대략적인 인기도" 지표로 사용 +- 정확한 수치가 필요한 경우 실시간 COUNT 쿼리 사용 \ No newline at end of file diff --git "a/docs/design/\354\242\213\354\225\204\354\232\224/02-sequence-diagrams.md" "b/docs/design/\354\242\213\354\225\204\354\232\224/02-sequence-diagrams.md" new file mode 100644 index 000000000..cd7d075f0 --- /dev/null +++ "b/docs/design/\354\242\213\354\225\204\354\232\224/02-sequence-diagrams.md" @@ -0,0 +1,466 @@ +# 시퀀스 다이어그램 + +## 다이어그램을 그리기 전에 + +이 문서에서는 **4개의 시퀀스 다이어그램**을 작성합니다: + +1. **좋아요 등록** - 단순 INSERT, 카운트는 비동기 반영(products.like_count) +2. **좋아요 취소** - 멱등성 보장 +3. **좋아요 목록 조회** - 본인 것만 조회 +4. **상품 조회 (좋아요 수 포함)** - products.like_count 조회 + +각 다이어그램에서 주목할 점: +- **트랜잭션 경계**: 어디까지가 하나의 원자적 작업인가? +- **책임 분리**: 각 객체는 무엇을 책임지는가? +- **확장 포인트**: 나중에 Redis로 전환할 때 어디를 바꾸면 되는가? + +--- + +## 1. 좋아요 등록 + +### 왜 이 다이어그램이 필요한가? +좋아요 등록은 **단순 INSERT**만 수행합니다. +이 다이어그램을 통해 다음을 검증합니다: +- 중복 좋아요는 어떻게 방지하는가? +- 트랜잭션 경계가 어디까지인가? +- 카운트는 어디서 계산되는가? + +```mermaid +sequenceDiagram + actor User as 사용자 + participant API as LikeController + participant Service as LikeService + participant ProdSvc as ProductService + participant Repo as LikeRepository + participant Pub as EventPublisher + participant DB as Database + + User->>API: POST /products/{productId}/likes + activate API + + API->>Service: createLike(userId, productId) + activate Service + + Service->>ProdSvc: validateProductActive(productId) + activate ProdSvc + ProdSvc->>DB: SELECT status FROM products WHERE id = ? + DB-->>ProdSvc: ACTIVE + ProdSvc-->>Service: OK + deactivate ProdSvc + + Note over Service: 중복 방지는 DB Unique가 최종 보루 + + Service->>Repo: save(Like) + activate Repo + Repo->>DB: INSERT INTO likes
(user_id, product_id, created_at) + Note over DB: Unique(user_id, product_id) + alt 중복이면 + DB-->>Repo: UniqueViolation + Repo-->>Service: DuplicateLikeException + else 성공 + DB-->>Repo: 성공 + Repo-->>Service: Like 객체 + end + deactivate Repo + + alt 성공 + Service->>Pub: publish(LikeCreated) + activate Pub + Pub-->>Service: publish 요청 완료(비동기) + deactivate Pub + + Service-->>API: 성공 + API-->>User: 201 Created + else 중복 + Service-->>API: 409 Conflict + API-->>User: 409 Conflict + end + deactivate Service + deactivate API +``` + +### 이 다이어그램에서 봐야 할 포인트 + +1. **트랜잭션 경계** + - `LikeService`의 `save(like)` 만 수행 + - 카운트 업데이트 없음 (실시간 계산) + +2. **중복 방지** + - 애플리케이션 레벨: `existsByUserIdAndProductId()` 체크 + - DB 레벨: Unique 제약으로 이중 안전장치 + +3. **응답 시점** + - `likes` 테이블 INSERT 성공 즉시 응답 + - 매우 빠른 응답 속도 (추가 작업 없음) + +4. **확장 포인트** + - 나중에 Redis 캐시를 추가할 때도 이 플로우는 변경 없음 + - 상품 조회 시 카운트 계산 로직만 수정하면 됨 + +--- + +## 2. 좋아요 취소 (멱등성 보장) + +### 왜 이 다이어그램이 필요한가? +우리는 **멱등성을 보장**하기로 했습니다. +존재하지 않는 좋아요를 취소해도 성공 응답을 주기 때문에, 이 플로우를 명확히 해야 합니다. + +```mermaid +ssequenceDiagram + actor User as 사용자 + participant API as LikeController + participant Service as LikeService + participant Repo as LikeRepository + participant Pub as EventPublisher + participant DB as Database + participant Logger as ErrorLogger + + User->>API: DELETE /products/{productId}/likes + activate API + + API->>Service: deleteLike(userId, productId) + activate Service + + Service->>Repo: findByUserIdAndProductId(userId, productId) + activate Repo + Repo->>DB: SELECT id FROM likes WHERE user_id=? AND product_id=? + DB-->>Repo: null or likeId + Repo-->>Service: null or likeId + deactivate Repo + + alt 좋아요 없음 + Service->>Logger: warn("이미 삭제된 좋아요", userId, productId) + activate Logger + Logger-->>Service: logged + deactivate Logger + + Service-->>API: 성공(멱등) + API-->>User: 204 No Content + else 좋아요 존재 + Service->>Repo: deleteById(likeId) + activate Repo + Repo->>DB: DELETE FROM likes WHERE id=? + DB-->>Repo: 성공 + Repo-->>Service: 완료 + deactivate Repo + + Service->>Pub: publish(LikeDeleted) + activate Pub + Pub-->>Service: publish 요청 완료(비동기) + deactivate Pub + + Service-->>API: 성공 + API-->>User: 204 No Content + end + + deactivate Service + deactivate API +``` + +### 정상 삭제 플로우 (참고) + +존재하는 좋아요를 취소하는 경우: + +```mermaid +sequenceDiagram + actor User as 사용자 + participant API as LikeController + participant Service as LikeService + participant Repo as LikeRepository + participant DB as Database + + User->>API: DELETE /products/{productId}/likes + activate API + + API->>Service: deleteLike(userId, productId) + activate Service + + Service->>Repo: findByUserIdAndProductId(userId, productId) + activate Repo + Repo->>DB: SELECT + DB-->>Repo: Like 객체 + Repo-->>Service: Like 객체 + deactivate Repo + + Note over Service: 좋아요 존재 확인 + + Service->>Repo: delete(like) + activate Repo + Repo->>DB: DELETE FROM likes
WHERE id = ? + DB-->>Repo: 성공 + Repo-->>Service: 완료 + deactivate Repo + + Note over Service: 트랜잭션 커밋
카운트는 조회 시 계산됨 + + Service-->>API: 성공 + deactivate Service + + API-->>User: 204 No Content + deactivate API +``` + +### 이 다이어그램에서 봐야 할 포인트 + +1. **멱등성 보장** + - 없는 좋아요를 취소해도 `204 No Content` 응답 + - 클라이언트 입장에서는 여러 번 호출해도 결과 동일 + +2. **로깅 전략** + - 비정상적인 접근을 추적하기 위해 로그는 남김 + - 하지만 에러로 처리하지는 않음 + +3. **카운트 처리** + - 좋아요 취소 시 별도의 카운트 감소 작업 없음 + - 상품 조회 시 실시간으로 COUNT 쿼리 실행 + +4. **트랜잭션 단순화** + - DELETE 한 번만 수행 + - 추가 업데이트 없어서 트랜잭션 짧고 명확함 + +--- + +## 3. 좋아요 목록 조회 + +### 왜 이 다이어그램이 필요한가? +읽기 플로우는 단순하지만, **URL의 userId를 무시하고 항상 로그인 사용자 것만 조회**하는 정책을 명확히 해야 합니다. + +```mermaid +sequenceDiagram + actor User as 사용자 + participant API as LikeController + participant Auth as AuthContext + participant Service as LikeService + participant Repo as LikeRepository + participant DB as Database + + User->>API: GET /users/{userId}/likes + activate API + + Note over API: URL의 userId는 무시 + + API->>Auth: getCurrentUserId() + activate Auth + Auth-->>API: authenticatedUserId + deactivate Auth + + API->>Service: findLikesByUserId(authenticatedUserId) + activate Service + + Service->>Repo: findByUserId(authenticatedUserId) + activate Repo + Repo->>DB: SELECT l.*, p.*
FROM likes l
JOIN products p ON l.product_id = p.id
WHERE l.user_id = ? + DB-->>Repo: List + Repo-->>Service: List + deactivate Repo + + Service-->>API: List + deactivate Service + + API-->>User: 200 OK + 좋아요 목록 + deactivate API +``` + +### 이 다이어그램에서 봐야 할 포인트 + +1. **URL 파라미터 무시** + - `GET /users/{userId}/likes` 의 `{userId}` 는 받지만 사용하지 않음 + - 항상 `AuthContext.getCurrentUserId()`로 로그인 사용자 확인 + +2. **책임 분리** + - `AuthContext`: 현재 로그인 사용자 식별 책임 + - `LikeService`: 비즈니스 로직 없이 Repository 호출만 + - `LikeRepository`: DB 조회 책임 + +3. **확장 가능성** + - 나중에 "친구 좋아요 조회" 기능이 추가되면? + - `API` 레이어에서 권한 체크만 추가하면 됨 + - Service, Repository는 수정 불필요 + +4. **조인 처리** + - `likes` 테이블만 조회하면 상품 정보가 없음 + - `products` 와 조인하여 상품 정보도 함께 반환 + - (페이지네이션은 추후 구체화) + +--- + +## 4. 상품 조회 (좋아요 수 포함) + +### 왜 이 다이어그램이 필요한가? +상품 목록/상세 조회 시 **좋아요 수를 어떻게 계산하는지**가 핵심입니다. +이 다이어그램을 통해 다음을 검증합니다: +- COUNT 쿼리는 어디서 실행되는가? +- 향후 Redis 캐시로 전환할 때 어디를 수정하면 되는가? + +```mermaid +sequenceDiagram + actor User as 사용자 + participant API as ProductController + participant Service as ProductService + participant ProdRepo as ProductRepository + participant DB as Database + + User->>API: GET /products/{productId} + activate API + + API->>Service: getProductDetail(productId) + activate Service + + Service->>ProdRepo: findActiveById(productId) + activate ProdRepo + ProdRepo->>DB: SELECT * FROM products
WHERE id = ? AND status='ACTIVE' + DB-->>ProdRepo: Product(+like_count) + ProdRepo-->>Service: Product(+like_count) + deactivate ProdRepo + + Note over Service: like_count는 products 테이블 값 사용(최종 일관성) + + Service-->>API: ProductDetailResponse
(product + likeCount) + deactivate Service + + API-->>User: 200 OK + 상품 정보 + deactivate API +``` + +### 상품 목록 조회의 경우 + +목록 조회는 여러 상품의 좋아요 수를 한 번에 계산해야 합니다: + +```mermaid +sequenceDiagram + actor User as 사용자 + participant API as ProductController + participant Service as ProductService + participant ProdRepo as ProductRepository + participant DB as Database + + User->>API: GET /products?page=1 + activate API + + API->>Service: getProductList(page) + activate Service + + Service->>ProdRepo: findAllActive(pageable) + activate ProdRepo + ProdRepo->>DB: SELECT * FROM products
WHERE status='ACTIVE'
LIMIT 20 OFFSET 0 + DB-->>ProdRepo: List + ProdRepo-->>Service: List + deactivate ProdRepo + + Note over Service: like_count는 각 Product에 포함되어 반환 + + Service-->>API: List + deactivate Service + + API-->>User: 200 OK + 상품 목록 + deactivate API +``` + +### 이 다이어그램에서 봐야 할 포인트 + +1. **실시간 계산** + + ~~- 매 조회마다 `COUNT(*)` 쿼리 실행~~ + + ~~- 항상 정확한 값 반환~~ + + ~~- 비동기 처리나 이벤트 없음~~ + +2. **성능 고려** + - 단일 상품: COUNT 쿼리 1번 + - 목록 조회: GROUP BY로 한 번에 계산 (N+1 문제 방지) + - 인덱스 필요: `likes(product_id)` + +3. **확장 포인트 (Redis 도입 시)** + ``` + 현재: Service → LikeRepository.countByProductId() → DB + 향후: Service → LikeCacheService.getLikeCount() → Redis (Cache Miss 시 DB) + ``` + - `LikeRepository.countByProductId()` 호출 부분만 수정 + - 나머지 플로우는 변경 없음 + +4. **트레이드오프** + - 장점: 정합성 100%, 구조 단순 + - 단점: 조회 시 COUNT 쿼리 부하 + - 해결: 트래픽 증가 시 Redis 캐시로 전환 + +--- + +## 4. 설계 의도 정리 + +### 트랜잭션 경계 설정 이유 +``` +[동기 트랜잭션] +- likes 테이블 INSERT/DELETE만 포함 +- 빠른 응답 가능 +- DB 락 시간 최소화 + +[비동기 처리] +- products.like_count 증감 +- 이벤트 실패/지연 시 정합성 불일치 가능 +- 배치로 정합성 복구 +``` + +### 객체별 책임 + +| 객체 | 책임 | +|------|------| +| `LikeController` | HTTP 요청/응답, 인증 확인 | +| `LikeService` | 좋아요 등록/취소, 상품 ACTIVE 검증 호출, 이벤트 발행 | +| `LikeRepository` | 데이터 접근, 쿼리 실행 | +| `EventPublisher` | 이벤트 발행(비동기 시작점) | +| `LikeCountConsumer` | like_count 증감 처리 (독립 프로세스) | +| `ProductRepository` | 상품 조회 및 like_count 제공 | +| `BatchJob` | likes와 like_count 불일치 보정 | + +### 호출 순서의 의미 + +**좋아요 등록:** +1. 중복 체크 (비즈니스 규칙) +2. 저장 (트랜잭션 커밋) +3. 이벤트 발행 (비동기 시작점) +4. 응답 반환 (사용자에게) +5. 카운트 업데이트 (백그라운드) + +**좋아요 취소:** +1. 존재 여부 확인 +2. 없으면 로그만 남기고 성공 응답 (멱등성) +3. 있으면 삭제 → 이벤트 발행 → 카운트 감소 + +--- + +## 5. 설계 리스크 및 트레이드오프 + +### ⚠️ 리스크 1: 이벤트 발행 실패 +**상황:** +`EventPublisher.publish()` 에서 예외 발생 + +**결과:** +- 좋아요는 DB에 저장됨 +- like_count는 갱신되지 않을 수 있음 +- 사용자는 성공 응답 받음 + +**대응:** +- 배치로 정합성 복구 +- `배치로 정합성 복구 (likes COUNT vs products.like_count 비교 후 보정) + +### ⚠️ 리스크 2: Consumer 처리 실패 +**상황:** +`LikeCountConsumer`에서 DB 업데이트 실패 + +**결과:** +- 메시지는 큐에서 사라짐 (ack 전에 실패하면 재처리) +- like_count가 실제와 어긋남 + +**대응:** +- 현재 설계에서는 재시도 없음 +- 배치로 정합성 복구 + +### ✅ 트레이드오프 정리 + +| 선택 | 얻은 것 | 잃은 것 | +|------|---------|---------| +| 비동기 카운트 업데이트 | 빠른 응답, 락 경합 없음 | 실시간 정합성 | +| 재시도 없음 | 구현 단순 | 실패 시 데이터 불일치 | +| 멱등성 보장 (취소) | 안정적인 API | 비정상 접근 추적 어려움 | +| URL userId 무시 | 확장 가능한 구조 | URL 파라미터 의미 모호 | \ No newline at end of file diff --git "a/docs/design/\354\243\274\353\254\270/01-requirements.md" "b/docs/design/\354\243\274\353\254\270/01-requirements.md" new file mode 100644 index 000000000..e69de29bb diff --git "a/docs/design/\354\243\274\353\254\270/02-sequence-diagrams.md" "b/docs/design/\354\243\274\353\254\270/02-sequence-diagrams.md" new file mode 100644 index 000000000..e69de29bb diff --git "a/docs/design/\354\243\274\353\254\270\354\203\235\354\204\261/01-requirements.md" "b/docs/design/\354\243\274\353\254\270\354\203\235\354\204\261/01-requirements.md" new file mode 100644 index 000000000..d52c9d146 --- /dev/null +++ "b/docs/design/\354\243\274\353\254\270\354\203\235\354\204\261/01-requirements.md" @@ -0,0 +1,69 @@ +주문 생성 시스템 - 요구사항 분석 및 설계 │ +│ │ +│ Context │ +│ │ +│ 현재 시스템에는 Example, Member 도메인만 존재하며, 커머스의 핵심인 상품/재고/주문 도메인이 없다. │ +│ "여러 상품을 한 번에 주문할 때, 재고와 주문 상태 사이의 불일치가 발생하지 않도록" All-or-Nothing 정책으로 주문 생성 기능을 구현해야 │ +│ 한다. │ +│ │ +│ --- │ +│ 1️⃣문제 상황 재해석 │ +│ │ +│ 사용자 관점: 여러 상품을 한 번에 주문했는데 일부만 처리되면 혼란. 되거나/안 되거나 명확한 결과를 기대한다. │ +│ │ +│ 비즈니스 관점: 재고-주문 불일치는 클레임 직결. 재고 확인과 주문 생성이 원자적으로 묶여야 하며, 향후 부분취소 확장도 고려해야 한다. │ +│ │ +│ 시스템 관점: Order + OrderItem 생성 + 재고 차감이 단일 트랜잭션으로 처리되어야 한다. FK 없이 ID 참조로 느슨한 결합 유지, 스냅샷으로 시점 │ +│ 정합성 보장. │ +│ │ +│ --- │ +│ 2️⃣합의된 설계 결정 │ +│ ┌─────────────────────┬─────────────────────────────────────────────────────────────────────────────┐ │ +│ │ 항목 │ 결정 │ │ +│ ├─────────────────────┼─────────────────────────────────────────────────────────────────────────────┤ │ +│ │ Product/Stock/Brand │ 주문과 함께 새로 생성 │ │ +│ ├─────────────────────┼─────────────────────────────────────────────────────────────────────────────┤ │ +│ │ Product 구조 │ Product + Brand 별도 엔티티 분리 │ │ +│ ├─────────────────────┼─────────────────────────────────────────────────────────────────────────────┤ │ +│ │ Stock 구조 │ 별도 Stock 엔티티 (productId, quantity) │ │ +│ ├─────────────────────┼─────────────────────────────────────────────────────────────────────────────┤ │ +│ │ OrderStatus │ Enum 미리 정의 (CREATED, CONFIRMED, CANCELLED), 전이 로직은 CREATED까지만 │ │ +│ ├─────────────────────┼─────────────────────────────────────────────────────────────────────────────┤ │ +│ │ OrderItemStatus │ Enum 미리 정의 (ORDERED, CANCELLED) — 부분취소 확장성 │ │ +│ ├─────────────────────┼─────────────────────────────────────────────────────────────────────────────┤ │ +│ │ 동시성 제어 │ 비관적 락 (SELECT FOR UPDATE on Stock) │ │ +│ ├─────────────────────┼─────────────────────────────────────────────────────────────────────────────┤ │ +│ │ 인증 │ 기존 X-Loopers-LoginId 헤더 인증 │ │ +│ ├─────────────────────┼─────────────────────────────────────────────────────────────────────────────┤ │ +│ │ FK 제약조건 │ 없음, ID 참조만 │ │ +│ ├─────────────────────┼─────────────────────────────────────────────────────────────────────────────┤ │ +│ │ Value Objects │ Money (가격/금액), Quantity (수량), ProductSnapshot (주문 시점 상품 스냅샷) │ │ +│ ├─────────────────────┼─────────────────────────────────────────────────────────────────────────────┤ │ +│ │ 도메인 서비스 │ StockDeductionService — 재고 차감 검증 로직 분리 │ │ +│ └─────────────────────┴─────────────────────────────────────────────────────────────────────────────┘ │ +│ --- │ +│ 3️⃣개념 모델 │ +│ │ +│ 액터 │ +│ - 사용자 (회원): 주문을 생성하는 주체 │ +│ - 관리자 (미구현): 상품/브랜드/재고를 관리하는 주체 (테스트 데이터로 대체) │ +│ │ +│ 핵심 도메인 │ +│ - Order (주문): 주문 생성, 상태 관리 │ +│ - OrderItem (주문 항목): 개별 상품 주문 정보 + ProductSnapshot VO │ +│ - Stock (재고): 재고 수량 관리, 동시성 제어 대상 │ +│ │ +│ 보조 도메인 │ +│ - Product (상품): 상품 정보 제공 │ +│ - Brand (브랜드): 브랜드 정보 제공 │ +│ - Member (회원): 인증, 주문자 식별 (기존 구현) │ +│ │ +│ Value Objects │ +│ - Money: 가격/금액을 감싸는 VO. value >= 0 불변식 보장. Product.price, Order.totalAmount, ProductSnapshot.price에 사용 │ +│ - Quantity: 수량을 감싸는 VO. value > 0 불변식 보장. OrderItem.quantity에 사용 │ +│ - ProductSnapshot: 주문 시점 상품 정보(@Embeddable). productName, price(Money), brandName을 묶어 OrderItem에 내장 │ +│ │ +│ 도메인 서비스 │ +│ - StockDeductionService: 여러 Stock 엔티티에 걸친 All-or-Nothing 재고 차감을 담당. 단일 엔티티에 속하지 않는 크로스 엔티티 비즈니스 │ +│ 로직. │ +│ \ No newline at end of file diff --git "a/docs/design/\354\243\274\353\254\270\354\203\235\354\204\261/02-sequence-diagrams.md" "b/docs/design/\354\243\274\353\254\270\354\203\235\354\204\261/02-sequence-diagrams.md" new file mode 100644 index 000000000..8a02999b3 --- /dev/null +++ "b/docs/design/\354\243\274\353\254\270\354\203\235\354\204\261/02-sequence-diagrams.md" @@ -0,0 +1,86 @@ +️⃣시퀀스 다이어그램 │ +│ │ +│ 왜 필요한가 │ +│ │ +│ 주문 생성은 Member, Product, Brand, Stock, Order, OrderItem 6개 도메인을 횡단한다. 호출 순서, 트랜잭션 경계, All-or-Nothing 실패 시점을 │ +│ 검증해야 한다. │ +│ │ +│ 검증 포인트 │ +│ │ +│ - 트랜잭션 경계가 어디서 시작하고 끝나는지 │ +│ - StockDeductionService의 책임 범위 │ +│ - 비관적 락 획득 순서 (데드락 방지) │ +│ - 재고 부족 시 롤백 시점 │ +│ │ +│ sequenceDiagram │ +│ actor User │ +│ participant Controller as OrderV1Controller │ +│ participant Facade as OrderFacade │ +│ participant MemberSvc as MemberService │ +│ participant ProductSvc as ProductService │ +│ participant OrderSvc as OrderService │ +│ participant StockDeduct as StockDeductionService │ +│ participant StockRepo as StockRepository │ +│ participant DB as Database │ +│ │ +│ User->>Controller: POST /api/v1/orders
[Header: X-Loopers-LoginId/LoginPw]
[Body: items[{productId, quantity}]] │ +│ Controller->>Facade: createOrder(loginId, password, request) │ +│ │ +│ Note over Facade: 트랜잭션 밖: 인증 + 조회 │ +│ Facade->>MemberSvc: authenticate(loginId, password) │ +│ MemberSvc-->>Facade: MemberModel (memberId) │ +│ │ +│ Facade->>ProductSvc: getProducts(productIds) │ +│ ProductSvc-->>Facade: List │ +│ Facade->>ProductSvc: getBrands(brandIds) │ +│ ProductSvc-->>Facade: List │ +│ │ +│ alt 존재하지 않는 상품/브랜드 │ +│ Facade-->>Controller: CoreException(NOT_FOUND) │ +│ Controller-->>User: 404 Not Found │ +│ end │ +│ │ +│ Note over Facade,DB: ── 트랜잭션 시작 (OrderService.createOrder) ── │ +│ │ +│ Facade->>OrderSvc: createOrder(memberId, products, brandMap, items) │ +│ │ +│ OrderSvc->>StockDeduct: deductAll(items) │ +│ Note over StockDeduct: productId 오름차순 정렬 → 데드락 방지 │ +│ loop 각 상품 (productId 오름차순) │ +│ StockDeduct->>StockRepo: findByProductIdWithLock(productId) │ +│ StockRepo->>DB: SELECT ... FOR UPDATE │ +│ DB-->>StockRepo: StockModel (locked) │ +│ StockRepo-->>StockDeduct: StockModel │ +│ │ +│ alt 재고 부족 │ +│ Note over StockDeduct,DB: 예외 → 트랜잭션 롤백 (All-or-Nothing) │ +│ StockDeduct-->>OrderSvc: CoreException(BAD_REQUEST) │ +│ OrderSvc-->>Facade: 예외 전파 │ +│ Facade-->>Controller: 예외 전파 │ +│ Controller-->>User: 400 Bad Request │ +│ end │ +│ │ +│ StockDeduct->>StockDeduct: stock.deduct(Quantity) │ +│ end │ +│ StockDeduct-->>OrderSvc: 차감 완료 │ +│ │ +│ Note over OrderSvc: 주문 생성 │ +│ OrderSvc->>OrderSvc: new OrderModel(memberId, Money(totalAmount), CREATED) │ +│ OrderSvc->>DB: INSERT orders │ +│ │ +│ loop 각 주문 항목 │ +│ OrderSvc->>OrderSvc: new OrderItemModel(orderId, productId,
ProductSnapshot, Quantity, ORDERED) │ +│ end │ +│ OrderSvc->>DB: INSERT order_item × N │ +│ │ +│ Note over Facade,DB: ── 트랜잭션 커밋 ── │ +│ │ +│ OrderSvc-->>Facade: OrderModel + OrderItems │ +│ Facade-->>Controller: OrderInfo │ +│ Controller-->>User: 201 Created + OrderResponse │ +│ │ +│ 읽는 법 │ +│ │ +│ - Facade는 조율자: 인증/조회는 트랜잭션 밖에서 처리하여 핵심 쓰기 트랜잭션 범위를 최소화. │ +│ - StockDeductionService가 재고 책임: 락 획득 순서, 재고 검증, 차감을 모두 담당. OrderService는 이 결과를 신뢰하고 주문만 생성. │ +│ - 실패 시점이 명확: 재고 부족이면 StockDeductionService에서 즉시 예외 → 전체 롤백. \ No newline at end of file diff --git "a/docs/design/\354\243\274\353\254\270\354\203\235\354\204\261/03-class-diagram.md" "b/docs/design/\354\243\274\353\254\270\354\203\235\354\204\261/03-class-diagram.md" new file mode 100644 index 000000000..a4c00279e --- /dev/null +++ "b/docs/design/\354\243\274\353\254\270\354\203\235\354\204\261/03-class-diagram.md" @@ -0,0 +1,150 @@ +5️⃣클래스 다이어그램 │ +│ │ +│ 왜 필요한가 │ +│ │ +│ 5개 엔티티 + 3개 VO + 도메인 서비스의 책임 분배, 의존 방향을 검증해야 한다. │ +│ │ +│ 검증 포인트 │ +│ │ +│ - VO가 어떤 엔티티에서 사용되는지 │ +│ - StockDeductionService의 위치와 의존 방향 │ +│ - OrderService와 StockDeductionService의 책임 경계 │ +│ │ +│ classDiagram │ +│ direction TB │ +│ │ +│ class BaseEntity { │ +│ <> │ +│ #Long id │ +│ #ZonedDateTime createdAt │ +│ #ZonedDateTime updatedAt │ +│ #ZonedDateTime deletedAt │ +│ +delete() │ +│ +restore() │ +│ } │ +│ │ +│ class Money { │ +│ <> │ +│ -Long value │ +│ +Money(Long value) │ +│ +getValue() Long │ +│ } │ +│ note for Money "불변식: value >= 0\nProduct.price, Order.totalAmount,\nProductSnapshot.price에 사용" │ +│ │ +│ class Quantity { │ +│ <> │ +│ -Long value │ +│ +Quantity(Long value) │ +│ +getValue() Long │ +│ } │ +│ note for Quantity "불변식: value > 0\nOrderItem.quantity에 사용" │ +│ │ +│ class ProductSnapshot { │ +│ <> │ +│ -String productName │ +│ -Money price │ +│ -String brandName │ +│ +ProductSnapshot(productName, price, brandName) │ +│ } │ +│ note for ProductSnapshot "주문 시점 상품 정보 스냅샷\nOrderItem에 @Embedded로 내장" │ +│ │ +│ class BrandModel { │ +│ -String name │ +│ +BrandModel(name) │ +│ } │ +│ │ +│ class ProductModel { │ +│ -Long brandId │ +│ -String name │ +│ -Money price │ +│ -String description │ +│ +ProductModel(brandId, name, price, description) │ +│ } │ +│ │ +│ class StockModel { │ +│ -Long productId │ +│ -Long quantity │ +│ +StockModel(productId, quantity) │ +│ +deduct(Quantity amount) void │ +│ +hasEnoughStock(Quantity amount) boolean │ +│ } │ +│ │ +│ class OrderModel { │ +│ -Long memberId │ +│ -OrderStatus status │ +│ -Money totalAmount │ +│ +OrderModel(memberId, totalAmount, status) │ +│ } │ +│ │ +│ class OrderItemModel { │ +│ -Long orderId │ +│ -Long productId │ +│ -OrderItemStatus status │ +│ -ProductSnapshot snapshot │ +│ -Quantity quantity │ +│ +OrderItemModel(orderId, productId, status, snapshot, quantity) │ +│ } │ +│ │ +│ class OrderStatus { │ +│ <> │ +│ CREATED │ +│ CONFIRMED │ +│ CANCELLED │ +│ } │ +│ │ +│ class OrderItemStatus { │ +│ <> │ +│ ORDERED │ +│ CANCELLED │ +│ } │ +│ │ +│ BaseEntity <|-- BrandModel │ +│ BaseEntity <|-- ProductModel │ +│ BaseEntity <|-- StockModel │ +│ BaseEntity <|-- OrderModel │ +│ BaseEntity <|-- OrderItemModel │ +│ │ +│ ProductModel --> Money : price │ +│ OrderModel --> Money : totalAmount │ +│ OrderModel --> OrderStatus │ +│ OrderItemModel --> ProductSnapshot : snapshot │ +│ OrderItemModel --> Quantity : quantity │ +│ OrderItemModel --> OrderItemStatus │ +│ ProductSnapshot --> Money : price │ +│ │ +│ class StockDeductionService { │ +│ <<도메인 서비스>> │ +│ +deductAll(List~OrderItemRequest~ items) void │ +│ } │ +│ note for StockDeductionService "크로스 엔티티 비즈니스 로직\nproductId 오름차순 락 획득\nAll-or-Nothing 재고 차감" │ +│ │ +│ class OrderFacade { │ +│ +createOrder(loginId, password, request) OrderInfo │ +│ } │ +│ class OrderService { │ +│ +createOrder(memberId, products, brandMap, items) OrderModel │ +│ } │ +│ class ProductService { │ +│ +getProducts(productIds) List~ProductModel~ │ +│ +getBrands(brandIds) List~BrandModel~ │ +│ } │ +│ │ +│ OrderFacade --> MemberService │ +│ OrderFacade --> ProductService │ +│ OrderFacade --> OrderService │ +│ OrderService --> StockDeductionService │ +│ OrderService ..> OrderRepository │ +│ OrderService ..> OrderItemRepository │ +│ StockDeductionService ..> StockRepository │ +│ ProductService ..> ProductRepository │ +│ ProductService ..> BrandRepository │ +│ │ +│ 읽는 법 │ +│ │ +│ - VO 적용 범위가 명확: Money는 가격/금액이 나오는 3곳, Quantity는 주문 수량, ProductSnapshot은 OrderItem 내 스냅샷. 각각 명확한 불변식을 │ +│ 가진다. │ +│ - StockDeductionService: OrderService에서 분리된 도메인 서비스. "여러 Stock에 걸친 원자적 차감"이라는 단일 엔티티에 속하지 않는 책임을 │ +│ 담당. │ +│ - StockModel.deduct(): 실제 차감 로직은 엔티티 내부에 유지. 도메인 서비스는 "어떤 순서로, 어떤 조건에서" 차감할지를 조율. │ +│ - StockModel.quantity는 Long: 재고는 0이 될 수 있으므로 Quantity VO(>0)를 쓰지 않고 Long으로 유지. deduct()의 파라미터만 Quantity VO. │ +│ \ No newline at end of file diff --git "a/docs/design/\354\243\274\353\254\270\354\203\235\354\204\261/04-erd.md" "b/docs/design/\354\243\274\353\254\270\354\203\235\354\204\261/04-erd.md" new file mode 100644 index 000000000..3dd152e52 --- /dev/null +++ "b/docs/design/\354\243\274\353\254\270\354\203\235\354\204\261/04-erd.md" @@ -0,0 +1,179 @@ +6️⃣ERD │ +│ │ +│ 왜 필요한가 │ +│ │ +│ FK 없이 ID 참조만 사용하므로, 논리적 관계를 문서로 명확히 해야 한다. VO는 DB 컬럼으로 풀어져 저장된다. │ +│ │ +│ 검증 포인트 │ +│ │ +│ - stock.product_id에 UNIQUE 제약 (1:1) │ +│ - order_item의 스냅샷 컬럼이 실제로 어떻게 매핑되는지 │ +│ - orders 테이블명 (MySQL 예약어 회피) │ +│ - VO(Money, Quantity)는 BIGINT 컬럼으로, ProductSnapshot은 개별 컬럼으로 매핑 │ +│ │ +│ erDiagram │ +│ brand { │ +│ BIGINT id PK "AUTO_INCREMENT" │ +│ VARCHAR name "NOT NULL" │ +│ DATETIME created_at "NOT NULL" │ +│ DATETIME updated_at "NOT NULL" │ +│ DATETIME deleted_at "NULL" │ +│ } │ +│ │ +│ product { │ +│ BIGINT id PK "AUTO_INCREMENT" │ +│ BIGINT brand_id "NOT NULL" │ +│ VARCHAR name "NOT NULL" │ +│ BIGINT price "NOT NULL (Money VO)" │ +│ VARCHAR description "NULL" │ +│ DATETIME created_at "NOT NULL" │ +│ DATETIME updated_at "NOT NULL" │ +│ DATETIME deleted_at "NULL" │ +│ } │ +│ │ +│ stock { │ +│ BIGINT id PK "AUTO_INCREMENT" │ +│ BIGINT product_id "NOT NULL, UNIQUE" │ +│ BIGINT quantity "NOT NULL, DEFAULT 0" │ +│ DATETIME created_at "NOT NULL" │ +│ DATETIME updated_at "NOT NULL" │ +│ DATETIME deleted_at "NULL" │ +│ } │ +│ │ +│ orders { │ +│ BIGINT id PK "AUTO_INCREMENT" │ +│ BIGINT member_id "NOT NULL" │ +│ VARCHAR status "NOT NULL" │ +│ BIGINT total_amount "NOT NULL (Money VO)" │ +│ DATETIME created_at "NOT NULL" │ +│ DATETIME updated_at "NOT NULL" │ +│ DATETIME deleted_at "NULL" │ +│ } │ +│ │ +│ order_item { │ +│ BIGINT id PK "AUTO_INCREMENT" │ +│ BIGINT order_id "NOT NULL" │ +│ BIGINT product_id "NOT NULL" │ +│ VARCHAR status "NOT NULL" │ +│ VARCHAR product_name "NOT NULL (ProductSnapshot)" │ +│ BIGINT product_price "NOT NULL (ProductSnapshot.Money)" │ +│ VARCHAR brand_name "NOT NULL (ProductSnapshot)" │ +│ BIGINT quantity "NOT NULL (Quantity VO)" │ +│ DATETIME created_at "NOT NULL" │ +│ DATETIME updated_at "NOT NULL" │ +│ DATETIME deleted_at "NULL" │ +│ } │ +│ │ +│ brand ||--o{ product : "has" │ +│ product ||--|| stock : "has" │ +│ member ||--o{ orders : "places" │ +│ orders ||--o{ order_item : "contains" │ +│ product ||--o{ order_item : "referenced by" │ +│ │ +│ 읽는 법 │ +│ │ +│ - VO → 컬럼 매핑: Money VO는 BIGINT 단일 컬럼, Quantity VO도 BIGINT 단일 컬럼, ProductSnapshot은 3개 컬럼(product_name, product_price, │ +│ brand_name)으로 풀어진다. │ +│ - stock ↔ product 1:1: 하나의 상품에 하나의 재고 레코드. UNIQUE 제약으로 보장. │ +│ - 논리적 참조만 존재: FK 없이 애플리케이션 레벨에서 ID 참조. +️⃣잠재 리스크 │ +│ │ +│ 트랜잭션 범위 │ +│ │ +│ Stock 락 + Order/OrderItem 저장이 단일 트랜잭션이므로, 주문 항목 수가 많으면 락 보유 시간이 길어진다. │ +│ - (A) 현재 유지 → 일반적 커머스 수준에서 충분. 단순하고 All-or-Nothing 보장 명확. │ +│ - (B) 2-phase (예약→확정) → 트랜잭션 분리 가능하나 복잡도 증가. 현 시점에서는 오버엔지니어링. │ +│ │ +│ 비관적 락 병목 │ +│ │ +│ 인기 상품에 동시 주문이 몰리면 Stock row 락 대기 발생. │ +│ - (A) 비관적 락 유지 → 정합성 확실, 트래픽이 극단적이지 않으면 충분. │ +│ - (B) Redis 분산 락 → 수평 확장 시 고려. 단일 DB에서는 불필요. │ +│ │ +│ 데드락 방지 │ +│ │ +│ 설계에 이미 반영: productId 오름차순으로 정렬 후 개별 락 획득 (StockDeductionService 책임). 애플리케이션 레벨에서 순서를 보장하는 것이 │ +│ DB 엔진 의존성보다 안전. │ +│ │ +│ VO JPA 매핑 복잡도 │ +│ │ +│ @Embeddable VO 사용 시 JPA 매핑이 추가된다. 특히 ProductSnapshot 내부의 Money VO는 중첩 @Embeddable이 된다. │ +│ - @AttributeOverrides로 컬럼명을 명시적으로 지정하여 해결. │ +│ - 복잡하다면 ProductSnapshot.price만 Long으로 직접 저장하는 것도 선택지. │ +│ │ +│ 스냅샷 시점 차이 │ +│ │ +│ Facade에서 Product/Brand를 조회한 시점과 OrderService에서 저장하는 시점의 미세한 차이가 있으나, 동일 HTTP 요청 내이므로 비즈니스적으로 │ +│ 수용 가능. │ +│ │ +│ --- │ +│ 생성할 파일 목록 │ +│ │ +│ domain 레이어 (com.loopers.domain) │ +│ │ +│ 공통 VO │ +│ - domain/vo/Money.java — 가격/금액 VO (@Embeddable, value >= 0) │ +│ - domain/vo/Quantity.java — 수량 VO (@Embeddable, value > 0) │ +│ │ +│ Brand │ +│ - domain/brand/BrandModel.java │ +│ - domain/brand/BrandRepository.java │ +│ │ +│ Product │ +│ - domain/product/ProductModel.java — Money VO 사용 │ +│ - domain/product/ProductRepository.java │ +│ - domain/product/ProductService.java │ +│ │ +│ Stock │ +│ - domain/stock/StockModel.java — deduct(Quantity), hasEnoughStock(Quantity) 메서드 포함 │ +│ - domain/stock/StockRepository.java │ +│ - domain/stock/StockDeductionService.java — 도메인 서비스: All-or-Nothing 재고 차감 │ +│ │ +│ Order │ +│ - domain/order/OrderModel.java — Money VO 사용 │ +│ - domain/order/OrderStatus.java │ +│ - domain/order/OrderItemModel.java — ProductSnapshot, Quantity VO 사용 │ +│ - domain/order/OrderItemStatus.java │ +│ - domain/order/ProductSnapshot.java — @Embeddable VO (productName, Money price, brandName) │ +│ - domain/order/OrderRepository.java │ +│ - domain/order/OrderItemRepository.java │ +│ - domain/order/OrderService.java │ +│ │ +│ infrastructure 레이어 (com.loopers.infrastructure) │ +│ │ +│ - infrastructure/brand/BrandJpaRepository.java │ +│ - infrastructure/brand/BrandRepositoryImpl.java │ +│ - infrastructure/product/ProductJpaRepository.java │ +│ - infrastructure/product/ProductRepositoryImpl.java │ +│ - infrastructure/stock/StockJpaRepository.java — @Lock(PESSIMISTIC_WRITE) 포함 │ +│ - infrastructure/stock/StockRepositoryImpl.java │ +│ - infrastructure/order/OrderJpaRepository.java │ +│ - infrastructure/order/OrderRepositoryImpl.java │ +│ - infrastructure/order/OrderItemJpaRepository.java │ +│ - infrastructure/order/OrderItemRepositoryImpl.java │ +│ │ +│ application 레이어 (com.loopers.application) │ +│ │ +│ - application/order/OrderFacade.java │ +│ - application/order/OrderInfo.java │ +│ │ +│ interfaces 레이어 (com.loopers.interfaces.api) │ +│ │ +│ - interfaces/api/order/OrderV1Controller.java │ +│ - interfaces/api/order/OrderV1ApiSpec.java │ +│ - interfaces/api/order/OrderV1Dto.java │ +│ │ +│ 참조할 기존 패턴 파일 │ +│ │ +│ - domain/example/ExampleModel.java — 엔티티 패턴 (BaseEntity 상속, guard()) │ +│ - domain/example/ExampleService.java — 서비스 패턴 (@Component + @Transactional) │ +│ - application/example/ExampleFacade.java — Facade 패턴 │ +│ - interfaces/api/member/MemberV1Controller.java — 헤더 인증 + ApiResponse 패턴 │ +│ - modules/jpa/src/main/java/com/loopers/domain/BaseEntity.java — BaseEntity │ +│ │ +│ 검증 방법 │ +│ │ +│ 1. 단위 테스트: Money/Quantity VO 불변식, StockModel.deduct(), ProductSnapshot 생성, OrderModel 생성 │ +│ 2. 통합 테스트: OrderService.createOrder() — 성공/재고부족 롤백, StockDeductionService 동시성 시나리오 │ +│ 3. E2E 테스트: POST /api/v1/orders 호출 → 주문 생성 확인, 재고 차감 확인 │ +│ 4. HTTP 파일: http/commerce-api/order-v1.http로 수동 확인 \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index 142d7120f..5ae37ac99 100644 --- a/gradle.properties +++ b/gradle.properties @@ -10,6 +10,7 @@ springBootVersion=3.4.4 springDependencyManagementVersion=1.1.7 springCloudDependenciesVersion=2024.0.1 ### Library versions ### +testcontainersVersion=2.0.2 springDocOpenApiVersion=2.7.0 springMockkVersion=4.0.2 mockitoVersion=5.14.0 diff --git a/http/commerce-api/admin-brand-v1.http b/http/commerce-api/admin-brand-v1.http new file mode 100644 index 000000000..860ba784d --- /dev/null +++ b/http/commerce-api/admin-brand-v1.http @@ -0,0 +1,47 @@ +### 브랜드 등록 (성공 → 201) +POST {{commerce-api}}/api-admin/v1/brands +X-Loopers-Ldap: loopers.admin +Content-Type: application/json + +{ + "name": "나이키", + "description": "스포츠 브랜드", + "logoImageUrl": "https://example.com/nike-logo.png" +} + +### 브랜드 등록 (LDAP 헤더 없음 → 403) +POST {{commerce-api}}/api-admin/v1/brands +Content-Type: application/json + +{ + "name": "나이키", + "description": "스포츠 브랜드", + "logoImageUrl": "https://example.com/nike-logo.png" +} + +### 브랜드 정보 수정 (성공 → 200) +PUT {{commerce-api}}/api-admin/v1/brands/1 +X-Loopers-Ldap: loopers.admin +Content-Type: application/json + +{ + "name": "나이키 코리아", + "description": "한국 나이키", + "logoImageUrl": "https://example.com/nike-korea-logo.png" +} + +### 브랜드 비활성화 (성공 → 204, 연관 상품 비동기 INACTIVE 처리) +DELETE {{commerce-api}}/api-admin/v1/brands/1 +X-Loopers-Ldap: loopers.admin + +### 브랜드 상세 조회 (성공 → 200, INACTIVE 포함 모든 상태 조회 가능) +GET {{commerce-api}}/api-admin/v1/brands/1 +X-Loopers-Ldap: loopers.admin + +### 브랜드 목록 조회 - 전체 (성공 → 200) +GET {{commerce-api}}/api-admin/v1/brands?page=0&size=20 +X-Loopers-Ldap: loopers.admin + +### 브랜드 목록 조회 - ACTIVE 필터 (성공 → 200, ACTIVE 브랜드만) +GET {{commerce-api}}/api-admin/v1/brands?status=ACTIVE&page=0&size=20 +X-Loopers-Ldap: loopers.admin diff --git a/http/commerce-api/admin-product-v1.http b/http/commerce-api/admin-product-v1.http new file mode 100644 index 000000000..2ef8ea5a7 --- /dev/null +++ b/http/commerce-api/admin-product-v1.http @@ -0,0 +1,60 @@ +### 상품 등록 (성공 → 201, ProductHistory 1건 자동 생성) +POST {{commerce-api}}/api-admin/v1/products +X-Loopers-Ldap: loopers.admin +Content-Type: application/json + +{ + "brandId": 1, + "name": "에어맥스 90", + "price": 150000, + "description": "클래식 러닝화", + "thumbnailImageUrl": "https://example.com/airmax90.png" +} + +### 상품 등록 (존재하지 않는 브랜드 → 404) +POST {{commerce-api}}/api-admin/v1/products +X-Loopers-Ldap: loopers.admin +Content-Type: application/json + +{ + "brandId": 999999, + "name": "에어맥스 90", + "price": 150000, + "description": "클래식 러닝화" +} + +### 상품 정보 수정 (성공 → 200, ProductHistory 누적) +PUT {{commerce-api}}/api-admin/v1/products/1 +X-Loopers-Ldap: loopers.admin +Content-Type: application/json + +{ + "brandId": 1, + "name": "에어맥스 90 리미티드", + "price": 180000, + "description": "한정판 러닝화" +} + +### 상품 변경 이력 조회 (성공 → 200, content + totalElements) +GET {{commerce-api}}/api-admin/v1/products/1/history?page=0&size=20 +X-Loopers-Ldap: loopers.admin + +### 상품 상세 조회 (성공 → 200, PENDING/INACTIVE 포함 모든 상태 조회 가능) +GET {{commerce-api}}/api-admin/v1/products/1 +X-Loopers-Ldap: loopers.admin + +### 상품 목록 조회 - 전체 (성공 → 200) +GET {{commerce-api}}/api-admin/v1/products?page=0&size=20 +X-Loopers-Ldap: loopers.admin + +### 상품 목록 조회 - brandId 필터 +GET {{commerce-api}}/api-admin/v1/products?brandId=1&page=0&size=20 +X-Loopers-Ldap: loopers.admin + +### 상품 목록 조회 - status 필터 +GET {{commerce-api}}/api-admin/v1/products?status=ACTIVE&page=0&size=20 +X-Loopers-Ldap: loopers.admin + +### 상품 비활성화 (성공 → 204) +DELETE {{commerce-api}}/api-admin/v1/products/1 +X-Loopers-Ldap: loopers.admin diff --git a/http/commerce-api/brand-v1.http b/http/commerce-api/brand-v1.http new file mode 100644 index 000000000..33cd8ef14 --- /dev/null +++ b/http/commerce-api/brand-v1.http @@ -0,0 +1,5 @@ +### 브랜드 정보 조회 (성공) +GET {{commerce-api}}/api/v1/brands/1 + +### 브랜드 정보 조회 (존재하지 않는 ID → 404) +GET {{commerce-api}}/api/v1/brands/999999 diff --git a/http/commerce-api/order-v1.http b/http/commerce-api/order-v1.http new file mode 100644 index 000000000..149676a13 --- /dev/null +++ b/http/commerce-api/order-v1.http @@ -0,0 +1,48 @@ +### 주문 생성 (성공) +POST {{commerce-api}}/api/v1/orders +X-Loopers-LoginId: testuser +X-Loopers-LoginPw: Test1234! +Content-Type: application/json + +{ + "items": [ + { "productId": 1, "quantity": 2 }, + { "productId": 2, "quantity": 3 } + ] +} + +### 주문 생성 (재고 부족 → 400) +POST {{commerce-api}}/api/v1/orders +X-Loopers-LoginId: testuser +X-Loopers-LoginPw: Test1234! +Content-Type: application/json + +{ + "items": [ + { "productId": 1, "quantity": 99999 } + ] +} + +### 주문 생성 (인증 실패 → 401) +POST {{commerce-api}}/api/v1/orders +X-Loopers-LoginId: testuser +X-Loopers-LoginPw: WrongPassword! +Content-Type: application/json + +{ + "items": [ + { "productId": 1, "quantity": 1 } + ] +} + +### 주문 생성 (존재하지 않는 상품 → 404) +POST {{commerce-api}}/api/v1/orders +X-Loopers-LoginId: testuser +X-Loopers-LoginPw: Test1234! +Content-Type: application/json + +{ + "items": [ + { "productId": 999999, "quantity": 1 } + ] +} diff --git a/http/commerce-api/products-v1.http b/http/commerce-api/products-v1.http new file mode 100644 index 000000000..283a117a3 --- /dev/null +++ b/http/commerce-api/products-v1.http @@ -0,0 +1,17 @@ +### 상품 목록 조회 (전체) +GET {{commerce-api}}/api/v1/products + +### 상품 목록 조회 (브랜드 필터) +GET {{commerce-api}}/api/v1/products?brandId=1 + +### 상품 목록 조회 (가격 오름차순) +GET {{commerce-api}}/api/v1/products?sort=price_asc + +### 상품 목록 조회 (브랜드 필터 + 정렬 + 페이지네이션) +GET {{commerce-api}}/api/v1/products?brandId=1&sort=price_asc&page=0&size=10 + +### 상품 상세 조회 (성공) +GET {{commerce-api}}/api/v1/products/1 + +### 상품 상세 조회 (존재하지 않는 ID → 404) +GET {{commerce-api}}/api/v1/products/999999 \ No newline at end of file diff --git a/modules/jpa/src/main/resources/jpa.yml b/modules/jpa/src/main/resources/jpa.yml index 37f4fb1b0..8b90b1c7b 100644 --- a/modules/jpa/src/main/resources/jpa.yml +++ b/modules/jpa/src/main/resources/jpa.yml @@ -42,7 +42,7 @@ spring: datasource: mysql-jpa: main: - jdbc-url: jdbc:mysql://localhost:3306/loopers + jdbc-url: jdbc:mysql://localhost:3307/loopers username: application password: application