diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 6e904ea4e..048f09c1b 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,7 +1,19 @@ { "permissions": { "allow": [ - "Bash(git add:*)" + "Bash(git add:*)", + "Bash(./gradlew :apps:commerce-api:test:*)", + "Bash(./gradlew:*)", + "Bash(find /mnt/c/Users/kdj10/Git/loop-pack-be-l2-vol3-java -name \"CacheConfig.java\" -o -name \"*CacheConfig*\" 2>/dev/null | head -20)", + "Bash(JAVA_HOME=\"/mnt/c/Users/kdj10/.jdks/ms-21.0.10\" ./gradlew :apps:commerce-api:compileJava 2>&1 | tail -30)", + "Bash(ls \"/mnt/c/Users/kdj10/.jdks/ms-21.0.10/bin/\" | head -5; \"/mnt/c/Users/kdj10/.jdks/ms-21.0.10/bin/java\" -version 2>&1 | head -3)", + "Read(//home/ubuntu/projects/loop-pack-be-l2-vol3-java/**)", + "Bash(sg docker:*)", + "Bash(cd ~/projects/loop-pack-be-l2-vol3-java && sg docker -c \"./gradlew :apps:commerce-api:test --tests 'com.loopers.domain.brand.BrandServiceTest'\" 2>&1 | grep -A5 \"FAILED\")", + "Bash(cd ~/projects/loop-pack-be-l2-vol3-java && sg docker -c \"./gradlew :apps:commerce-api:test --tests 'com.loopers.domain.*'\" 2>&1 | grep \"FAILED\\\\|tests completed\" | tail -5)", + "Bash(cd ~/projects/loop-pack-be-l2-vol3-java && sg docker -c \"./gradlew :apps:commerce-api:test --tests 'com.loopers.domain.coupon.CouponModelTest'\" 2>&1 | grep -A3 \"FAILED\")", + "Bash(cd ~/projects/loop-pack-be-l2-vol3-java && sg docker -c \"./gradlew :apps:commerce-api:test --tests 'com.loopers.domain.order.OrderCartRestoreIdempotencyTest'\" 2>&1 | grep -A3 \"FAILED\")", + "Bash(cd ~/projects/loop-pack-be-l2-vol3-java && sg docker -c \"./gradlew :apps:commerce-api:test --tests 'com.loopers.infrastructure.*'\" 2>&1 | grep \"FAILED\\\\|tests completed\" | tail -5)" ] }, "hooks": { diff --git a/.claude/skills/anaylize-query/SKILL.md b/.claude/skills/anaylize-query/SKILL.md new file mode 100644 index 000000000..d160c04a8 --- /dev/null +++ b/.claude/skills/anaylize-query/SKILL.md @@ -0,0 +1,97 @@ +--- +name: analyze-query +description: + 대상이 되는 코드 범위를 탐색하고, Spring @Transactional, JPA, QueryDSL 기반의 코드에 대해 트랜잭션 범위, 영속성 컨텍스트, 쿼리 실행 시점 관점에서 분석한다. + + 특히 다음을 중점적으로 점검한다. + - 트랜잭션이 불필요하게 크게 잡혀 있지는 않은지 + - 조회/쓰기 로직이 하나의 트랜잭션에 혼합되어 있지는 않은지 + - JPA의 지연 로딩, flush 타이밍, 변경 감지로 인해 + 의도치 않은 쿼리 또는 락이 발생할 가능성은 없는지 + + 단순한 정답 제시가 아니라, 현재 구조의 의도와 trade-off를 드러내고 개선 가능 지점을 선택적으로 판단할 수 있도록 돕는다. +--- + +### 📌 Analysis Scope +이 스킬은 아래 대상에 대해 분석한다. +- @Transactional 이 선언된 클래스 / 메서드 +- Service / Facade / Application Layer 코드 +- JPA Entity, Repository, QueryDSL 사용 코드 +- 하나의 유즈케이스(요청 흐름) 단위 +> 컨트롤러 → 서비스 → 레포지토리 전체 흐름을 기준으로 분석하며 특정 메서드만 떼어내어 판단하지 않는다. + +### 🔍 Analysis Checklist +#### 1. Transaction Boundary 분석 +다음을 순서대로 확인한다. +- 트랜잭션 시작 지점은 어디인가? + - Service / Facade / 그 외 계층? +- 트랜잭션이 실제로 필요한 작업은 무엇인가? + - 상태 변경 (쓰기) + - 단순 조회 +- 트랜잭션 내부에서 수행되는 작업 나열 + - 외부 API 호출 + - 복잡한 조회(QueryDSL) + - 반복문 기반 처리 + +**출력 예시** +```markdown +- 현재 트랜잭션 범위: +OrderFacade.placeOrder() + ├─ 유저 검증 + ├─ 상품 조회 + ├─ 주문 생성 + ├─ 결제 요청 + └─ 재고 차감 + +- 트랜잭션이 필요한 핵심 작업: +- 주문 생성 +- 재고 차감 +``` + +#### 2. 불필요하게 큰 트랜잭션 식별 +아래 패턴이 존재하는지 점검한다. +- Controller 에서 Transactional 이 사용되고 있음 +- 읽기 전용 로직이 쓰기 트랜잭션에 포함됨 +- 외부 시스템 호출이 트랜잭션 내부에 포함됨 +- 트랜잭션 내부에서 대량 조회 / 복잡한 QueryDSL 실행 +- 상태 변경 이후에도 트랜잭션이 길게 유지됨 + +**문제 후보 예시** +- 결제 API 호출이 트랜잭션 내부에 포함되어 있음 +- 주문 생성 이후 추천 상품 조회 로직까지 동일 트랜잭션에 포함됨 + +#### 3. JPA / 영속성 컨텍스트 관점 분석 +다음을 중심으로 분석한다. +- Entity 변경이 언제 flush 되는지 +- 조회용 Entity가 변경 감지 대상이 되는지 +- 지연 로딩으로 인해 트랜잭션 후반에 쿼리가 발생할 가능성 +- @Transactional(readOnly = true) 미적용 여부 + +**체크리스트 예시** +```markdown +- 단순 조회인데 Entity 반환 후 변경 가능성 존재? +- DTO Projection 대신 Entity 조회 사용 여부 +- QueryDSL 조회 결과가 영속성 컨텍스트에 포함되는지 +``` + +#### 4. Improvement Proposal (선택적 제안) +개선안은 강제하지 않고 선택지로 제시한다. +- 트랜잭션 분리 + - 조회 → 쓰기 분리 + - Facade에서 orchestration, Service는 최소 트랜잭션 +- `@Transactional(readOnly = true)` 적용 +- DTO Projection (읽기 전용 모델) 도입 +- 외부 호출 / 이벤트 발행을 트랜잭션 외부로 이동 +- Application Service / Domain Service 책임 재조정 + +**개선안 예시** +```markdown +[개선안 1] +- 주문 생성과 결제 요청을 분리 +- 주문 생성까지만 트랜잭션 유지 +- 결제 요청은 트랜잭션 종료 후 수행 + +[고려 사항] +- 결제 실패 시 주문 상태 관리 필요 +- 보상 트랜잭션 또는 상태 전이 설계 필요 +``` \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 3b9ee7594..25e33f2a3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,43 +64,46 @@ Only `apps/*` modules produce BootJar. All other modules produce plain Jar. Layered Architecture with strict dependency direction (DIP): ``` +CustomerAuthInterceptor (고객 인증, userId를 request attribute에 저장) + ↓ interfaces/ → application/ → domain/ ← infrastructure/ -(Controller) (AppService/Facade) (Service, Model, Repository interface) (Repository impl) +(Controller) (Facade only) (Service, Model, Repository interface) (Repository impl) ``` ### Application Layer 적용 기준 | 구분 | 구조 | 해당 도메인 | |------|------|------------| -| **단순 도메인** | Controller → **AppService** → Service (returns Model) | Example, User, Brand, Like, Stats | -| **복잡한 도메인** | Controller → **Facade** → 여러 Service (returns Model) | Product, Cart, Order | +| **단순 도메인** | Controller → **Service** 직접 호출 | User, Brand, Like, Stats | +| **복잡한 도메인** | Controller → **Facade** → 여러 Service | Product, Cart, Order | -- **AppService**: 단일 도메인 서비스를 호출하고 Model → Info 변환을 담당하는 얇은 application 레이어 클래스. -- **Facade**: 여러 도메인 서비스를 조합(orchestration)하고 Model → Info 변환을 담당하는 application 레이어 클래스. -- **도메인 서비스는 Model을 반환**한다. Info DTO 변환은 항상 application 레이어(AppService/Facade)에서 수행한다. +- **Facade**: 여러 도메인 서비스를 조합(orchestration)하고, 복잡한 비즈니스 플로우(재고 hold/release, 보상 트랜잭션 등)를 담당하는 application 레이어 클래스. +- **단순 도메인은 AppService 없이 Controller에서 Service를 직접 호출**한다. +- **인증은 `CustomerAuthInterceptor`에서 처리**하고, 인증된 사용자는 `@AuthUser` 어노테이션으로 Controller에 주입된다. ### Layer Responsibilities -**interfaces/** — HTTP concerns only. Controllers receive requests, call AppService/Facade, return `ApiResponse`. +**interfaces/** — HTTP concerns only. Controllers receive requests, call Service/Facade, return `ApiResponse`. - DTOs are inner static classes in a wrapper class (e.g., `UserV1Dto.RegisterRequest`, `UserV1Dto.RegisterResponse`) - Request DTOs use Bean Validation annotations (`@NotBlank`, `@Size`, etc.) -- Response DTOs have `static from(Info)` factory methods +- Response DTOs have `static from(Model)` factory methods — 도메인 모델에서 직접 변환 - All responses wrapped in `ApiResponse` — `record ApiResponse(Metadata meta, T data)` +- `CustomerAuthInterceptor`: 고객 API 인증 처리, `@AuthUser`로 인증된 사용자 주입 +- `AdminAuthInterceptor`: 관리자 API 인증 처리 -**application/** — AppService (단순) / Facade (복잡). Orchestrates domain services, sets transaction boundaries, converts Domain Model → Info DTO. -- AppService: `*AppService` (annotated `@Service`) — 단일 서비스 호출 + Model → Info 변환 -- Facade: `*Facade` (annotated `@Service`) — 여러 서비스 조합 + Model → Info 변환 -- `*Info` DTO는 여기에 위치 — application 레이어에서 정의, interfaces 레이어로 전달 +**application/** — Facade만 사용 (복잡한 도메인). 여러 도메인 서비스를 조합, 트랜잭션 경계 설정. +- Facade: `*Facade` (annotated `@Service`) — 여러 서비스 조합 + 트랜잭션 관리 +- `*Info` DTO는 복잡한 도메인(Order, Cart, Product)에서 조합된 결과를 표현할 때만 사용 - `@Transactional(readOnly = true)` at class level, `@Transactional` on write methods **domain/** — Business logic. Service + Model (JPA Entity) + Repository interface. -- `*Service` contains business logic, returns `*Model` (NOT Info DTO) +- `*Service` contains business logic, returns `*Model` - `*Model` is the JPA entity with validation logic and factory methods -- `*Repository` is a plain Java interface (no Spring Data extends). Repository 반환 타입도 도메인 레이어 타입만 사용 (application 레이어 DTO 금지) -- 통계 등 집계 쿼리는 `*Projection` DTO를 도메인 레이어에 정의하여 Repository/Service가 반환하고, application 레이어에서 `*Info`로 변환한다 (예: `StatsProjection` → `StatsInfo`) +- `*Repository` is a plain Java interface (no Spring Data extends) +- 통계 등 집계 쿼리는 `*Projection` DTO를 도메인 레이어에 정의하여 Repository/Service가 반환하고, Controller에서 Response DTO로 변환한다 - Domain models do NOT depend on infrastructure (e.g., password encoding via `PasswordEncoder` interface defined in domain) - 비즈니스 규칙은 도메인 객체/enum에 캡슐화한다 (예: `UnavailableReason.evaluate()` 정적 팩토리로 주문 불가 사유 판별) -- **도메인 서비스 독립성**: `*Service`는 자신의 Repository만 의존한다. 다른 도메인 서비스를 직접 참조하지 않는다. 크로스 도메인 조합은 반드시 AppService/Facade에서 수행한다. +- **도메인 서비스 크로스 참조 허용**: `*Service`는 비즈니스 로직 수행을 위해 다른 `*Service`를 참조할 수 있다. (예: `LikeService` → `ProductService`로 상품 존재 검증) **infrastructure/** — Implements domain repository interfaces. - `*RepositoryImpl` delegates to `*JpaRepository` (Spring Data JPA) @@ -108,22 +111,22 @@ interfaces/ → application/ → domain/ ← infrastructure/ ### DTO Conversion Chain -**단순 도메인**: `V1Dto.Request` → AppService → Service (returns Model) → `Info.from(Model)` → `V1Dto.Response` -**복잡한 도메인**: `V1Dto.Request` → Facade → 여러 Service (returns Model) → `Info.from(Model)` → `V1Dto.Response` +**단순 도메인**: `V1Dto.Request` → Service (returns Model) → `V1Dto.Response.from(Model)` +**복잡한 도메인**: `V1Dto.Request` → Facade → 여러 Service (returns Model) → `Info.from(Model)` → `V1Dto.Response.from(Info)` ## Domain Catalog | 도메인 | 설명 | Base Entity | Application Layer | |--------|------|-------------|-------------------| -| **Example** | 예제 도메인 | `BaseEntity` (Long PK) | AppService | -| **User** | 회원 (Like/Cart/Order가 참조) | `BaseStringIdEntity` (String PK) | AppService | -| **Brand** | 브랜드 CRUD, 소프트 삭제, display_status | `BaseStringIdEntity` | AppService | +| **Example** | 예제 도메인 | `BaseEntity` (Long PK) | - | +| **User** | 회원 (Like/Cart/Order가 참조) | `BaseStringIdEntity` (Long PK) | Service 직접 호출 | +| **Brand** | 브랜드 CRUD, 소프트 삭제, display_status | `BaseStringIdEntity` | Service 직접 호출 | | **Product** | 상품 CRUD, revision 이력, sale_status | `BaseStringIdEntity` | Facade | | **ProductStock** | 재고 관리 (on_hand, reserved), CAS hold/release | - | (Product Facade) | -| **Like** | 상품 좋아요 등록/취소 (멱등), 복합 PK | `BaseStringIdEntity` | AppService | +| **Like** | 상품 좋아요 등록/취소 (멱등), 복합 PK, ProductService 참조 | `BaseStringIdEntity` | Service 직접 호출 | | **Cart** | 장바구니 CRUD, 주문 연계 복원, 복합 PK | `BaseStringIdEntity` | Facade | | **Order** | 주문 생성(DIRECT/CART), 취소, 만료 | `BaseStringIdEntity` | Facade | -| **Stats** | 운영 통계 (주문 현황, 인기 상품) | - | AppService | +| **Stats** | 운영 통계 (주문 현황, 인기 상품) | - | Service 직접 호출 | ## Entity Base Classes @@ -131,7 +134,7 @@ Two coexisting base entity patterns in `modules/jpa`: | 항목 | `BaseEntity` (기존) | `BaseStringIdEntity` (신규) | |------|--------------------|-----------------------------| -| PK | `Long` (auto-increment) | 서브클래스에서 `@Id @UuidGenerator`로 정의 (String UUID 36자) | +| PK | `Long` (auto-increment) | 서브클래스에서 `@Id @GeneratedValue(IDENTITY)`로 정의 (Long, bigint auto_increment) | | PK 컬럼명 | `id` (고정) | 서브클래스에서 직접 정의 (user_id, brand_id, product_id, order_id) | | 삭제 방식 | `deletedAt` 단일 | `del_yn` + `deletedAt` 이중 관리 | | 삭제 메서드 | `delete()` / `restore()` | `softDelete()` / `restore()` (멱등) | @@ -141,10 +144,17 @@ Both provide: `createdAt`, `updatedAt`, `guard()` override for entity validation ## API Authentication -| 구분 | Prefix | 인증 방식 | -|------|--------|-----------| -| 고객 API | `/api/v1` | `X-Loopers-LoginId` + `X-Loopers-LoginPw` headers | -| 관리자 API | `/api-admin/v1` | `X-Loopers-Ldap: loopers.admin` header | +| 구분 | Prefix | 인증 방식 | 처리 | +|------|--------|-----------|------| +| 고객 API | `/api/v1` | `X-Loopers-LoginId` + `X-Loopers-LoginPw` headers | `CustomerAuthInterceptor` → `@AuthUser` | +| 관리자 API | `/api-admin/v1` | `X-Loopers-Ldap: loopers.admin` header | `AdminAuthInterceptor` | + +### 고객 인증 흐름 + +1. `CustomerAuthInterceptor`가 인증 헤더 검증 및 `UserService.authenticate()` 호출 +2. 인증된 `UserModel`을 request attribute에 저장 +3. `AuthUserArgumentResolver`가 `@AuthUser` 어노테이션 파라미터에 `UserModel` 주입 +4. Controller에서 `@AuthUser UserModel user`로 인증된 사용자 접근 ## Error Handling @@ -244,10 +254,11 @@ DIRECT 주문 취소/만료 시 `order_cart_restore` 테이블 `existsById` 확 ## 설계 원칙 - 도메인 객체는 비즈니스 규칙을 캡슐화한다. 규칙이 여러 서비스에 나타나면 도메인 객체에 속할 가능성이 높다. -- 애플리케이션 서비스(AppService/Facade)는 서로 다른 도메인을 조립하여 기능을 제공한다. -- API request/response DTO와 application 레이어의 Info DTO는 분리한다. -- 도메인 모델(`*Model`)은 interfaces 레이어에 노출하지 않는다. application 레이어에서 `*Info` DTO로 변환하여 반환한다. -- 각 기능에 대한 책임과 결합도에 대해 개발자의 의도를 확인하고 개발을 진행한다. +- **단순 도메인은 Controller에서 Service를 직접 호출**한다. AppService 레이어는 제거되었다. +- **복잡한 도메인(Product, Cart, Order)은 Facade를 통해 여러 서비스를 조합**한다. +- **도메인 서비스는 필요시 다른 도메인 서비스를 참조**할 수 있다. (예: `LikeService` → `ProductService`) +- **인증은 Interceptor에서 처리**하고, `@AuthUser`로 Controller에 주입한다. +- Response DTO는 도메인 모델에서 직접 변환한다 (`V1Dto.Response.from(Model)`). - 패키징 전략은 4개 레이어 패키지를 두고, 하위에 도메인 별로 패키징한다. ## Implementation Guide diff --git a/apps/commerce-api/build.gradle.kts b/apps/commerce-api/build.gradle.kts index c6a363556..eaa0d69a4 100644 --- a/apps/commerce-api/build.gradle.kts +++ b/apps/commerce-api/build.gradle.kts @@ -14,6 +14,9 @@ dependencies { // security (for password encoding) implementation("org.springframework.security:spring-security-crypto") + // L1 cache (Caffeine) + implementation("com.github.ben-manes.caffeine:caffeine") + // querydsl annotationProcessor("com.querydsl:querydsl-apt::jakarta") annotationProcessor("jakarta.persistence:jakarta.persistence-api") diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandAppService.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandAppService.java deleted file mode 100644 index 14193d43a..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandAppService.java +++ /dev/null @@ -1,90 +0,0 @@ -package com.loopers.application.brand; - -import com.loopers.domain.brand.BrandService; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.List; - -/** - * 브랜드 도메인 Application Service. - * 도메인 서비스를 호출하고 Model → Info 변환을 담당한다. - */ -@Service -@RequiredArgsConstructor -@Transactional(readOnly = true) -public class BrandAppService { - - private final BrandService brandService; - - /** - * 새 브랜드를 등록한다. - * - * @param brandName 브랜드명 - * @param description 설명 - * @param address 주소 - * @return 생성된 브랜드 정보 DTO - */ - @Transactional - public BrandInfo createBrand(String brandName, String description, String address) { - return BrandInfo.from(brandService.createBrand(brandName, description, address)); - } - - /** - * 관리자용 전체 브랜드 목록을 조회한다 (삭제 포함). - * - * @return 전체 브랜드 정보 DTO 목록 - */ - public List findAllForAdmin() { - return brandService.findAllForAdmin().stream() - .map(BrandInfo::from) - .toList(); - } - - /** - * 고객에게 노출 가능한 브랜드를 ID로 조회한다. - * - * @param brandId 브랜드 ID - * @return 브랜드 정보 DTO - */ - public BrandInfo findVisibleById(String brandId) { - return BrandInfo.from(brandService.findVisibleById(brandId)); - } - - /** - * 고객에게 노출 가능한 브랜드 목록을 조회한다. 키워드가 있으면 검색한다. - * - * @param keyword 검색 키워드 (null이면 전체 조회) - * @return 브랜드 정보 DTO 목록 - */ - public List findAllVisibleBrands(String keyword) { - return brandService.findAllVisibleBrands(keyword).stream() - .map(BrandInfo::from) - .toList(); - } - - /** - * 브랜드 정보를 수정한다. - * - * @param brandId 브랜드 ID - * @param brandName 새 브랜드명 - * @param description 새 설명 - * @param address 새 주소 - * @return 수정된 브랜드 정보 DTO - */ - @Transactional - public BrandInfo updateBrand(String brandId, String brandName, String description, String address) { - return BrandInfo.from(brandService.updateBrand(brandId, brandName, description, address)); - } - - /** - * 브랜드를 소프트 삭제한다. - * - * @param brandId 삭제할 브랜드 ID - */ - @Transactional - public void deleteBrand(String brandId) { - brandService.deleteBrand(brandId); - } -} 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 deleted file mode 100644 index 34633812f..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.loopers.application.brand; - -import com.loopers.domain.brand.BrandModel; -import com.loopers.support.enums.DisplayStatus; -import lombok.Builder; -import lombok.Getter; - -import java.time.ZonedDateTime; - -/** - * 브랜드 정보 DTO. - * 도메인 모델({@link BrandModel})을 직접 노출하지 않고 인터페이스 레이어에 전달하기 위한 응답 객체이다. - */ -@Getter -@Builder -public class BrandInfo { - private final String brandId; - private final String brandName; - private final String description; - private final String address; - private final DisplayStatus displayStatus; - private final String attachFile; - private final String delYn; - private final ZonedDateTime deletedAt; - private final ZonedDateTime createdAt; - - /** - * BrandModel을 BrandInfo DTO로 변환한다. - * - * @param model 변환할 브랜드 엔티티 - * @return 브랜드 정보 DTO - */ - public static BrandInfo from(BrandModel model) { - return BrandInfo.builder() - .brandId(model.getBrandId()) - .brandName(model.getBrandName()) - .description(model.getDescription()) - .address(model.getAddress()) - .displayStatus(model.getDisplayStatus()) - .attachFile(model.getAttachFile()) - .delYn(model.getDelYn()) - .deletedAt(model.getDeletedAt()) - .createdAt(model.getCreatedAt()) - .build(); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/cart/CartAppService.java b/apps/commerce-api/src/main/java/com/loopers/application/cart/CartAppService.java deleted file mode 100644 index 4a0c0558b..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/cart/CartAppService.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.loopers.application.cart; - -import com.loopers.domain.cart.CartService; -import com.loopers.domain.user.UserModel; -import com.loopers.domain.user.UserService; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -/** - * 장바구니 도메인 Application Service. - * - *

단일 도메인 서비스(CartService)만 호출하는 얇은 메서드를 담당한다. - * 인증 후 CartService에 위임한다.

- */ -@Service -@RequiredArgsConstructor -@Transactional(readOnly = true) -public class CartAppService { - - private final CartService cartService; - private final UserService userService; - - /** - * 장바구니에서 상품을 삭제한다. - * - * @param loginId 로그인 ID - * @param loginPw 로그인 비밀번호 - * @param productId 삭제할 상품 ID - */ - @Transactional - public void removeItem(String loginId, String loginPw, String productId) { - UserModel user = userService.authenticate(loginId, loginPw); - cartService.removeItem(user.getUserId(), productId); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/cart/CartFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/cart/CartFacade.java index 94430dcf3..929d9ba34 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/cart/CartFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/cart/CartFacade.java @@ -8,11 +8,7 @@ import com.loopers.domain.product.ProductService; import com.loopers.domain.product.ProductStockModel; import com.loopers.domain.product.StockService; -import com.loopers.domain.user.UserModel; -import com.loopers.domain.user.UserService; import com.loopers.support.enums.UnavailableReason; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -27,16 +23,17 @@ /** * 장바구니 Facade (퍼사드) * - *

UserService, CartService, ProductService, StockService, BrandService를 + *

CartService, ProductService, StockService, BrandService를 * 조합(orchestration)하여 장바구니 비즈니스 플로우를 완성한다.

* *
    - *
  • 사용자 인증
  • *
  • 트랜잭션 경계 설정
  • *
  • 상품 주문 가능 여부 및 재고 검증 후 장바구니 추가
  • *
  • 재고 검증 후 수량 변경
  • *
  • 장바구니 항목 + 상품/브랜드/재고 조합 조회
  • *
+ * + *

인증은 {@link com.loopers.interfaces.api.CustomerAuthInterceptor}에서 처리된다.

*/ @Service @RequiredArgsConstructor @@ -44,7 +41,6 @@ public class CartFacade { private final CartService cartService; - private final UserService userService; private final ProductService productService; private final StockService stockService; private final BrandService brandService; @@ -55,13 +51,11 @@ public class CartFacade { *

장바구니 항목에 상품, 브랜드, 재고 정보를 조합하여 * 주문 가능 여부와 불가 사유를 포함한 CartInfo 목록을 반환한다.

* - * @param loginId 로그인 ID - * @param loginPw 로그인 비밀번호 + * @param userId 사용자 ID * @return 해당 사용자의 장바구니 항목 목록 (상품/브랜드/재고 정보 포함) */ - public List getCart(String loginId, String loginPw) { - UserModel user = userService.authenticate(loginId, loginPw); - return buildCartInfoList(cartService.getCartItems(user.getUserId())); + public List getCart(Long userId) { + return buildCartInfoList(cartService.getCartItems(userId)); } /** @@ -69,23 +63,18 @@ public List getCart(String loginId, String loginPw) { * *

상품 주문 가능 여부와 재고 초과 여부를 검증한 뒤 장바구니에 추가한다.

* - * @param loginId 로그인 ID - * @param loginPw 로그인 비밀번호 + * @param userId 사용자 ID * @param productId 추가할 상품 ID * @param qty 수량 */ @Transactional - public void addItem(String loginId, String loginPw, String productId, int qty) { - UserModel user = userService.authenticate(loginId, loginPw); - + public void addItem(Long userId, Long productId, int qty) { productService.findOrderableById(productId); ProductStockModel stock = stockService.findByProductId(productId); - if (stock.getAvailableQty() < qty) { - throw new CoreException(ErrorType.CART_STOCK_EXCEEDED); - } + stock.validateCanHold(qty); - cartService.addItem(user.getUserId(), productId, qty); + cartService.addItem(userId, productId, qty); } /** @@ -93,21 +82,27 @@ public void addItem(String loginId, String loginPw, String productId, int qty) { * *

재고 초과 여부를 검증한 뒤 수량을 변경한다.

* - * @param loginId 로그인 ID - * @param loginPw 로그인 비밀번호 + * @param userId 사용자 ID * @param productId 수량을 변경할 상품 ID * @param newQty 변경할 새 수량 */ @Transactional - public void changeQuantity(String loginId, String loginPw, String productId, int newQty) { - UserModel user = userService.authenticate(loginId, loginPw); - + public void changeQuantity(Long userId, Long productId, int newQty) { ProductStockModel stock = stockService.findByProductId(productId); - if (stock.getAvailableQty() < newQty) { - throw new CoreException(ErrorType.CART_STOCK_EXCEEDED); - } + stock.validateCanHold(newQty); - cartService.changeQuantity(user.getUserId(), productId, newQty); + cartService.changeQuantity(userId, productId, newQty); + } + + /** + * 장바구니에서 상품을 삭제한다. + * + * @param userId 사용자 ID + * @param productId 삭제할 상품 ID + */ + @Transactional + public void removeItem(Long userId, Long productId) { + cartService.removeItem(userId, productId); } /** @@ -119,7 +114,7 @@ public void changeQuantity(String loginId, String loginPw, String productId, int * @param userId 조회할 사용자 ID * @return 해당 사용자의 장바구니 항목 목록 (상품/브랜드/재고 정보 포함) */ - public List getCartForAdmin(String userId) { + public List getCartForAdmin(Long userId) { return buildCartInfoList(cartService.getCartItems(userId)); } @@ -132,18 +127,18 @@ private List buildCartInfoList(List items) { return List.of(); } - List productIds = items.stream() + List productIds = items.stream() .map(CartItemModel::getProductId).distinct().toList(); - Map productMap = productService.findAllByIds(productIds) + Map productMap = productService.findAllByIds(productIds) .stream().collect(Collectors.toMap(ProductModel::getProductId, Function.identity())); - Set brandIds = productMap.values().stream() + Set brandIds = productMap.values().stream() .map(ProductModel::getBrandId).collect(Collectors.toSet()); - Map brandMap = brandService.findAllByIds(brandIds) + Map brandMap = brandService.findAllByIds(brandIds) .stream().collect(Collectors.toMap(BrandModel::getBrandId, Function.identity())); - Map stockMap = stockService.findAllByProductIds(productIds) + Map stockMap = stockService.findAllByProductIds(productIds) .stream().collect(Collectors.toMap(ProductStockModel::getProductId, Function.identity())); List result = new ArrayList<>(); diff --git a/apps/commerce-api/src/main/java/com/loopers/application/cart/CartInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/cart/CartInfo.java index 22fe99ac7..4fd575108 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/cart/CartInfo.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/cart/CartInfo.java @@ -20,14 +20,14 @@ @Getter @Builder public class CartInfo { - private final String userId; - private final String productId; + private final Long userId; + private final Long productId; private final int quantity; private final boolean available; private final UnavailableReason unavailableReason; private final String productName; private final BigDecimal price; - private final String brandId; + private final Long brandId; private final String brandName; private final String imageUrl; private final int availableStock; diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeAppService.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeAppService.java deleted file mode 100644 index e6844943d..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeAppService.java +++ /dev/null @@ -1,63 +0,0 @@ -package com.loopers.application.like; - -import com.loopers.domain.like.LikeService; -import com.loopers.domain.user.UserModel; -import com.loopers.domain.user.UserService; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.List; - -/** - * 좋아요 도메인 Application Service. - * 사용자 인증과 좋아요 도메인 서비스를 조합하고 Model → Info 변환을 담당한다. - */ -@Service -@RequiredArgsConstructor -@Transactional(readOnly = true) -public class LikeAppService { - - private final UserService userService; - private final LikeService likeService; - - /** - * 상품에 좋아요를 등록한다. 이미 좋아요한 경우 무시한다 (멱등). - * - * @param loginId 로그인 ID - * @param loginPw 비밀번호 - * @param productId 상품 ID - */ - @Transactional - public void addLike(String loginId, String loginPw, String productId) { - UserModel user = userService.authenticate(loginId, loginPw); - likeService.addLike(user.getUserId(), productId); - } - - /** - * 상품 좋아요를 취소한다. 좋아요가 없으면 무시한다 (멱등). - * - * @param loginId 로그인 ID - * @param loginPw 비밀번호 - * @param productId 상품 ID - */ - @Transactional - public void removeLike(String loginId, String loginPw, String productId) { - UserModel user = userService.authenticate(loginId, loginPw); - likeService.removeLike(user.getUserId(), productId); - } - - /** - * 특정 사용자의 좋아요 목록을 조회한다. - * - * @param loginId 로그인 ID - * @param loginPw 비밀번호 - * @return 좋아요 정보 DTO 목록 - */ - public List getMyLikes(String loginId, String loginPw) { - UserModel user = userService.authenticate(loginId, loginPw); - return likeService.getMyLikes(user.getUserId()).stream() - .map(LikeInfo::from) - .toList(); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeInfo.java deleted file mode 100644 index bfae8c31e..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeInfo.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.loopers.application.like; - -import com.loopers.domain.like.LikeModel; -import lombok.Builder; -import lombok.Getter; - -import java.time.LocalDateTime; - -/** - * 좋아요 정보 DTO. - * 도메인 모델({@link LikeModel})을 직접 노출하지 않고 인터페이스 레이어에 전달하기 위한 응답 객체이다. - */ -@Getter -@Builder -public class LikeInfo { - private final String userId; - private final String productId; - private final LocalDateTime createdAt; - - /** - * LikeModel을 LikeInfo DTO로 변환한다. - * - * @param model 변환할 좋아요 엔티티 - * @return 좋아요 정보 DTO - */ - public static LikeInfo from(LikeModel model) { - return LikeInfo.builder() - .userId(model.getUserId()) - .productId(model.getProductId()) - .createdAt(model.getCreatedAt()) - .build(); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderAppService.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderAppService.java deleted file mode 100644 index 52f0bf151..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderAppService.java +++ /dev/null @@ -1,100 +0,0 @@ -package com.loopers.application.order; - -import com.loopers.domain.order.OrderItemModel; -import com.loopers.domain.order.OrderModel; -import com.loopers.domain.order.OrderService; -import com.loopers.domain.user.UserModel; -import com.loopers.domain.user.UserService; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.time.LocalDateTime; -import java.util.List; -import java.util.Map; - -/** - * 주문 도메인 Application Service. - * - *

단일 도메인 서비스(OrderService)만 호출하는 얇은 조회 메서드를 담당한다. - * 고객용 메서드는 UserService 인증을 포함한다. - * Model → Info 변환을 수행한다.

- */ -@Service -@RequiredArgsConstructor -@Transactional(readOnly = true) -public class OrderAppService { - - private final OrderService orderService; - private final UserService userService; - - /** - * 주문 상세 정보를 조회한다. - * - * @param loginId 로그인 ID - * @param loginPw 로그인 비밀번호 - * @param orderId 조회할 주문 ID - * @return 주문 상세 정보 - */ - public OrderInfo getOrderDetail(String loginId, String loginPw, String orderId) { - UserModel user = userService.authenticate(loginId, loginPw); - OrderModel order = orderService.findByIdAndUserId(orderId, user.getUserId()); - List items = orderService.findOrderItems(order.getOrderId()); - return OrderInfo.from(order, items); - } - - /** - * 기간별 주문 목록을 조회한다. - * - * @param loginId 로그인 ID - * @param loginPw 로그인 비밀번호 - * @param start 조회 시작 일시 - * @param end 조회 종료 일시 - * @return 조회 기간 내 주문 목록 - */ - public List getOrders(String loginId, String loginPw, - LocalDateTime start, LocalDateTime end) { - UserModel user = userService.authenticate(loginId, loginPw); - List orders = orderService.findAllByUserId(user.getUserId(), start, end); - return toOrderInfoList(orders); - } - - /** - * 주문 ID로 주문 상세를 조회한다 (관리자용). - * - * @param orderId 주문 ID - * @return 주문 정보 DTO - */ - public OrderInfo findOrderById(String orderId) { - OrderModel order = orderService.findOrderById(orderId); - List items = orderService.findOrderItems(order.getOrderId()); - return OrderInfo.from(order, items); - } - - /** - * 기간별 전체 주문 목록을 조회한다 (관리자용). - * - * @param start 조회 시작 일시 - * @param end 조회 종료 일시 - * @return 주문 정보 DTO 목록 - */ - public List findAllOrders(LocalDateTime start, LocalDateTime end) { - List orders = orderService.findAllOrders(start, end); - return toOrderInfoList(orders); - } - - /** - * 주문 목록을 배치 로딩으로 OrderInfo 목록으로 변환한다. - * N+1 쿼리 대신 단일 IN 쿼리로 주문 항목을 일괄 조회한다. - */ - private List toOrderInfoList(List orders) { - if (orders.isEmpty()) { - return List.of(); - } - List orderIds = orders.stream().map(OrderModel::getOrderId).toList(); - Map> itemMap = orderService.findOrderItemsByOrderIds(orderIds); - return orders.stream() - .map(order -> OrderInfo.from(order, itemMap.getOrDefault(order.getOrderId(), List.of()))) - .toList(); - } -} 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 index b165045ad..bfb60f321 100644 --- 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 @@ -3,6 +3,9 @@ import com.loopers.domain.brand.BrandModel; import com.loopers.domain.brand.BrandService; import com.loopers.domain.cart.CartService; +import com.loopers.domain.coupon.CouponModel; +import com.loopers.domain.coupon.CouponService; +import com.loopers.domain.coupon.UserCouponModel; import com.loopers.domain.order.OrderCartRestoreModel; import com.loopers.domain.order.OrderItemCommand; import com.loopers.domain.order.OrderItemModel; @@ -12,8 +15,6 @@ import com.loopers.domain.product.ProductModel; import com.loopers.domain.product.ProductService; import com.loopers.domain.product.StockService; -import com.loopers.domain.user.UserModel; -import com.loopers.domain.user.UserService; import com.loopers.support.enums.OrderType; import com.loopers.support.enums.RestoreReason; import com.loopers.support.enums.RestoreTriggerSource; @@ -23,24 +24,28 @@ import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Comparator; import java.util.List; +import java.util.Map; import java.util.Optional; /** * 주문 Facade (퍼사드) * - *

UserService, OrderService, ProductService, BrandService, StockService, CartService를 + *

OrderService, ProductService, BrandService, StockService, CartService, CouponService를 * 조합(orchestration)하여 주문 비즈니스 플로우를 완성한다.

* *
    - *
  • 사용자 인증
  • *
  • 트랜잭션 경계 설정
  • - *
  • 직접 주문(DIRECT) 및 장바구니 주문(CART) 생성 — 상품 검증, 재고 hold, 스냅샷 생성
  • + *
  • 직접 주문(DIRECT) 및 장바구니 주문(CART) 생성 — 쿠폰 검증, 상품 검증, 재고 hold, 스냅샷 생성
  • *
  • 주문 취소/만료 — CAS 상태 전이 후 재고 release, 장바구니 복원
  • *
  • 주문 목록 조회, 상세 조회
  • *
+ * + *

인증은 {@link com.loopers.interfaces.api.CustomerAuthInterceptor}에서 처리된다.

*/ @Service @RequiredArgsConstructor @@ -48,62 +53,103 @@ public class OrderFacade { private final OrderService orderService; - private final UserService userService; private final ProductService productService; private final BrandService brandService; private final StockService stockService; private final CartService cartService; + private final CouponService couponService; /** * 직접 주문을 생성한다. * - *

상품 상세 페이지에서 바로 주문하는 DIRECT 주문 방식이다. - * 상품 검증 → 브랜드 조회 → 재고 hold → 주문 저장 순으로 진행하며, - * 재고 hold 실패 시 이미 hold된 재고를 역순으로 release한다.

- * - * @param loginId 로그인 ID - * @param loginPw 로그인 비밀번호 - * @param items 주문할 상품 항목 목록 + * @param userId 사용자 ID + * @param items 주문할 상품 항목 목록 + * @param userCouponId 적용할 발급 쿠폰 ID (없으면 null) * @return 생성된 주문 정보 */ @Transactional - public OrderInfo createDirectOrder(String loginId, String loginPw, List items) { - UserModel user = userService.authenticate(loginId, loginPw); - return processOrder(user.getUserId(), OrderType.DIRECT, items); + public OrderInfo createDirectOrder(Long userId, List items, Long userCouponId) { + return processOrder(userId, OrderType.DIRECT, items, userCouponId); } /** * 장바구니 주문을 생성한다. * - *

장바구니에서 선택한 상품들을 주문하는 CART 주문 방식이다. - * 상품 검증 → 브랜드 조회 → 재고 hold → 주문 저장 순으로 진행하며, - * 재고 hold 실패 시 이미 hold된 재고를 역순으로 release한다.

- * - * @param loginId 로그인 ID - * @param loginPw 로그인 비밀번호 - * @param items 주문할 상품 항목 목록 + * @param userId 사용자 ID + * @param items 주문할 상품 항목 목록 + * @param userCouponId 적용할 발급 쿠폰 ID (없으면 null) * @return 생성된 주문 정보 */ @Transactional - public OrderInfo createCartOrder(String loginId, String loginPw, List items) { - UserModel user = userService.authenticate(loginId, loginPw); - return processOrder(user.getUserId(), OrderType.CART, items); + public OrderInfo createCartOrder(Long userId, List items, Long userCouponId) { + return processOrder(userId, OrderType.CART, items, userCouponId); + } + + /** + * 주문 목록을 조회한다 (기간 필터). + */ + public List getOrders(Long userId, LocalDateTime start, LocalDateTime end) { + List orders = orderService.findAllByUserId(userId, start, end); + Map> itemMap = orderService.batchLoadOrderItems(orders); + return orders.stream() + .map(order -> OrderInfo.from(order, itemMap.getOrDefault(order.getOrderId(), List.of()))) + .toList(); + } + + /** + * 주문 상세 정보를 조회한다. + */ + public OrderInfo getOrderDetail(Long userId, Long orderId) { + OrderModel order = orderService.findByIdAndUserId(orderId, userId); + List items = orderService.findOrderItems(order.getOrderId()); + return OrderInfo.from(order, items); + } + + /** + * 관리자용 전체 주문 목록을 조회한다 (기간 필터). + */ + public List getOrdersForAdmin(LocalDateTime start, LocalDateTime end) { + List orders = orderService.findAllOrders(start, end); + Map> itemMap = orderService.batchLoadOrderItems(orders); + return orders.stream() + .map(order -> OrderInfo.from(order, itemMap.getOrDefault(order.getOrderId(), List.of()))) + .toList(); + } + + /** + * 관리자용 주문 상세 정보를 조회한다. + */ + public OrderInfo getOrderDetailForAdmin(Long orderId) { + OrderModel order = orderService.findOrderById(orderId); + List items = orderService.findOrderItems(order.getOrderId()); + return OrderInfo.from(order, items); } /** * 주문 생성 공통 로직. *

- * 주문 항목 검증/병합 → 상품 조회 → 브랜드 조회 → 재고 hold → 스냅샷 생성 → 주문 저장. + * 쿠폰 검증 → 재고 hold → 할인 배분 → 주문 저장 → 쿠폰 사용 처리. * 데드락 방지를 위해 productId 오름차순으로 재고 예약을 수행한다. * 재고 hold 실패 시 이미 hold된 재고를 역순으로 release한다. *

*/ - private OrderInfo processOrder(String userId, OrderType orderType, List items) { + private OrderInfo processOrder(Long userId, OrderType orderType, List items, + Long userCouponId) { List merged = orderService.validateAndPrepare(userId, items); - List snapshots = new ArrayList<>(); - List heldProductIds = new ArrayList<>(); - BigDecimal totalAmount = BigDecimal.ZERO; + // Step 1: 쿠폰 검증 (재고 hold 전에 수행 → 보상 복잡도 최소화) + UserCouponModel userCoupon = null; + CouponModel coupon = null; + if (userCouponId != null) { + userCoupon = couponService.validateAndGetUserCoupon(userId, userCouponId); + coupon = couponService.findByIdForAdmin(userCoupon.getCouponId()); + } + + // Step 2: 재고 hold + 스냅샷 생성 + 쿠폰 할인 계산 + List rawSnapshots = new ArrayList<>(); + List heldProductIds = new ArrayList<>(); + BigDecimal totalOriginalAmount = BigDecimal.ZERO; + BigDecimal totalDiscount = BigDecimal.ZERO; try { for (OrderItemCommand item : merged) { @@ -113,47 +159,87 @@ private OrderInfo processOrder(String userId, OrderType orderType, List finalSnapshots = applyDiscountProportionally(rawSnapshots, totalOriginalAmount, + totalDiscount); + BigDecimal totalFinalAmount = totalOriginalAmount.subtract(totalDiscount); + + // Step 4: 주문 생성 + OrderModel order = orderService.createOrder(userId, orderType, totalFinalAmount, finalSnapshots); + + // Step 5: 쿠폰 사용 처리 (동일 트랜잭션) + if (userCoupon != null) { + couponService.markCouponAsUsed(userCoupon.getUserCouponId(), order.getOrderId()); + } + List orderItems = orderService.findOrderItems(order.getOrderId()); return OrderInfo.from(order, orderItems); } + /** + * 할인 금액을 주문 항목별 originalAmount 비율로 배분한다. + * 반올림 오차는 마지막 항목이 흡수한다. + */ + private List applyDiscountProportionally(List rawSnapshots, + BigDecimal totalOriginalAmount, + BigDecimal totalDiscount) { + if (totalDiscount.compareTo(BigDecimal.ZERO) == 0) { + return rawSnapshots; + } + + List result = new ArrayList<>(); + BigDecimal allocatedDiscount = BigDecimal.ZERO; + + for (int i = 0; i < rawSnapshots.size(); i++) { + OrderItemSnapshot raw = rawSnapshots.get(i); + boolean isLast = (i == rawSnapshots.size() - 1); + BigDecimal itemDiscount; + if (isLast) { + itemDiscount = totalDiscount.subtract(allocatedDiscount); + } else { + itemDiscount = totalDiscount + .multiply(raw.originalAmount()) + .divide(totalOriginalAmount, 0, RoundingMode.FLOOR); + allocatedDiscount = allocatedDiscount.add(itemDiscount); + } + + OrderItemSnapshot snapshotWithDiscount = new OrderItemSnapshot( + raw.productId(), raw.quantity(), raw.productName(), + raw.unitPrice(), raw.brandId(), raw.brandName(), raw.imageUrl(), + raw.originalAmount(), itemDiscount, raw.originalAmount().subtract(itemDiscount)); + result.add(snapshotWithDiscount); + } + return result; + } /** * 주문을 취소한다. - * - *

CAS 상태 전이 후 재고를 해제하고, DIRECT 주문인 경우 장바구니를 복원한다.

- * - * @param loginId 로그인 ID - * @param loginPw 로그인 비밀번호 - * @param orderId 취소할 주문 ID */ @Transactional - public void cancelOrder(String loginId, String loginPw, String orderId) { - UserModel user = userService.authenticate(loginId, loginPw); - Optional order = orderService.cancelOrder(user.getUserId(), orderId); + public void cancelOrder(Long userId, Long orderId) { + Optional order = orderService.cancelOrder(userId, orderId); order.ifPresent(o -> releaseStocksAndRestore(o, RestoreReason.USER_CANCELLED, RestoreTriggerSource.CANCEL_API)); } /** * 배치 스케줄러가 주문을 만료 처리한다. - * - *

CAS 상태 전이 후 재고를 해제하고, DIRECT 주문인 경우 장바구니를 복원한다. - * 인증이 필요하지 않은 시스템 내부 호출용 메서드이다.

- * - * @param orderId 주문 ID */ @Transactional - public void expireOrder(String orderId) { + public void expireOrder(Long orderId) { Optional order = orderService.expireOrder(orderId); order.ifPresent(o -> releaseStocksAndRestore(o, RestoreReason.EXPIRED, RestoreTriggerSource.EXPIRE_JOB)); } @@ -161,9 +247,9 @@ public void expireOrder(String orderId) { /** * 재고 hold 실패 시 이미 hold된 재고를 역순으로 release한다. */ - private void compensateHeldStocks(List heldProductIds, List merged) { + private void compensateHeldStocks(List heldProductIds, List merged) { for (int i = heldProductIds.size() - 1; i >= 0; i--) { - String heldProductId = heldProductIds.get(i); + Long heldProductId = heldProductIds.get(i); int qty = merged.stream() .filter(m -> m.productId().equals(heldProductId)) .findFirst().map(OrderItemCommand::quantity).orElse(0); @@ -173,11 +259,6 @@ private void compensateHeldStocks(List heldProductIds, List - * 주문 항목의 재고를 productId 오름차순으로 해제하고, - * DIRECT 주문인 경우 장바구니 복원을 수행한다. - * PK 충돌 시 이미 복원된 것으로 간주하여 skip한다 (멱등 보장). - *

*/ private void releaseStocksAndRestore(OrderModel order, RestoreReason reason, RestoreTriggerSource triggerSource) { @@ -190,6 +271,9 @@ private void releaseStocksAndRestore(OrderModel order, RestoreReason reason, stockService.release(item.getProductId(), item.getQuantity()); } + // 쿠폰 복원 (멱등) + couponService.restoreCoupon(order.getOrderId()); + if (order.getOrderType() == OrderType.DIRECT) { if (!orderService.existsCartRestore(order.getOrderId())) { orderService.saveCartRestore( 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 index 029d03d2f..5e5f8a6b6 100644 --- 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 @@ -22,8 +22,8 @@ @Getter @Builder public class OrderInfo { - private final String orderId; - private final String userId; + private final Long orderId; + private final Long userId; private final OrderType orderType; private final OrderStatus status; private final BigDecimal totalAmount; @@ -33,10 +33,6 @@ public class OrderInfo { /** * OrderModel과 OrderItemModel 목록을 조합하여 OrderInfo DTO로 변환한다. - * - * @param order 주문 엔티티 - * @param items 주문 항목 엔티티 목록 (null이면 빈 리스트) - * @return 주문 정보 DTO (주문 항목 스냅샷 포함) */ public static OrderInfo from(OrderModel order, List items) { return OrderInfo.builder() @@ -55,29 +51,25 @@ public static OrderInfo from(OrderModel order, List items) { /** * 주문 항목 정보 DTO. - *

- * 주문 시점의 상품 스냅샷 데이터(상품명, 단가, 브랜드, 이미지 등)를 포함하여 - * 주문 후 상품 정보가 변경되더라도 주문 당시 정보를 보존한다. - *

*/ @Getter @Builder public static class OrderItemInfo { - private final String orderId; + private final Long orderId; private final int orderItemSeq; - private final String productId; + private final Long productId; private final int quantity; private final String snapshotProductName; private final BigDecimal snapshotUnitPrice; private final String snapshotBrandId; private final String snapshotBrandName; private final String snapshotImageUrl; + private final BigDecimal originalAmount; + private final BigDecimal discountAmount; + private final BigDecimal finalAmount; /** * OrderItemModel을 OrderItemInfo DTO로 변환한다. - * - * @param item 주문 항목 엔티티 - * @return 주문 항목 정보 DTO (스냅샷 데이터 포함) */ public static OrderItemInfo from(OrderItemModel item) { return OrderItemInfo.builder() @@ -90,6 +82,9 @@ public static OrderItemInfo from(OrderItemModel item) { .snapshotBrandId(item.getSnapshotBrandId()) .snapshotBrandName(item.getSnapshotBrandName()) .snapshotImageUrl(item.getSnapshotImageUrl()) + .originalAmount(item.getOriginalAmount()) + .discountAmount(item.getDiscountAmount()) + .finalAmount(item.getFinalAmount()) .build(); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductAppService.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductAppService.java deleted file mode 100644 index af296ee8c..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductAppService.java +++ /dev/null @@ -1,55 +0,0 @@ -package com.loopers.application.product; - -import com.loopers.domain.product.ProductService; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.List; - -/** - * 상품 도메인 Application Service. - * - *

단일 도메인 서비스(ProductService)만 호출하는 얇은 메서드를 담당한다. - * Model → Info 변환을 수행한다.

- */ -@Service -@RequiredArgsConstructor -@Transactional(readOnly = true) -public class ProductAppService { - - private final ProductService productService; - - /** - * 상품을 소프트 삭제한다. - * - * @param productId 삭제할 상품 ID - */ - @Transactional - public void deleteProduct(String productId) { - productService.deleteProduct(productId); - } - - /** - * 상품의 변경 이력(Revision) 목록을 조회한다. - * - * @param productId 이력을 조회할 상품 ID - * @return 상품 변경 이력 Info 목록 - */ - public List getRevisions(String productId) { - return productService.findRevisionsByProductId(productId).stream() - .map(ProductRevisionInfo::from) - .toList(); - } - - /** - * 특정 상품의 개별 변경 이력 상세를 조회한다. - * - * @param productId 상품 ID - * @param revisionSeq 변경 순번 - * @return 변경 이력 Info - */ - public ProductRevisionInfo getRevisionDetail(String productId, Long revisionSeq) { - return ProductRevisionInfo.from(productService.findRevisionById(productId, revisionSeq)); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductCreateCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductCreateCommand.java index e8ac41a43..a7a6a3eb7 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductCreateCommand.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductCreateCommand.java @@ -14,7 +14,7 @@ * @param description 상품 설명 * @param initialStock 초기 재고 수량 */ -public record ProductCreateCommand(String productName, String brandId, +public record ProductCreateCommand(String productName, Long brandId, BigDecimal price, String description, int initialStock) { } 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 index 4cf8e5060..cf54f6d89 100644 --- 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 @@ -2,21 +2,20 @@ import com.loopers.domain.brand.BrandModel; import com.loopers.domain.brand.BrandService; -import com.loopers.domain.like.LikeService; import com.loopers.domain.product.ProductModel; import com.loopers.domain.product.ProductService; import com.loopers.domain.product.ProductStockModel; import com.loopers.domain.product.StockService; import com.loopers.interfaces.api.PageResponse; import com.loopers.support.enums.ProductSortType; +import com.loopers.support.page.PageQuery; +import com.loopers.support.page.PagedResult; import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Sort; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.Comparator; import java.util.List; import java.util.Map; import java.util.function.Function; @@ -45,7 +44,6 @@ public class ProductFacade { private final ProductService productService; private final StockService stockService; private final BrandService brandService; - private final LikeService likeService; /** * 고객용 상품 목록을 정렬 + 페이징하여 조회한다. @@ -60,24 +58,34 @@ public class ProductFacade { * @param size 페이지 크기 * @return 페이징된 상품 정보 목록 (재고, 브랜드명, 좋아요 수 포함) */ - public PageResponse getProductsForCustomer(String keyword, String brandId, + public PageResponse getProductsForCustomer(String keyword, Long brandId, ProductSortType sort, int page, int size) { - if (sort == ProductSortType.LIKES_DESC) { - return getProductsSortedByLikes(keyword, brandId, page, size); + if (keyword == null && page == 0 && size == 20) { + return getCachedProductList(brandId, sort, page, size); } + return getProductsFromDb(keyword, brandId, sort, page, size); + } + + @Cacheable(cacheNames = "productList", + key = "T(String).valueOf(#brandId) + ':' + #sort.name() + ':p' + #page + ':s' + #size") + public PageResponse getCachedProductList(Long brandId, + ProductSortType sort, int page, int size) { + return getProductsFromDb(null, brandId, sort, page, size); + } - Sort dbSort = switch (sort) { - case LATEST -> Sort.by(Sort.Direction.DESC, "createdAt"); - case PRICE_ASC -> Sort.by(Sort.Direction.ASC, "price"); - default -> Sort.by(Sort.Direction.DESC, "createdAt"); + private PageResponse getProductsFromDb(String keyword, Long brandId, + ProductSortType sort, int page, int size) { + PageQuery query = switch (sort) { + case LATEST -> new PageQuery(page, size, "createdAt", false); + case PRICE_ASC -> new PageQuery(page, size, "price", true); + case LIKES_DESC -> new PageQuery(page, size, "likeCount", false); }; - Page productPage = productService.findAllForCustomer(keyword, brandId, - PageRequest.of(page, size, dbSort)); - List enriched = enrichProducts(productPage.getContent()); + PagedResult productPage = productService.findAllForCustomer(keyword, brandId, query); + List enriched = enrichProducts(productPage.content()); - return new PageResponse<>(enriched, productPage.getNumber(), productPage.getSize(), - productPage.getTotalElements(), productPage.getTotalPages()); + return new PageResponse<>(enriched, productPage.page(), productPage.size(), + productPage.totalElements(), productPage.totalPages()); } /** @@ -88,12 +96,11 @@ public PageResponse getProductsForCustomer(String keyword, String b * @param productId 조회할 상품 ID * @return 상품 상세 정보 (재고, 브랜드명, 좋아요 수 포함) */ - public ProductInfo getProductDetailForCustomer(String productId) { + public ProductInfo getProductDetailForCustomer(Long productId) { ProductModel product = productService.findById(productId); ProductStockModel stock = stockService.findByProductId(productId); BrandModel brand = brandService.findById(product.getBrandId()); - long likeCount = likeService.countByProductId(productId); - return ProductInfo.from(product, stock, brand.getBrandName(), likeCount); + return ProductInfo.from(product, stock, brand.getBrandName(), product.getLikeCount()); } /** @@ -106,11 +113,11 @@ public ProductInfo getProductDetailForCustomer(String productId) { */ public List getProductsForAdmin(boolean includeDeleted) { List products = productService.findAllForAdmin(includeDeleted); + List productIds = products.stream().map(ProductModel::getProductId).toList(); + Map stockMap = stockService.findAllByProductIds(productIds).stream() + .collect(Collectors.toMap(ProductStockModel::getProductId, Function.identity())); return products.stream() - .map(product -> { - ProductStockModel stock = stockService.findByProductId(product.getProductId()); - return ProductInfo.from(product, stock); - }) + .map(product -> ProductInfo.from(product, stockMap.get(product.getProductId()))) .toList(); } @@ -122,6 +129,7 @@ public List getProductsForAdmin(boolean includeDeleted) { * @param command 상품 생성 커맨드 * @return 생성된 상품 정보 */ + @CacheEvict(cacheNames = "productList", allEntries = true) @Transactional public ProductInfo createProduct(ProductCreateCommand command) { brandService.findById(command.brandId()); @@ -139,6 +147,7 @@ public ProductInfo createProduct(ProductCreateCommand command) { * @param command 상품 수정 커맨드 * @return 수정된 상품 정보 */ + @CacheEvict(cacheNames = "productList", allEntries = true) @Transactional public ProductInfo updateProduct(ProductUpdateCommand command) { ProductModel product = productService.updateProduct( @@ -149,48 +158,28 @@ public ProductInfo updateProduct(ProductUpdateCommand command) { } /** - * 좋아요 수 기준 내림차순 정렬 + 수동 페이징. like count가 별도 테이블이므로 DB-level 정렬 불가. - */ - private PageResponse getProductsSortedByLikes(String keyword, String brandId, - int page, int size) { - List allProducts = productService.findAllForCustomer(keyword, brandId); - List enriched = enrichProducts(allProducts); - - List sorted = enriched.stream() - .sorted(Comparator.comparingLong(ProductInfo::getLikeCount).reversed()) - .toList(); - - int totalElements = sorted.size(); - int totalPages = (totalElements + size - 1) / size; - int fromIndex = Math.min(page * size, totalElements); - int toIndex = Math.min(fromIndex + size, totalElements); - List pageContent = sorted.subList(fromIndex, toIndex); - - return new PageResponse<>(pageContent, page, size, totalElements, totalPages); - } - - /** - * 상품 목록에 재고, 브랜드명, 좋아요 수를 배치 조회하여 결합한다 (N+1 방지). + * 상품 목록에 재고, 브랜드명을 배치 조회하여 결합한다 (N+1 방지). + * likeCount는 ProductModel에서 직접 읽는다 (비정규화). */ private List enrichProducts(List products) { if (products.isEmpty()) { return List.of(); } - List productIds = products.stream().map(ProductModel::getProductId).toList(); - List brandIds = products.stream().map(ProductModel::getBrandId).distinct().toList(); + List productIds = products.stream().map(ProductModel::getProductId).toList(); + List brandIds = products.stream().map(ProductModel::getBrandId).distinct().toList(); - Map brandMap = brandService.findAllByIds(brandIds).stream() + Map stockMap = stockService.findAllByProductIds(productIds).stream() + .collect(Collectors.toMap(ProductStockModel::getProductId, Function.identity())); + Map brandMap = brandService.findAllByIds(brandIds).stream() .collect(Collectors.toMap(BrandModel::getBrandId, Function.identity())); - Map likeCountMap = likeService.countByProductIds(productIds); return products.stream() .map(product -> { - ProductStockModel stock = stockService.findByProductId(product.getProductId()); + ProductStockModel stock = stockMap.get(product.getProductId()); BrandModel brand = brandMap.get(product.getBrandId()); String brandName = brand != null ? brand.getBrandName() : null; - long likeCount = likeCountMap.getOrDefault(product.getProductId(), 0L); - return ProductInfo.from(product, stock, brandName, likeCount); + return ProductInfo.from(product, stock, brandName, product.getLikeCount()); }) .toList(); } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java index e50766ac2..c906abf04 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java @@ -20,8 +20,8 @@ @Getter @Builder public class ProductInfo { - private final String productId; - private final String brandId; + private final Long productId; + private final Long brandId; private final String productName; private final String description; private final BigDecimal price; diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductRevisionInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductRevisionInfo.java deleted file mode 100644 index 3abcb23fa..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductRevisionInfo.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.loopers.application.product; - -import com.loopers.domain.product.ProductRevisionModel; -import com.loopers.support.enums.ProductRevisionAction; -import lombok.Builder; -import lombok.Getter; - -import java.time.LocalDateTime; - -/** - * 상품 변경 이력 정보 DTO. - *

- * 도메인 모델({@link ProductRevisionModel})을 직접 노출하지 않고 - * interfaces 계층에 전달하기 위한 응답 객체이다. - *

- */ -@Getter -@Builder -public class ProductRevisionInfo { - private final String productId; - private final Long revisionSeq; - private final ProductRevisionAction action; - private final String changedBy; - private final String changeReason; - private final String beforeSnapshot; - private final String afterSnapshot; - private final LocalDateTime createdAt; - - /** - * ProductRevisionModel을 ProductRevisionInfo DTO로 변환한다. - * - * @param model 상품 변경 이력 엔티티 - * @return 변경 이력 정보 DTO - */ - public static ProductRevisionInfo from(ProductRevisionModel model) { - return ProductRevisionInfo.builder() - .productId(model.getProductId()) - .revisionSeq(model.getRevisionSeq()) - .action(model.getAction()) - .changedBy(model.getChangedBy()) - .changeReason(model.getChangeReason()) - .beforeSnapshot(model.getBeforeSnapshot()) - .afterSnapshot(model.getAfterSnapshot()) - .createdAt(model.getCreatedAt()) - .build(); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductUpdateCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductUpdateCommand.java index 6b94398b1..ab90a13be 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductUpdateCommand.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductUpdateCommand.java @@ -14,7 +14,7 @@ * @param description 변경할 상품 설명 * @param imageUrl 변경할 이미지 URL */ -public record ProductUpdateCommand(String productId, String productName, +public record ProductUpdateCommand(Long productId, String productName, BigDecimal price, String description, String imageUrl) { } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/stats/StatsAppService.java b/apps/commerce-api/src/main/java/com/loopers/application/stats/StatsAppService.java deleted file mode 100644 index 4d0bdc982..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/stats/StatsAppService.java +++ /dev/null @@ -1,49 +0,0 @@ -package com.loopers.application.stats; - -import com.loopers.domain.stats.StatsService; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.time.LocalDate; -import java.util.List; - -/** - * 운영 통계 Application Service. - * 도메인 서비스를 호출하고 StatsProjection → StatsInfo 변환을 수행한다. - */ -@Service -@RequiredArgsConstructor -@Transactional(readOnly = true) -public class StatsAppService { - - private final StatsService statsService; - - public StatsInfo.Overview getOverview(LocalDate startAt, LocalDate endAt) { - return StatsInfo.Overview.from(statsService.getOverview(startAt, endAt)); - } - - public List getDailyOrderStats(LocalDate startAt, LocalDate endAt) { - return statsService.getDailyOrderStats(startAt, endAt).stream() - .map(StatsInfo.DailyOrderStat::from) - .toList(); - } - - public List getTopLikedProducts(int limit) { - return statsService.getTopLikedProducts(limit).stream() - .map(StatsInfo.ProductStat::from) - .toList(); - } - - public List getTopOrderedProducts(int limit) { - return statsService.getTopOrderedProducts(limit).stream() - .map(StatsInfo.ProductStat::from) - .toList(); - } - - public List getLowStockProducts(int threshold) { - return statsService.getLowStockProducts(threshold).stream() - .map(StatsInfo.LowStockProduct::from) - .toList(); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/stats/StatsInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/stats/StatsInfo.java deleted file mode 100644 index 955835776..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/stats/StatsInfo.java +++ /dev/null @@ -1,104 +0,0 @@ -package com.loopers.application.stats; - -import com.loopers.domain.stats.StatsProjection; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; - -import java.math.BigDecimal; -import java.time.LocalDate; - -/** - * 운영 통계 정보 DTO 모음. - * 주문 현황 개요, 일별 주문 통계, 인기 상품, 저재고 상품 정보를 포함한다. - */ -public class StatsInfo { - - /** - * 주문 현황 개요 DTO. - * 결제 대기, 취소, 만료 건수를 포함한다. - */ - @Getter - @Builder - @AllArgsConstructor - public static class Overview { - private final long pendingCount; - private final long cancelledCount; - private final long expiredCount; - - public static Overview from(StatsProjection.Overview projection) { - return Overview.builder() - .pendingCount(projection.getPendingCount()) - .cancelledCount(projection.getCancelledCount()) - .expiredCount(projection.getExpiredCount()) - .build(); - } - } - - /** - * 일별 주문 통계 DTO. - * 특정 날짜의 주문 건수와 총 주문 금액을 포함한다. - */ - @Getter - @Builder - @AllArgsConstructor - public static class DailyOrderStat { - private final LocalDate date; - private final long orderCount; - private final BigDecimal totalAmount; - - public static DailyOrderStat from(StatsProjection.DailyOrderStat projection) { - return DailyOrderStat.builder() - .date(projection.getDate()) - .orderCount(projection.getOrderCount()) - .totalAmount(projection.getTotalAmount()) - .build(); - } - } - - /** - * 상품 통계 DTO. - * 인기 좋아요 상품, 인기 주문 상품 등 상품별 집계 결과를 표현한다. - */ - @Getter - @Builder - @AllArgsConstructor - public static class ProductStat { - private final String productId; - private final String productName; - private final long count; - - public static ProductStat from(StatsProjection.ProductStat projection) { - return ProductStat.builder() - .productId(projection.getProductId()) - .productName(projection.getProductName()) - .count(projection.getCount()) - .build(); - } - } - - /** - * 저재고 상품 DTO. - * 가용 재고가 임계값 이하인 상품의 재고 현황을 포함한다. - */ - @Getter - @Builder - @AllArgsConstructor - public static class LowStockProduct { - private final String productId; - private final String productName; - private final int onHand; - private final int reserved; - private final int availableQty; - - public static LowStockProduct from(StatsProjection.LowStockProduct projection) { - return LowStockProduct.builder() - .productId(projection.getProductId()) - .productName(projection.getProductName()) - .onHand(projection.getOnHand()) - .reserved(projection.getReserved()) - .availableQty(projection.getAvailableQty()) - .build(); - } - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserAppService.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserAppService.java deleted file mode 100644 index 96444aa99..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/user/UserAppService.java +++ /dev/null @@ -1,55 +0,0 @@ -package com.loopers.application.user; - -import com.loopers.domain.user.UserRegisterCommand; -import com.loopers.domain.user.UserService; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -/** - * 사용자 도메인 Application Service. - * 도메인 서비스를 호출하고 Model → Info 변환을 담당한다. - */ -@Service -@RequiredArgsConstructor -@Transactional(readOnly = true) -public class UserAppService { - - private final UserService userService; - - /** - * 회원가입을 수행한다. - * - * @param command 회원가입 커맨드 - * @return 생성된 사용자 정보 - */ - @Transactional - public UserInfo register(UserRegisterCommand command) { - return UserInfo.from(userService.register(command)); - } - - /** - * 인증 후 본인 정보를 조회하여 UserInfo로 반환한다. - * - * @param loginId 로그인 ID - * @param loginPw 비밀번호 - * @return 사용자 정보 DTO (마스킹된 이름 포함) - */ - public UserInfo getMyInfo(String loginId, String loginPw) { - return UserInfo.from(userService.authenticate(loginId, loginPw)); - } - - /** - * 인증 헤더로 인증한 뒤 비밀번호를 변경한다. - * - * @param loginId 인증 헤더의 로그인 ID - * @param loginPw 인증 헤더의 비밀번호 - * @param currentPw 현재 비밀번호 (body) - * @param newPw 새 비밀번호 (body) - */ - @Transactional - public void changePassword(String loginId, String loginPw, - String currentPw, String newPw) { - userService.authenticateAndChangePassword(loginId, loginPw, currentPw, newPw); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java deleted file mode 100644 index 7b8c49a3a..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.loopers.application.user; - -import com.loopers.domain.user.UserModel; -import lombok.Builder; -import lombok.Getter; - -/** - * 사용자 정보 DTO. - * 도메인 모델({@link UserModel})을 직접 노출하지 않고 인터페이스 레이어에 전달하기 위한 응답 객체이다. - * 비밀번호를 제외하고 마스킹된 이름을 포함한다. - */ -@Getter -@Builder -public class UserInfo { - private final String userId; - private final String loginId; - private final String maskedName; - private final String birthday; - private final String email; - private final String address; - - /** - * UserModel을 UserInfo DTO로 변환한다. password를 제외하고 maskedName을 포함한다. - * - * @param user 변환할 사용자 엔티티 - * @return 사용자 정보 DTO - */ - public static UserInfo from(UserModel user) { - return UserInfo.builder() - .userId(user.getUserId()) - .loginId(user.getLoginId()) - .maskedName(user.getMaskedName()) - .birthday(user.getBirthday()) - .email(user.getEmail()) - .address(user.getAddress()) - .build(); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/batch/OrderExpiryScheduler.java b/apps/commerce-api/src/main/java/com/loopers/batch/OrderExpiryScheduler.java index 2e36ad88c..f4303b59e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/batch/OrderExpiryScheduler.java +++ b/apps/commerce-api/src/main/java/com/loopers/batch/OrderExpiryScheduler.java @@ -28,13 +28,13 @@ public class OrderExpiryScheduler { */ @Scheduled(fixedDelay = 60000) public void expireOrders() { - List expiredOrderIds = orderService.findExpiredPendingOrderIds(); + List expiredOrderIds = orderService.findExpiredPendingOrderIds(); if (expiredOrderIds.isEmpty()) { return; } log.info("만료 대상 주문 {}건 처리 시작", expiredOrderIds.size()); int successCount = 0; - for (String orderId : expiredOrderIds) { + for (Long orderId : expiredOrderIds) { try { orderFacade.expireOrder(orderId); successCount++; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandModel.java index f6fe7cd95..39ce24388 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandModel.java @@ -8,12 +8,13 @@ import jakarta.persistence.Entity; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.Table; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; -import org.hibernate.annotations.UuidGenerator; /** * 브랜드 JPA 엔티티. @@ -27,9 +28,9 @@ public class BrandModel extends BaseStringIdEntity { @Id - @UuidGenerator - @Column(name = "brand_id", length = 36) - private String brandId; + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "brand_id") + private Long brandId; @Column(name = "brand_name", nullable = false) private String brandName; 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 index 76d279438..bc3b11ba2 100644 --- 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 @@ -26,7 +26,7 @@ public interface BrandRepository { * @param brandId 브랜드 ID * @return 브랜드 (Optional) */ - Optional findById(String brandId); + Optional findById(Long brandId); /** * 전체 브랜드 목록을 조회한다 (관리자용, 삭제 포함). @@ -58,5 +58,5 @@ public interface BrandRepository { * @param brandIds 브랜드 ID 목록 * @return 해당 브랜드 목록 */ - List findAllByIds(Collection brandIds); + List findAllByIds(Collection brandIds); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java index 10781f353..0370df605 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java @@ -1,9 +1,12 @@ package com.loopers.domain.brand; +import com.loopers.domain.product.ProductService; import com.loopers.support.enums.DisplayStatus; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -20,6 +23,7 @@ public class BrandService { private final BrandRepository brandRepository; + private final ProductService productService; /** * 새 브랜드를 등록한다. @@ -57,11 +61,12 @@ public List findAllForAdmin() { * @param brandIds 브랜드 ID 목록 * @return 브랜드 엔티티 목록 */ - public List findAllByIds(Collection brandIds) { + public List findAllByIds(Collection brandIds) { return brandRepository.findAllByIds(brandIds); } - public BrandModel findById(String brandId) { + @Cacheable(cacheNames = "brandDetail", key = "#brandId") + public BrandModel findById(Long brandId) { return brandRepository.findById(brandId) .orElseThrow(() -> new CoreException(ErrorType.BRAND_NOT_FOUND)); } @@ -73,7 +78,7 @@ public BrandModel findById(String brandId) { * @return 브랜드 정보 DTO * @throws CoreException 브랜드가 존재하지 않거나 비노출 상태일 때 (BRAND_NOT_FOUND) */ - public BrandModel findVisibleById(String brandId) { + public BrandModel findVisibleById(Long brandId) { BrandModel brand = findById(brandId); if (!brand.isVisibleForCustomer()) { throw new CoreException(ErrorType.BRAND_NOT_FOUND); @@ -104,23 +109,25 @@ public List findAllVisibleBrands(String keyword) { * @return 수정된 브랜드 정보 DTO * @throws CoreException 브랜드가 존재하지 않을 때 (BRAND_NOT_FOUND) */ + @CacheEvict(cacheNames = "brandDetail", key = "#brandId") @Transactional - public BrandModel updateBrand(String brandId, String brandName, String description, String address) { + public BrandModel updateBrand(Long brandId, String brandName, String description, String address) { BrandModel brand = findById(brandId); brand.updateInfo(brandName, description, address); return brand; } /** - * 브랜드를 소프트 삭제한다. + * 브랜드를 소프트 삭제한다. 소속 상품도 연쇄 소프트 삭제된다. * * @param brandId 삭제할 브랜드 ID * @throws CoreException 브랜드가 존재하지 않을 때 (BRAND_NOT_FOUND) - * @수정요망 : 브랜드 삭제시 브랜드의 상품들도 소프트 딜리트 */ + @CacheEvict(cacheNames = "brandDetail", key = "#brandId") @Transactional - public void deleteBrand(String brandId) { + public void deleteBrand(Long brandId) { BrandModel brand = findById(brandId); brand.softDelete(); + productService.softDeleteByBrandId(brandId); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartItemId.java b/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartItemId.java index 94860f810..5850db315 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartItemId.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartItemId.java @@ -20,6 +20,6 @@ @AllArgsConstructor @EqualsAndHashCode public class CartItemId implements Serializable { - private String userId; - private String productId; + private Long userId; + private Long productId; } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartItemModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartItemModel.java index 7e545c338..b2c511330 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartItemModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartItemModel.java @@ -30,12 +30,12 @@ public class CartItemModel { @Id - @Column(name = "user_id", length = 36) - private String userId; + @Column(name = "user_id") + private Long userId; @Id - @Column(name = "product_id", length = 36) - private String productId; + @Column(name = "product_id") + private Long productId; @Column(nullable = false) private int quantity; @@ -46,7 +46,7 @@ public class CartItemModel { @Column(name = "updated_at", nullable = false) private LocalDateTime updatedAt; - private CartItemModel(String userId, String productId, int quantity) { + private CartItemModel(Long userId, Long productId, int quantity) { validateQuantity(quantity); this.userId = userId; this.productId = productId; @@ -63,7 +63,7 @@ private CartItemModel(String userId, String productId, int quantity) { * @return 생성된 CartItemModel 인스턴스 * @throws CoreException quantity <= 0인 경우 (BAD_REQUEST) */ - public static CartItemModel create(String userId, String productId, int quantity) { + public static CartItemModel create(Long userId, Long productId, int quantity) { return new CartItemModel(userId, productId, quantity); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartItemRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartItemRepository.java index a7f10739f..85f19b1de 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartItemRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartItemRepository.java @@ -41,5 +41,5 @@ public interface CartItemRepository { * @param userId 사용자 ID * @return 해당 사용자의 장바구니 항목 목록 */ - List findAllByUserId(String userId); + List findAllByUserId(Long userId); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartService.java b/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartService.java index e7cf9f76b..6e7a787a0 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartService.java @@ -28,7 +28,7 @@ public class CartService { * @param productId 상품 ID * @param quantity 복원할 수량 */ - public record RestoreItem(String productId, int quantity) { + public record RestoreItem(Long productId, int quantity) { } private final CartItemRepository cartItemRepository; @@ -45,7 +45,7 @@ public record RestoreItem(String productId, int quantity) { * @param qty 추가할 수량 */ @Transactional - public void addItem(String userId, String productId, int qty) { + public void addItem(Long userId, Long productId, int qty) { CartItemId cartItemId = new CartItemId(userId, productId); cartItemRepository.findById(cartItemId).ifPresentOrElse( existingItem -> existingItem.mergeQuantity(qty), @@ -62,7 +62,7 @@ public void addItem(String userId, String productId, int qty) { * @throws CoreException 장바구니 항목이 존재하지 않을 때 (CART_ITEM_NOT_FOUND) */ @Transactional - public void changeQuantity(String userId, String productId, int newQty) { + public void changeQuantity(Long userId, Long productId, int newQty) { CartItemId cartItemId = new CartItemId(userId, productId); CartItemModel item = cartItemRepository.findById(cartItemId) .orElseThrow(() -> new CoreException(ErrorType.CART_ITEM_NOT_FOUND)); @@ -76,7 +76,7 @@ public void changeQuantity(String userId, String productId, int newQty) { * @param productId 상품 ID */ @Transactional - public void removeItem(String userId, String productId) { + public void removeItem(Long userId, Long productId) { CartItemId cartItemId = new CartItemId(userId, productId); cartItemRepository.findById(cartItemId).ifPresent(cartItemRepository::delete); } @@ -90,7 +90,7 @@ public void removeItem(String userId, String productId) { * @param userId 사용자 ID * @return 장바구니 항목 엔티티 목록 */ - public List getCartItems(String userId) { + public List getCartItems(Long userId) { return cartItemRepository.findAllByUserId(userId); } @@ -104,7 +104,7 @@ public List getCartItems(String userId) { * @param items 복원할 항목 목록 (상품 ID + 수량) */ @Transactional - public void restoreFromOrder(String userId, List items) { + public void restoreFromOrder(Long userId, List items) { for (RestoreItem item : items) { CartItemId cartItemId = new CartItemId(userId, item.productId()); cartItemRepository.findById(cartItemId).ifPresentOrElse( diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponModel.java new file mode 100644 index 000000000..3230f4b91 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponModel.java @@ -0,0 +1,130 @@ +package com.loopers.domain.coupon; + +import com.loopers.domain.BaseStringIdEntity; +import com.loopers.support.enums.DiscountType; +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.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDateTime; +import java.time.ZonedDateTime; + +/** + * 쿠폰 템플릿 JPA 엔티티. + *

+ * 고정 금액(FIXED) 또는 정률(RATE) 할인을 제공하는 쿠폰 템플릿이다. + * {@link BaseStringIdEntity}를 상속하여 소프트 삭제를 지원한다. + *

+ */ +@Entity +@Table(name = "coupons") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class CouponModel extends BaseStringIdEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "coupon_id") + private Long couponId; + + @Column(nullable = false) + private String name; + + @Enumerated(EnumType.STRING) + @Column(name = "type", nullable = false, length = 10) + private DiscountType discountType; + + @Column(name = "value", nullable = false, precision = 10, scale = 2) + private BigDecimal discountValue; + + @Column(name = "min_order_amount", precision = 12, scale = 2) + private BigDecimal minOrderAmount; + + @Column(name = "expired_at", nullable = false) + private LocalDateTime expiredAt; + + private CouponModel(String name, DiscountType discountType, BigDecimal discountValue, + BigDecimal minOrderAmount, LocalDateTime expiredAt) { + this.name = name; + this.discountType = discountType; + this.discountValue = discountValue; + this.minOrderAmount = minOrderAmount; + this.expiredAt = expiredAt; + } + + /** + * 쿠폰 템플릿을 생성한다. + */ + public static CouponModel create(String name, DiscountType discountType, BigDecimal discountValue, + BigDecimal minOrderAmount, LocalDateTime expiredAt) { + return new CouponModel(name, discountType, discountValue, minOrderAmount, expiredAt); + } + + /** + * 할인 금액을 계산한다. + *
    + *
  • FIXED: discountValue (주문 금액 초과 불가)
  • + *
  • RATE: totalAmount * discountValue / 100, FLOOR 처리
  • + *
+ */ + public BigDecimal calculateDiscount(BigDecimal totalAmount) { + if (discountType == DiscountType.FIXED) { + return discountValue.compareTo(totalAmount) > 0 ? totalAmount : discountValue; + } + // RATE: 내림(FLOOR) 처리 + return totalAmount.multiply(discountValue) + .divide(BigDecimal.valueOf(100), 0, RoundingMode.FLOOR); + } + + /** + * 쿠폰 적용 가능 여부를 검증한다. + * + * @param totalAmount 주문 총액 (null이면 minOrderAmount 검증 생략) + * @throws CoreException 삭제/만료 또는 최소 주문 금액 미충족 시 COUPON_NOT_APPLICABLE + */ + public void validateApplicable(BigDecimal totalAmount) { + if (isDeleted() || LocalDateTime.now().isAfter(expiredAt)) { + throw new CoreException(ErrorType.COUPON_NOT_APPLICABLE); + } + if (totalAmount != null && minOrderAmount != null && totalAmount.compareTo(minOrderAmount) < 0) { + throw new CoreException(ErrorType.COUPON_NOT_APPLICABLE); + } + } + + /** + * 쿠폰 정보를 수정한다. + */ + public void updateInfo(String name, DiscountType discountType, BigDecimal discountValue, + BigDecimal minOrderAmount, LocalDateTime expiredAt) { + this.name = name; + this.discountType = discountType; + this.discountValue = discountValue; + this.minOrderAmount = minOrderAmount; + this.expiredAt = expiredAt; + } + + @Override + protected void guard() { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "쿠폰명은 필수입니다."); + } + if (discountValue == null || discountValue.compareTo(BigDecimal.ZERO) <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "할인값은 0보다 커야 합니다."); + } + if (discountType == DiscountType.RATE && discountValue.compareTo(BigDecimal.valueOf(100)) > 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "정률 할인값은 100 이하여야 합니다."); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponRepository.java new file mode 100644 index 000000000..578e6bf66 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponRepository.java @@ -0,0 +1,26 @@ +package com.loopers.domain.coupon; + +import com.loopers.support.page.PageQuery; +import com.loopers.support.page.PagedResult; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +/** + * 쿠폰 템플릿 레포지토리 인터페이스 (도메인 레이어). + */ +public interface CouponRepository { + + Optional findById(Long couponId); + + Optional findByIdWithLock(Long couponId); + + CouponModel save(CouponModel coupon); + + List findAll(); + + List findAllByIdIn(Collection ids); + + PagedResult findAllPaged(PageQuery query); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponService.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponService.java new file mode 100644 index 000000000..55e5d84a6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponService.java @@ -0,0 +1,209 @@ +package com.loopers.domain.coupon; + +import com.loopers.support.enums.DiscountType; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import com.loopers.support.page.PageQuery; +import com.loopers.support.page.PagedResult; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; + +/** + * 쿠폰 도메인 서비스. + *

+ * 쿠폰 템플릿 CRUD, 사용자 쿠폰 발급/사용 비즈니스 로직을 담당한다. + * 쿠폰 발급 시 비관적 락(SELECT FOR UPDATE)을 사용하여 중복 발급을 방지한다. + *

+ */ +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CouponService { + + private final CouponRepository couponRepository; + private final UserCouponRepository userCouponRepository; + + // ============================ + // 관리자용 메서드 + // ============================ + + /** + * 쿠폰 템플릿을 생성한다. + */ + @Transactional + public CouponModel createCoupon(String name, DiscountType discountType, BigDecimal discountValue, + BigDecimal minOrderAmount, LocalDateTime expiredAt) { + CouponModel coupon = CouponModel.create(name, discountType, discountValue, minOrderAmount, expiredAt); + return couponRepository.save(coupon); + } + + /** + * 쿠폰 목록을 조회한다 (관리자 — 삭제된 쿠폰 포함). + */ + public List findAllForAdmin() { + return couponRepository.findAll(); + } + + /** + * 쿠폰 상세를 조회한다 (관리자 — 삭제된 쿠폰 포함). + * + * @throws CoreException 쿠폰이 없는 경우 COUPON_NOT_FOUND + */ + public CouponModel findByIdForAdmin(Long couponId) { + return couponRepository.findById(couponId) + .orElseThrow(() -> new CoreException(ErrorType.COUPON_NOT_FOUND)); + } + + /** + * 쿠폰 정보를 수정한다. + * + * @throws CoreException 쿠폰이 없는 경우 COUPON_NOT_FOUND + */ + @Transactional + public CouponModel updateCoupon(Long couponId, String name, DiscountType discountType, + BigDecimal discountValue, BigDecimal minOrderAmount, + LocalDateTime expiredAt) { + CouponModel coupon = couponRepository.findById(couponId) + .orElseThrow(() -> new CoreException(ErrorType.COUPON_NOT_FOUND)); + coupon.updateInfo(name, discountType, discountValue, minOrderAmount, expiredAt); + return couponRepository.save(coupon); + } + + /** + * 쿠폰을 소프트 삭제한다. + * + * @throws CoreException 쿠폰이 없는 경우 COUPON_NOT_FOUND + */ + @Transactional + public void deleteCoupon(Long couponId) { + CouponModel coupon = couponRepository.findById(couponId) + .orElseThrow(() -> new CoreException(ErrorType.COUPON_NOT_FOUND)); + coupon.softDelete(); + couponRepository.save(coupon); + } + + /** + * 특정 쿠폰의 발급 내역을 조회한다 (관리자). + */ + public List findIssueHistory(Long couponId) { + return userCouponRepository.findAllByCouponId(couponId); + } + + /** + * 관리자용 쿠폰 목록을 페이징 조회한다. + */ + public PagedResult findAllForAdminPaged(PageQuery query) { + return couponRepository.findAllPaged(query); + } + + /** + * 특정 쿠폰의 발급 내역을 페이징 조회한다 (관리자). + */ + public PagedResult findIssueHistoryPaged(Long couponId, PageQuery query) { + return userCouponRepository.findAllByCouponIdPaged(couponId, query); + } + + // ============================ + // 대고객 메서드 + // ============================ + + /** + * 쿠폰을 발급한다. + *

+ * 비관적 락을 사용하여 동시 발급 요청 시 중복 발급을 방지한다. + * DB UNIQUE KEY(user_id, coupon_id)가 최후 안전망으로 작동한다. + *

+ * + * @throws CoreException 쿠폰 없음(COUPON_NOT_FOUND), 만료/삭제(COUPON_NOT_APPLICABLE), + * 중복 발급(COUPON_ALREADY_ISSUED) + */ + @Transactional + public UserCouponModel issueCoupon(Long userId, Long couponId) { + CouponModel coupon = couponRepository.findByIdWithLock(couponId) + .orElseThrow(() -> new CoreException(ErrorType.COUPON_NOT_FOUND)); + + coupon.validateApplicable(null); + + if (userCouponRepository.existsByUserIdAndCouponId(userId, couponId)) { + throw new CoreException(ErrorType.COUPON_ALREADY_ISSUED); + } + + UserCouponModel userCoupon = UserCouponModel.create(userId, couponId); + return userCouponRepository.save(userCoupon); + } + + /** + * 내 쿠폰 목록을 조회한다 (AVAILABLE/USED/EXPIRED 모두 포함). + */ + public List findMyCoupons(Long userId) { + return userCouponRepository.findAllByUserId(userId); + } + + // ============================ + // OrderFacade 내부용 메서드 + // ============================ + + /** + * 유효한 발급 쿠폰을 조회한다. + *

+ * 비관적 락으로 조회하여 동시 주문 시 쿠폰 중복 사용을 방지한다. + *

+ * + * @param userId 쿠폰 소유자 ID + * @param userCouponId 발급 쿠폰 ID + * @return 조회된 UserCouponModel (AVAILABLE 상태) + * @throws CoreException 쿠폰 없음/타 유저 소유(USER_COUPON_NOT_FOUND), + * 사용 불가(COUPON_NOT_AVAILABLE) + */ + @Transactional + public UserCouponModel validateAndGetUserCoupon(Long userId, Long userCouponId) { + UserCouponModel userCoupon = userCouponRepository.findByIdWithLock(userCouponId) + .filter(uc -> uc.getUserId().equals(userId)) + .orElseThrow(() -> new CoreException(ErrorType.USER_COUPON_NOT_FOUND)); + + if (!userCoupon.isAvailable()) { + throw new CoreException(ErrorType.COUPON_NOT_AVAILABLE); + } + return userCoupon; + } + + /** + * 쿠폰을 사용 완료 처리한다. 반드시 상위 트랜잭션 내에서 호출해야 한다. + * + * @param userCouponId 발급 쿠폰 ID + * @param orderId 사용된 주문 ID + */ + @Transactional(propagation = Propagation.MANDATORY) + public void markCouponAsUsed(Long userCouponId, Long orderId) { + UserCouponModel userCoupon = userCouponRepository.findByIdWithLock(userCouponId) + .orElseThrow(() -> new CoreException(ErrorType.USER_COUPON_NOT_FOUND)); + userCoupon.markAsUsed(orderId); + userCouponRepository.save(userCoupon); + } + + /** + * ID 목록으로 쿠폰 템플릿을 일괄 조회한다 (N+1 방지). + */ + public List findAllByIds(Collection couponIds) { + return couponRepository.findAllByIdIn(couponIds); + } + + /** + * 주문 취소/만료 시 사용된 쿠폰을 복원한다 (멱등). + * 반드시 상위 트랜잭션 내에서 호출해야 한다. + * + * @param orderId 주문 ID + */ + @Transactional(propagation = Propagation.MANDATORY) + public void restoreCoupon(Long orderId) { + userCouponRepository.findByOrderId(orderId) + .ifPresent(UserCouponModel::restoreToAvailable); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/UserCouponModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/UserCouponModel.java new file mode 100644 index 000000000..bef9b50bb --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/UserCouponModel.java @@ -0,0 +1,118 @@ +package com.loopers.domain.coupon; + +import com.loopers.support.enums.UserCouponStatus; +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.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 사용자 발급 쿠폰 JPA 엔티티. + *

+ * 발급 이력을 관리하며, 상태(AVAILABLE/USED/EXPIRED)로 생명주기를 관리한다. + * 소프트 삭제 대신 상태 관리를 사용하므로 {@code BaseStringIdEntity}를 상속하지 않는다. + *

+ */ +@Entity +@Table(name = "user_coupons", + uniqueConstraints = @UniqueConstraint( + name = "uq_user_coupons", + columnNames = {"user_id", "coupon_id"} + )) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class UserCouponModel { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "user_coupon_id") + private Long userCouponId; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "coupon_id", nullable = false) + private Long couponId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private UserCouponStatus status; + + @Column(name = "issued_at", nullable = false, updatable = false) + private LocalDateTime issuedAt; + + @Column(name = "used_at") + private LocalDateTime usedAt; + + @Column(name = "order_id") + private Long orderId; + + private UserCouponModel(Long userId, Long couponId) { + this.userId = userId; + this.couponId = couponId; + this.status = UserCouponStatus.AVAILABLE; + this.issuedAt = LocalDateTime.now(); + } + + /** + * 사용자 발급 쿠폰을 생성한다. 상태는 AVAILABLE, 발급 일시는 현재 시각으로 초기화된다. + */ + public static UserCouponModel create(Long userId, Long couponId) { + return new UserCouponModel(userId, couponId); + } + + /** + * 쿠폰을 사용 완료 처리한다. + * + * @param orderId 사용된 주문 ID + * @throws CoreException 이미 사용된 쿠폰인 경우 COUPON_NOT_AVAILABLE + */ + public void markAsUsed(Long orderId) { + if (this.status != UserCouponStatus.AVAILABLE) { + throw new CoreException(ErrorType.COUPON_NOT_AVAILABLE); + } + this.status = UserCouponStatus.USED; + this.usedAt = LocalDateTime.now(); + this.orderId = orderId; + } + + /** + * 쿠폰 사용 가능 여부를 반환한다. + */ + public boolean isAvailable() { + return this.status == UserCouponStatus.AVAILABLE; + } + + /** + * 사용된 쿠폰을 사용 가능 상태로 복원한다 (멱등). + * 이미 AVAILABLE이면 무시한다. + */ + public void restoreToAvailable() { + if (this.status == UserCouponStatus.AVAILABLE) { + return; + } + this.status = UserCouponStatus.AVAILABLE; + this.usedAt = null; + this.orderId = null; + } + + @PrePersist + private void prePersist() { + if (this.issuedAt == null) { + this.issuedAt = LocalDateTime.now(); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/UserCouponRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/UserCouponRepository.java new file mode 100644 index 000000000..61d2a6d35 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/UserCouponRepository.java @@ -0,0 +1,31 @@ +package com.loopers.domain.coupon; + +import com.loopers.support.page.PageQuery; +import com.loopers.support.page.PagedResult; + +import java.util.List; +import java.util.Optional; + +/** + * 사용자 발급 쿠폰 레포지토리 인터페이스 (도메인 레이어). + */ +public interface UserCouponRepository { + + UserCouponModel save(UserCouponModel userCoupon); + + Optional findById(Long userCouponId); + + Optional findByIdWithLock(Long userCouponId); + + Optional findByUserIdAndCouponId(Long userId, Long couponId); + + List findAllByUserId(Long userId); + + List findAllByCouponId(Long couponId); + + boolean existsByUserIdAndCouponId(Long userId, Long couponId); + + PagedResult findAllByCouponIdPaged(Long couponId, PageQuery query); + + Optional findByOrderId(Long orderId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeId.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeId.java index e3f26a7e8..3a74fff5b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeId.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeId.java @@ -17,6 +17,6 @@ @AllArgsConstructor @EqualsAndHashCode public class LikeId implements Serializable { - private String userId; - private String productId; + private Long userId; + private Long productId; } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeModel.java index 75c381030..2b09655a7 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeModel.java @@ -27,17 +27,17 @@ public class LikeModel { @Id - @Column(name = "user_id", length = 36) - private String userId; + @Column(name = "user_id") + private Long userId; @Id - @Column(name = "product_id", length = 36) - private String productId; + @Column(name = "product_id") + private Long productId; @Column(name = "created_at", nullable = false, updatable = false) private LocalDateTime createdAt; - private LikeModel(String userId, String productId) { + private LikeModel(Long userId, Long productId) { validateUserId(userId); validateProductId(productId); this.userId = userId; @@ -50,9 +50,9 @@ private LikeModel(String userId, String productId) { * @param userId 사용자 ID (필수) * @param productId 상품 ID (필수) * @return 생성된 LikeModel 인스턴스 - * @throws CoreException userId 또는 productId가 null/blank인 경우 (BAD_REQUEST) + * @throws CoreException userId 또는 productId가 null인 경우 (BAD_REQUEST) */ - public static LikeModel create(String userId, String productId) { + public static LikeModel create(Long userId, Long productId) { return new LikeModel(userId, productId); } @@ -61,14 +61,14 @@ private void prePersist() { this.createdAt = LocalDateTime.now(); } - private static void validateUserId(String userId) { - if (userId == null || userId.isBlank()) { + private static void validateUserId(Long userId) { + if (userId == null) { throw new CoreException(ErrorType.BAD_REQUEST, "사용자 ID는 필수입니다."); } } - private static void validateProductId(String productId) { - if (productId == null || productId.isBlank()) { + private static void validateProductId(Long productId) { + if (productId == null) { throw new CoreException(ErrorType.BAD_REQUEST, "상품 ID는 필수입니다."); } } 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 index 44c094501..ad38b48d8 100644 --- 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 @@ -40,7 +40,7 @@ public interface LikeRepository { * @param userId 사용자 ID * @return 사용자의 좋아요 목록 */ - List findAllByUserId(String userId); + List findAllByUserId(Long userId); /** * 특정 상품의 좋아요 수를 조회한다. @@ -48,7 +48,7 @@ public interface LikeRepository { * @param productId 상품 ID * @return 좋아요 수 */ - long countByProductId(String productId); + long countByProductId(Long productId); /** * 여러 상품의 좋아요 수를 일괄 조회한다 (N+1 방지용 배치 쿼리). @@ -56,5 +56,5 @@ public interface LikeRepository { * @param productIds 상품 ID 목록 * @return 상품 ID → 좋아요 수 맵 */ - Map countByProductIds(Collection productIds); + Map countByProductIds(Collection productIds); } 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 index a6e0f83cb..550f969cb 100644 --- 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 @@ -14,6 +14,7 @@ /** * 좋아요 도메인 서비스. * 상품 좋아요 등록(멱등)/취소(멱등), 사용자별 좋아요 목록 조회를 담당한다. + * 상품 존재 여부 검증을 위해 {@link ProductService}를 참조한다. */ @Service @RequiredArgsConstructor @@ -31,9 +32,10 @@ public class LikeService { * @throws CoreException 상품이 존재하지 않을 때 (LIKE_PRODUCT_NOT_FOUND) */ @Transactional - public void addLike(String userId, String productId) { + public void addLike(Long userId, Long productId) { + // 비관적 락으로 상품 조회 → 동일 상품 좋아요 연산 직렬화 try { - productService.findById(productId); + productService.findByIdWithLock(productId); } catch (CoreException e) { throw new CoreException(ErrorType.LIKE_PRODUCT_NOT_FOUND); } @@ -45,6 +47,7 @@ public void addLike(String userId, String productId) { LikeModel like = LikeModel.create(userId, productId); likeRepository.save(like); + productService.incrementLikeCount(productId); } /** @@ -54,9 +57,19 @@ public void addLike(String userId, String productId) { * @param productId 상품 ID */ @Transactional - public void removeLike(String userId, String productId) { + public void removeLike(Long userId, Long productId) { + // 비관적 락으로 상품 조회 → 동일 상품 좋아요 연산 직렬화 + try { + productService.findByIdWithLock(productId); + } catch (CoreException e) { + return; // 상품이 없으면 좋아요도 없으므로 무시 + } + LikeId likeId = new LikeId(userId, productId); - likeRepository.findById(likeId).ifPresent(likeRepository::delete); + likeRepository.findById(likeId).ifPresent(like -> { + likeRepository.delete(like); + productService.decrementLikeCount(productId); + }); } /** @@ -65,7 +78,7 @@ public void removeLike(String userId, String productId) { * @param userId 사용자 ID * @return 좋아요 정보 DTO 목록 */ - public List getMyLikes(String userId) { + public List getMyLikes(Long userId) { return likeRepository.findAllByUserId(userId); } @@ -75,7 +88,7 @@ public List getMyLikes(String userId) { * @param productId 상품 ID * @return 좋아요 수 */ - public long countByProductId(String productId) { + public long countByProductId(Long productId) { return likeRepository.countByProductId(productId); } @@ -85,7 +98,7 @@ public long countByProductId(String productId) { * @param productIds 상품 ID 목록 * @return 상품 ID → 좋아요 수 맵 */ - public Map countByProductIds(Collection productIds) { + public Map countByProductIds(Collection productIds) { return likeRepository.countByProductIds(productIds); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderCartRestoreModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderCartRestoreModel.java index 0faff1af1..14e067e7f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderCartRestoreModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderCartRestoreModel.java @@ -30,11 +30,11 @@ public class OrderCartRestoreModel { @Id - @Column(name = "order_id", length = 36) - private String orderId; + @Column(name = "order_id") + private Long orderId; - @Column(name = "user_id", nullable = false, length = 36) - private String userId; + @Column(name = "user_id", nullable = false) + private Long userId; @Enumerated(EnumType.STRING) @Column(nullable = false, length = 30) @@ -47,7 +47,7 @@ public class OrderCartRestoreModel { @Column(name = "restored_at", nullable = false) private LocalDateTime restoredAt; - private OrderCartRestoreModel(String orderId, String userId, + private OrderCartRestoreModel(Long orderId, Long userId, RestoreReason reason, RestoreTriggerSource triggerSource) { this.orderId = orderId; this.userId = userId; @@ -65,7 +65,7 @@ private OrderCartRestoreModel(String orderId, String userId, * @param triggerSource 복원 트리거 출처 (CANCEL_API / EXPIRE_JOB 등) * @return 생성된 OrderCartRestoreModel 인스턴스 */ - public static OrderCartRestoreModel create(String orderId, String userId, + public static OrderCartRestoreModel create(Long orderId, Long userId, RestoreReason reason, RestoreTriggerSource triggerSource) { return new OrderCartRestoreModel(orderId, userId, reason, triggerSource); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderCartRestoreRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderCartRestoreRepository.java index 006294423..de735fe76 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderCartRestoreRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderCartRestoreRepository.java @@ -27,5 +27,5 @@ public interface OrderCartRestoreRepository { * @param orderId 주문 ID * @return 존재 여부 */ - boolean existsById(String orderId); + boolean existsById(Long orderId); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemCommand.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemCommand.java index e0391de22..dd431587b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemCommand.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemCommand.java @@ -10,5 +10,5 @@ * @param productId 상품 ID * @param quantity 주문 수량 */ -public record OrderItemCommand(String productId, int quantity) { +public record OrderItemCommand(Long productId, int quantity) { } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemId.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemId.java index 32786c6aa..8988206ef 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemId.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemId.java @@ -20,6 +20,6 @@ @AllArgsConstructor @EqualsAndHashCode public class OrderItemId implements Serializable { - private String orderId; + private Long orderId; private int orderItemSeq; } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemModel.java index a12ae996f..9c28ad28d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemModel.java @@ -32,18 +32,18 @@ public class OrderItemModel { @Id - @Column(name = "order_id", length = 36) - private String orderId; + @Column(name = "order_id") + private Long orderId; @Id @Column(name = "order_item_seq") private int orderItemSeq; - @Column(name = "user_id", nullable = false, length = 36) - private String userId; + @Column(name = "user_id", nullable = false) + private Long userId; - @Column(name = "product_id", nullable = false, length = 36) - private String productId; + @Column(name = "product_id", nullable = false) + private Long productId; @Column(nullable = false) private int quantity; @@ -63,6 +63,15 @@ public class OrderItemModel { @Column(name = "snapshot_image_url") private String snapshotImageUrl; + @Column(name = "original_amount", precision = 12, scale = 2) + private BigDecimal originalAmount; + + @Column(name = "discount_amount", precision = 12, scale = 2) + private BigDecimal discountAmount; + + @Column(name = "final_amount", precision = 12, scale = 2) + private BigDecimal finalAmount; + @Column(name = "del_yn", nullable = false, length = 1) private String delYn = "N"; @@ -75,11 +84,12 @@ public class OrderItemModel { @Column(name = "updated_at", nullable = false) private LocalDateTime updatedAt; - private OrderItemModel(String orderId, int orderItemSeq, String userId, - String productId, int quantity, + private OrderItemModel(Long orderId, int orderItemSeq, Long userId, + Long productId, int quantity, String snapshotProductName, BigDecimal snapshotUnitPrice, String snapshotBrandId, String snapshotBrandName, - String snapshotImageUrl) { + String snapshotImageUrl, + BigDecimal originalAmount, BigDecimal discountAmount, BigDecimal finalAmount) { validateQuantity(quantity); this.orderId = orderId; this.orderItemSeq = orderItemSeq; @@ -91,41 +101,50 @@ private OrderItemModel(String orderId, int orderItemSeq, String userId, this.snapshotBrandId = snapshotBrandId; this.snapshotBrandName = snapshotBrandName; this.snapshotImageUrl = snapshotImageUrl; + this.originalAmount = originalAmount; + this.discountAmount = discountAmount; + this.finalAmount = finalAmount; } /** - * 주문 항목 엔티티를 생성한다. 주문 시점의 상품 정보를 스냅샷으로 보존한다. - *

정적 팩토리 메서드 패턴을 사용하여 생성자를 대신한다.

- * - * @param orderId 주문 ID - * @param orderItemSeq 주문 내 항목 순번 - * @param userId 주문자 ID - * @param productId 상품 ID - * @param quantity 주문 수량 (1 이상) - * @param snapshotProductName 주문 시점 상품명 - * @param snapshotUnitPrice 주문 시점 단가 - * @param snapshotBrandId 주문 시점 브랜드 ID - * @param snapshotBrandName 주문 시점 브랜드명 - * @param snapshotImageUrl 주문 시점 이미지 URL - * @return 생성된 OrderItemModel 인스턴스 - * @throws CoreException quantity <= 0인 경우 (BAD_REQUEST) + * 주문 항목 엔티티를 생성한다 (하위 호환 — 할인 없음). */ - public static OrderItemModel create(String orderId, int orderItemSeq, String userId, - String productId, int quantity, + public static OrderItemModel create(Long orderId, int orderItemSeq, Long userId, + Long productId, int quantity, String snapshotProductName, BigDecimal snapshotUnitPrice, String snapshotBrandId, String snapshotBrandName, String snapshotImageUrl) { + BigDecimal lineTotal = snapshotUnitPrice != null + ? snapshotUnitPrice.multiply(BigDecimal.valueOf(quantity)) + : null; + return new OrderItemModel(orderId, orderItemSeq, userId, productId, quantity, + snapshotProductName, snapshotUnitPrice, snapshotBrandId, snapshotBrandName, + snapshotImageUrl, lineTotal, BigDecimal.ZERO, lineTotal); + } + + /** + * 주문 항목 엔티티를 생성한다 (할인 금액 포함). + */ + public static OrderItemModel create(Long orderId, int orderItemSeq, Long userId, + Long productId, int quantity, + String snapshotProductName, BigDecimal snapshotUnitPrice, + String snapshotBrandId, String snapshotBrandName, + String snapshotImageUrl, + BigDecimal originalAmount, BigDecimal discountAmount, + BigDecimal finalAmount) { return new OrderItemModel(orderId, orderItemSeq, userId, productId, quantity, snapshotProductName, snapshotUnitPrice, snapshotBrandId, snapshotBrandName, - snapshotImageUrl); + snapshotImageUrl, originalAmount, discountAmount, finalAmount); } /** - * 주문 항목 소계를 계산한다 (snapshotUnitPrice x quantity). - * - * @return 소계 금액 + * 주문 항목 소계를 계산한다. + * finalAmount가 있으면 finalAmount를, 없으면 snapshotUnitPrice x quantity를 반환한다. */ public BigDecimal getSubtotal() { + if (finalAmount != null) { + return finalAmount; + } return snapshotUnitPrice.multiply(BigDecimal.valueOf(quantity)); } 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 index 69060279d..3970d6c17 100644 --- 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 @@ -33,7 +33,7 @@ public interface OrderItemRepository { * @param orderId 주문 ID * @return 해당 주문의 주문 항목 목록 */ - List findAllByOrderId(String orderId); + List findAllByOrderId(Long orderId); /** * 여러 주문 ID에 해당하는 주문 항목을 일괄 조회한다. @@ -41,5 +41,5 @@ public interface OrderItemRepository { * @param orderIds 주문 ID 목록 * @return 해당 주문들의 주문 항목 목록 */ - List findAllByOrderIds(List orderIds); + List findAllByOrderIds(List orderIds); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemSnapshot.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemSnapshot.java index 62b72e76d..0780927bb 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemSnapshot.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemSnapshot.java @@ -12,39 +12,62 @@ * 도메인 서비스(OrderService)에 전달하여 주문 항목을 저장하는 데 사용한다. *

* - * @param productId 상품 ID - * @param quantity 주문 수량 - * @param productName 상품명 - * @param unitPrice 단가 - * @param brandId 브랜드 ID - * @param brandName 브랜드명 - * @param imageUrl 이미지 URL + * @param productId 상품 ID + * @param quantity 주문 수량 + * @param productName 상품명 + * @param unitPrice 단가 + * @param brandId 브랜드 ID + * @param brandName 브랜드명 + * @param imageUrl 이미지 URL + * @param originalAmount 할인 전 금액 (unitPrice * quantity) + * @param discountAmount 이 항목에 비례 배분된 할인 금액 + * @param finalAmount 최종 금액 (originalAmount - discountAmount) */ -public record OrderItemSnapshot(String productId, int quantity, String productName, +public record OrderItemSnapshot(Long productId, int quantity, String productName, BigDecimal unitPrice, String brandId, - String brandName, String imageUrl) { + String brandName, String imageUrl, + BigDecimal originalAmount, + BigDecimal discountAmount, + BigDecimal finalAmount) { /** - * ProductModel과 BrandModel로부터 주문 항목 스냅샷을 생성한다. - * - * @param product 상품 도메인 모델 - * @param brand 브랜드 도메인 모델 - * @param quantity 주문 수량 - * @return 주문 항목 스냅샷 + * 할인 없이 ProductModel과 BrandModel로부터 주문 항목 스냅샷을 생성한다. */ public static OrderItemSnapshot from(ProductModel product, BrandModel brand, int quantity) { + BigDecimal lineTotal = product.getPrice().multiply(BigDecimal.valueOf(quantity)); + return new OrderItemSnapshot( + product.getProductId(), quantity, + product.getProductName(), product.getPrice(), + String.valueOf(brand.getBrandId()), brand.getBrandName(), product.getImageUrl(), + lineTotal, BigDecimal.ZERO, lineTotal); + } + + /** + * 할인 금액을 적용하여 주문 항목 스냅샷을 생성한다. + * + * @param product 상품 도메인 모델 + * @param brand 브랜드 도메인 모델 + * @param quantity 주문 수량 + * @param discountAmount 이 항목에 배분된 할인 금액 + * @return 할인이 적용된 주문 항목 스냅샷 + */ + public static OrderItemSnapshot from(ProductModel product, BrandModel brand, int quantity, + BigDecimal discountAmount) { + BigDecimal originalAmount = product.getPrice().multiply(BigDecimal.valueOf(quantity)); + BigDecimal finalAmount = originalAmount.subtract(discountAmount); return new OrderItemSnapshot( product.getProductId(), quantity, product.getProductName(), product.getPrice(), - brand.getBrandId(), brand.getBrandName(), product.getImageUrl()); + String.valueOf(brand.getBrandId()), brand.getBrandName(), product.getImageUrl(), + originalAmount, discountAmount, finalAmount); } /** - * 항목별 주문 금액(단가 x 수량)을 계산한다. + * 항목별 최종 주문 금액을 반환한다. * - * @return 항목 금액 + * @return finalAmount */ public BigDecimal lineTotal() { - return unitPrice.multiply(BigDecimal.valueOf(quantity)); + return finalAmount; } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderModel.java index 3f6630ea7..f98689a22 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderModel.java @@ -9,12 +9,13 @@ import jakarta.persistence.Entity; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.Table; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; -import org.hibernate.annotations.UuidGenerator; import java.math.BigDecimal; import java.time.LocalDateTime; @@ -38,12 +39,12 @@ public class OrderModel extends BaseStringIdEntity { private static final int EXPIRES_MINUTES = 15; @Id - @UuidGenerator - @Column(name = "order_id", length = 36) - private String orderId; + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "order_id") + private Long orderId; - @Column(name = "user_id", nullable = false, length = 36) - private String userId; + @Column(name = "user_id", nullable = false) + private Long userId; @Enumerated(EnumType.STRING) @Column(name = "order_type", nullable = false, length = 20) @@ -62,7 +63,7 @@ public class OrderModel extends BaseStringIdEntity { @Column(name = "paid_at") private LocalDateTime paidAt; - private OrderModel(String userId, OrderType orderType, BigDecimal totalAmount) { + private OrderModel(Long userId, OrderType orderType, BigDecimal totalAmount) { validateUserId(userId); this.userId = userId; this.orderType = orderType; @@ -81,7 +82,7 @@ private OrderModel(String userId, OrderType orderType, BigDecimal totalAmount) { * @return 생성된 OrderModel 인스턴스 * @throws CoreException userId가 null/blank인 경우 (BAD_REQUEST) */ - public static OrderModel create(String userId, OrderType orderType, BigDecimal totalAmount) { + public static OrderModel create(Long userId, OrderType orderType, BigDecimal totalAmount) { return new OrderModel(userId, orderType, totalAmount); } @@ -143,8 +144,8 @@ protected void guard() { validateUserId(this.userId); } - private static void validateUserId(String userId) { - if (userId == null || userId.isBlank()) { + private static void validateUserId(Long userId) { + if (userId == null) { throw new CoreException(ErrorType.BAD_REQUEST, "사용자 ID는 필수입니다."); } } 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 index 651510a24..1bce6204b 100644 --- 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 @@ -30,7 +30,7 @@ public interface OrderRepository { * @param orderId 주문 ID * @return 주문 엔티티 (존재하지 않으면 빈 Optional) */ - Optional findById(String orderId); + Optional findById(Long orderId); /** * 주문 ID와 사용자 ID로 주문을 조회한다. @@ -39,7 +39,7 @@ public interface OrderRepository { * @param userId 사용자 ID * @return 주문 엔티티 (존재하지 않으면 빈 Optional) */ - Optional findByIdAndUserId(String orderId, String userId); + Optional findByIdAndUserId(Long orderId, Long userId); /** * 특정 사용자의 기간별 주문 목록을 조회한다. @@ -49,7 +49,7 @@ public interface OrderRepository { * @param end 조회 종료 일시 * @return 해당 기간의 주문 목록 */ - List findAllByUserIdAndPeriod(String userId, LocalDateTime start, LocalDateTime end); + List findAllByUserIdAndPeriod(Long userId, LocalDateTime start, LocalDateTime end); /** * 기간별 전체 주문 목록을 조회한다 (관리자용). @@ -73,7 +73,7 @@ public interface OrderRepository { * @param to 변경 후 상태 * @return 영향받은 행 수 (0이면 상태 변경 실패 -- 이미 다른 상태로 전이됨) */ - int casUpdateStatus(String orderId, OrderStatus from, OrderStatus to); + int casUpdateStatus(Long orderId, OrderStatus from, OrderStatus to); /** * 특정 사용자의 특정 상태 주문 건수를 조회한다. @@ -82,7 +82,7 @@ public interface OrderRepository { * @param status 주문 상태 * @return 해당 상태의 주문 건수 */ - long countByUserIdAndStatus(String userId, OrderStatus status); + long countByUserIdAndStatus(Long userId, OrderStatus status); /** * 만료 시간이 지난 결제 대기(PENDING_PAYMENT) 주문 목록을 조회한다. 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 index cd0709615..979fffb58 100644 --- 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 @@ -48,7 +48,7 @@ public class OrderService { * @return 병합/정렬된 주문 항목 목록 * @throws CoreException 주문 항목이 비어있거나, 결제 대기 주문 수 초과 시 */ - public List validateAndPrepare(String userId, List items) { + public List validateAndPrepare(Long userId, List items) { validateNotEmpty(items); validatePendingLimit(userId); return mergeAndSort(items); @@ -64,7 +64,7 @@ public List validateAndPrepare(String userId, List snapshots) { OrderModel order = OrderModel.create(userId, orderType, totalAmount); order = orderRepository.save(order); @@ -86,7 +86,7 @@ public OrderModel createOrder(String userId, OrderType orderType, * @throws CoreException 주문이 존재하지 않거나 취소 불가한 상태일 때 */ @Transactional - public Optional cancelOrder(String userId, String orderId) { + public Optional cancelOrder(Long userId, Long orderId) { OrderModel order = orderRepository.findByIdAndUserId(orderId, userId) .orElseThrow(() -> new CoreException(ErrorType.ORDER_NOT_FOUND)); @@ -114,7 +114,7 @@ public Optional cancelOrder(String userId, String orderId) { * @return 만료된 주문 엔티티 (이미 상태 전이된 경우 빈 Optional) */ @Transactional - public Optional expireOrder(String orderId) { + public Optional expireOrder(Long orderId) { int affected = orderRepository.casUpdateStatus(orderId, OrderStatus.PENDING_PAYMENT, OrderStatus.EXPIRED); if (affected == 0) { return Optional.empty(); @@ -131,7 +131,7 @@ public Optional expireOrder(String orderId) { * @param orderId 주문 ID * @return 존재 여부 */ - public boolean existsCartRestore(String orderId) { + public boolean existsCartRestore(Long orderId) { return orderCartRestoreRepository.existsById(orderId); } @@ -154,7 +154,7 @@ public void saveCartRestore(OrderCartRestoreModel restore) { * * @return 만료 대상 주문 ID 목록 */ - public List findExpiredPendingOrderIds() { + public List findExpiredPendingOrderIds() { return orderRepository.findExpiredPendingOrders().stream() .map(OrderModel::getOrderId) .toList(); @@ -167,7 +167,7 @@ public List findExpiredPendingOrderIds() { * @return 주문 엔티티 * @throws CoreException 주문이 존재하지 않을 때 (ORDER_NOT_FOUND) */ - public OrderModel findOrderById(String orderId) { + public OrderModel findOrderById(Long orderId) { return orderRepository.findById(orderId) .orElseThrow(() -> new CoreException(ErrorType.ORDER_NOT_FOUND)); } @@ -191,7 +191,7 @@ public List findAllOrders(LocalDateTime start, LocalDateTime end) { * @return 주문 엔티티 * @throws CoreException 주문이 존재하지 않을 때 (ORDER_NOT_FOUND) */ - public OrderModel findByIdAndUserId(String orderId, String userId) { + public OrderModel findByIdAndUserId(Long orderId, Long userId) { return orderRepository.findByIdAndUserId(orderId, userId) .orElseThrow(() -> new CoreException(ErrorType.ORDER_NOT_FOUND)); } @@ -204,7 +204,7 @@ public OrderModel findByIdAndUserId(String orderId, String userId) { * @param end 조회 종료 일시 * @return 주문 엔티티 목록 */ - public List findAllByUserId(String userId, LocalDateTime start, LocalDateTime end) { + public List findAllByUserId(Long userId, LocalDateTime start, LocalDateTime end) { return orderRepository.findAllByUserIdAndPeriod(userId, start, end); } @@ -214,7 +214,7 @@ public List findAllByUserId(String userId, LocalDateTime start, Loca * @param orderId 주문 ID * @return 주문 항목 엔티티 목록 */ - public List findOrderItems(String orderId) { + public List findOrderItems(Long orderId) { return orderItemRepository.findAllByOrderId(orderId); } @@ -224,7 +224,7 @@ public List findOrderItems(String orderId) { * @param orderIds 주문 ID 목록 * @return 주문 ID를 키로, 주문 항목 목록을 값으로 하는 맵 */ - public Map> findOrderItemsByOrderIds(List orderIds) { + public Map> findOrderItemsByOrderIds(List orderIds) { if (orderIds.isEmpty()) { return Map.of(); } @@ -238,7 +238,7 @@ private void validateNotEmpty(List items) { } } - private void validatePendingLimit(String userId) { + private void validatePendingLimit(Long userId) { long pendingCount = orderRepository.countByUserIdAndStatus(userId, OrderStatus.PENDING_PAYMENT); if (pendingCount >= MAX_PENDING_ORDERS) { throw new CoreException(ErrorType.ORDER_PENDING_LIMIT_EXCEEDED); @@ -246,7 +246,7 @@ private void validatePendingLimit(String userId) { } private List mergeAndSort(List items) { - Map merged = items.stream() + Map merged = items.stream() .collect(Collectors.groupingBy(OrderItemCommand::productId, Collectors.summingInt(OrderItemCommand::quantity))); return merged.entrySet().stream() @@ -255,7 +255,7 @@ private List mergeAndSort(List items) { .toList(); } - private List saveOrderItems(OrderModel order, String userId, + private List saveOrderItems(OrderModel order, Long userId, List snapshots) { List items = new ArrayList<>(); int seq = 1; @@ -264,9 +264,24 @@ private List saveOrderItems(OrderModel order, String userId, order.getOrderId(), seq++, userId, snapshot.productId(), snapshot.quantity(), snapshot.productName(), snapshot.unitPrice(), - snapshot.brandId(), snapshot.brandName(), snapshot.imageUrl())); + snapshot.brandId(), snapshot.brandName(), snapshot.imageUrl(), + snapshot.originalAmount(), snapshot.discountAmount(), snapshot.finalAmount())); } return orderItemRepository.saveAll(items); } + /** + * 주문 목록을 배치 로딩으로 주문 항목을 포함하여 반환한다. + * N+1 쿼리 대신 단일 IN 쿼리로 주문 항목을 일괄 조회한다. + * + * @param orders 주문 엔티티 목록 + * @return 주문 ID를 키로, 주문 항목 목록을 값으로 하는 맵이 포함된 쌍 + */ + public Map> batchLoadOrderItems(List orders) { + if (orders.isEmpty()) { + return Map.of(); + } + List orderIds = orders.stream().map(OrderModel::getOrderId).toList(); + return findOrderItemsByOrderIds(orderIds); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java index f008e6596..3842393dd 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java @@ -9,12 +9,13 @@ import jakarta.persistence.Entity; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.Table; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; -import org.hibernate.annotations.UuidGenerator; import java.math.BigDecimal; @@ -37,12 +38,12 @@ public class ProductModel extends BaseStringIdEntity { @Id - @UuidGenerator - @Column(name = "product_id", length = 36) - private String productId; + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "product_id") + private Long productId; - @Column(name = "brand_id", nullable = false, length = 36) - private String brandId; + @Column(name = "brand_id", nullable = false) + private Long brandId; @Column(name = "product_name", nullable = false) private String productName; @@ -82,7 +83,10 @@ public class ProductModel extends BaseStringIdEntity { @Column(name = "revision_seq", nullable = false) private Long revisionSeq; - private ProductModel(String productName, String brandId, BigDecimal price, + @Column(name = "like_count", nullable = false) + private long likeCount = 0; + + private ProductModel(String productName, Long brandId, BigDecimal price, String description, String category, String color, String size, String option, String imageUrl, String attachFile) { validateProductName(productName); @@ -120,7 +124,7 @@ private ProductModel(String productName, String brandId, BigDecimal price, * @return 생성된 ProductModel 인스턴스 * @throws CoreException productName/brandId null 또는 price <= 0인 경우 (BAD_REQUEST) */ - public static ProductModel create(String productName, String brandId, BigDecimal price, + public static ProductModel create(String productName, Long brandId, BigDecimal price, String description, String category, String color, String size, String option, String imageUrl, String attachFile) { return new ProductModel(productName, brandId, price, description, category, color, @@ -213,8 +217,8 @@ private static void validateProductName(String productName) { } } - private static void validateBrandId(String brandId) { - if (brandId == null || brandId.isBlank()) { + private static void validateBrandId(Long brandId) { + if (brandId == null) { throw new CoreException(ErrorType.BAD_REQUEST, "브랜드 ID는 필수입니다."); } } 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 index 772228782..95799a6a0 100644 --- 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 @@ -1,7 +1,7 @@ package com.loopers.domain.product; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; +import com.loopers.support.page.PageQuery; +import com.loopers.support.page.PagedResult; import java.util.Collection; import java.util.List; @@ -30,7 +30,16 @@ public interface ProductRepository { * @param productId 상품 ID * @return 상품 엔티티 (존재하지 않으면 빈 Optional) */ - Optional findById(String productId); + Optional findById(Long productId); + + /** + * 비관적 쓰기 락(SELECT FOR UPDATE)으로 상품을 조회한다. + * 동일 상품에 대한 좋아요 연산 직렬화를 위해 사용된다. + * + * @param productId 상품 ID + * @return 상품 엔티티 (존재하지 않으면 빈 Optional) + */ + Optional findByIdWithLock(Long productId); /** * 전체 상품을 조회한다 (삭제된 상품 포함). @@ -54,7 +63,7 @@ public interface ProductRepository { * @param brandId 브랜드 ID 필터 (null이면 전체) * @return 고객 노출 조건을 만족하는 상품 목록 */ - List findAllForCustomer(String keyword, String brandId); + List findAllForCustomer(String keyword, Long brandId); /** * 특정 브랜드에 소속된 상품 목록을 조회한다. @@ -62,7 +71,7 @@ public interface ProductRepository { * @param brandId 브랜드 ID * @return 해당 브랜드 소속 상품 목록 */ - List findAllByBrandId(String brandId); + List findAllByBrandId(Long brandId); /** * 상품 ID 목록으로 상품을 일괄 조회한다. @@ -70,15 +79,29 @@ public interface ProductRepository { * @param productIds 상품 ID 목록 * @return 해당 상품 목록 */ - List findAllByProductIds(Collection productIds); + List findAllByProductIds(Collection productIds); /** * 고객용 상품 목록을 페이징하여 조회한다. * - * @param keyword 검색 키워드 (null이면 전체) - * @param brandId 브랜드 ID 필터 (null이면 전체) - * @param pageable 페이징/정렬 정보 + * @param keyword 검색 키워드 (null이면 전체) + * @param brandId 브랜드 ID 필터 (null이면 전체) + * @param query 페이징/정렬 요청 정보 * @return 페이징된 상품 목록 */ - Page findAllForCustomer(String keyword, String brandId, Pageable pageable); + PagedResult findAllForCustomer(String keyword, Long brandId, PageQuery query); + + /** + * 상품의 좋아요 수를 1 증가시킨다. + * + * @param productId 상품 ID + */ + void incrementLikeCount(Long productId); + + /** + * 상품의 좋아요 수를 1 감소시킨다 (최솟값 0 보장). + * + * @param productId 상품 ID + */ + void decrementLikeCount(Long productId); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRevisionId.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRevisionId.java index b05bfb57f..0a76f733a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRevisionId.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRevisionId.java @@ -20,6 +20,6 @@ @AllArgsConstructor @EqualsAndHashCode public class ProductRevisionId implements Serializable { - private String productId; + private Long productId; private Long revisionSeq; } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRevisionModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRevisionModel.java index 9a5a769fd..360ee0702 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRevisionModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRevisionModel.java @@ -31,8 +31,8 @@ public class ProductRevisionModel { @Id - @Column(name = "product_id", length = 36) - private String productId; + @Column(name = "product_id") + private Long productId; @Id @Column(name = "revision_seq") @@ -57,7 +57,7 @@ public class ProductRevisionModel { @Column(name = "created_at", nullable = false, updatable = false) private LocalDateTime createdAt; - private ProductRevisionModel(String productId, Long revisionSeq, ProductRevisionAction action, + private ProductRevisionModel(Long productId, Long revisionSeq, ProductRevisionAction action, String changedBy, String changeReason, String beforeSnapshot, String afterSnapshot) { this.productId = productId; @@ -82,7 +82,7 @@ private ProductRevisionModel(String productId, Long revisionSeq, ProductRevision * @param afterSnapshot 변경 후 상품 상태 JSON (DELETE 시 null) * @return 생성된 ProductRevisionModel 인스턴스 */ - public static ProductRevisionModel create(String productId, Long revisionSeq, + public static ProductRevisionModel create(Long productId, Long revisionSeq, ProductRevisionAction action, String changedBy, String changeReason, String beforeSnapshot, String afterSnapshot) { diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRevisionRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRevisionRepository.java index 9d7ed5b00..ab49ef739 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRevisionRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRevisionRepository.java @@ -26,7 +26,7 @@ public interface ProductRevisionRepository { * @param productId 상품 ID * @return 해당 상품의 변경 이력 목록 */ - List findAllByProductId(String productId); + List findAllByProductId(Long productId); /** * 복합 PK(productId + revisionSeq)로 변경 이력을 조회한다. 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 index 000979516..4094ce743 100644 --- 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 @@ -4,9 +4,11 @@ import com.loopers.support.enums.ProductSaleStatus; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; +import com.loopers.support.page.PageQuery; +import com.loopers.support.page.PagedResult; import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -29,6 +31,7 @@ public class ProductService { private final ProductRepository productRepository; private final ProductRevisionRepository revisionRepository; + private final org.springframework.cache.CacheManager cacheManager; /** * 상품을 생성한다. @@ -44,7 +47,7 @@ public class ProductService { * @return 생성된 상품 엔티티 */ @Transactional - public ProductModel createProduct(String productName, String brandId, BigDecimal price, + public ProductModel createProduct(String productName, Long brandId, BigDecimal price, String description) { ProductModel product = ProductModel.create(productName, brandId, price, description, null, null, null, null, null, null); @@ -65,11 +68,26 @@ public ProductModel createProduct(String productName, String brandId, BigDecimal * @return 상품 엔티티 * @throws CoreException 상품이 존재하지 않을 때 (PRODUCT_NOT_FOUND) */ - public ProductModel findById(String productId) { + @Cacheable(cacheNames = "productDetail", key = "#productId") + public ProductModel findById(Long productId) { return productRepository.findById(productId) .orElseThrow(() -> new CoreException(ErrorType.PRODUCT_NOT_FOUND)); } + /** + * 비관적 쓰기 락으로 상품을 조회한다. + * 동일 상품에 대한 좋아요 연산 직렬화를 위해 사용된다. + * + * @param productId 상품 ID + * @return 상품 엔티티 + * @throws CoreException 상품이 존재하지 않을 때 (PRODUCT_NOT_FOUND) + */ + @Transactional + public ProductModel findByIdWithLock(Long productId) { + return productRepository.findByIdWithLock(productId) + .orElseThrow(() -> new CoreException(ErrorType.PRODUCT_NOT_FOUND)); + } + /** * 주문 가능한 상품을 조회한다. *

@@ -86,11 +104,11 @@ public ProductModel findById(String productId) { * @param productIds 상품 ID 목록 * @return 상품 엔티티 목록 */ - public List findAllByIds(Collection productIds) { + public List findAllByIds(Collection productIds) { return productRepository.findAllByProductIds(productIds); } - public ProductModel findOrderableById(String productId) { + public ProductModel findOrderableById(Long productId) { ProductModel product = findById(productId); if (!product.isOrderable()) { throw new CoreException(ErrorType.PRODUCT_NOT_ORDERABLE); @@ -118,20 +136,20 @@ public List findAllForAdmin(boolean includeDeleted) { * @param brandId 브랜드 ID 필터 (null이면 전체) * @return 고객 노출 조건을 만족하는 상품 목록 */ - public List findAllForCustomer(String keyword, String brandId) { + public List findAllForCustomer(String keyword, Long brandId) { return productRepository.findAllForCustomer(keyword, brandId); } /** * 고객용 상품 목록을 페이징하여 조회한다. * - * @param keyword 검색 키워드 (null이면 전체) - * @param brandId 브랜드 ID 필터 (null이면 전체) - * @param pageable 페이징/정렬 정보 + * @param keyword 검색 키워드 (null이면 전체) + * @param brandId 브랜드 ID 필터 (null이면 전체) + * @param query 페이징/정렬 요청 정보 * @return 페이징된 상품 목록 */ - public Page findAllForCustomer(String keyword, String brandId, Pageable pageable) { - return productRepository.findAllForCustomer(keyword, brandId, pageable); + public PagedResult findAllForCustomer(String keyword, Long brandId, PageQuery query) { + return productRepository.findAllForCustomer(keyword, brandId, query); } /** @@ -149,8 +167,9 @@ public Page findAllForCustomer(String keyword, String brandId, Pag * @return 수정된 상품 엔티티 * @throws CoreException 상품이 존재하지 않을 때 (PRODUCT_NOT_FOUND) */ + @CacheEvict(cacheNames = "productDetail", key = "#productId") @Transactional - public ProductModel updateProduct(String productId, String productName, BigDecimal price, + public ProductModel updateProduct(Long productId, String productName, BigDecimal price, String description, String imageUrl) { ProductModel product = findById(productId); @@ -176,8 +195,9 @@ public ProductModel updateProduct(String productId, String productName, BigDecim * @param productId 상품 ID * @throws CoreException 상품이 존재하지 않을 때 (PRODUCT_NOT_FOUND) */ + @CacheEvict(cacheNames = "productDetail", key = "#productId") @Transactional - public void deleteProduct(String productId) { + public void deleteProduct(Long productId) { ProductModel product = findById(productId); if (product.isDeleted()) { return; @@ -201,8 +221,9 @@ public void deleteProduct(String productId) { * @param brandId 브랜드 ID */ @Transactional - public void softDeleteByBrandId(String brandId) { + public void softDeleteByBrandId(Long brandId) { List products = productRepository.findAllByBrandId(brandId); + org.springframework.cache.Cache cache = cacheManager.getCache("productDetail"); for (ProductModel product : products) { if (!product.isDeleted()) { String beforeSnapshot = toSnapshot(product); @@ -213,6 +234,10 @@ public void softDeleteByBrandId(String brandId) { product.getProductId(), revSeq, ProductRevisionAction.DELETE, null, null, beforeSnapshot, null); revisionRepository.save(revision); + + if (cache != null) { + cache.evict(product.getProductId()); + } } } } @@ -227,8 +252,9 @@ public void softDeleteByBrandId(String brandId) { * @param newStatus 새 판매 상태 * @throws CoreException 상품이 존재하지 않을 때 (PRODUCT_NOT_FOUND) */ + @CacheEvict(cacheNames = "productDetail", key = "#productId") @Transactional - public void changeSaleStatus(String productId, ProductSaleStatus newStatus) { + public void changeSaleStatus(Long productId, ProductSaleStatus newStatus) { ProductModel product = findById(productId); String beforeSnapshot = toSnapshot(product); product.changeSaleStatus(newStatus); @@ -247,7 +273,7 @@ public void changeSaleStatus(String productId, ProductSaleStatus newStatus) { * @param productId 상품 ID * @return 변경 이력 목록 */ - public List findRevisionsByProductId(String productId) { + public List findRevisionsByProductId(Long productId) { return revisionRepository.findAllByProductId(productId); } @@ -259,11 +285,33 @@ public List findRevisionsByProductId(String productId) { * @return 변경 이력 엔티티 * @throws CoreException 변경 이력이 존재하지 않을 때 (PRODUCT_NOT_FOUND) */ - public ProductRevisionModel findRevisionById(String productId, Long revisionSeq) { + public ProductRevisionModel findRevisionById(Long productId, Long revisionSeq) { return revisionRepository.findById(new ProductRevisionId(productId, revisionSeq)) .orElseThrow(() -> new CoreException(ErrorType.PRODUCT_NOT_FOUND)); } + /** + * 상품의 좋아요 수를 1 증가시킨다. + * + * @param productId 상품 ID + */ + @CacheEvict(cacheNames = "productDetail", key = "#productId") + @Transactional + public void incrementLikeCount(Long productId) { + productRepository.incrementLikeCount(productId); + } + + /** + * 상품의 좋아요 수를 1 감소시킨다 (최솟값 0 보장). + * + * @param productId 상품 ID + */ + @CacheEvict(cacheNames = "productDetail", key = "#productId") + @Transactional + public void decrementLikeCount(Long productId) { + productRepository.decrementLikeCount(productId); + } + private String toSnapshot(ProductModel product) { return "{\"productName\":\"" + product.getProductName() + "\",\"price\":" + product.getPrice() diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductStockModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductStockModel.java index 63b7d978e..6c3c293f1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductStockModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductStockModel.java @@ -32,8 +32,8 @@ public class ProductStockModel { @Id - @Column(name = "product_id", length = 36) - private String productId; + @Column(name = "product_id") + private Long productId; @Column(name = "on_hand", nullable = false) private int onHand; @@ -47,7 +47,7 @@ public class ProductStockModel { @Column(name = "updated_at", nullable = false) private LocalDateTime updatedAt; - private ProductStockModel(String productId, int onHand, int reserved) { + private ProductStockModel(Long productId, int onHand, int reserved) { if (onHand < 0) { throw new CoreException(ErrorType.INVALID_STOCK_UPDATE, "총 재고는 음수일 수 없습니다."); } @@ -65,7 +65,7 @@ private ProductStockModel(String productId, int onHand, int reserved) { * @return 생성된 ProductStockModel 인스턴스 * @throws CoreException onHand < 0인 경우 (INVALID_STOCK_UPDATE) */ - public static ProductStockModel create(String productId, int onHand) { + public static ProductStockModel create(Long productId, int onHand) { return new ProductStockModel(productId, onHand, 0); } @@ -78,7 +78,7 @@ public static ProductStockModel create(String productId, int onHand) { * @param reserved 예약 재고 수량 * @return 생성된 ProductStockModel 인스턴스 */ - public static ProductStockModel createWithReserved(String productId, int onHand, int reserved) { + public static ProductStockModel createWithReserved(Long productId, int onHand, int reserved) { return new ProductStockModel(productId, onHand, reserved); } @@ -105,6 +105,18 @@ public boolean canHold(int qty) { return getAvailableQty() >= qty; } + /** + * 요청 수량이 가용 재고를 초과하면 예외를 던진다. + * + * @param qty 요청 수량 + * @throws CoreException 가용 재고보다 요청 수량이 많을 때 (CART_STOCK_EXCEEDED) + */ + public void validateCanHold(int qty) { + if (!canHold(qty)) { + throw new CoreException(ErrorType.CART_STOCK_EXCEEDED); + } + } + /** * 관리자가 총 재고(onHand)를 수정한다. 예약 재고보다 작게 설정하면 예외 발생. * diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductStockRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductStockRepository.java index 5dc402cdd..86879b9e7 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductStockRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductStockRepository.java @@ -29,7 +29,7 @@ public interface ProductStockRepository { * @param productId 상품 ID * @return 재고 엔티티 (존재하지 않으면 빈 Optional) */ - Optional findByProductId(String productId); + Optional findByProductId(Long productId); /** * CAS(Compare-And-Set) 방식으로 재고를 예약(hold)한다. @@ -42,7 +42,7 @@ public interface ProductStockRepository { * @param qty 예약할 수량 * @return 영향받은 행 수 (0이면 가용 재고 부족으로 예약 실패) */ - int reserveStock(String productId, int qty); + int reserveStock(Long productId, int qty); /** * CAS(Compare-And-Set) 방식으로 예약된 재고를 해제(release)한다. @@ -56,7 +56,7 @@ public interface ProductStockRepository { * @param qty 해제할 수량 * @return 영향받은 행 수 (0이면 예약 재고 부족으로 해제 실패) */ - int releaseStock(String productId, int qty); + int releaseStock(Long productId, int qty); /** * CAS(Compare-And-Set) 방식으로 예약된 재고를 확정(commit)한다. @@ -69,7 +69,7 @@ public interface ProductStockRepository { * @param qty 확정할 수량 * @return 영향받은 행 수 (0이면 확정 실패) */ - int commitStock(String productId, int qty); + int commitStock(Long productId, int qty); /** * 상품 ID 목록으로 재고를 일괄 조회한다. @@ -77,5 +77,5 @@ public interface ProductStockRepository { * @param productIds 상품 ID 목록 * @return 해당 상품들의 재고 목록 */ - List findAllByProductIds(Collection productIds); + List findAllByProductIds(Collection productIds); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/StockService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/StockService.java index 14a7ee59e..2005f649f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/StockService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/StockService.java @@ -3,6 +3,8 @@ import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -32,7 +34,7 @@ public class StockService { * @return 생성된 재고 엔티티 */ @Transactional - public ProductStockModel createStock(String productId, int onHand) { + public ProductStockModel createStock(Long productId, int onHand) { ProductStockModel stock = ProductStockModel.create(productId, onHand); return productStockRepository.save(stock); } @@ -50,11 +52,12 @@ public ProductStockModel createStock(String productId, int onHand) { * @param productIds 상품 ID 목록 * @return 재고 엔티티 목록 */ - public List findAllByProductIds(Collection productIds) { + public List findAllByProductIds(Collection productIds) { return productStockRepository.findAllByProductIds(productIds); } - public ProductStockModel findByProductId(String productId) { + @Cacheable(cacheNames = "stockAvailable", key = "#productId") + public ProductStockModel findByProductId(Long productId) { return productStockRepository.findByProductId(productId) .orElseThrow(() -> new CoreException(ErrorType.PRODUCT_NOT_FOUND)); } @@ -71,8 +74,9 @@ public ProductStockModel findByProductId(String productId) { * @param qty 예약할 수량 * @throws CoreException 가용 재고 부족 시 (STOCK_NOT_ENOUGH) */ + @CacheEvict(cacheNames = "stockAvailable", key = "#productId") @Transactional - public void hold(String productId, int qty) { + public void hold(Long productId, int qty) { int affected = productStockRepository.reserveStock(productId, qty); if (affected == 0) { throw new CoreException(ErrorType.STOCK_NOT_ENOUGH); @@ -91,8 +95,9 @@ public void hold(String productId, int qty) { * @param qty 해제할 수량 * @throws CoreException 예약 재고 부족 시 (STOCK_NOT_ENOUGH) */ + @CacheEvict(cacheNames = "stockAvailable", key = "#productId") @Transactional - public void release(String productId, int qty) { + public void release(Long productId, int qty) { int affected = productStockRepository.releaseStock(productId, qty); if (affected == 0) { throw new CoreException(ErrorType.STOCK_NOT_ENOUGH); @@ -110,8 +115,9 @@ public void release(String productId, int qty) { * @param qty 확정할 수량 * @throws CoreException 확정 실패 시 (STOCK_NOT_ENOUGH) */ + @CacheEvict(cacheNames = "stockAvailable", key = "#productId") @Transactional - public void commit(String productId, int qty) { + public void commit(Long productId, int qty) { int affected = productStockRepository.commitStock(productId, qty); if (affected == 0) { throw new CoreException(ErrorType.STOCK_NOT_ENOUGH); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/stats/StatsProjection.java b/apps/commerce-api/src/main/java/com/loopers/domain/stats/StatsProjection.java index 8c9141a65..4838efc1a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/stats/StatsProjection.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/stats/StatsProjection.java @@ -9,7 +9,7 @@ /** * 운영 통계 도메인 프로젝션 DTO 모음. - * 도메인/인프라 레이어에서 사용되며, application 레이어의 {@link com.loopers.application.stats.StatsInfo}로 변환된다. + * 도메인/인프라 레이어에서 사용되며, Controller에서 API 응답 DTO로 변환된다. */ public class StatsProjection { @@ -44,7 +44,7 @@ public static class DailyOrderStat { @Builder @AllArgsConstructor public static class ProductStat { - private final String productId; + private final Long productId; private final String productName; private final long count; } @@ -56,7 +56,7 @@ public static class ProductStat { @Builder @AllArgsConstructor public static class LowStockProduct { - private final String productId; + private final Long productId; private final String productName; private final int onHand; private final int reserved; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/stats/StatsService.java b/apps/commerce-api/src/main/java/com/loopers/domain/stats/StatsService.java index a758d3aec..c7f08a2d8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/stats/StatsService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/stats/StatsService.java @@ -1,6 +1,7 @@ package com.loopers.domain.stats; import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -26,6 +27,7 @@ public class StatsService { * @param endAt 조회 종료일 * @return 결제 대기·취소·만료 건수를 포함하는 주문 현황 개요 */ + @Cacheable(cacheNames = "statsOverview", key = "#startAt.toString() + ':' + #endAt.toString()") public StatsProjection.Overview getOverview(LocalDate startAt, LocalDate endAt) { return statsRepository.getOverview(startAt, endAt); } @@ -37,6 +39,7 @@ public StatsProjection.Overview getOverview(LocalDate startAt, LocalDate endAt) * @param endAt 조회 종료일 * @return 일별 주문 건수 및 총 금액 목록 */ + @Cacheable(cacheNames = "statsDaily", key = "#startAt.toString() + ':' + #endAt.toString()") public List getDailyOrderStats(LocalDate startAt, LocalDate endAt) { return statsRepository.getDailyOrderStats(startAt, endAt); } @@ -47,6 +50,7 @@ public List getDailyOrderStats(LocalDate startAt * @param limit 조회할 상위 상품 수 * @return 좋아요 수 내림차순 상품 목록 */ + @Cacheable(cacheNames = "statsTopLiked", key = "#limit") public List getTopLikedProducts(int limit) { return statsRepository.getTopLikedProducts(limit); } @@ -57,6 +61,7 @@ public List getTopLikedProducts(int limit) { * @param limit 조회할 상위 상품 수 * @return 주문 수 내림차순 상품 목록 */ + @Cacheable(cacheNames = "statsTopOrdered", key = "#limit") public List getTopOrderedProducts(int limit) { return statsRepository.getTopOrderedProducts(limit); } @@ -67,6 +72,7 @@ public List getTopOrderedProducts(int limit) { * @param threshold 재고 임계값 * @return 저재고 상품 목록 */ + @Cacheable(cacheNames = "statsLowStock", key = "#threshold") public List getLowStockProducts(int threshold) { return statsRepository.getLowStockProducts(threshold); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java index e44390630..fc8f40f8e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java @@ -5,12 +5,13 @@ import com.loopers.support.error.ErrorType; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.Table; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; -import org.hibernate.annotations.UuidGenerator; import java.util.regex.Pattern; @@ -31,9 +32,9 @@ public class UserModel extends BaseStringIdEntity { private static final int PASSWORD_MAX_LENGTH = 16; @Id - @UuidGenerator - @Column(name = "user_id", length = 36) - private String userId; + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "user_id") + private Long userId; @Column(name = "login_id", nullable = false, unique = true, length = 50) private String loginId; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java index 643ae5c89..8ccdb00f2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java @@ -22,7 +22,7 @@ public interface UserRepository { * @param userId 사용자 UUID * @return 사용자 (Optional) */ - Optional findByUserId(String userId); + Optional findByUserId(Long userId); /** * 로그인 ID로 사용자를 조회한다. diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java index fd9ab6dc9..0fce7a861 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java @@ -3,6 +3,8 @@ import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -47,7 +49,7 @@ public UserModel register(UserRegisterCommand command) { * @return 조회된 UserModel * @throws CoreException 사용자가 존재하지 않는 경우 (USER_NOT_FOUND) */ - public UserModel findByUserId(String userId) { + public UserModel findByUserId(Long userId) { return userRepository.findByUserId(userId) .orElseThrow(() -> new CoreException(ErrorType.USER_NOT_FOUND)); } @@ -59,6 +61,7 @@ public UserModel findByUserId(String userId) { * @return 조회된 UserModel * @throws CoreException 사용자가 존재하지 않는 경우 (USER_NOT_FOUND) */ + @Cacheable(cacheNames = "authUser", key = "#loginId") public UserModel findByLoginId(String loginId) { return userRepository.findByLoginId(loginId) .orElseThrow(() -> new CoreException(ErrorType.USER_NOT_FOUND)); @@ -101,6 +104,7 @@ public UserModel getMyInfo(String loginId, String loginPw) { * @param newPw 새 비밀번호 * @throws CoreException 현재 비밀번호 불일치(PASSWORD_MISMATCH), 동일 비밀번호(SAME_PASSWORD), 규칙 위반(INVALID_PASSWORD) */ + @CacheEvict(cacheNames = "authUser", key = "#loginId") @Transactional public void changePassword(String loginId, String currentPw, String newPw) { UserModel user = findByLoginId(loginId); 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 index 968775cc3..eb42ce0bd 100644 --- 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 @@ -12,7 +12,7 @@ *

JpaRepository를 상속받아 기본 CRUD 메서드(save, findById, findAll, delete 등)가 자동 제공되며, * 메서드 이름 규칙 기반의 쿼리 메서드를 추가로 정의한다.

*/ -public interface BrandJpaRepository extends JpaRepository { +public interface BrandJpaRepository extends JpaRepository { /** * 삭제 여부와 노출 상태로 브랜드 목록을 조회한다. 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 index 30425ad51..67bc3367c 100644 --- 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 @@ -40,7 +40,7 @@ public BrandModel save(BrandModel brand) { * @return 브랜드 (Optional) */ @Override - public Optional findById(String brandId) { + public Optional findById(Long brandId) { return jpaRepository.findById(brandId); } @@ -88,7 +88,7 @@ public List findAllByKeyword(String keyword) { * @return 해당 브랜드 목록 */ @Override - public List findAllByIds(Collection brandIds) { + public List findAllByIds(Collection brandIds) { return jpaRepository.findAllById(brandIds); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/CacheConfig.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/CacheConfig.java new file mode 100644 index 000000000..e0abc72f5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/CacheConfig.java @@ -0,0 +1,132 @@ +package com.loopers.infrastructure.cache; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.github.benmanes.caffeine.cache.Caffeine; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.CachingConfigurer; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.caffeine.CaffeineCacheManager; +import org.springframework.cache.interceptor.CacheErrorHandler; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.cache.RedisCacheConfiguration; +import org.springframework.data.redis.cache.RedisCacheManager; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.RedisSerializationContext; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +import java.time.Duration; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +@Slf4j +@Configuration +@EnableCaching +@RequiredArgsConstructor +public class CacheConfig implements CachingConfigurer { + + private static final Set TWO_LEVEL_CACHES = Set.of( + "productDetail", + "statsOverview", + "statsTopLiked", + "statsTopOrdered" + ); + + private final LettuceConnectionFactory connectionFactory; + + @Bean + public CacheManager cacheManager() { + RedisCacheManager redisCacheManager = buildRedisCacheManager(); + CaffeineCacheManager caffeineCacheManager = buildCaffeineCacheManager(); + return new TwoLevelCacheManager(caffeineCacheManager, redisCacheManager, TWO_LEVEL_CACHES); + } + + private RedisCacheManager buildRedisCacheManager() { + RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig() + .serializeKeysWith(RedisSerializationContext.SerializationPair + .fromSerializer(new StringRedisSerializer())) + .serializeValuesWith(RedisSerializationContext.SerializationPair + .fromSerializer(redisSerializer())) + .disableCachingNullValues() + .entryTtl(Duration.ofMinutes(10)); + + Map cacheConfigs = Map.of( + "productDetail", defaultConfig.entryTtl(Duration.ofMinutes(10)), + "brandDetail", defaultConfig.entryTtl(Duration.ofMinutes(30)), + "stockAvailable", defaultConfig.entryTtl(Duration.ofSeconds(30)), + "authUser", defaultConfig.entryTtl(Duration.ofMinutes(1)), + "statsOverview", defaultConfig.entryTtl(Duration.ofMinutes(5)), + "statsDaily", defaultConfig.entryTtl(Duration.ofMinutes(5)), + "statsTopLiked", defaultConfig.entryTtl(Duration.ofMinutes(5)), + "statsTopOrdered", defaultConfig.entryTtl(Duration.ofMinutes(5)), + "statsLowStock", defaultConfig.entryTtl(Duration.ofMinutes(5)) + ); + + // productList는 Map.of 10개 제한으로 별도 추가 + Map allConfigs = new java.util.HashMap<>(cacheConfigs); + allConfigs.put("productList", defaultConfig.entryTtl(Duration.ofMinutes(5))); + + RedisCacheManager manager = RedisCacheManager.builder(connectionFactory) + .cacheDefaults(defaultConfig) + .withInitialCacheConfigurations(allConfigs) + .build(); + manager.afterPropertiesSet(); + return manager; + } + + private CaffeineCacheManager buildCaffeineCacheManager() { + CaffeineCacheManager manager = new CaffeineCacheManager(); + manager.setCaffeine(Caffeine.newBuilder() + .maximumSize(1000) + .expireAfterWrite(2, TimeUnit.MINUTES) + .recordStats()); + return manager; + } + + private GenericJackson2JsonRedisSerializer redisSerializer() { + ObjectMapper om = new ObjectMapper(); + om.registerModule(new JavaTimeModule()); + om.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + om.disable(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); + om.activateDefaultTyping( + BasicPolymorphicTypeValidator.builder() + .allowIfBaseType(Object.class) + .build(), + ObjectMapper.DefaultTyping.NON_FINAL + ); + return new GenericJackson2JsonRedisSerializer(om); + } + + @Override + public CacheErrorHandler errorHandler() { + return new CacheErrorHandler() { + @Override + public void handleCacheGetError(RuntimeException e, Cache cache, Object key) { + log.warn("Cache GET failed [cache={}, key={}]: {}", cache.getName(), key, e.getMessage()); + } + + @Override + public void handleCachePutError(RuntimeException e, Cache cache, Object key, Object value) { + log.warn("Cache PUT failed [cache={}, key={}]: {}", cache.getName(), key, e.getMessage()); + } + + @Override + public void handleCacheEvictError(RuntimeException e, Cache cache, Object key) { + log.warn("Cache EVICT failed [cache={}, key={}]: {}", cache.getName(), key, e.getMessage()); + } + + @Override + public void handleCacheClearError(RuntimeException e, Cache cache) { + log.warn("Cache CLEAR failed [cache={}]: {}", cache.getName(), e.getMessage()); + } + }; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/TwoLevelCache.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/TwoLevelCache.java new file mode 100644 index 000000000..749580005 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/TwoLevelCache.java @@ -0,0 +1,89 @@ +package com.loopers.infrastructure.cache; + +import lombok.RequiredArgsConstructor; +import org.springframework.cache.Cache; + +import java.util.concurrent.Callable; + +@RequiredArgsConstructor +public class TwoLevelCache implements Cache { + + private final Cache l1Cache; + private final Cache l2Cache; + + @Override + public String getName() { + return l2Cache.getName(); + } + + @Override + public Object getNativeCache() { + return l2Cache.getNativeCache(); + } + + @Override + public ValueWrapper get(Object key) { + ValueWrapper l1Value = l1Cache.get(key); + if (l1Value != null) { + return l1Value; + } + + ValueWrapper l2Value = l2Cache.get(key); + if (l2Value != null) { + l1Cache.put(key, l2Value.get()); + return l2Value; + } + + return null; + } + + @Override + @SuppressWarnings("unchecked") + public T get(Object key, Class type) { + T l1Value = l1Cache.get(key, type); + if (l1Value != null) { + return l1Value; + } + + T l2Value = l2Cache.get(key, type); + if (l2Value != null) { + l1Cache.put(key, l2Value); + return l2Value; + } + + return null; + } + + @Override + @SuppressWarnings("unchecked") + public T get(Object key, Callable valueLoader) { + T l1Value = l1Cache.get(key, (Class) Object.class); + if (l1Value != null) { + return l1Value; + } + + T value = l2Cache.get(key, valueLoader); + if (value != null) { + l1Cache.put(key, value); + } + return value; + } + + @Override + public void put(Object key, Object value) { + l2Cache.put(key, value); + l1Cache.put(key, value); + } + + @Override + public void evict(Object key) { + l1Cache.evict(key); + l2Cache.evict(key); + } + + @Override + public void clear() { + l1Cache.clear(); + l2Cache.clear(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/TwoLevelCacheManager.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/TwoLevelCacheManager.java new file mode 100644 index 000000000..097d56b8d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/TwoLevelCacheManager.java @@ -0,0 +1,38 @@ +package com.loopers.infrastructure.cache; + +import lombok.RequiredArgsConstructor; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.cache.caffeine.CaffeineCacheManager; +import org.springframework.data.redis.cache.RedisCacheManager; + +import java.util.Collection; +import java.util.Set; + +@RequiredArgsConstructor +public class TwoLevelCacheManager implements CacheManager { + + private final CaffeineCacheManager l1CacheManager; + private final RedisCacheManager l2CacheManager; + private final Set twoLevelCacheNames; + + @Override + public Cache getCache(String name) { + Cache l2 = l2CacheManager.getCache(name); + if (l2 == null) { + return null; + } + + if (twoLevelCacheNames.contains(name)) { + Cache l1 = l1CacheManager.getCache(name); + return new TwoLevelCache(l1, l2); + } + + return l2; + } + + @Override + public Collection getCacheNames() { + return l2CacheManager.getCacheNames(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/cart/CartItemJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/cart/CartItemJpaRepository.java index f25152c20..3aac1b5e6 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/cart/CartItemJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/cart/CartItemJpaRepository.java @@ -22,5 +22,5 @@ public interface CartItemJpaRepository extends JpaRepository findAllByUserId(String userId); + List findAllByUserId(Long userId); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/cart/CartItemRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/cart/CartItemRepositoryImpl.java index 01d335694..f34b0dba8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/cart/CartItemRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/cart/CartItemRepositoryImpl.java @@ -60,7 +60,7 @@ public void delete(CartItemModel item) { * @return 해당 사용자의 장바구니 항목 목록 */ @Override - public List findAllByUserId(String userId) { + public List findAllByUserId(Long userId) { return jpaRepository.findAllByUserId(userId); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponJpaRepository.java new file mode 100644 index 000000000..f4a79ef76 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponJpaRepository.java @@ -0,0 +1,29 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.domain.coupon.CouponModel; +import jakarta.persistence.LockModeType; +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; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +/** + * 쿠폰 JPA 레포지토리. + */ +public interface CouponJpaRepository extends JpaRepository { + + /** + * 비관적 쓰기 락(SELECT FOR UPDATE)으로 쿠폰을 조회한다. + * 동시 발급 요청 시 중복 발급 방지를 위해 사용된다. + */ + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT c FROM CouponModel c WHERE c.couponId = :couponId") + Optional findByIdWithLock(@Param("couponId") Long couponId); + + @Query("SELECT c FROM CouponModel c WHERE c.couponId IN :ids") + List findAllByCouponIdIn(@Param("ids") Collection ids); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponRepositoryImpl.java new file mode 100644 index 000000000..cde7817ec --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponRepositoryImpl.java @@ -0,0 +1,61 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.domain.coupon.CouponModel; +import com.loopers.domain.coupon.CouponRepository; +import com.loopers.support.page.PageQuery; +import com.loopers.support.page.PagedResult; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Repository; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +/** + * 쿠폰 레포지토리 구현체. + */ +@Repository +@RequiredArgsConstructor +public class CouponRepositoryImpl implements CouponRepository { + + private final CouponJpaRepository couponJpaRepository; + + @Override + public Optional findById(Long couponId) { + return couponJpaRepository.findById(couponId); + } + + @Override + public Optional findByIdWithLock(Long couponId) { + return couponJpaRepository.findByIdWithLock(couponId); + } + + @Override + public CouponModel save(CouponModel coupon) { + return couponJpaRepository.save(coupon); + } + + @Override + public List findAll() { + return couponJpaRepository.findAll(); + } + + @Override + public List findAllByIdIn(Collection ids) { + return couponJpaRepository.findAllByCouponIdIn(ids); + } + + @Override + public PagedResult findAllPaged(PageQuery query) { + Sort sort = query.ascending() + ? Sort.by(Sort.Direction.ASC, query.sortField()) + : Sort.by(Sort.Direction.DESC, query.sortField()); + Page page = couponJpaRepository.findAll( + PageRequest.of(query.page(), query.size(), sort)); + return new PagedResult<>(page.getContent(), page.getNumber(), page.getSize(), + page.getTotalElements(), page.getTotalPages()); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/UserCouponJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/UserCouponJpaRepository.java new file mode 100644 index 000000000..99184d167 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/UserCouponJpaRepository.java @@ -0,0 +1,40 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.domain.coupon.UserCouponModel; +import jakarta.persistence.LockModeType; +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; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.List; +import java.util.Optional; + +/** + * 사용자 발급 쿠폰 JPA 레포지토리. + */ +public interface UserCouponJpaRepository extends JpaRepository { + + /** + * 비관적 쓰기 락(SELECT FOR UPDATE)으로 발급 쿠폰을 조회한다. + * 동시 주문 시 쿠폰 중복 사용 방지를 위해 사용된다. + */ + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT uc FROM UserCouponModel uc WHERE uc.userCouponId = :userCouponId") + Optional findByIdWithLock(@Param("userCouponId") Long userCouponId); + + Optional findByUserIdAndCouponId(Long userId, Long couponId); + + List findAllByUserId(Long userId); + + List findAllByCouponId(Long couponId); + + boolean existsByUserIdAndCouponId(Long userId, Long couponId); + + Page findAllByCouponId(Long couponId, Pageable pageable); + + Optional findByOrderId(Long orderId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/UserCouponRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/UserCouponRepositoryImpl.java new file mode 100644 index 000000000..93dad66ac --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/UserCouponRepositoryImpl.java @@ -0,0 +1,75 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.domain.coupon.UserCouponModel; +import com.loopers.domain.coupon.UserCouponRepository; +import com.loopers.support.page.PageQuery; +import com.loopers.support.page.PagedResult; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +/** + * 사용자 발급 쿠폰 레포지토리 구현체. + */ +@Repository +@RequiredArgsConstructor +public class UserCouponRepositoryImpl implements UserCouponRepository { + + private final UserCouponJpaRepository userCouponJpaRepository; + + @Override + public UserCouponModel save(UserCouponModel userCoupon) { + return userCouponJpaRepository.save(userCoupon); + } + + @Override + public Optional findById(Long userCouponId) { + return userCouponJpaRepository.findById(userCouponId); + } + + @Override + public Optional findByIdWithLock(Long userCouponId) { + return userCouponJpaRepository.findByIdWithLock(userCouponId); + } + + @Override + public Optional findByUserIdAndCouponId(Long userId, Long couponId) { + return userCouponJpaRepository.findByUserIdAndCouponId(userId, couponId); + } + + @Override + public List findAllByUserId(Long userId) { + return userCouponJpaRepository.findAllByUserId(userId); + } + + @Override + public List findAllByCouponId(Long couponId) { + return userCouponJpaRepository.findAllByCouponId(couponId); + } + + @Override + public boolean existsByUserIdAndCouponId(Long userId, Long couponId) { + return userCouponJpaRepository.existsByUserIdAndCouponId(userId, couponId); + } + + @Override + public PagedResult findAllByCouponIdPaged(Long couponId, PageQuery query) { + Sort sort = query.ascending() + ? Sort.by(Sort.Direction.ASC, query.sortField()) + : Sort.by(Sort.Direction.DESC, query.sortField()); + Page page = userCouponJpaRepository.findAllByCouponId( + couponId, PageRequest.of(query.page(), query.size(), sort)); + return new PagedResult<>(page.getContent(), page.getNumber(), page.getSize(), + page.getTotalElements(), page.getTotalPages()); + } + + @Override + public Optional findByOrderId(Long orderId) { + return userCouponJpaRepository.findByOrderId(orderId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java index cb28da326..700bb377f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java @@ -25,7 +25,7 @@ public interface LikeJpaRepository extends JpaRepository { * @param userId 사용자 ID * @return 해당 사용자의 좋아요 목록 */ - List findAllByUserId(String userId); + List findAllByUserId(Long userId); /** * 상품 ID에 대한 좋아요 수를 조회한다. @@ -35,7 +35,7 @@ public interface LikeJpaRepository extends JpaRepository { * @param productId 상품 ID * @return 해당 상품의 좋아요 수 */ - long countByProductId(String productId); + long countByProductId(Long productId); /** * 여러 상품의 좋아요 수를 GROUP BY로 일괄 조회한다. @@ -44,5 +44,5 @@ public interface LikeJpaRepository extends JpaRepository { * @return [productId, count] 배열 목록 */ @Query("SELECT l.productId, COUNT(l) FROM LikeModel l WHERE l.productId IN :productIds GROUP BY l.productId") - List countByProductIdIn(@Param("productIds") Collection productIds); + List countByProductIdIn(@Param("productIds") Collection productIds); } 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 index 7fd3e9fa3..fbd980cec 100644 --- 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 @@ -64,7 +64,7 @@ public void delete(LikeModel like) { * @return 해당 사용자의 좋아요 목록 */ @Override - public List findAllByUserId(String userId) { + public List findAllByUserId(Long userId) { return jpaRepository.findAllByUserId(userId); } @@ -75,18 +75,18 @@ public List findAllByUserId(String userId) { * @return 해당 상품의 좋아요 수 */ @Override - public long countByProductId(String productId) { + public long countByProductId(Long productId) { return jpaRepository.countByProductId(productId); } @Override - public Map countByProductIds(Collection productIds) { + public Map countByProductIds(Collection productIds) { if (productIds == null || productIds.isEmpty()) { return Collections.emptyMap(); } return jpaRepository.countByProductIdIn(productIds).stream() .collect(Collectors.toMap( - row -> (String) row[0], + row -> (Long) row[0], row -> (Long) row[1] )); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderCartRestoreJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderCartRestoreJpaRepository.java index 162afbc3c..21fe0959b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderCartRestoreJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderCartRestoreJpaRepository.java @@ -9,5 +9,5 @@ *

JpaRepository를 상속받아 기본 CRUD 메서드(save, findById, findAll, delete 등)가 자동 제공된다. * PK가 order_id이므로, 동일 주문에 대해 1회만 복원이 수행됨을 보장한다.

*/ -public interface OrderCartRestoreJpaRepository extends JpaRepository { +public interface OrderCartRestoreJpaRepository extends JpaRepository { } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderCartRestoreRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderCartRestoreRepositoryImpl.java index f42845cc0..1cb2ab1f5 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderCartRestoreRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderCartRestoreRepositoryImpl.java @@ -38,7 +38,7 @@ public OrderCartRestoreModel save(OrderCartRestoreModel restore) { * @return 존재 여부 */ @Override - public boolean existsById(String orderId) { + public boolean existsById(Long orderId) { return jpaRepository.existsById(orderId); } } 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 index 219b2b503..a3ff347a3 100644 --- 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 @@ -22,7 +22,7 @@ public interface OrderItemJpaRepository extends JpaRepository findAllByOrderId(String orderId); + List findAllByOrderId(Long orderId); /** * 여러 주문 ID에 해당하는 주문 항목을 일괄 조회한다. @@ -30,5 +30,5 @@ public interface OrderItemJpaRepository extends JpaRepository findAllByOrderIdIn(List orderIds); + List findAllByOrderIdIn(List orderIds); } 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 index 7c920f8ec..d19504a61 100644 --- 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 @@ -48,7 +48,7 @@ public List saveAll(List items) { * @return 해당 주문의 주문 항목 목록 */ @Override - public List findAllByOrderId(String orderId) { + public List findAllByOrderId(Long orderId) { return jpaRepository.findAllByOrderId(orderId); } @@ -59,7 +59,7 @@ public List findAllByOrderId(String orderId) { * @return 해당 주문들의 주문 항목 목록 */ @Override - public List findAllByOrderIds(List orderIds) { + public List findAllByOrderIds(List orderIds) { return jpaRepository.findAllByOrderIdIn(orderIds); } } 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 index 49f5bee2f..f2635438d 100644 --- 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 @@ -17,7 +17,7 @@ *

JpaRepository를 상속받아 기본 CRUD 메서드가 자동 제공되며, * CAS(Compare-And-Set) 기반의 상태 변경 쿼리와 다양한 조회 쿼리를 정의한다.

*/ -public interface OrderJpaRepository extends JpaRepository { +public interface OrderJpaRepository extends JpaRepository { /** * 주문 ID와 사용자 ID로 주문을 조회한다. @@ -28,7 +28,7 @@ public interface OrderJpaRepository extends JpaRepository { * @param userId 사용자 ID * @return 주문 (Optional) */ - Optional findByOrderIdAndUserId(String orderId, String userId); + Optional findByOrderIdAndUserId(Long orderId, Long userId); /** * 사용자 ID와 기간으로 주문 목록을 조회한다 (최신순 정렬). @@ -41,7 +41,7 @@ public interface OrderJpaRepository extends JpaRepository { @Query("SELECT o FROM OrderModel o " + "WHERE o.userId = :userId AND o.createdAt BETWEEN :start AND :end " + "ORDER BY o.createdAt DESC") - List findAllByUserIdAndPeriod(@Param("userId") String userId, + List findAllByUserIdAndPeriod(@Param("userId") Long userId, @Param("start") LocalDateTime start, @Param("end") LocalDateTime end); @@ -72,7 +72,7 @@ List findAllByPeriod(@Param("start") LocalDateTime start, @Modifying @Query("UPDATE OrderModel o SET o.status = :toStatus, o.updatedAt = CURRENT_TIMESTAMP " + "WHERE o.orderId = :orderId AND o.status = :fromStatus") - int casUpdateStatus(@Param("orderId") String orderId, + int casUpdateStatus(@Param("orderId") Long orderId, @Param("fromStatus") OrderStatus fromStatus, @Param("toStatus") OrderStatus toStatus); @@ -85,7 +85,7 @@ int casUpdateStatus(@Param("orderId") String orderId, * @param status 주문 상태 * @return 조건에 해당하는 주문 건수 */ - long countByUserIdAndStatus(String userId, OrderStatus status); + long countByUserIdAndStatus(Long userId, OrderStatus status); /** * 만료 시각이 지난 결제 대기 주문 목록을 조회한다. 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 index f18699a8b..f8cb17b10 100644 --- 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 @@ -40,7 +40,7 @@ public OrderModel save(OrderModel order) { * @return 주문 (Optional) */ @Override - public Optional findById(String orderId) { + public Optional findById(Long orderId) { return jpaRepository.findById(orderId); } @@ -52,7 +52,7 @@ public Optional findById(String orderId) { * @return 주문 (Optional) */ @Override - public Optional findByIdAndUserId(String orderId, String userId) { + public Optional findByIdAndUserId(Long orderId, Long userId) { return jpaRepository.findByOrderIdAndUserId(orderId, userId); } @@ -65,7 +65,7 @@ public Optional findByIdAndUserId(String orderId, String userId) { * @return 기간 내 해당 사용자의 주문 목록 */ @Override - public List findAllByUserIdAndPeriod(String userId, LocalDateTime start, LocalDateTime end) { + public List findAllByUserIdAndPeriod(Long userId, LocalDateTime start, LocalDateTime end) { return jpaRepository.findAllByUserIdAndPeriod(userId, start, end); } @@ -93,7 +93,7 @@ public List findAllByPeriod(LocalDateTime start, LocalDateTime end) * @return 변경된 행 수 (0이면 상태 전이 실패) */ @Override - public int casUpdateStatus(String orderId, OrderStatus from, OrderStatus to) { + public int casUpdateStatus(Long orderId, OrderStatus from, OrderStatus to) { return jpaRepository.casUpdateStatus(orderId, from, to); } @@ -105,7 +105,7 @@ public int casUpdateStatus(String orderId, OrderStatus from, OrderStatus to) { * @return 조건에 해당하는 주문 건수 */ @Override - public long countByUserIdAndStatus(String userId, OrderStatus status) { + public long countByUserIdAndStatus(Long userId, OrderStatus status) { return jpaRepository.countByUserIdAndStatus(userId, status); } 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 index 7800abd4b..84626da7a 100644 --- 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 @@ -4,10 +4,15 @@ 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.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import jakarta.persistence.LockModeType; +import org.springframework.data.jpa.repository.Lock; + import java.util.List; +import java.util.Optional; /** * 상품 엔티티에 대한 Spring Data JPA Repository 인터페이스. @@ -15,7 +20,7 @@ *

JpaRepository를 상속받아 기본 CRUD 메서드가 자동 제공되며, * 고객용 검색 쿼리와 관리자용 조회 쿼리를 정의한다.

*/ -public interface ProductJpaRepository extends JpaRepository { +public interface ProductJpaRepository extends JpaRepository { /** * 고객용 상품 목록을 조회한다. @@ -32,7 +37,7 @@ public interface ProductJpaRepository extends JpaRepository findAllForCustomer(@Param("keyword") String keyword, - @Param("brandId") String brandId); + @Param("brandId") Long brandId); /** * 브랜드 ID로 상품 목록을 조회한다. @@ -42,7 +47,7 @@ List findAllForCustomer(@Param("keyword") String keyword, * @param brandId 브랜드 ID * @return 해당 브랜드의 상품 목록 */ - List findAllByBrandId(String brandId); + List findAllByBrandId(Long brandId); /** * 삭제 여부로 상품 목록을 조회한다. @@ -67,6 +72,19 @@ List findAllForCustomer(@Param("keyword") String keyword, "AND (:keyword IS NULL OR p.productName LIKE %:keyword%) " + "AND (:brandId IS NULL OR p.brandId = :brandId)") Page findAllForCustomerPaged(@Param("keyword") String keyword, - @Param("brandId") String brandId, + @Param("brandId") Long brandId, Pageable pageable); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT p FROM ProductModel p WHERE p.productId = :productId") + Optional findByIdWithLock(@Param("productId") Long productId); + + @Modifying + @Query("UPDATE ProductModel p SET p.likeCount = p.likeCount + 1 WHERE p.productId = :productId") + void incrementLikeCount(@Param("productId") Long productId); + + @Modifying + @Query("UPDATE ProductModel p SET p.likeCount = GREATEST(p.likeCount - 1, 0) WHERE p.productId = :productId") + void decrementLikeCount(@Param("productId") Long productId); + } 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 index f898b42bb..845c8173e 100644 --- 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 @@ -2,9 +2,12 @@ import com.loopers.domain.product.ProductModel; import com.loopers.domain.product.ProductRepository; +import com.loopers.support.page.PageQuery; +import com.loopers.support.page.PagedResult; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; import org.springframework.stereotype.Repository; import java.util.Collection; @@ -41,10 +44,15 @@ public ProductModel save(ProductModel product) { * @return 상품 (Optional) */ @Override - public Optional findById(String productId) { + public Optional findById(Long productId) { return jpaRepository.findById(productId); } + @Override + public Optional findByIdWithLock(Long productId) { + return jpaRepository.findByIdWithLock(productId); + } + /** * 전체 상품 목록을 조회한다. * @@ -76,7 +84,7 @@ public List findAllByDelYn(String delYn) { * @return 조건에 부합하는 상품 목록 */ @Override - public List findAllForCustomer(String keyword, String brandId) { + public List findAllForCustomer(String keyword, Long brandId) { return jpaRepository.findAllForCustomer(keyword, brandId); } @@ -87,7 +95,7 @@ public List findAllForCustomer(String keyword, String brandId) { * @return 해당 브랜드의 상품 목록 */ @Override - public List findAllByBrandId(String brandId) { + public List findAllByBrandId(Long brandId) { return jpaRepository.findAllByBrandId(brandId); } @@ -98,12 +106,29 @@ public List findAllByBrandId(String brandId) { * @return 해당 상품 목록 */ @Override - public List findAllByProductIds(Collection productIds) { + public List findAllByProductIds(Collection productIds) { return jpaRepository.findAllById(productIds); } @Override - public Page findAllForCustomer(String keyword, String brandId, Pageable pageable) { - return jpaRepository.findAllForCustomerPaged(keyword, brandId, pageable); + public PagedResult findAllForCustomer(String keyword, Long brandId, PageQuery query) { + Sort sort = query.ascending() + ? Sort.by(Sort.Direction.ASC, query.sortField()) + : Sort.by(Sort.Direction.DESC, query.sortField()); + Page page = jpaRepository.findAllForCustomerPaged( + keyword, brandId, PageRequest.of(query.page(), query.size(), sort)); + return new PagedResult<>(page.getContent(), page.getNumber(), page.getSize(), + page.getTotalElements(), page.getTotalPages()); + } + + @Override + public void incrementLikeCount(Long productId) { + jpaRepository.incrementLikeCount(productId); } + + @Override + public void decrementLikeCount(Long productId) { + jpaRepository.decrementLikeCount(productId); + } + } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRevisionJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRevisionJpaRepository.java index f36e1b967..aafb95d5e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRevisionJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRevisionJpaRepository.java @@ -22,5 +22,5 @@ public interface ProductRevisionJpaRepository extends JpaRepository findAllByProductIdOrderByRevisionSeqDesc(String productId); + List findAllByProductIdOrderByRevisionSeqDesc(Long productId); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRevisionRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRevisionRepositoryImpl.java index 42f7cc72f..161dcdbb4 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRevisionRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRevisionRepositoryImpl.java @@ -39,7 +39,7 @@ public ProductRevisionModel save(ProductRevisionModel revision) { * @return 해당 상품의 변경 이력 목록 (최신순) */ @Override - public List findAllByProductId(String productId) { + public List findAllByProductId(Long productId) { return jpaRepository.findAllByProductIdOrderByRevisionSeqDesc(productId); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductStockJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductStockJpaRepository.java index 73508c3ca..a5ede81cd 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductStockJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductStockJpaRepository.java @@ -13,7 +13,7 @@ * CAS(Compare-And-Set) 기반의 재고 예약/해제/확정 쿼리를 정의하여 * 오버셀(초과 판매)을 방지한다.

*/ -public interface ProductStockJpaRepository extends JpaRepository { +public interface ProductStockJpaRepository extends JpaRepository { /** * CAS 방식으로 재고를 예약(hold)한다. @@ -28,7 +28,7 @@ public interface ProductStockJpaRepository extends JpaRepository= :qty") - int reserveStock(@Param("productId") String productId, @Param("qty") int qty); + int reserveStock(@Param("productId") Long productId, @Param("qty") int qty); /** * CAS 방식으로 예약된 재고를 해제(release)한다. @@ -43,7 +43,7 @@ public interface ProductStockJpaRepository extends JpaRepository= :qty") - int releaseStock(@Param("productId") String productId, @Param("qty") int qty); + int releaseStock(@Param("productId") Long productId, @Param("qty") int qty); /** * CAS 방식으로 예약된 재고를 확정(commit)한다. @@ -59,5 +59,5 @@ public interface ProductStockJpaRepository extends JpaRepository= :qty") - int commitStock(@Param("productId") String productId, @Param("qty") int qty); + int commitStock(@Param("productId") Long productId, @Param("qty") int qty); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductStockRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductStockRepositoryImpl.java index e617fc7d2..43ec12dad 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductStockRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductStockRepositoryImpl.java @@ -40,7 +40,7 @@ public ProductStockModel save(ProductStockModel stock) { * @return 상품 재고 (Optional) */ @Override - public Optional findByProductId(String productId) { + public Optional findByProductId(Long productId) { return jpaRepository.findById(productId); } @@ -52,7 +52,7 @@ public Optional findByProductId(String productId) { * @return 변경된 행 수 (0이면 재고 부족으로 예약 실패) */ @Override - public int reserveStock(String productId, int qty) { + public int reserveStock(Long productId, int qty) { return jpaRepository.reserveStock(productId, qty); } @@ -64,7 +64,7 @@ public int reserveStock(String productId, int qty) { * @return 변경된 행 수 (0이면 해제 실패) */ @Override - public int releaseStock(String productId, int qty) { + public int releaseStock(Long productId, int qty) { return jpaRepository.releaseStock(productId, qty); } @@ -76,7 +76,7 @@ public int releaseStock(String productId, int qty) { * @return 변경된 행 수 (0이면 확정 실패) */ @Override - public int commitStock(String productId, int qty) { + public int commitStock(Long productId, int qty) { return jpaRepository.commitStock(productId, qty); } @@ -87,7 +87,7 @@ public int commitStock(String productId, int qty) { * @return 해당 상품들의 재고 목록 */ @Override - public List findAllByProductIds(Collection productIds) { + public List findAllByProductIds(Collection productIds) { return jpaRepository.findAllById(productIds); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java index 47ca5e8d9..372d90799 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java @@ -11,7 +11,7 @@ *

JpaRepository를 상속받아 기본 CRUD 메서드(save, findById, findAll, delete 등)가 자동 제공되며, * 메서드 이름 규칙 기반의 쿼리 메서드를 추가로 정의한다.

*/ -public interface UserJpaRepository extends JpaRepository { +public interface UserJpaRepository extends JpaRepository { /** * 로그인 ID로 사용자를 조회한다. diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java index 6ad0552c1..3adb2e3fc 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java @@ -37,7 +37,7 @@ public UserModel save(UserModel user) { * @return 사용자 (Optional) */ @Override - public Optional findByUserId(String userId) { + public Optional findByUserId(Long userId) { return jpaRepository.findById(userId); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/AuthUser.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/AuthUser.java new file mode 100644 index 000000000..b918decc9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/AuthUser.java @@ -0,0 +1,24 @@ +package com.loopers.interfaces.api; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 컨트롤러 메서드 파라미터에 인증된 사용자 정보를 주입하기 위한 어노테이션. + * + *

{@link CustomerAuthInterceptor}에서 인증된 {@link com.loopers.domain.user.UserModel}을 + * 컨트롤러 메서드 파라미터로 주입받을 때 사용한다.

+ * + *
{@code
+ * @GetMapping("/me")
+ * public ResponseEntity<...> getMyInfo(@AuthUser UserModel user) {
+ *     // user is the authenticated user
+ * }
+ * }
+ */ +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface AuthUser { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/AuthUserArgumentResolver.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/AuthUserArgumentResolver.java new file mode 100644 index 000000000..b441d0dbc --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/AuthUserArgumentResolver.java @@ -0,0 +1,55 @@ +package com.loopers.interfaces.api; + +import com.loopers.domain.user.UserModel; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.springframework.core.MethodParameter; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.context.request.RequestAttributes; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +/** + * {@link AuthUser} 어노테이션이 붙은 파라미터에 인증된 사용자 정보를 주입하는 ArgumentResolver. + * + *

{@link CustomerAuthInterceptor}에서 request attribute에 저장된 {@link UserModel}을 + * 컨트롤러 메서드 파라미터로 주입한다.

+ */ +@Component +public class AuthUserArgumentResolver implements HandlerMethodArgumentResolver { + + /** + * 해당 파라미터를 지원하는지 확인한다. + * + * @param parameter 메서드 파라미터 + * @return {@link AuthUser} 어노테이션이 있고 타입이 {@link UserModel}이면 {@code true} + */ + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(AuthUser.class) + && UserModel.class.isAssignableFrom(parameter.getParameterType()); + } + + /** + * 인증된 사용자 정보를 반환한다. + * + * @param parameter 메서드 파라미터 + * @param mavContainer ModelAndView 컨테이너 + * @param webRequest 웹 요청 + * @param binderFactory 바인더 팩토리 + * @return 인증된 {@link UserModel} + * @throws CoreException 인증된 사용자가 없을 때 (UNAUTHORIZED) + */ + @Override + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { + Object user = webRequest.getAttribute(CustomerAuthInterceptor.AUTH_USER_ATTR, + RequestAttributes.SCOPE_REQUEST); + if (user == null) { + throw new CoreException(ErrorType.UNAUTHORIZED); + } + return user; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/CustomerAuthInterceptor.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/CustomerAuthInterceptor.java new file mode 100644 index 000000000..d35f62f37 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/CustomerAuthInterceptor.java @@ -0,0 +1,58 @@ +package com.loopers.interfaces.api; + +import com.loopers.domain.user.UserModel; +import com.loopers.domain.user.UserService; +import com.loopers.support.error.CoreException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +/** + * 고객 API 인증 처리를 담당하는 인터셉터. + * + *

{@code X-Loopers-LoginId}와 {@code X-Loopers-LoginPw} 헤더 값을 검증하여 고객 인증을 수행한다. + * 인증 성공 시 {@link UserModel}을 request attribute에 저장한다. + * 인증에 실패하면 {@link CoreException}을 발생시킨다.

+ */ +@Component +@RequiredArgsConstructor +public class CustomerAuthInterceptor implements HandlerInterceptor { + + private static final String LOGIN_ID_HEADER = "X-Loopers-LoginId"; + private static final String LOGIN_PW_HEADER = "X-Loopers-LoginPw"; + + /** + * Request attribute key for the authenticated user. + */ + public static final String AUTH_USER_ATTR = "authenticatedUser"; + + private final UserService userService; + + /** + * 요청을 처리하기 전에 고객 인증을 수행한다. + * + *

인증 헤더가 없으면 통과시키고, 헤더가 있으면 인증을 수행한다. + * 인증 성공 시 UserModel을 request attribute에 저장한다.

+ * + * @param request HTTP 요청 객체 + * @param response HTTP 응답 객체 + * @param handler 요청을 처리할 핸들러 객체 + * @return 항상 {@code true} (인증 실패 시 예외 발생) + * @throws CoreException 인증 실패 시 예외 발생 + */ + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + String loginId = request.getHeader(LOGIN_ID_HEADER); + String loginPw = request.getHeader(LOGIN_PW_HEADER); + + if (loginId == null || loginPw == null) { + return true; // 헤더 없으면 통과 (인증 불필요 API용) + } + + UserModel user = userService.authenticate(loginId, loginPw); + request.setAttribute(AUTH_USER_ATTR, user); + return true; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/PageResponse.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/PageResponse.java index 31ef1e4a2..09f6be0de 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/PageResponse.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/PageResponse.java @@ -1,21 +1,19 @@ package com.loopers.interfaces.api; -import org.springframework.data.domain.Page; - import java.util.List; /** * 페이징 응답 래퍼. * - *

Spring Data의 {@link Page} 객체를 API 응답에 적합한 형태로 변환하는 제네릭 레코드이다. - * 페이지 콘텐츠, 현재 페이지 번호, 페이지 크기, 전체 요소 수, 전체 페이지 수를 포함한다.

+ *

페이지 콘텐츠, 현재 페이지 번호, 페이지 크기, 전체 요소 수, 전체 페이지 수를 포함한다. + * Spring Data 타입에 의존하지 않는다.

* - * @param content 현재 페이지의 콘텐츠 목록 - * @param page 현재 페이지 번호 (0부터 시작) - * @param size 페이지 크기 + * @param content 현재 페이지의 콘텐츠 목록 + * @param page 현재 페이지 번호 (0부터 시작) + * @param size 페이지 크기 * @param totalElements 전체 요소 수 - * @param totalPages 전체 페이지 수 - * @param 콘텐츠 요소 타입 + * @param totalPages 전체 페이지 수 + * @param 콘텐츠 요소 타입 */ public record PageResponse( List content, @@ -24,20 +22,4 @@ public record PageResponse( long totalElements, int totalPages ) { - /** - * Spring Data {@link Page} 객체를 {@link PageResponse}로 변환하는 팩토리 메서드. - * - * @param page 변환할 Spring Data Page 객체 - * @param 콘텐츠 요소 타입 - * @return 변환된 PageResponse - */ - public static PageResponse from(Page page) { - return new PageResponse<>( - page.getContent(), - page.getNumber(), - page.getSize(), - page.getTotalElements(), - page.getTotalPages() - ); - } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java index 5559c11c2..801d8ec18 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java @@ -1,7 +1,7 @@ package com.loopers.interfaces.api.brand; -import com.loopers.application.brand.BrandInfo; -import com.loopers.application.brand.BrandAppService; +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.brand.BrandService; import com.loopers.interfaces.api.ApiResponse; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; @@ -13,14 +13,14 @@ * 브랜드 고객 API V1 REST 엔드포인트를 제공하는 컨트롤러. * *

활성 상태의 브랜드 목록 조회 및 브랜드 상세 조회 기능을 제공한다. - * {@link BrandAppService}를 호출한다.

+ * {@link BrandService}를 직접 호출한다.

*/ @RestController @RequestMapping("/api/v1/brands") @RequiredArgsConstructor public class BrandV1Controller { - private final BrandAppService brandAppService; + private final BrandService brandService; /** * 활성 브랜드 목록을 조회한다. @@ -31,7 +31,7 @@ public class BrandV1Controller { @GetMapping public ResponseEntity>> list( @RequestParam(value = "q", required = false) String keyword) { - List brands = brandAppService.findAllVisibleBrands(keyword); + List brands = brandService.findAllVisibleBrands(keyword); List response = brands.stream() .map(BrandV1Dto.BrandResponse::from) .toList(); @@ -45,8 +45,8 @@ public ResponseEntity>> list( * @return 브랜드 상세 정보 응답 */ @GetMapping("/{brandId}") - public ResponseEntity> detail(@PathVariable String brandId) { - BrandInfo info = brandAppService.findVisibleById(brandId); - return ResponseEntity.ok(ApiResponse.success(BrandV1Dto.BrandResponse.from(info))); + public ResponseEntity> detail(@PathVariable Long brandId) { + BrandModel brand = brandService.findVisibleById(brandId); + return ResponseEntity.ok(ApiResponse.success(BrandV1Dto.BrandResponse.from(brand))); } } 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 index ae239e2d4..acf036d41 100644 --- 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 @@ -1,6 +1,6 @@ package com.loopers.interfaces.api.brand; -import com.loopers.application.brand.BrandInfo; +import com.loopers.domain.brand.BrandModel; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -21,25 +21,25 @@ public class BrandV1Dto { @AllArgsConstructor @Builder public static class BrandResponse { - private String brandId; + private Long brandId; private String brandName; private String description; private String address; private String attachFile; /** - * {@link BrandInfo}를 브랜드 응답 DTO로 변환하는 팩토리 메서드. + * {@link BrandModel}을 브랜드 응답 DTO로 변환하는 팩토리 메서드. * - * @param info 변환할 브랜드 도메인 Info 객체 + * @param model 변환할 브랜드 도메인 모델 * @return 변환된 BrandResponse */ - public static BrandResponse from(BrandInfo info) { + public static BrandResponse from(BrandModel model) { return BrandResponse.builder() - .brandId(info.getBrandId()) - .brandName(info.getBrandName()) - .description(info.getDescription()) - .address(info.getAddress()) - .attachFile(info.getAttachFile()) + .brandId(model.getBrandId()) + .brandName(model.getBrandName()) + .description(model.getDescription()) + .address(model.getAddress()) + .attachFile(model.getAttachFile()) .build(); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/cart/CartV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/cart/CartV1Controller.java index 9a74cc953..931032561 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/cart/CartV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/cart/CartV1Controller.java @@ -1,9 +1,10 @@ package com.loopers.interfaces.api.cart; -import com.loopers.application.cart.CartAppService; import com.loopers.application.cart.CartFacade; import com.loopers.application.cart.CartInfo; +import com.loopers.domain.user.UserModel; import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.AuthUser; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; @@ -15,7 +16,8 @@ * 장바구니 고객 API V1 REST 엔드포인트를 제공하는 컨트롤러. * *

장바구니 조회, 상품 추가, 수량 변경, 상품 삭제 기능을 제공한다. - * 복잡한 도메인으로 {@link CartFacade}를 통해 여러 서비스를 조합하여 처리한다.

+ * 복잡한 도메인으로 {@link CartFacade}를 통해 여러 서비스를 조합하여 처리한다. + * 모든 처리는 {@link CartFacade}를 통해 위임한다.

*/ @RestController @RequestMapping("/api/v1/cart") @@ -23,20 +25,16 @@ public class CartV1Controller { private final CartFacade cartFacade; - private final CartAppService cartAppService; /** * 장바구니 항목 목록을 조회한다. * - * @param loginId 로그인 ID (인증 헤더) - * @param loginPw 비밀번호 (인증 헤더) + * @param user 인증된 사용자 (인터셉터에서 주입) * @return 장바구니 항목 목록 응답 (주문 가능 여부 포함) */ @GetMapping - public ResponseEntity>> getCart( - @RequestHeader("X-Loopers-LoginId") String loginId, - @RequestHeader("X-Loopers-LoginPw") String loginPw) { - List cart = cartFacade.getCart(loginId, loginPw); + public ResponseEntity>> getCart(@AuthUser UserModel user) { + List cart = cartFacade.getCart(user.getUserId()); List response = cart.stream() .map(CartV1Dto.CartItemResponse::from) .toList(); @@ -46,53 +44,47 @@ public ResponseEntity>> getCart( /** * 장바구니에 상품을 추가한다. * - * @param loginId 로그인 ID (인증 헤더) - * @param loginPw 비밀번호 (인증 헤더) + * @param user 인증된 사용자 (인터셉터에서 주입) * @param request 상품 추가 요청 (상품 ID, 수량) * @return 성공 응답 */ @PostMapping("/items") public ResponseEntity> addItem( - @RequestHeader("X-Loopers-LoginId") String loginId, - @RequestHeader("X-Loopers-LoginPw") String loginPw, + @AuthUser UserModel user, @Valid @RequestBody CartV1Dto.AddItemRequest request) { - cartFacade.addItem(loginId, loginPw, request.getProductId(), request.getQuantity()); + cartFacade.addItem(user.getUserId(), request.getProductId(), request.getQuantity()); return ResponseEntity.ok(ApiResponse.success()); } /** * 장바구니 항목의 수량을 변경한다. * - * @param loginId 로그인 ID (인증 헤더) - * @param loginPw 비밀번호 (인증 헤더) + * @param user 인증된 사용자 (인터셉터에서 주입) * @param productId 수량을 변경할 상품 ID * @param request 수량 변경 요청 (새 수량) * @return 성공 응답 */ @PatchMapping("/items/{productId}") public ResponseEntity> changeQuantity( - @RequestHeader("X-Loopers-LoginId") String loginId, - @RequestHeader("X-Loopers-LoginPw") String loginPw, - @PathVariable String productId, + @AuthUser UserModel user, + @PathVariable Long productId, @Valid @RequestBody CartV1Dto.ChangeQtyRequest request) { - cartFacade.changeQuantity(loginId, loginPw, productId, request.getQuantity()); + cartFacade.changeQuantity(user.getUserId(), productId, request.getQuantity()); return ResponseEntity.ok(ApiResponse.success()); } /** * 장바구니에서 상품을 삭제한다. * - * @param loginId 로그인 ID (인증 헤더) - * @param loginPw 비밀번호 (인증 헤더) + * @param user 인증된 사용자 (인터셉터에서 주입) * @param productId 삭제할 상품 ID * @return 성공 응답 */ @DeleteMapping("/items/{productId}") public ResponseEntity> removeItem( - @RequestHeader("X-Loopers-LoginId") String loginId, - @RequestHeader("X-Loopers-LoginPw") String loginPw, - @PathVariable String productId) { - cartAppService.removeItem(loginId, loginPw, productId); + @AuthUser UserModel user, + @PathVariable Long productId) { + cartFacade.removeItem(user.getUserId(), productId); return ResponseEntity.ok(ApiResponse.success()); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/cart/CartV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/cart/CartV1Dto.java index 7ee70d252..f141a6aef 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/cart/CartV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/cart/CartV1Dto.java @@ -3,7 +3,6 @@ import com.loopers.application.cart.CartInfo; import com.loopers.support.enums.UnavailableReason; import jakarta.validation.constraints.Min; -import jakarta.validation.constraints.NotBlank; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -28,8 +27,7 @@ public class CartV1Dto { @AllArgsConstructor @Builder public static class AddItemRequest { - @NotBlank(message = "상품 ID는 필수입니다") - private String productId; + private Long productId; @Min(value = 1, message = "수량은 1 이상이어야 합니다") private int quantity; } @@ -57,7 +55,7 @@ public static class ChangeQtyRequest { @AllArgsConstructor @Builder public static class CartItemResponse { - private String productId; + private Long productId; private String productName; private BigDecimal price; private String brandName; diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Controller.java new file mode 100644 index 000000000..8777705a1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Controller.java @@ -0,0 +1,69 @@ +package com.loopers.interfaces.api.coupon; + +import com.loopers.domain.coupon.CouponModel; +import com.loopers.domain.coupon.CouponService; +import com.loopers.domain.coupon.UserCouponModel; +import com.loopers.domain.user.UserModel; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.AuthUser; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * 쿠폰 고객 API V1 컨트롤러. + */ +@RestController +@RequestMapping("/api/v1") +@RequiredArgsConstructor +public class CouponV1Controller { + + private final CouponService couponService; + + /** + * 쿠폰을 발급한다. + * + * @param user 인증된 사용자 + * @param couponId 쿠폰 템플릿 ID + * @return 발급된 쿠폰 정보 (HTTP 200) + */ + @PostMapping("/coupons/{couponId}/issue") + public ResponseEntity> issueCoupon( + @AuthUser UserModel user, + @PathVariable Long couponId) { + UserCouponModel userCoupon = couponService.issueCoupon(user.getUserId(), couponId); + CouponModel coupon = couponService.findByIdForAdmin(couponId); + return ResponseEntity.ok(ApiResponse.success(CouponV1Dto.UserCouponResponse.from(userCoupon, coupon))); + } + + /** + * 내 쿠폰 목록을 조회한다. + * + * @param user 인증된 사용자 + * @return 발급된 쿠폰 목록 (HTTP 200) + */ + @GetMapping("/users/me/coupons") + public ResponseEntity>> getMyCoupons( + @AuthUser UserModel user) { + List userCoupons = couponService.findMyCoupons(user.getUserId()); + + List couponIds = userCoupons.stream().map(UserCouponModel::getCouponId).toList(); + Map couponMap = couponService.findAllByIds(couponIds).stream() + .collect(Collectors.toMap(CouponModel::getCouponId, Function.identity())); + + List response = userCoupons.stream() + .filter(uc -> couponMap.containsKey(uc.getCouponId())) + .map(uc -> CouponV1Dto.UserCouponResponse.from(uc, couponMap.get(uc.getCouponId()))) + .toList(); + return ResponseEntity.ok(ApiResponse.success(response)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Dto.java new file mode 100644 index 000000000..a9d7f87be --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Dto.java @@ -0,0 +1,55 @@ +package com.loopers.interfaces.api.coupon; + +import com.loopers.domain.coupon.CouponModel; +import com.loopers.domain.coupon.UserCouponModel; +import com.loopers.support.enums.DiscountType; +import com.loopers.support.enums.UserCouponStatus; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 쿠폰 고객 API V1 요청/응답 DTO 모음. + */ +public class CouponV1Dto { + + /** + * 발급된 쿠폰 응답 DTO. + */ + @Getter + @AllArgsConstructor + @Builder + public static class UserCouponResponse { + private Long userCouponId; + private Long couponId; + private String couponName; + private DiscountType discountType; + private BigDecimal discountValue; + private BigDecimal minOrderAmount; + private LocalDateTime expiredAt; + private UserCouponStatus status; + private LocalDateTime issuedAt; + private LocalDateTime usedAt; + + /** + * UserCouponModel과 CouponModel로부터 응답 DTO를 생성한다. + */ + public static UserCouponResponse from(UserCouponModel userCoupon, CouponModel coupon) { + return UserCouponResponse.builder() + .userCouponId(userCoupon.getUserCouponId()) + .couponId(coupon.getCouponId()) + .couponName(coupon.getName()) + .discountType(coupon.getDiscountType()) + .discountValue(coupon.getDiscountValue()) + .minOrderAmount(coupon.getMinOrderAmount()) + .expiredAt(coupon.getExpiredAt()) + .status(userCoupon.getStatus()) + .issuedAt(userCoupon.getIssuedAt()) + .usedAt(userCoupon.getUsedAt()) + .build(); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java index 35ee18cc8..76319ac0e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java @@ -1,8 +1,10 @@ package com.loopers.interfaces.api.like; -import com.loopers.application.like.LikeInfo; -import com.loopers.application.like.LikeAppService; +import com.loopers.domain.like.LikeModel; +import com.loopers.domain.like.LikeService; +import com.loopers.domain.user.UserModel; import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.AuthUser; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -13,31 +15,29 @@ * 좋아요 고객 API V1 REST 엔드포인트를 제공하는 컨트롤러. * *

상품 좋아요 등록, 취소 및 내 좋아요 목록 조회 기능을 제공한다. - * {@link LikeAppService}를 호출한다. + * {@link LikeService}를 직접 호출한다. * 좋아요 등록/취소는 멱등성을 보장한다.

*/ @RestController @RequiredArgsConstructor public class LikeV1Controller { - private final LikeAppService likeAppService; + private final LikeService likeService; /** * 상품에 좋아요를 등록한다. * *

이미 좋아요가 등록된 경우 멱등하게 처리한다.

* - * @param loginId 로그인 ID (인증 헤더) - * @param loginPw 비밀번호 (인증 헤더) + * @param user 인증된 사용자 (인터셉터에서 주입) * @param productId 좋아요를 등록할 상품 ID * @return 성공 응답 */ @PostMapping("/api/v1/products/{productId}/likes") public ResponseEntity> addLike( - @RequestHeader("X-Loopers-LoginId") String loginId, - @RequestHeader("X-Loopers-LoginPw") String loginPw, - @PathVariable String productId) { - likeAppService.addLike(loginId, loginPw, productId); + @AuthUser UserModel user, + @PathVariable Long productId) { + likeService.addLike(user.getUserId(), productId); return ResponseEntity.ok(ApiResponse.success()); } @@ -46,32 +46,27 @@ public ResponseEntity> addLike( * *

이미 취소된 경우 멱등하게 처리한다.

* - * @param loginId 로그인 ID (인증 헤더) - * @param loginPw 비밀번호 (인증 헤더) + * @param user 인증된 사용자 (인터셉터에서 주입) * @param productId 좋아요를 취소할 상품 ID * @return 성공 응답 */ @DeleteMapping("/api/v1/products/{productId}/likes") public ResponseEntity> removeLike( - @RequestHeader("X-Loopers-LoginId") String loginId, - @RequestHeader("X-Loopers-LoginPw") String loginPw, - @PathVariable String productId) { - likeAppService.removeLike(loginId, loginPw, productId); + @AuthUser UserModel user, + @PathVariable Long productId) { + likeService.removeLike(user.getUserId(), productId); return ResponseEntity.ok(ApiResponse.success()); } /** * 내 좋아요 목록을 조회한다. * - * @param loginId 로그인 ID (인증 헤더) - * @param loginPw 비밀번호 (인증 헤더) + * @param user 인증된 사용자 (인터셉터에서 주입) * @return 좋아요 목록 응답 */ @GetMapping("/api/v1/users/me/likes") - public ResponseEntity>> getMyLikes( - @RequestHeader("X-Loopers-LoginId") String loginId, - @RequestHeader("X-Loopers-LoginPw") String loginPw) { - List likes = likeAppService.getMyLikes(loginId, loginPw); + public ResponseEntity>> getMyLikes(@AuthUser UserModel user) { + List likes = likeService.getMyLikes(user.getUserId()); List response = likes.stream() .map(LikeV1Dto.LikeResponse::from) .toList(); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java index 3a08c41ea..d6f7f6d24 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java @@ -1,6 +1,6 @@ package com.loopers.interfaces.api.like; -import com.loopers.application.like.LikeInfo; +import com.loopers.domain.like.LikeModel; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -23,19 +23,19 @@ public class LikeV1Dto { @AllArgsConstructor @Builder public static class LikeResponse { - private String productId; + private Long productId; private LocalDateTime createdAt; /** - * {@link LikeInfo}를 좋아요 응답 DTO로 변환하는 팩토리 메서드. + * {@link LikeModel}을 좋아요 응답 DTO로 변환하는 팩토리 메서드. * - * @param info 변환할 좋아요 도메인 Info 객체 + * @param model 변환할 좋아요 도메인 모델 * @return 변환된 LikeResponse */ - public static LikeResponse from(LikeInfo info) { + public static LikeResponse from(LikeModel model) { return LikeResponse.builder() - .productId(info.getProductId()) - .createdAt(info.getCreatedAt()) + .productId(model.getProductId()) + .createdAt(model.getCreatedAt()) .build(); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java index 8caf654c2..145384fd1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java @@ -1,9 +1,10 @@ package com.loopers.interfaces.api.order; -import com.loopers.application.order.OrderAppService; import com.loopers.application.order.OrderFacade; import com.loopers.application.order.OrderInfo; +import com.loopers.domain.user.UserModel; import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.AuthUser; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.format.annotation.DateTimeFormat; @@ -19,7 +20,8 @@ * *

직접 주문 생성, 장바구니 주문 생성, 주문 목록 조회, 주문 상세 조회, * 주문 취소 기능을 제공한다. 복잡한 도메인으로 {@link OrderFacade}를 통해 - * 여러 서비스(주문, 재고, 장바구니 등)를 조합하여 처리한다.

+ * 여러 서비스(주문, 재고, 장바구니 등)를 조합하여 처리한다. + * 모든 처리는 {@link OrderFacade}를 통해 위임한다.

*/ @RestController @RequestMapping("/api/v1/orders") @@ -27,24 +29,21 @@ public class OrderV1Controller { private final OrderFacade orderFacade; - private final OrderAppService orderAppService; /** * 직접(DIRECT) 주문을 생성한다. * *

상품을 장바구니에 담지 않고 바로 주문한다. CAS 재고 예약이 수행된다.

* - * @param loginId 로그인 ID (인증 헤더) - * @param loginPw 비밀번호 (인증 헤더) + * @param user 인증된 사용자 (인터셉터에서 주입) * @param request 직접 주문 생성 요청 (주문 항목 목록) * @return 생성된 주문 상세 정보 (HTTP 201) */ @PostMapping public ResponseEntity> createDirectOrder( - @RequestHeader("X-Loopers-LoginId") String loginId, - @RequestHeader("X-Loopers-LoginPw") String loginPw, + @AuthUser UserModel user, @Valid @RequestBody OrderV1Dto.CreateDirectOrderRequest request) { - OrderInfo info = orderFacade.createDirectOrder(loginId, loginPw, request.toItems()); + OrderInfo info = orderFacade.createDirectOrder(user.getUserId(), request.toItems(), request.getCouponId()); return ResponseEntity.status(201).body(ApiResponse.success(OrderV1Dto.OrderDetailResponse.from(info))); } @@ -53,17 +52,15 @@ public ResponseEntity> createDirectO * *

장바구니에 담긴 상품들로 주문을 생성한다. CAS 재고 예약이 수행된다.

* - * @param loginId 로그인 ID (인증 헤더) - * @param loginPw 비밀번호 (인증 헤더) + * @param user 인증된 사용자 (인터셉터에서 주입) * @param request 장바구니 주문 생성 요청 (주문 항목 목록) * @return 생성된 주문 상세 정보 (HTTP 201) */ @PostMapping("/cart") public ResponseEntity> createCartOrder( - @RequestHeader("X-Loopers-LoginId") String loginId, - @RequestHeader("X-Loopers-LoginPw") String loginPw, + @AuthUser UserModel user, @Valid @RequestBody OrderV1Dto.CreateCartOrderRequest request) { - OrderInfo info = orderFacade.createCartOrder(loginId, loginPw, request.toItems()); + OrderInfo info = orderFacade.createCartOrder(user.getUserId(), request.toItems(), request.getCouponId()); return ResponseEntity.status(201).body(ApiResponse.success(OrderV1Dto.OrderDetailResponse.from(info))); } @@ -72,24 +69,21 @@ public ResponseEntity> createCartOrd * *

조회 기간을 지정하지 않으면 최근 1개월 주문을 조회한다.

* - * @param loginId 로그인 ID (인증 헤더) - * @param loginPw 비밀번호 (인증 헤더) + * @param user 인증된 사용자 (인터셉터에서 주입) * @param startAt 조회 시작 날짜 (선택, 기본값: 1개월 전) - * @param endAt 조회 종료 날짜 (선택, 기본값: 오늘) + * @param endAt 조회 종료 날짜 (선택, 기본값: 오늘) * @return 주문 목록 응답 */ @GetMapping public ResponseEntity>> getOrders( - @RequestHeader("X-Loopers-LoginId") String loginId, - @RequestHeader("X-Loopers-LoginPw") String loginPw, + @AuthUser UserModel user, @RequestParam(value = "startAt", required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startAt, @RequestParam(value = "endAt", required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endAt) { LocalDate now = LocalDate.now(); LocalDateTime start = (startAt != null ? startAt : now.minusMonths(1)).atStartOfDay(); LocalDateTime end = (endAt != null ? endAt : now).plusDays(1).atStartOfDay(); - List orders = orderAppService.getOrders(loginId, loginPw, start, end); - List response = orders.stream() + List response = orderFacade.getOrders(user.getUserId(), start, end).stream() .map(OrderV1Dto.OrderResponse::from) .toList(); return ResponseEntity.ok(ApiResponse.success(response)); @@ -98,17 +92,15 @@ public ResponseEntity>> getOrders( /** * 주문 상세 정보를 조회한다. * - * @param loginId 로그인 ID (인증 헤더) - * @param loginPw 비밀번호 (인증 헤더) + * @param user 인증된 사용자 (인터셉터에서 주입) * @param orderId 조회할 주문 ID * @return 주문 상세 정보 응답 (주문 항목 포함) */ @GetMapping("/{orderId}") public ResponseEntity> getOrderDetail( - @RequestHeader("X-Loopers-LoginId") String loginId, - @RequestHeader("X-Loopers-LoginPw") String loginPw, - @PathVariable String orderId) { - OrderInfo info = orderAppService.getOrderDetail(loginId, loginPw, orderId); + @AuthUser UserModel user, + @PathVariable Long orderId) { + OrderInfo info = orderFacade.getOrderDetail(user.getUserId(), orderId); return ResponseEntity.ok(ApiResponse.success(OrderV1Dto.OrderDetailResponse.from(info))); } @@ -118,17 +110,15 @@ public ResponseEntity> getOrderDetai *

PENDING_PAYMENT 상태의 주문만 취소 가능하다. * 취소 시 CAS 재고 해제 및 장바구니 복원(DIRECT 주문의 경우)이 수행된다.

* - * @param loginId 로그인 ID (인증 헤더) - * @param loginPw 비밀번호 (인증 헤더) + * @param user 인증된 사용자 (인터셉터에서 주입) * @param orderId 취소할 주문 ID * @return 성공 응답 */ @PostMapping("/{orderId}/cancel") public ResponseEntity> cancelOrder( - @RequestHeader("X-Loopers-LoginId") String loginId, - @RequestHeader("X-Loopers-LoginPw") String loginPw, - @PathVariable String orderId) { - orderFacade.cancelOrder(loginId, loginPw, orderId); + @AuthUser UserModel user, + @PathVariable Long orderId) { + orderFacade.cancelOrder(user.getUserId(), orderId); return ResponseEntity.ok(ApiResponse.success()); } } 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 index adb90a6b5..747de2ae7 100644 --- 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 @@ -6,7 +6,6 @@ import com.loopers.support.enums.OrderType; import jakarta.validation.Valid; import jakarta.validation.constraints.Min; -import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotEmpty; import lombok.AllArgsConstructor; import lombok.Builder; @@ -19,15 +18,11 @@ /** * 주문 API V1 요청/응답 DTO 모음. - * - *

주문 관련 REST API의 HTTP 요청 및 응답 데이터 구조를 정의한다.

*/ public class OrderV1Dto { /** * 직접(DIRECT) 주문 생성 요청 DTO. - * - *

장바구니를 거치지 않고 직접 주문할 상품 항목 목록을 포함한다.

*/ @Getter @NoArgsConstructor @@ -38,11 +33,8 @@ public static class CreateDirectOrderRequest { @Valid private List items; - /** - * 요청 DTO의 주문 항목을 도메인 서비스 파라미터 형식으로 변환한다. - * - * @return 변환된 {@link OrderItemRequest} 목록 - */ + private Long couponId; // 발급된 쿠폰 ID (user_coupon_id), nullable + public List toItems() { return items.stream() .map(item -> new OrderItemCommand(item.getProductId(), item.getQuantity())) @@ -52,8 +44,6 @@ public List toItems() { /** * 장바구니(CART) 주문 생성 요청 DTO. - * - *

장바구니에 담긴 상품 중 주문할 항목 목록을 포함한다.

*/ @Getter @NoArgsConstructor @@ -64,11 +54,8 @@ public static class CreateCartOrderRequest { @Valid private List items; - /** - * 요청 DTO의 주문 항목을 도메인 서비스 파라미터 형식으로 변환한다. - * - * @return 변환된 {@link OrderItemRequest} 목록 - */ + private Long couponId; // 발급된 쿠폰 ID (user_coupon_id), nullable + public List toItems() { return items.stream() .map(item -> new OrderItemCommand(item.getProductId(), item.getQuantity())) @@ -78,41 +65,30 @@ public List toItems() { /** * 주문 항목 요청 DTO. - * - *

주문할 상품 ID와 수량을 포함한다.

*/ @Getter @NoArgsConstructor @AllArgsConstructor @Builder public static class OrderItemDto { - @NotBlank(message = "상품 ID는 필수입니다") - private String productId; + private Long productId; @Min(value = 1, message = "수량은 1 이상이어야 합니다") private int quantity; } /** * 주문 목록 조회 응답 DTO. - * - *

주문 ID, 주문 유형, 상태, 총 금액, 만료 일시를 포함한다.

*/ @Getter @AllArgsConstructor @Builder public static class OrderResponse { - private String orderId; + private Long orderId; private OrderType orderType; private OrderStatus status; private BigDecimal totalAmount; private LocalDateTime expiresAt; - /** - * {@link OrderInfo}를 주문 목록 응답 DTO로 변환하는 팩토리 메서드. - * - * @param info 변환할 주문 도메인 Info 객체 - * @return 변환된 OrderResponse - */ public static OrderResponse from(OrderInfo info) { return OrderResponse.builder() .orderId(info.getOrderId()) @@ -126,26 +102,18 @@ public static OrderResponse from(OrderInfo info) { /** * 주문 상세 조회 응답 DTO. - * - *

주문 기본 정보와 함께 주문 항목 목록을 포함한다.

*/ @Getter @AllArgsConstructor @Builder public static class OrderDetailResponse { - private String orderId; + private Long orderId; private OrderType orderType; private OrderStatus status; private BigDecimal totalAmount; private LocalDateTime expiresAt; private List items; - /** - * {@link OrderInfo}를 주문 상세 응답 DTO로 변환하는 팩토리 메서드. - * - * @param info 변환할 주문 도메인 Info 객체 - * @return 변환된 OrderDetailResponse (주문 항목 포함) - */ public static OrderDetailResponse from(OrderInfo info) { return OrderDetailResponse.builder() .orderId(info.getOrderId()) @@ -161,27 +129,22 @@ public static OrderDetailResponse from(OrderInfo info) { } /** - * 주문 항목 응답 DTO. - * - *

주문 시점의 상품 스냅샷 정보(상품명, 단가, 브랜드명, 이미지 URL)를 포함한다.

+ * 주문 항목 응답 DTO (할인 금액 필드 포함). */ @Getter @AllArgsConstructor @Builder public static class OrderItemResponse { - private String productId; + private Long productId; private int quantity; private String snapshotProductName; private BigDecimal snapshotUnitPrice; private String snapshotBrandName; private String snapshotImageUrl; + private BigDecimal originalAmount; + private BigDecimal discountAmount; + private BigDecimal finalAmount; - /** - * {@link OrderInfo.OrderItemInfo}를 주문 항목 응답 DTO로 변환하는 팩토리 메서드. - * - * @param item 변환할 주문 항목 도메인 Info 객체 - * @return 변환된 OrderItemResponse - */ public static OrderItemResponse from(OrderInfo.OrderItemInfo item) { return OrderItemResponse.builder() .productId(item.getProductId()) @@ -190,6 +153,9 @@ public static OrderItemResponse from(OrderInfo.OrderItemInfo item) { .snapshotUnitPrice(item.getSnapshotUnitPrice()) .snapshotBrandName(item.getSnapshotBrandName()) .snapshotImageUrl(item.getSnapshotImageUrl()) + .originalAmount(item.getOriginalAmount()) + .discountAmount(item.getDiscountAmount()) + .finalAmount(item.getFinalAmount()) .build(); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java index b776a263f..1139c1a9a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java @@ -38,7 +38,7 @@ public class ProductV1Controller { @GetMapping public ResponseEntity>> list( @RequestParam(value = "q", required = false) String keyword, - @RequestParam(value = "brandId", required = false) String brandId, + @RequestParam(value = "brandId", required = false) Long brandId, @RequestParam(value = "sort", defaultValue = "LATEST") ProductSortType sort, @RequestParam(value = "page", defaultValue = "0") int page, @RequestParam(value = "size", defaultValue = "20") int size) { @@ -61,7 +61,7 @@ public ResponseEntity>> l */ @GetMapping("/{productId}") public ResponseEntity> detail( - @PathVariable String productId) { + @PathVariable Long productId) { ProductInfo info = productFacade.getProductDetailForCustomer(productId); return ResponseEntity.ok(ApiResponse.success(ProductV1Dto.ProductDetailResponse.from(info))); } 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 index 11cfda081..bab467807 100644 --- 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 @@ -23,8 +23,8 @@ public class ProductV1Dto { @AllArgsConstructor @Builder public static class ProductResponse { - private String productId; - private String brandId; + private Long productId; + private Long brandId; private String brandName; private String productName; private BigDecimal price; @@ -61,8 +61,8 @@ public static ProductResponse from(ProductInfo info) { @AllArgsConstructor @Builder public static class ProductDetailResponse { - private String productId; - private String brandId; + private Long productId; + private Long brandId; private String brandName; private String productName; private String description; diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java index 5f3286710..21ab0efc4 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java @@ -1,9 +1,10 @@ package com.loopers.interfaces.api.user; -import com.loopers.application.user.UserInfo; -import com.loopers.application.user.UserAppService; +import com.loopers.domain.user.UserModel; import com.loopers.domain.user.UserRegisterCommand; +import com.loopers.domain.user.UserService; import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.AuthUser; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; @@ -13,14 +14,14 @@ * 사용자(User) 고객 API V1 REST 엔드포인트를 제공하는 컨트롤러. * *

회원가입, 내 정보 조회, 비밀번호 변경 기능을 제공한다. - * {@link UserAppService}를 호출한다.

+ * {@link UserService}를 직접 호출한다.

*/ @RestController @RequestMapping("/api/v1/users") @RequiredArgsConstructor public class UserV1Controller { - private final UserAppService userAppService; + private final UserService userService; /** * 회원가입 API. 유효한 입력으로 신규 사용자를 등록한다. @@ -31,42 +32,36 @@ public class UserV1Controller { @PostMapping public ResponseEntity> register( @Valid @RequestBody UserV1Dto.RegisterRequest request) { - UserInfo info = userAppService.register(new UserRegisterCommand( + UserModel user = userService.register(new UserRegisterCommand( request.getLoginId(), request.getPassword(), request.getUserName(), request.getBirthday(), request.getEmail(), request.getAddress())); - return ResponseEntity.ok(ApiResponse.success(UserV1Dto.RegisterResponse.from(info))); + return ResponseEntity.ok(ApiResponse.success(UserV1Dto.RegisterResponse.from(user))); } /** - * 내 정보 조회 API. 인증 헤더로 본인 확인 후 마스킹된 사용자 정보를 반환한다. + * 내 정보 조회 API. 인터셉터에서 인증된 사용자 정보를 반환한다. * - * @param loginId 로그인 ID (인증 헤더) - * @param loginPw 비밀번호 (인증 헤더) + * @param user 인증된 사용자 (인터셉터에서 주입) * @return 마스킹된 사용자 정보 응답 */ @GetMapping("/me") - public ResponseEntity> getMyInfo( - @RequestHeader("X-Loopers-LoginId") String loginId, - @RequestHeader("X-Loopers-LoginPw") String loginPw) { - UserInfo info = userAppService.getMyInfo(loginId, loginPw); - return ResponseEntity.ok(ApiResponse.success(UserV1Dto.MyInfoResponse.from(info))); + public ResponseEntity> getMyInfo(@AuthUser UserModel user) { + return ResponseEntity.ok(ApiResponse.success(UserV1Dto.MyInfoResponse.from(user))); } /** * 비밀번호 변경 API. 인증 후 현재 비밀번호 확인 및 새 비밀번호로 변경한다. * - * @param loginId 로그인 ID (인증 헤더) - * @param loginPw 비밀번호 (인증 헤더) + * @param user 인증된 사용자 (인터셉터에서 주입) * @param request 비밀번호 변경 요청 DTO (현재 비밀번호, 새 비밀번호) * @return 성공 응답 */ @PatchMapping("/me/password") public ResponseEntity> changePassword( - @RequestHeader("X-Loopers-LoginId") String loginId, - @RequestHeader("X-Loopers-LoginPw") String loginPw, + @AuthUser UserModel user, @Valid @RequestBody UserV1Dto.ChangePasswordRequest request) { - userAppService.changePassword(loginId, loginPw, + userService.changePassword(user.getLoginId(), request.getCurrentPassword(), request.getNewPassword()); return ResponseEntity.ok(ApiResponse.success()); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java index 14dfe3343..e551ccf27 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java @@ -1,6 +1,6 @@ package com.loopers.interfaces.api.user; -import com.loopers.application.user.UserInfo; +import com.loopers.domain.user.UserModel; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; @@ -55,7 +55,7 @@ public static class RegisterRequest { @AllArgsConstructor @Builder public static class RegisterResponse { - private String userId; + private Long userId; private String loginId; private String maskedName; private String birthday; @@ -63,19 +63,19 @@ public static class RegisterResponse { private String address; /** - * {@link UserInfo}를 회원가입 응답 DTO로 변환하는 팩토리 메서드. + * {@link UserModel}을 회원가입 응답 DTO로 변환하는 팩토리 메서드. * - * @param info 변환할 사용자 도메인 Info 객체 + * @param model 변환할 사용자 도메인 모델 * @return 변환된 RegisterResponse */ - public static RegisterResponse from(UserInfo info) { + public static RegisterResponse from(UserModel model) { return RegisterResponse.builder() - .userId(info.getUserId()) - .loginId(info.getLoginId()) - .maskedName(info.getMaskedName()) - .birthday(info.getBirthday()) - .email(info.getEmail()) - .address(info.getAddress()) + .userId(model.getUserId()) + .loginId(model.getLoginId()) + .maskedName(model.getMaskedName()) + .birthday(model.getBirthday()) + .email(model.getEmail()) + .address(model.getAddress()) .build(); } } @@ -89,7 +89,7 @@ public static RegisterResponse from(UserInfo info) { @AllArgsConstructor @Builder public static class MyInfoResponse { - private String userId; + private Long userId; private String loginId; private String maskedName; private String birthday; @@ -97,19 +97,19 @@ public static class MyInfoResponse { private String address; /** - * {@link UserInfo}를 내 정보 조회 응답 DTO로 변환하는 팩토리 메서드. + * {@link UserModel}을 내 정보 조회 응답 DTO로 변환하는 팩토리 메서드. * - * @param info 변환할 사용자 도메인 Info 객체 + * @param model 변환할 사용자 도메인 모델 * @return 변환된 MyInfoResponse */ - public static MyInfoResponse from(UserInfo info) { + public static MyInfoResponse from(UserModel model) { return MyInfoResponse.builder() - .userId(info.getUserId()) - .loginId(info.getLoginId()) - .maskedName(info.getMaskedName()) - .birthday(info.getBirthday()) - .email(info.getEmail()) - .address(info.getAddress()) + .userId(model.getUserId()) + .loginId(model.getLoginId()) + .maskedName(model.getMaskedName()) + .birthday(model.getBirthday()) + .email(model.getEmail()) + .address(model.getAddress()) .build(); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/apiadmin/AdminBrandV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/apiadmin/AdminBrandV1Controller.java index a2963e3de..bef1954e5 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/apiadmin/AdminBrandV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/apiadmin/AdminBrandV1Controller.java @@ -1,8 +1,7 @@ package com.loopers.interfaces.apiadmin; -import com.loopers.application.brand.BrandInfo; -import com.loopers.application.brand.BrandAppService; -import com.loopers.domain.product.ProductService; +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.brand.BrandService; import com.loopers.interfaces.api.ApiResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -15,15 +14,15 @@ * 관리자 전용 브랜드 REST API 엔드포인트를 제공하는 컨트롤러. * *

브랜드의 전체 조회, 등록, 수정, 삭제 기능을 관리자에게 제공한다. - * 브랜드 삭제 시 소속 상품도 연쇄적으로 소프트 삭제된다.

+ * 브랜드 삭제 시 소속 상품도 연쇄적으로 소프트 삭제된다. + * {@link BrandService}를 직접 호출한다.

*/ @RestController @RequestMapping("/api-admin/v1/brands") @RequiredArgsConstructor public class AdminBrandV1Controller { - private final BrandAppService brandAppService; - private final ProductService productService; + private final BrandService brandService; /** * 전체 브랜드 목록을 조회한다 (삭제된 브랜드 포함). @@ -32,7 +31,7 @@ public class AdminBrandV1Controller { */ @GetMapping public ResponseEntity>> list() { - List response = brandAppService.findAllForAdmin().stream() + List response = brandService.findAllForAdmin().stream() .map(AdminBrandV1Dto.AdminBrandResponse::from) .toList(); return ResponseEntity.ok(ApiResponse.success(response)); @@ -47,9 +46,9 @@ public ResponseEntity>> lis @PostMapping public ResponseEntity> create( @Valid @RequestBody AdminBrandV1Dto.CreateBrandRequest request) { - BrandInfo info = brandAppService.createBrand( + BrandModel brand = brandService.createBrand( request.getBrandName(), request.getDescription(), request.getAddress()); - return ResponseEntity.ok(ApiResponse.success(AdminBrandV1Dto.AdminBrandResponse.from(info))); + return ResponseEntity.ok(ApiResponse.success(AdminBrandV1Dto.AdminBrandResponse.from(brand))); } /** @@ -61,11 +60,11 @@ public ResponseEntity> create( */ @PutMapping("/{brandId}") public ResponseEntity> update( - @PathVariable String brandId, + @PathVariable Long brandId, @Valid @RequestBody AdminBrandV1Dto.UpdateBrandRequest request) { - BrandInfo info = brandAppService.updateBrand(brandId, + BrandModel brand = brandService.updateBrand(brandId, request.getBrandName(), request.getDescription(), request.getAddress()); - return ResponseEntity.ok(ApiResponse.success(AdminBrandV1Dto.AdminBrandResponse.from(info))); + return ResponseEntity.ok(ApiResponse.success(AdminBrandV1Dto.AdminBrandResponse.from(brand))); } /** @@ -75,9 +74,8 @@ public ResponseEntity> update( * @return 삭제 성공 응답 */ @DeleteMapping("/{brandId}") - public ResponseEntity> delete(@PathVariable String brandId) { - brandAppService.deleteBrand(brandId); - productService.softDeleteByBrandId(brandId); + public ResponseEntity> delete(@PathVariable Long brandId) { + brandService.deleteBrand(brandId); return ResponseEntity.ok(ApiResponse.success()); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/apiadmin/AdminBrandV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/apiadmin/AdminBrandV1Dto.java index fa630242d..a1c71509f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/apiadmin/AdminBrandV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/apiadmin/AdminBrandV1Dto.java @@ -1,6 +1,6 @@ package com.loopers.interfaces.apiadmin; -import com.loopers.application.brand.BrandInfo; +import com.loopers.domain.brand.BrandModel; import com.loopers.support.enums.DisplayStatus; import jakarta.validation.constraints.NotBlank; import lombok.AllArgsConstructor; @@ -52,7 +52,7 @@ public static class UpdateBrandRequest { @AllArgsConstructor @Builder public static class AdminBrandResponse { - private String brandId; + private Long brandId; private String brandName; private String description; private String address; @@ -62,21 +62,21 @@ public static class AdminBrandResponse { private ZonedDateTime createdAt; /** - * {@link BrandInfo}를 관리자 브랜드 응답 DTO로 변환하는 정적 팩토리 메서드. + * {@link BrandModel}을 관리자 브랜드 응답 DTO로 변환하는 정적 팩토리 메서드. * - * @param info 브랜드 정보 DTO + * @param model 브랜드 도메인 모델 * @return 변환된 관리자 브랜드 응답 DTO */ - public static AdminBrandResponse from(BrandInfo info) { + public static AdminBrandResponse from(BrandModel model) { return AdminBrandResponse.builder() - .brandId(info.getBrandId()) - .brandName(info.getBrandName()) - .description(info.getDescription()) - .address(info.getAddress()) - .displayStatus(info.getDisplayStatus()) - .delYn(info.getDelYn()) - .deletedAt(info.getDeletedAt()) - .createdAt(info.getCreatedAt()) + .brandId(model.getBrandId()) + .brandName(model.getBrandName()) + .description(model.getDescription()) + .address(model.getAddress()) + .displayStatus(model.getDisplayStatus()) + .delYn(model.getDelYn()) + .deletedAt(model.getDeletedAt()) + .createdAt(model.getCreatedAt()) .build(); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/apiadmin/AdminCartV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/apiadmin/AdminCartV1Controller.java index 05d74b87a..34d965d17 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/apiadmin/AdminCartV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/apiadmin/AdminCartV1Controller.java @@ -30,7 +30,7 @@ public class AdminCartV1Controller { */ @GetMapping("/users/{userId}/cart") public ResponseEntity>> getUserCart( - @PathVariable String userId) { + @PathVariable Long userId) { List cart = cartFacade.getCartForAdmin(userId); List response = cart.stream() .map(AdminCartV1Dto.AdminCartItemResponse::from) diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/apiadmin/AdminCartV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/apiadmin/AdminCartV1Dto.java index 4224f90e2..d43a406eb 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/apiadmin/AdminCartV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/apiadmin/AdminCartV1Dto.java @@ -22,8 +22,8 @@ public class AdminCartV1Dto { @AllArgsConstructor @Builder public static class AdminCartItemResponse { - private String userId; - private String productId; + private Long userId; + private Long productId; private int quantity; private boolean available; private UnavailableReason unavailableReason; diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/apiadmin/AdminCouponV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/apiadmin/AdminCouponV1Controller.java new file mode 100644 index 000000000..c8ca98d22 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/apiadmin/AdminCouponV1Controller.java @@ -0,0 +1,115 @@ +package com.loopers.interfaces.apiadmin; + +import com.loopers.domain.coupon.CouponModel; +import com.loopers.domain.coupon.CouponService; +import com.loopers.domain.coupon.UserCouponModel; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.PageResponse; +import com.loopers.support.page.PageQuery; +import com.loopers.support.page.PagedResult; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +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.RestController; + +import java.util.List; + +/** + * 관리자 쿠폰 API V1 컨트롤러. + */ +@RestController +@RequestMapping("/api-admin/v1/coupons") +@RequiredArgsConstructor +public class AdminCouponV1Controller { + + private final CouponService couponService; + + /** + * 쿠폰 템플릿을 등록한다. + */ + @PostMapping + public ResponseEntity> createCoupon( + @Valid @RequestBody AdminCouponV1Dto.CouponRequest request) { + CouponModel coupon = couponService.createCoupon( + request.getName(), request.getType(), request.getValue(), + request.getMinOrderAmount(), request.getExpiredAt()); + return ResponseEntity.ok(ApiResponse.success(AdminCouponV1Dto.CouponResponse.from(coupon))); + } + + /** + * 쿠폰 목록을 페이징 조회한다 (삭제 포함). + */ + @GetMapping + public ResponseEntity>> getCoupons( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + PageQuery query = new PageQuery(page, size, "couponId", false); + PagedResult pagedResult = couponService.findAllForAdminPaged(query); + List content = pagedResult.content().stream() + .map(AdminCouponV1Dto.CouponResponse::from) + .toList(); + PageResponse pageResponse = new PageResponse<>( + content, pagedResult.page(), pagedResult.size(), + pagedResult.totalElements(), pagedResult.totalPages()); + return ResponseEntity.ok(ApiResponse.success(pageResponse)); + } + + /** + * 쿠폰 상세를 조회한다. + */ + @GetMapping("/{couponId}") + public ResponseEntity> getCoupon( + @PathVariable Long couponId) { + CouponModel coupon = couponService.findByIdForAdmin(couponId); + return ResponseEntity.ok(ApiResponse.success(AdminCouponV1Dto.CouponResponse.from(coupon))); + } + + /** + * 쿠폰을 수정한다. + */ + @PutMapping("/{couponId}") + public ResponseEntity> updateCoupon( + @PathVariable Long couponId, + @Valid @RequestBody AdminCouponV1Dto.CouponRequest request) { + CouponModel coupon = couponService.updateCoupon( + couponId, request.getName(), request.getType(), request.getValue(), + request.getMinOrderAmount(), request.getExpiredAt()); + return ResponseEntity.ok(ApiResponse.success(AdminCouponV1Dto.CouponResponse.from(coupon))); + } + + /** + * 쿠폰을 소프트 삭제한다. + */ + @DeleteMapping("/{couponId}") + public ResponseEntity> deleteCoupon(@PathVariable Long couponId) { + couponService.deleteCoupon(couponId); + return ResponseEntity.ok(ApiResponse.success()); + } + + /** + * 특정 쿠폰의 발급 내역을 페이징 조회한다. + */ + @GetMapping("/{couponId}/issues") + public ResponseEntity>> getIssueHistory( + @PathVariable Long couponId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + PageQuery query = new PageQuery(page, size, "userCouponId", false); + PagedResult pagedResult = couponService.findIssueHistoryPaged(couponId, query); + List content = pagedResult.content().stream() + .map(AdminCouponV1Dto.IssueHistoryResponse::from) + .toList(); + PageResponse pageResponse = new PageResponse<>( + content, pagedResult.page(), pagedResult.size(), + pagedResult.totalElements(), pagedResult.totalPages()); + return ResponseEntity.ok(ApiResponse.success(pageResponse)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/apiadmin/AdminCouponV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/apiadmin/AdminCouponV1Dto.java new file mode 100644 index 000000000..1876fa1f2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/apiadmin/AdminCouponV1Dto.java @@ -0,0 +1,104 @@ +package com.loopers.interfaces.apiadmin; + +import com.loopers.domain.coupon.CouponModel; +import com.loopers.domain.coupon.UserCouponModel; +import com.loopers.support.enums.DiscountType; +import com.loopers.support.enums.UserCouponStatus; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 관리자 쿠폰 API V1 요청/응답 DTO 모음. + */ +public class AdminCouponV1Dto { + + /** + * 쿠폰 생성/수정 요청 DTO. + */ + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class CouponRequest { + @NotBlank(message = "쿠폰명은 필수입니다") + private String name; + + @NotNull(message = "할인 유형은 필수입니다") + private DiscountType type; + + @NotNull(message = "할인값은 필수입니다") + @DecimalMin(value = "0.01", message = "할인값은 0보다 커야 합니다") + private BigDecimal value; + + private BigDecimal minOrderAmount; + + @NotNull(message = "만료 일시는 필수입니다") + private LocalDateTime expiredAt; + } + + /** + * 쿠폰 응답 DTO. + */ + @Getter + @AllArgsConstructor + @Builder + public static class CouponResponse { + private Long couponId; + private String name; + private DiscountType discountType; + private BigDecimal discountValue; + private BigDecimal minOrderAmount; + private LocalDateTime expiredAt; + private String delYn; + private LocalDateTime deletedAt; + + public static CouponResponse from(CouponModel coupon) { + return CouponResponse.builder() + .couponId(coupon.getCouponId()) + .name(coupon.getName()) + .discountType(coupon.getDiscountType()) + .discountValue(coupon.getDiscountValue()) + .minOrderAmount(coupon.getMinOrderAmount()) + .expiredAt(coupon.getExpiredAt()) + .delYn(coupon.getDelYn()) + .deletedAt(coupon.getDeletedAt() != null ? coupon.getDeletedAt().toLocalDateTime() : null) + .build(); + } + } + + /** + * 발급 내역 응답 DTO. + */ + @Getter + @AllArgsConstructor + @Builder + public static class IssueHistoryResponse { + private Long userCouponId; + private Long userId; + private Long couponId; + private UserCouponStatus status; + private LocalDateTime issuedAt; + private LocalDateTime usedAt; + private Long orderId; + + public static IssueHistoryResponse from(UserCouponModel userCoupon) { + return IssueHistoryResponse.builder() + .userCouponId(userCoupon.getUserCouponId()) + .userId(userCoupon.getUserId()) + .couponId(userCoupon.getCouponId()) + .status(userCoupon.getStatus()) + .issuedAt(userCoupon.getIssuedAt()) + .usedAt(userCoupon.getUsedAt()) + .orderId(userCoupon.getOrderId()) + .build(); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/apiadmin/AdminOrderV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/apiadmin/AdminOrderV1Controller.java index 1e9110bba..b92e70b91 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/apiadmin/AdminOrderV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/apiadmin/AdminOrderV1Controller.java @@ -1,6 +1,6 @@ package com.loopers.interfaces.apiadmin; -import com.loopers.application.order.OrderAppService; +import com.loopers.application.order.OrderFacade; import com.loopers.application.order.OrderInfo; import com.loopers.interfaces.api.ApiResponse; import lombok.RequiredArgsConstructor; @@ -9,19 +9,21 @@ import org.springframework.web.bind.annotation.*; import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.List; /** * 관리자 전용 주문 REST API 엔드포인트를 제공하는 컨트롤러. * - *

기간별 주문 목록 조회 및 개별 주문 상세 조회 기능을 관리자에게 제공한다.

+ *

기간별 주문 목록 조회 및 개별 주문 상세 조회 기능을 관리자에게 제공한다. + * {@link OrderFacade}를 통해 조회한다.

*/ @RestController @RequestMapping("/api-admin/v1/orders") @RequiredArgsConstructor public class AdminOrderV1Controller { - private final OrderAppService orderAppService; + private final OrderFacade orderFacade; /** * 기간별 전체 주문 목록을 조회한다. @@ -37,12 +39,10 @@ public ResponseEntity>> lis @RequestParam(value = "startAt", required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startAt, @RequestParam(value = "endAt", required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endAt) { LocalDate now = LocalDate.now(); - LocalDate start = startAt != null ? startAt : now.minusMonths(1); - LocalDate end = endAt != null ? endAt : now; + LocalDateTime start = (startAt != null ? startAt : now.minusMonths(1)).atStartOfDay(); + LocalDateTime end = (endAt != null ? endAt : now).plusDays(1).atStartOfDay(); - List orders = orderAppService.findAllOrders( - start.atStartOfDay(), end.plusDays(1).atStartOfDay()); - List response = orders.stream() + List response = orderFacade.getOrdersForAdmin(start, end).stream() .map(AdminOrderV1Dto.AdminOrderResponse::from) .toList(); return ResponseEntity.ok(ApiResponse.success(response)); @@ -56,8 +56,8 @@ public ResponseEntity>> lis */ @GetMapping("/{orderId}") public ResponseEntity> detail( - @PathVariable String orderId) { - OrderInfo info = orderAppService.findOrderById(orderId); + @PathVariable Long orderId) { + OrderInfo info = orderFacade.getOrderDetailForAdmin(orderId); return ResponseEntity.ok(ApiResponse.success(AdminOrderV1Dto.AdminOrderResponse.from(info))); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/apiadmin/AdminOrderV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/apiadmin/AdminOrderV1Dto.java index 7dfcee84b..3f2f552d6 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/apiadmin/AdminOrderV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/apiadmin/AdminOrderV1Dto.java @@ -18,15 +18,13 @@ public class AdminOrderV1Dto { /** * 관리자용 주문 응답 DTO. - * - *

주문 유형, 상태, 총 금액, 만료 일시, 결제 일시 및 주문 항목 목록을 포함한다.

*/ @Getter @AllArgsConstructor @Builder public static class AdminOrderResponse { - private String orderId; - private String userId; + private Long orderId; + private Long userId; private OrderType orderType; private OrderStatus status; private BigDecimal totalAmount; @@ -34,12 +32,6 @@ public static class AdminOrderResponse { private LocalDateTime paidAt; private List items; - /** - * {@link OrderInfo}를 관리자 주문 응답 DTO로 변환하는 정적 팩토리 메서드. - * - * @param info 주문 정보 DTO - * @return 변환된 관리자 주문 응답 DTO - */ public static AdminOrderResponse from(OrderInfo info) { return AdminOrderResponse.builder() .orderId(info.getOrderId()) @@ -57,26 +49,21 @@ public static AdminOrderResponse from(OrderInfo info) { } /** - * 관리자용 주문 항목 응답 DTO. - * - *

주문 시점의 스냅샷 정보(상품명, 단가, 브랜드명)를 포함한다.

+ * 관리자용 주문 항목 응답 DTO (할인 금액 필드 포함). */ @Getter @AllArgsConstructor @Builder public static class AdminOrderItemResponse { - private String productId; + private Long productId; private int quantity; private String snapshotProductName; private BigDecimal snapshotUnitPrice; private String snapshotBrandName; + private BigDecimal originalAmount; + private BigDecimal discountAmount; + private BigDecimal finalAmount; - /** - * {@link OrderInfo.OrderItemInfo}를 관리자 주문 항목 응답 DTO로 변환하는 정적 팩토리 메서드. - * - * @param item 주문 항목 정보 DTO - * @return 변환된 관리자 주문 항목 응답 DTO - */ public static AdminOrderItemResponse from(OrderInfo.OrderItemInfo item) { return AdminOrderItemResponse.builder() .productId(item.getProductId()) @@ -84,6 +71,9 @@ public static AdminOrderItemResponse from(OrderInfo.OrderItemInfo item) { .snapshotProductName(item.getSnapshotProductName()) .snapshotUnitPrice(item.getSnapshotUnitPrice()) .snapshotBrandName(item.getSnapshotBrandName()) + .originalAmount(item.getOriginalAmount()) + .discountAmount(item.getDiscountAmount()) + .finalAmount(item.getFinalAmount()) .build(); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/apiadmin/AdminProductV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/apiadmin/AdminProductV1Controller.java index 7f24f18a3..79e550759 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/apiadmin/AdminProductV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/apiadmin/AdminProductV1Controller.java @@ -1,11 +1,11 @@ package com.loopers.interfaces.apiadmin; -import com.loopers.application.product.ProductAppService; import com.loopers.application.product.ProductCreateCommand; import com.loopers.application.product.ProductFacade; import com.loopers.application.product.ProductUpdateCommand; import com.loopers.application.product.ProductInfo; -import com.loopers.application.product.ProductRevisionInfo; +import com.loopers.domain.product.ProductRevisionModel; +import com.loopers.domain.product.ProductService; import com.loopers.interfaces.api.ApiResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -18,7 +18,8 @@ * 관리자 전용 상품 REST API 엔드포인트를 제공하는 컨트롤러. * *

상품의 전체 조회, 등록, 수정, 삭제 및 변경 이력(revision) 조회 기능을 관리자에게 제공한다. - * 복잡한 도메인으로 {@link ProductFacade}를 통해 여러 서비스를 조합하여 처리한다.

+ * 복잡한 도메인으로 {@link ProductFacade}를 통해 여러 서비스를 조합하여 처리한다. + * 단순 삭제 및 이력 조회는 {@link ProductService}를 직접 호출한다.

*/ @RestController @RequestMapping("/api-admin/v1/products") @@ -26,7 +27,7 @@ public class AdminProductV1Controller { private final ProductFacade productFacade; - private final ProductAppService productAppService; + private final ProductService productService; /** * 전체 상품 목록을 조회한다. @@ -68,7 +69,7 @@ public ResponseEntity> creat */ @PutMapping("/{productId}") public ResponseEntity> update( - @PathVariable String productId, + @PathVariable Long productId, @Valid @RequestBody AdminProductV1Dto.UpdateProductRequest request) { ProductInfo info = productFacade.updateProduct(new ProductUpdateCommand( productId, request.getProductName(), request.getPrice(), @@ -83,8 +84,8 @@ public ResponseEntity> updat * @return 삭제 성공 응답 */ @DeleteMapping("/{productId}") - public ResponseEntity> delete(@PathVariable String productId) { - productAppService.deleteProduct(productId); + public ResponseEntity> delete(@PathVariable Long productId) { + productService.deleteProduct(productId); return ResponseEntity.ok(ApiResponse.success()); } @@ -96,8 +97,9 @@ public ResponseEntity> delete(@PathVariable String productId */ @GetMapping("/{productId}/revisions") public ResponseEntity>> getRevisions( - @PathVariable String productId) { - List response = productAppService.getRevisions(productId).stream() + @PathVariable Long productId) { + List revisions = productService.findRevisionsByProductId(productId); + List response = revisions.stream() .map(AdminProductV1Dto.RevisionResponse::from) .toList(); return ResponseEntity.ok(ApiResponse.success(response)); @@ -112,8 +114,8 @@ public ResponseEntity>> get */ @GetMapping("/{productId}/revisions/{seq}") public ResponseEntity> getRevisionDetail( - @PathVariable String productId, @PathVariable Long seq) { - ProductRevisionInfo info = productAppService.getRevisionDetail(productId, seq); - return ResponseEntity.ok(ApiResponse.success(AdminProductV1Dto.RevisionResponse.from(info))); + @PathVariable Long productId, @PathVariable Long seq) { + ProductRevisionModel revision = productService.findRevisionById(productId, seq); + return ResponseEntity.ok(ApiResponse.success(AdminProductV1Dto.RevisionResponse.from(revision))); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/apiadmin/AdminProductV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/apiadmin/AdminProductV1Dto.java index 3264a4e04..f9d6ee392 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/apiadmin/AdminProductV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/apiadmin/AdminProductV1Dto.java @@ -1,7 +1,7 @@ package com.loopers.interfaces.apiadmin; import com.loopers.application.product.ProductInfo; -import com.loopers.application.product.ProductRevisionInfo; +import com.loopers.domain.product.ProductRevisionModel; import com.loopers.support.enums.DisplayStatus; import com.loopers.support.enums.ProductRevisionAction; import com.loopers.support.enums.ProductSaleStatus; @@ -33,8 +33,7 @@ public class AdminProductV1Dto { public static class CreateProductRequest { @NotBlank(message = "상품 이름은 필수입니다") private String productName; - @NotBlank(message = "브랜드 ID는 필수입니다") - private String brandId; + private Long brandId; @Positive(message = "가격은 0보다 커야 합니다") private BigDecimal price; private String description; @@ -70,8 +69,8 @@ public static class UpdateProductRequest { @AllArgsConstructor @Builder public static class AdminProductResponse { - private String productId; - private String brandId; + private Long productId; + private Long brandId; private String productName; private String description; private BigDecimal price; @@ -111,7 +110,7 @@ public static AdminProductResponse from(ProductInfo info) { @AllArgsConstructor @Builder public static class RevisionResponse { - private String productId; + private Long productId; private Long revisionSeq; private ProductRevisionAction action; private String changedBy; @@ -121,21 +120,21 @@ public static class RevisionResponse { private LocalDateTime createdAt; /** - * {@link ProductRevisionInfo}를 상품 변경 이력 응답 DTO로 변환하는 정적 팩토리 메서드. + * {@link ProductRevisionModel}을 상품 변경 이력 응답 DTO로 변환하는 정적 팩토리 메서드. * - * @param info 상품 변경 이력 정보 DTO + * @param model 상품 변경 이력 도메인 모델 * @return 변환된 상품 변경 이력 응답 DTO */ - public static RevisionResponse from(ProductRevisionInfo info) { + public static RevisionResponse from(ProductRevisionModel model) { return RevisionResponse.builder() - .productId(info.getProductId()) - .revisionSeq(info.getRevisionSeq()) - .action(info.getAction()) - .changedBy(info.getChangedBy()) - .changeReason(info.getChangeReason()) - .beforeSnapshot(info.getBeforeSnapshot()) - .afterSnapshot(info.getAfterSnapshot()) - .createdAt(info.getCreatedAt()) + .productId(model.getProductId()) + .revisionSeq(model.getRevisionSeq()) + .action(model.getAction()) + .changedBy(model.getChangedBy()) + .changeReason(model.getChangeReason()) + .beforeSnapshot(model.getBeforeSnapshot()) + .afterSnapshot(model.getAfterSnapshot()) + .createdAt(model.getCreatedAt()) .build(); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/apiadmin/AdminStatsV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/apiadmin/AdminStatsV1Controller.java index 1549ad34c..4fd2329cc 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/apiadmin/AdminStatsV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/apiadmin/AdminStatsV1Controller.java @@ -1,6 +1,6 @@ package com.loopers.interfaces.apiadmin; -import com.loopers.application.stats.StatsAppService; +import com.loopers.domain.stats.StatsService; import com.loopers.interfaces.api.ApiResponse; import lombok.RequiredArgsConstructor; import org.springframework.format.annotation.DateTimeFormat; @@ -13,14 +13,15 @@ /** * 관리자 전용 운영 통계 REST API 엔드포인트를 제공하는 컨트롤러. * - *

주문 현황 개요, 일별 주문 통계, 인기 상품(좋아요/주문 기준), 저재고 상품 조회 기능을 관리자에게 제공한다.

+ *

주문 현황 개요, 일별 주문 통계, 인기 상품(좋아요/주문 기준), 저재고 상품 조회 기능을 관리자에게 제공한다. + * {@link StatsService}를 직접 호출한다.

*/ @RestController @RequestMapping("/api-admin/v1/stats") @RequiredArgsConstructor public class AdminStatsV1Controller { - private final StatsAppService statsAppService; + private final StatsService statsService; /** * 기간별 주문 현황 개요(대기/취소/만료 건수)를 조회한다. @@ -33,7 +34,7 @@ public class AdminStatsV1Controller { public ResponseEntity> overview( @RequestParam("startAt") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startAt, @RequestParam("endAt") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endAt) { - var overview = statsAppService.getOverview(startAt, endAt); + var overview = statsService.getOverview(startAt, endAt); return ResponseEntity.ok(ApiResponse.success(AdminStatsV1Dto.OverviewResponse.from(overview))); } @@ -48,7 +49,7 @@ public ResponseEntity> overview( public ResponseEntity>> dailyOrderStats( @RequestParam("startAt") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startAt, @RequestParam("endAt") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endAt) { - var stats = statsAppService.getDailyOrderStats(startAt, endAt); + var stats = statsService.getDailyOrderStats(startAt, endAt); List response = stats.stream() .map(AdminStatsV1Dto.DailyOrderStatResponse::from) .toList(); @@ -64,7 +65,7 @@ public ResponseEntity>> @GetMapping("/products/top-liked") public ResponseEntity>> topLikedProducts( @RequestParam(value = "limit", defaultValue = "10") int limit) { - var stats = statsAppService.getTopLikedProducts(limit); + var stats = statsService.getTopLikedProducts(limit); List response = stats.stream() .map(AdminStatsV1Dto.ProductStatResponse::from) .toList(); @@ -80,7 +81,7 @@ public ResponseEntity>> to @GetMapping("/products/top-ordered") public ResponseEntity>> topOrderedProducts( @RequestParam(value = "limit", defaultValue = "10") int limit) { - var stats = statsAppService.getTopOrderedProducts(limit); + var stats = statsService.getTopOrderedProducts(limit); List response = stats.stream() .map(AdminStatsV1Dto.ProductStatResponse::from) .toList(); @@ -96,7 +97,7 @@ public ResponseEntity>> to @GetMapping("/stocks/low") public ResponseEntity>> lowStockProducts( @RequestParam(value = "threshold", defaultValue = "10") int threshold) { - var stocks = statsAppService.getLowStockProducts(threshold); + var stocks = statsService.getLowStockProducts(threshold); List response = stocks.stream() .map(AdminStatsV1Dto.LowStockProductResponse::from) .toList(); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/apiadmin/AdminStatsV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/apiadmin/AdminStatsV1Dto.java index 3199e52d3..3199ed3f9 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/apiadmin/AdminStatsV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/apiadmin/AdminStatsV1Dto.java @@ -1,6 +1,6 @@ package com.loopers.interfaces.apiadmin; -import com.loopers.application.stats.StatsInfo; +import com.loopers.domain.stats.StatsProjection; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -27,16 +27,16 @@ public static class OverviewResponse { private long expiredCount; /** - * {@link StatsInfo.Overview}를 주문 현황 개요 응답 DTO로 변환하는 정적 팩토리 메서드. + * {@link StatsProjection.Overview}를 주문 현황 개요 응답 DTO로 변환하는 정적 팩토리 메서드. * - * @param overview 주문 현황 개요 정보 + * @param projection 주문 현황 개요 프로젝션 * @return 변환된 주문 현황 개요 응답 DTO */ - public static OverviewResponse from(StatsInfo.Overview overview) { + public static OverviewResponse from(StatsProjection.Overview projection) { return OverviewResponse.builder() - .pendingCount(overview.getPendingCount()) - .cancelledCount(overview.getCancelledCount()) - .expiredCount(overview.getExpiredCount()) + .pendingCount(projection.getPendingCount()) + .cancelledCount(projection.getCancelledCount()) + .expiredCount(projection.getExpiredCount()) .build(); } } @@ -55,16 +55,16 @@ public static class DailyOrderStatResponse { private BigDecimal totalAmount; /** - * {@link StatsInfo.DailyOrderStat}을 일별 주문 통계 응답 DTO로 변환하는 정적 팩토리 메서드. + * {@link StatsProjection.DailyOrderStat}을 일별 주문 통계 응답 DTO로 변환하는 정적 팩토리 메서드. * - * @param stat 일별 주문 통계 정보 + * @param projection 일별 주문 통계 프로젝션 * @return 변환된 일별 주문 통계 응답 DTO */ - public static DailyOrderStatResponse from(StatsInfo.DailyOrderStat stat) { + public static DailyOrderStatResponse from(StatsProjection.DailyOrderStat projection) { return DailyOrderStatResponse.builder() - .date(stat.getDate()) - .orderCount(stat.getOrderCount()) - .totalAmount(stat.getTotalAmount()) + .date(projection.getDate()) + .orderCount(projection.getOrderCount()) + .totalAmount(projection.getTotalAmount()) .build(); } } @@ -78,21 +78,21 @@ public static DailyOrderStatResponse from(StatsInfo.DailyOrderStat stat) { @AllArgsConstructor @Builder public static class ProductStatResponse { - private String productId; + private Long productId; private String productName; private long count; /** - * {@link StatsInfo.ProductStat}을 상품 통계 응답 DTO로 변환하는 정적 팩토리 메서드. + * {@link StatsProjection.ProductStat}을 상품 통계 응답 DTO로 변환하는 정적 팩토리 메서드. * - * @param stat 상품 통계 정보 + * @param projection 상품 통계 프로젝션 * @return 변환된 상품 통계 응답 DTO */ - public static ProductStatResponse from(StatsInfo.ProductStat stat) { + public static ProductStatResponse from(StatsProjection.ProductStat projection) { return ProductStatResponse.builder() - .productId(stat.getProductId()) - .productName(stat.getProductName()) - .count(stat.getCount()) + .productId(projection.getProductId()) + .productName(projection.getProductName()) + .count(projection.getCount()) .build(); } } @@ -106,25 +106,25 @@ public static ProductStatResponse from(StatsInfo.ProductStat stat) { @AllArgsConstructor @Builder public static class LowStockProductResponse { - private String productId; + private Long productId; private String productName; private int onHand; private int reserved; private int availableQty; /** - * {@link StatsInfo.LowStockProduct}를 저재고 상품 응답 DTO로 변환하는 정적 팩토리 메서드. + * {@link StatsProjection.LowStockProduct}를 저재고 상품 응답 DTO로 변환하는 정적 팩토리 메서드. * - * @param stock 저재고 상품 정보 + * @param projection 저재고 상품 프로젝션 * @return 변환된 저재고 상품 응답 DTO */ - public static LowStockProductResponse from(StatsInfo.LowStockProduct stock) { + public static LowStockProductResponse from(StatsProjection.LowStockProduct projection) { return LowStockProductResponse.builder() - .productId(stock.getProductId()) - .productName(stock.getProductName()) - .onHand(stock.getOnHand()) - .reserved(stock.getReserved()) - .availableQty(stock.getAvailableQty()) + .productId(projection.getProductId()) + .productName(projection.getProductName()) + .onHand(projection.getOnHand()) + .reserved(projection.getReserved()) + .availableQty(projection.getAvailableQty()) .build(); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/config/WebMvcConfig.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/config/WebMvcConfig.java index cdd108beb..b78421d74 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/config/WebMvcConfig.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/config/WebMvcConfig.java @@ -1,27 +1,35 @@ package com.loopers.interfaces.config; +import com.loopers.interfaces.api.AuthUserArgumentResolver; +import com.loopers.interfaces.api.CustomerAuthInterceptor; import com.loopers.interfaces.apiadmin.AdminAuthInterceptor; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import java.util.List; + /** * Spring MVC 설정 클래스. * *

인터셉터 등록 등 웹 계층의 공통 설정을 담당한다. - * 관리자 API({@code /api-admin/**}) 경로에 대한 인증 인터셉터를 등록한다.

+ * 관리자 API({@code /api-admin/**}) 경로와 고객 API 인증 경로에 대한 인터셉터를 등록한다.

*/ @Configuration @RequiredArgsConstructor public class WebMvcConfig implements WebMvcConfigurer { private final AdminAuthInterceptor adminAuthInterceptor; + private final CustomerAuthInterceptor customerAuthInterceptor; + private final AuthUserArgumentResolver authUserArgumentResolver; /** * 인터셉터를 등록한다. * - *

관리자 API 경로({@code /api-admin/**})에 {@link AdminAuthInterceptor}를 적용한다.

+ *

관리자 API 경로({@code /api-admin/**})에 {@link AdminAuthInterceptor}를 적용하고, + * 고객 인증 필요 API 경로에 {@link CustomerAuthInterceptor}를 적용한다.

* * @param registry 인터셉터 레지스트리 */ @@ -29,5 +37,24 @@ public class WebMvcConfig implements WebMvcConfigurer { public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(adminAuthInterceptor) .addPathPatterns("/api-admin/**"); + + registry.addInterceptor(customerAuthInterceptor) + .addPathPatterns("/api/v1/users/me/**") + .addPathPatterns("/api/v1/products/*/likes") + .addPathPatterns("/api/v1/cart/**") + .addPathPatterns("/api/v1/orders/**") + .addPathPatterns("/api/v1/coupons/**"); + } + + /** + * ArgumentResolver를 등록한다. + * + *

{@link AuthUserArgumentResolver}를 등록하여 {@code @AuthUser} 어노테이션을 지원한다.

+ * + * @param resolvers ArgumentResolver 목록 + */ + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(authUserArgumentResolver); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/support/enums/DiscountType.java b/apps/commerce-api/src/main/java/com/loopers/support/enums/DiscountType.java new file mode 100644 index 000000000..6d2bd2087 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/support/enums/DiscountType.java @@ -0,0 +1,9 @@ +package com.loopers.support.enums; + +/** + * 쿠폰 할인 유형. + */ +public enum DiscountType { + FIXED, // 고정 금액 할인 + RATE // 정률(%) 할인 +} diff --git a/apps/commerce-api/src/main/java/com/loopers/support/enums/UserCouponStatus.java b/apps/commerce-api/src/main/java/com/loopers/support/enums/UserCouponStatus.java new file mode 100644 index 000000000..526773f6d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/support/enums/UserCouponStatus.java @@ -0,0 +1,10 @@ +package com.loopers.support.enums; + +/** + * 사용자 발급 쿠폰 상태. + */ +public enum UserCouponStatus { + AVAILABLE, // 사용 가능 + USED, // 사용 완료 + EXPIRED // 만료 +} 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 442d7dde1..54e0bd77f 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 @@ -55,7 +55,14 @@ public enum ErrorType { ORDER_PENDING_LIMIT_EXCEEDED(HttpStatus.CONFLICT, "ORDER_PENDING_LIMIT_EXCEEDED", "동시 결제 대기 주문은 최대 3건까지 가능합니다."), /** Admin */ - ADMIN_UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "ADMIN_UNAUTHORIZED", "관리자 인증에 실패했습니다."); + ADMIN_UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "ADMIN_UNAUTHORIZED", "관리자 인증에 실패했습니다."), + + /** Coupon */ + COUPON_NOT_FOUND(HttpStatus.NOT_FOUND, "COUPON_NOT_FOUND", "쿠폰을 찾을 수 없습니다."), + COUPON_ALREADY_ISSUED(HttpStatus.CONFLICT, "COUPON_ALREADY_ISSUED", "이미 발급된 쿠폰입니다."), + COUPON_NOT_APPLICABLE(HttpStatus.BAD_REQUEST, "COUPON_NOT_APPLICABLE", "적용할 수 없는 쿠폰입니다."), + COUPON_NOT_AVAILABLE(HttpStatus.BAD_REQUEST, "COUPON_NOT_AVAILABLE", "사용 불가능한 쿠폰입니다."), + USER_COUPON_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_COUPON_NOT_FOUND", "발급된 쿠폰을 찾을 수 없습니다."); private final HttpStatus status; private final String code; diff --git a/apps/commerce-api/src/main/java/com/loopers/support/page/PageQuery.java b/apps/commerce-api/src/main/java/com/loopers/support/page/PageQuery.java new file mode 100644 index 000000000..cb751e9aa --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/support/page/PageQuery.java @@ -0,0 +1,15 @@ +package com.loopers.support.page; + +/** + * 도메인 레이어에서 사용하는 페이징/정렬 요청 객체. + * + *

Spring Data의 {@code Pageable}에 의존하지 않으며, + * infrastructure 계층에서 {@code Pageable}로 변환하여 사용한다.

+ * + * @param page 페이지 번호 (0부터 시작) + * @param size 페이지 크기 + * @param sortField 정렬 기준 필드명 (엔티티 필드명) + * @param ascending true이면 오름차순, false이면 내림차순 + */ +public record PageQuery(int page, int size, String sortField, boolean ascending) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/support/page/PagedResult.java b/apps/commerce-api/src/main/java/com/loopers/support/page/PagedResult.java new file mode 100644 index 000000000..19d4445e2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/support/page/PagedResult.java @@ -0,0 +1,19 @@ +package com.loopers.support.page; + +import java.util.List; + +/** + * 도메인 레이어에서 사용하는 페이징 결과 객체. + * + *

Spring Data의 {@code Page}에 의존하지 않으며, + * infrastructure 계층에서 {@code Page}를 변환하여 반환한다.

+ * + * @param content 현재 페이지의 콘텐츠 목록 + * @param page 현재 페이지 번호 (0부터 시작) + * @param size 페이지 크기 + * @param totalElements 전체 요소 수 + * @param totalPages 전체 페이지 수 + * @param 콘텐츠 요소 타입 + */ +public record PagedResult(List content, int page, int size, long totalElements, int totalPages) { +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/cart/CartAppServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/cart/CartAppServiceTest.java deleted file mode 100644 index 34d7f6b1f..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/application/cart/CartAppServiceTest.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.loopers.application.cart; - -import com.loopers.domain.cart.CartService; -import com.loopers.domain.user.UserModel; -import com.loopers.domain.user.UserService; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -@DisplayName("CartAppService 단위 테스트") -class CartAppServiceTest { - - @Mock - CartService cartService; - - @Mock - UserService userService; - - @InjectMocks - CartAppService cartAppService; - - @Test - @DisplayName("항목 삭제 시 인증 후 CartService.removeItem이 호출된다") - void removeItem_ShouldCallServiceMethod() { - UserModel user = mock(UserModel.class); - when(user.getUserId()).thenReturn("user-1"); - when(userService.authenticate("login1", "pw1")).thenReturn(user); - - cartAppService.removeItem("login1", "pw1", "p1"); - - verify(userService).authenticate("login1", "pw1"); - verify(cartService).removeItem("user-1", "p1"); - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/cart/CartFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/cart/CartFacadeTest.java index db45551be..df9c077f3 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/cart/CartFacadeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/cart/CartFacadeTest.java @@ -9,8 +9,6 @@ import com.loopers.domain.product.ProductService; import com.loopers.domain.product.ProductStockModel; import com.loopers.domain.product.StockService; -import com.loopers.domain.user.UserModel; -import com.loopers.domain.user.UserService; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import org.junit.jupiter.api.DisplayName; @@ -33,8 +31,9 @@ @DisplayName("CartFacade 단위 테스트") class CartFacadeTest { + private static final Long USER_ID = 1L; + @Mock CartService cartService; - @Mock UserService userService; @Mock ProductService productService; @Mock StockService stockService; @Mock BrandService brandService; @@ -42,15 +41,8 @@ class CartFacadeTest { @InjectMocks CartFacade cartFacade; - private UserModel mockAuthenticate() { - UserModel user = mock(UserModel.class); - when(user.getUserId()).thenReturn("user-1"); - when(userService.authenticate("login1", "pw1")).thenReturn(user); - return user; - } - private ProductModel createTestProduct() { - return ProductModel.create("테스트상품", "brand-id", BigDecimal.valueOf(10000), + return ProductModel.create("테스트상품", 1L, BigDecimal.valueOf(10000), "설명", null, null, null, null, null, null); } @@ -63,33 +55,31 @@ private BrandModel createTestBrand() { class GetCartTests { @Test - @DisplayName("인증 후 장바구니 항목 + 상품/브랜드/재고 정보를 배치 조회하여 반환한다") + @DisplayName("장바구니 항목 + 상품/브랜드/재고 정보를 배치 조회하여 반환한다") void getCart_ShouldReturnCartInfoListWithProductInfo() { - mockAuthenticate(); ProductModel product = mock(ProductModel.class); - when(product.getProductId()).thenReturn("product-1"); + when(product.getProductId()).thenReturn(1L); when(product.getProductName()).thenReturn("테스트상품"); - when(product.getBrandId()).thenReturn("brand-id"); + when(product.getBrandId()).thenReturn(1L); when(product.getPrice()).thenReturn(BigDecimal.valueOf(10000)); BrandModel brand = mock(BrandModel.class); - when(brand.getBrandId()).thenReturn("brand-id"); + when(brand.getBrandId()).thenReturn(1L); when(brand.getBrandName()).thenReturn("테스트브랜드"); - CartItemModel item = CartItemModel.create("user-1", "product-1", 2); - when(cartService.getCartItems("user-1")).thenReturn(List.of(item)); + CartItemModel item = CartItemModel.create(USER_ID, 1L, 2); + when(cartService.getCartItems(USER_ID)).thenReturn(List.of(item)); when(productService.findAllByIds(anyCollection())).thenReturn(List.of(product)); when(brandService.findAllByIds(anyCollection())).thenReturn(List.of(brand)); when(stockService.findAllByProductIds(anyCollection())) - .thenReturn(List.of(ProductStockModel.create("product-1", 100))); + .thenReturn(List.of(ProductStockModel.create(1L, 100))); - List result = cartFacade.getCart("login1", "pw1"); + List result = cartFacade.getCart(USER_ID); assertThat(result).hasSize(1); assertThat(result.get(0).isAvailable()).isTrue(); assertThat(result.get(0).getProductName()).isEqualTo("테스트상품"); - verify(userService).authenticate("login1", "pw1"); - verify(cartService).getCartItems("user-1"); + verify(cartService).getCartItems(USER_ID); verify(productService).findAllByIds(anyCollection()); verify(brandService).findAllByIds(anyCollection()); verify(stockService).findAllByProductIds(anyCollection()); @@ -98,10 +88,9 @@ void getCart_ShouldReturnCartInfoListWithProductInfo() { @Test @DisplayName("빈 장바구니 조회 시 빈 리스트를 반환한다") void getCart_EmptyCart_ShouldReturnEmptyList() { - mockAuthenticate(); - when(cartService.getCartItems("user-1")).thenReturn(List.of()); + when(cartService.getCartItems(USER_ID)).thenReturn(List.of()); - List result = cartFacade.getCart("login1", "pw1"); + List result = cartFacade.getCart(USER_ID); assertThat(result).isEmpty(); verify(productService, never()).findAllByIds(anyCollection()); @@ -113,34 +102,31 @@ void getCart_EmptyCart_ShouldReturnEmptyList() { class AddItemTests { @Test - @DisplayName("인증 → 상품 검증 → 재고 검증 → 장바구니 추가 오케스트레이션이 수행된다") + @DisplayName("상품 검증 → 재고 검증 → 장바구니 추가 오케스트레이션이 수행된다") void addItem_ShouldOrchestrate() { - mockAuthenticate(); - when(productService.findOrderableById("p1")).thenReturn(createTestProduct()); - when(stockService.findByProductId("p1")) - .thenReturn(ProductStockModel.create("p1", 100)); + when(productService.findOrderableById(1L)).thenReturn(createTestProduct()); + when(stockService.findByProductId(1L)) + .thenReturn(ProductStockModel.create(1L, 100)); - cartFacade.addItem("login1", "pw1", "p1", 3); + cartFacade.addItem(USER_ID, 1L, 3); - verify(userService).authenticate("login1", "pw1"); - verify(productService).findOrderableById("p1"); - verify(stockService).findByProductId("p1"); - verify(cartService).addItem("user-1", "p1", 3); + verify(productService).findOrderableById(1L); + verify(stockService).findByProductId(1L); + verify(cartService).addItem(USER_ID, 1L, 3); } @Test @DisplayName("가용 재고 초과 시 CART_STOCK_EXCEEDED 예외가 발생한다") void addItem_ExceedAvailableStock_ShouldThrow() { - mockAuthenticate(); - when(productService.findOrderableById("p1")).thenReturn(createTestProduct()); - when(stockService.findByProductId("p1")) - .thenReturn(ProductStockModel.create("p1", 5)); + when(productService.findOrderableById(1L)).thenReturn(createTestProduct()); + when(stockService.findByProductId(1L)) + .thenReturn(ProductStockModel.create(1L, 5)); - assertThatThrownBy(() -> cartFacade.addItem("login1", "pw1", "p1", 10)) + assertThatThrownBy(() -> cartFacade.addItem(USER_ID, 1L, 10)) .isInstanceOf(CoreException.class) .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()) .isEqualTo(ErrorType.CART_STOCK_EXCEEDED)); - verify(cartService, never()).addItem(anyString(), anyString(), anyInt()); + verify(cartService, never()).addItem(anyLong(), anyLong(), anyInt()); } } @@ -149,31 +135,28 @@ void addItem_ExceedAvailableStock_ShouldThrow() { class ChangeQuantityTests { @Test - @DisplayName("인증 → 재고 검증 → 수량 변경 오케스트레이션이 수행된다") + @DisplayName("재고 검증 → 수량 변경 오케스트레이션이 수행된다") void changeQuantity_ShouldOrchestrate() { - mockAuthenticate(); - when(stockService.findByProductId("p1")) - .thenReturn(ProductStockModel.create("p1", 100)); + when(stockService.findByProductId(1L)) + .thenReturn(ProductStockModel.create(1L, 100)); - cartFacade.changeQuantity("login1", "pw1", "p1", 5); + cartFacade.changeQuantity(USER_ID, 1L, 5); - verify(userService).authenticate("login1", "pw1"); - verify(stockService).findByProductId("p1"); - verify(cartService).changeQuantity("user-1", "p1", 5); + verify(stockService).findByProductId(1L); + verify(cartService).changeQuantity(USER_ID, 1L, 5); } @Test @DisplayName("가용 재고 초과 시 CART_STOCK_EXCEEDED 예외가 발생한다") void changeQuantity_ExceedAvailableStock_ShouldThrow() { - mockAuthenticate(); - when(stockService.findByProductId("p1")) - .thenReturn(ProductStockModel.create("p1", 3)); + when(stockService.findByProductId(1L)) + .thenReturn(ProductStockModel.create(1L, 3)); - assertThatThrownBy(() -> cartFacade.changeQuantity("login1", "pw1", "p1", 10)) + assertThatThrownBy(() -> cartFacade.changeQuantity(USER_ID, 1L, 10)) .isInstanceOf(CoreException.class) .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()) .isEqualTo(ErrorType.CART_STOCK_EXCEEDED)); - verify(cartService, never()).changeQuantity(anyString(), anyString(), anyInt()); + verify(cartService, never()).changeQuantity(anyLong(), anyLong(), anyInt()); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderAppServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderAppServiceTest.java deleted file mode 100644 index 8124d69a0..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderAppServiceTest.java +++ /dev/null @@ -1,105 +0,0 @@ -package com.loopers.application.order; - -import com.loopers.domain.order.OrderItemModel; -import com.loopers.domain.order.OrderModel; -import com.loopers.domain.order.OrderService; -import com.loopers.domain.user.UserModel; -import com.loopers.domain.user.UserService; -import com.loopers.support.enums.OrderType; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.math.BigDecimal; -import java.time.LocalDateTime; -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -@DisplayName("OrderAppService 단위 테스트") -class OrderAppServiceTest { - - @Mock - OrderService orderService; - - @Mock - UserService userService; - - @InjectMocks - OrderAppService orderAppService; - - private UserModel mockAuthenticate() { - UserModel user = mock(UserModel.class); - when(user.getUserId()).thenReturn("user-1"); - when(userService.authenticate("login1", "pw1")).thenReturn(user); - return user; - } - - @Test - @DisplayName("주문 상세 조회 시 인증 후 스냅샷 포함 OrderInfo를 반환한다") - void getOrderDetail_ShouldReturnOrderInfoWithSnapshots() { - mockAuthenticate(); - OrderModel order = OrderModel.create("user-1", OrderType.DIRECT, BigDecimal.valueOf(30000)); - when(orderService.findByIdAndUserId("order-1", "user-1")).thenReturn(order); - when(orderService.findOrderItems(any())).thenReturn(List.of()); - - OrderInfo result = orderAppService.getOrderDetail("login1", "pw1", "order-1"); - - assertThat(result).isNotNull(); - verify(userService).authenticate("login1", "pw1"); - verify(orderService).findByIdAndUserId("order-1", "user-1"); - } - - @Test - @DisplayName("내 주문 목록 조회 시 인증 후 OrderInfo 리스트를 반환한다") - void getOrders_ShouldReturnOrderInfoList() { - mockAuthenticate(); - LocalDateTime start = LocalDateTime.of(2026, 1, 1, 0, 0); - LocalDateTime end = LocalDateTime.of(2026, 1, 31, 23, 59); - OrderModel order = OrderModel.create("user-1", OrderType.DIRECT, BigDecimal.valueOf(10000)); - when(orderService.findAllByUserId("user-1", start, end)).thenReturn(List.of(order)); - when(orderService.findOrderItems(any())).thenReturn(List.of()); - - List result = orderAppService.getOrders("login1", "pw1", start, end); - - assertThat(result).hasSize(1); - verify(userService).authenticate("login1", "pw1"); - verify(orderService).findAllByUserId("user-1", start, end); - } - - @Test - @DisplayName("관리자용 주문 상세 조회 시 인증 없이 OrderInfo를 반환한다") - void findOrderById_ShouldReturnOrderInfo() { - OrderModel order = OrderModel.create("user-1", OrderType.DIRECT, BigDecimal.valueOf(20000)); - when(orderService.findOrderById("order-1")).thenReturn(order); - when(orderService.findOrderItems(any())).thenReturn(List.of()); - - OrderInfo result = orderAppService.findOrderById("order-1"); - - assertThat(result).isNotNull(); - verify(orderService).findOrderById("order-1"); - verifyNoInteractions(userService); - } - - @Test - @DisplayName("관리자용 주문 목록 조회 시 인증 없이 OrderInfo 리스트를 반환한다") - void findAllOrders_ShouldReturnOrderInfoList() { - LocalDateTime start = LocalDateTime.of(2026, 1, 1, 0, 0); - LocalDateTime end = LocalDateTime.of(2026, 1, 31, 23, 59); - OrderModel order = OrderModel.create("user-1", OrderType.CART, BigDecimal.valueOf(15000)); - when(orderService.findAllOrders(start, end)).thenReturn(List.of(order)); - when(orderService.findOrderItems(any())).thenReturn(List.of()); - - List result = orderAppService.findAllOrders(start, end); - - assertThat(result).hasSize(1); - verify(orderService).findAllOrders(start, end); - verifyNoInteractions(userService); - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java index 5a88ea6a0..d5155e934 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java @@ -3,18 +3,18 @@ import com.loopers.domain.brand.BrandModel; import com.loopers.domain.brand.BrandService; import com.loopers.domain.cart.CartService; +import com.loopers.domain.coupon.CouponModel; +import com.loopers.domain.coupon.CouponService; +import com.loopers.domain.coupon.UserCouponModel; import com.loopers.domain.order.OrderCartRestoreModel; import com.loopers.domain.order.OrderItemCommand; import com.loopers.domain.order.OrderItemModel; -import com.loopers.domain.order.OrderItemSnapshot; import com.loopers.domain.order.OrderModel; import com.loopers.domain.order.OrderService; import com.loopers.domain.product.ProductModel; import com.loopers.domain.product.ProductService; import com.loopers.domain.product.StockService; -import com.loopers.domain.user.UserModel; -import com.loopers.domain.user.UserService; -import com.loopers.support.enums.OrderStatus; +import com.loopers.support.enums.DiscountType; import com.loopers.support.enums.OrderType; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; @@ -27,6 +27,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import java.math.BigDecimal; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; @@ -38,30 +39,27 @@ @DisplayName("OrderFacade 단위 테스트") class OrderFacadeTest { + private static final Long USER_ID = 1L; + private static final Long USER_COUPON_ID = 100L; + private static final Long COUPON_ID = 10L; + @Mock OrderService orderService; - @Mock UserService userService; @Mock ProductService productService; @Mock BrandService brandService; @Mock StockService stockService; @Mock CartService cartService; + @Mock CouponService couponService; @InjectMocks OrderFacade orderFacade; - private UserModel mockAuthenticate() { - UserModel user = mock(UserModel.class); - when(user.getUserId()).thenReturn("user-1"); - when(userService.authenticate("login1", "pw1")).thenReturn(user); - return user; - } - private ProductModel createTestProduct() { - return ProductModel.create("테스트상품", "brand-id", BigDecimal.valueOf(10000), + return ProductModel.create("테스트상품", 1L, BigDecimal.valueOf(10000), "설명", null, null, null, null, null, null); } - private ProductModel createTestProduct(String productId) { - return ProductModel.create("테스트상품-" + productId, "brand-id", BigDecimal.valueOf(10000), + private ProductModel createTestProduct(Long productId) { + return ProductModel.create("테스트상품-" + productId, 1L, BigDecimal.valueOf(10000), "설명", null, null, null, null, null, null); } @@ -70,13 +68,13 @@ private BrandModel createTestBrand() { } private void setupOrderCreationMocks() { - List merged = List.of(new OrderItemCommand("product-1", 2)); - when(orderService.validateAndPrepare("user-1", anyList())).thenReturn(merged); - when(productService.findOrderableById("product-1")).thenReturn(createTestProduct()); - when(brandService.findById("brand-id")).thenReturn(createTestBrand()); + List merged = List.of(new OrderItemCommand(1L, 2)); + when(orderService.validateAndPrepare(eq(USER_ID), anyList())).thenReturn(merged); + when(productService.findOrderableById(1L)).thenReturn(createTestProduct()); + when(brandService.findById(1L)).thenReturn(createTestBrand()); - OrderModel savedOrder = OrderModel.create("user-1", OrderType.DIRECT, BigDecimal.valueOf(20000)); - when(orderService.createOrder(eq("user-1"), any(OrderType.class), any(BigDecimal.class), anyList())) + OrderModel savedOrder = OrderModel.create(USER_ID, OrderType.DIRECT, BigDecimal.valueOf(20000)); + when(orderService.createOrder(eq(USER_ID), any(OrderType.class), any(BigDecimal.class), anyList())) .thenReturn(savedOrder); when(orderService.findOrderItems(any())).thenReturn(List.of()); } @@ -90,49 +88,45 @@ class DirectOrderTests { @Test @DisplayName("상품 검증 → 재고 hold → 주문 저장의 전체 플로우가 수행된다") void createDirectOrder_ShouldValidateProduct_ReserveStock_SaveOrder() { - mockAuthenticate(); setupOrderCreationMocks(); - List items = List.of(new OrderItemCommand("product-1", 2)); - OrderInfo result = orderFacade.createDirectOrder("login1", "pw1", items); + List items = List.of(new OrderItemCommand(1L, 2)); + OrderInfo result = orderFacade.createDirectOrder(USER_ID, items, null); assertThat(result).isNotNull(); - verify(userService).authenticate("login1", "pw1"); - verify(productService).findOrderableById("product-1"); - verify(stockService).hold("product-1", 2); - verify(orderService).createOrder(eq("user-1"), eq(OrderType.DIRECT), any(BigDecimal.class), anyList()); + verify(productService).findOrderableById(1L); + verify(stockService).hold(1L, 2); + verify(orderService).createOrder(eq(USER_ID), eq(OrderType.DIRECT), any(BigDecimal.class), anyList()); } @Test @DisplayName("주문 불가 상품으로 주문 시 예외가 발생하고 hold가 호출되지 않는다") void createDirectOrder_ProductNotOrderable_ShouldThrow() { - mockAuthenticate(); - List merged = List.of(new OrderItemCommand("product-1", 2)); - when(orderService.validateAndPrepare("user-1", anyList())).thenReturn(merged); - when(productService.findOrderableById("product-1")) + List merged = List.of(new OrderItemCommand(1L, 2)); + when(orderService.validateAndPrepare(eq(USER_ID), anyList())).thenReturn(merged); + when(productService.findOrderableById(1L)) .thenThrow(new CoreException(ErrorType.PRODUCT_NOT_ORDERABLE)); - assertThatThrownBy(() -> orderFacade.createDirectOrder("login1", "pw1", - List.of(new OrderItemCommand("product-1", 2)))) + assertThatThrownBy(() -> orderFacade.createDirectOrder(USER_ID, + List.of(new OrderItemCommand(1L, 2)), null)) .isInstanceOf(CoreException.class) .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()) .isEqualTo(ErrorType.PRODUCT_NOT_ORDERABLE)); - verify(stockService, never()).hold(anyString(), anyInt()); + verify(stockService, never()).hold(anyLong(), anyInt()); } @Test @DisplayName("재고 부족 시 STOCK_NOT_ENOUGH 예외가 발생한다") void createDirectOrder_InsufficientStock_ShouldThrow() { - mockAuthenticate(); - List merged = List.of(new OrderItemCommand("product-1", 100)); - when(orderService.validateAndPrepare("user-1", anyList())).thenReturn(merged); - when(productService.findOrderableById("product-1")).thenReturn(createTestProduct()); - when(brandService.findById("brand-id")).thenReturn(createTestBrand()); + List merged = List.of(new OrderItemCommand(1L, 100)); + when(orderService.validateAndPrepare(eq(USER_ID), anyList())).thenReturn(merged); + when(productService.findOrderableById(1L)).thenReturn(createTestProduct()); + when(brandService.findById(1L)).thenReturn(createTestBrand()); doThrow(new CoreException(ErrorType.STOCK_NOT_ENOUGH)) - .when(stockService).hold("product-1", 100); + .when(stockService).hold(1L, 100); - assertThatThrownBy(() -> orderFacade.createDirectOrder("login1", "pw1", - List.of(new OrderItemCommand("product-1", 100)))) + assertThatThrownBy(() -> orderFacade.createDirectOrder(USER_ID, + List.of(new OrderItemCommand(1L, 100)), null)) .isInstanceOf(CoreException.class) .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()) .isEqualTo(ErrorType.STOCK_NOT_ENOUGH)); @@ -141,23 +135,88 @@ void createDirectOrder_InsufficientStock_ShouldThrow() { @Test @DisplayName("총액이 sum(unitPrice * quantity)와 일치한다") void createDirectOrder_ShouldCalculateTotalAmount() { - mockAuthenticate(); - List merged = List.of(new OrderItemCommand("product-1", 3)); - when(orderService.validateAndPrepare("user-1", anyList())).thenReturn(merged); - when(productService.findOrderableById("product-1")).thenReturn(createTestProduct()); - when(brandService.findById("brand-id")).thenReturn(createTestBrand()); - - OrderModel savedOrder = OrderModel.create("user-1", OrderType.DIRECT, BigDecimal.valueOf(30000)); - when(orderService.createOrder(eq("user-1"), eq(OrderType.DIRECT), + List merged = List.of(new OrderItemCommand(1L, 3)); + when(orderService.validateAndPrepare(eq(USER_ID), anyList())).thenReturn(merged); + when(productService.findOrderableById(1L)).thenReturn(createTestProduct()); + when(brandService.findById(1L)).thenReturn(createTestBrand()); + + OrderModel savedOrder = OrderModel.create(USER_ID, OrderType.DIRECT, BigDecimal.valueOf(30000)); + when(orderService.createOrder(eq(USER_ID), eq(OrderType.DIRECT), eq(BigDecimal.valueOf(30000)), anyList())) .thenReturn(savedOrder); when(orderService.findOrderItems(any())).thenReturn(List.of()); - OrderInfo result = orderFacade.createDirectOrder("login1", "pw1", - List.of(new OrderItemCommand("product-1", 3))); + OrderInfo result = orderFacade.createDirectOrder(USER_ID, + List.of(new OrderItemCommand(1L, 3)), null); assertThat(result.getTotalAmount()).isEqualByComparingTo(BigDecimal.valueOf(30000)); } + + @Test + @DisplayName("유효한 쿠폰 적용 시 할인된 금액으로 주문이 생성된다") + void createDirectOrder_WithValidCoupon_ShouldApplyDiscount() { + List merged = List.of(new OrderItemCommand(1L, 1)); + when(orderService.validateAndPrepare(eq(USER_ID), anyList())).thenReturn(merged); + when(productService.findOrderableById(1L)).thenReturn(createTestProduct()); // price: 10000 + when(brandService.findById(1L)).thenReturn(createTestBrand()); + + UserCouponModel userCoupon = UserCouponModel.create(USER_ID, COUPON_ID); + when(couponService.validateAndGetUserCoupon(USER_ID, USER_COUPON_ID)).thenReturn(userCoupon); + + CouponModel coupon = CouponModel.create("쿠폰", DiscountType.FIXED, BigDecimal.valueOf(2000), + null, LocalDateTime.now().plusDays(30)); + when(couponService.findByIdForAdmin(COUPON_ID)).thenReturn(coupon); + + OrderModel savedOrder = OrderModel.create(USER_ID, OrderType.DIRECT, BigDecimal.valueOf(8000)); + when(orderService.createOrder(eq(USER_ID), eq(OrderType.DIRECT), + eq(BigDecimal.valueOf(8000)), anyList())) + .thenReturn(savedOrder); + when(orderService.findOrderItems(any())).thenReturn(List.of()); + + OrderInfo result = orderFacade.createDirectOrder(USER_ID, + List.of(new OrderItemCommand(1L, 1)), USER_COUPON_ID); + + assertThat(result.getTotalAmount()).isEqualByComparingTo(BigDecimal.valueOf(8000)); + verify(couponService).markCouponAsUsed(userCoupon.getUserCouponId(), savedOrder.getOrderId()); + } + + @Test + @DisplayName("쿠폰 검증 실패 시 재고 hold가 호출되지 않는다") + void createDirectOrder_CouponValidationFails_ShouldNotHoldStocks() { + List merged = List.of(new OrderItemCommand(1L, 2)); + when(orderService.validateAndPrepare(eq(USER_ID), anyList())).thenReturn(merged); + when(couponService.validateAndGetUserCoupon(USER_ID, USER_COUPON_ID)) + .thenThrow(new CoreException(ErrorType.COUPON_NOT_AVAILABLE)); + + assertThatThrownBy(() -> orderFacade.createDirectOrder(USER_ID, + List.of(new OrderItemCommand(1L, 2)), USER_COUPON_ID)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()) + .isEqualTo(ErrorType.COUPON_NOT_AVAILABLE)); + verify(stockService, never()).hold(anyLong(), anyInt()); + } + + @Test + @DisplayName("쿠폰 없이 주문 시 할인 없이 전체 금액으로 주문된다") + void createDirectOrder_NoCoupon_ShouldUseFullAmount() { + List merged = List.of(new OrderItemCommand(1L, 2)); + when(orderService.validateAndPrepare(eq(USER_ID), anyList())).thenReturn(merged); + when(productService.findOrderableById(1L)).thenReturn(createTestProduct()); + when(brandService.findById(1L)).thenReturn(createTestBrand()); + + OrderModel savedOrder = OrderModel.create(USER_ID, OrderType.DIRECT, BigDecimal.valueOf(20000)); + when(orderService.createOrder(eq(USER_ID), eq(OrderType.DIRECT), + eq(BigDecimal.valueOf(20000)), anyList())) + .thenReturn(savedOrder); + when(orderService.findOrderItems(any())).thenReturn(List.of()); + + OrderInfo result = orderFacade.createDirectOrder(USER_ID, + List.of(new OrderItemCommand(1L, 2)), null); + + assertThat(result.getTotalAmount()).isEqualByComparingTo(BigDecimal.valueOf(20000)); + verify(couponService, never()).validateAndGetUserCoupon(anyLong(), anyLong()); + verify(couponService, never()).markCouponAsUsed(anyLong(), anyLong()); + } } // === 장바구니 주문 (CART) === @@ -169,64 +228,61 @@ class CartOrderTests { @Test @DisplayName("모든 상품이 순서대로 검증되고 hold된다") void createCartOrder_ShouldValidateAllProducts_ReserveAllStocks() { - mockAuthenticate(); setupOrderCreationMocks(); - OrderInfo result = orderFacade.createCartOrder("login1", "pw1", - List.of(new OrderItemCommand("product-1", 2))); + OrderInfo result = orderFacade.createCartOrder(USER_ID, + List.of(new OrderItemCommand(1L, 2)), null); assertThat(result).isNotNull(); - verify(productService).findOrderableById("product-1"); - verify(stockService).hold("product-1", 2); - verify(orderService).createOrder(eq("user-1"), eq(OrderType.CART), any(BigDecimal.class), anyList()); + verify(productService).findOrderableById(1L); + verify(stockService).hold(1L, 2); + verify(orderService).createOrder(eq(USER_ID), eq(OrderType.CART), any(BigDecimal.class), anyList()); } @Test @DisplayName("2번째 상품 hold 실패 시 1번째 hold가 release된다 (부분 성공 금지)") void createCartOrder_PartialStockFailure_ShouldRollbackAllReservations() { - mockAuthenticate(); List merged = List.of( - new OrderItemCommand("product-a", 2), - new OrderItemCommand("product-b", 3)); - when(orderService.validateAndPrepare("user-1", anyList())).thenReturn(merged); - when(productService.findOrderableById("product-a")).thenReturn(createTestProduct("product-a")); - when(productService.findOrderableById("product-b")).thenReturn(createTestProduct("product-b")); - when(brandService.findById("brand-id")).thenReturn(createTestBrand()); - doNothing().when(stockService).hold("product-a", 2); + new OrderItemCommand(1L, 2), + new OrderItemCommand(2L, 3)); + when(orderService.validateAndPrepare(eq(USER_ID), anyList())).thenReturn(merged); + when(productService.findOrderableById(1L)).thenReturn(createTestProduct(1L)); + when(productService.findOrderableById(2L)).thenReturn(createTestProduct(2L)); + when(brandService.findById(1L)).thenReturn(createTestBrand()); + doNothing().when(stockService).hold(1L, 2); doThrow(new CoreException(ErrorType.STOCK_NOT_ENOUGH)) - .when(stockService).hold("product-b", 3); + .when(stockService).hold(2L, 3); - assertThatThrownBy(() -> orderFacade.createCartOrder("login1", "pw1", List.of( - new OrderItemCommand("product-a", 2), - new OrderItemCommand("product-b", 3)))) + assertThatThrownBy(() -> orderFacade.createCartOrder(USER_ID, List.of( + new OrderItemCommand(1L, 2), + new OrderItemCommand(2L, 3)), null)) .isInstanceOf(CoreException.class); - verify(stockService).release("product-a", 2); + verify(stockService).release(1L, 2); } @Test @DisplayName("hold 순서가 productId 오름차순이다 (데드락 방지)") void createCartOrder_ShouldHoldInProductIdAscOrder() { - mockAuthenticate(); List merged = List.of( - new OrderItemCommand("aaa-product", 1), - new OrderItemCommand("zzz-product", 1)); - when(orderService.validateAndPrepare("user-1", anyList())).thenReturn(merged); - when(productService.findOrderableById("aaa-product")).thenReturn(createTestProduct("aaa-product")); - when(productService.findOrderableById("zzz-product")).thenReturn(createTestProduct("zzz-product")); - when(brandService.findById("brand-id")).thenReturn(createTestBrand()); - OrderModel savedOrder = OrderModel.create("user-1", OrderType.CART, BigDecimal.valueOf(20000)); - when(orderService.createOrder(eq("user-1"), any(OrderType.class), any(BigDecimal.class), anyList())) + new OrderItemCommand(1L, 1), + new OrderItemCommand(3L, 1)); + when(orderService.validateAndPrepare(eq(USER_ID), anyList())).thenReturn(merged); + when(productService.findOrderableById(1L)).thenReturn(createTestProduct(1L)); + when(productService.findOrderableById(3L)).thenReturn(createTestProduct(3L)); + when(brandService.findById(1L)).thenReturn(createTestBrand()); + OrderModel savedOrder = OrderModel.create(USER_ID, OrderType.CART, BigDecimal.valueOf(20000)); + when(orderService.createOrder(eq(USER_ID), any(OrderType.class), any(BigDecimal.class), anyList())) .thenReturn(savedOrder); when(orderService.findOrderItems(any())).thenReturn(List.of()); - orderFacade.createCartOrder("login1", "pw1", List.of( - new OrderItemCommand("zzz-product", 1), - new OrderItemCommand("aaa-product", 1))); + orderFacade.createCartOrder(USER_ID, List.of( + new OrderItemCommand(3L, 1), + new OrderItemCommand(1L, 1)), null); var inOrder = inOrder(stockService); - inOrder.verify(stockService).hold("aaa-product", 1); - inOrder.verify(stockService).hold("zzz-product", 1); + inOrder.verify(stockService).hold(1L, 1); + inOrder.verify(stockService).hold(3L, 1); } } @@ -237,59 +293,56 @@ void createCartOrder_ShouldHoldInProductIdAscOrder() { class CancelOrderTests { @Test - @DisplayName("취소 시 모든 주문 항목의 재고가 release된다") + @DisplayName("취소 시 모든 주문 항목의 재고가 release되고 쿠폰이 복원된다") void cancelOrder_ShouldReleaseAllStocks() { - mockAuthenticate(); - OrderModel order = OrderModel.create("user-1", OrderType.CART, BigDecimal.valueOf(10000)); - when(orderService.cancelOrder("user-1", "order-1")).thenReturn(Optional.of(order)); - OrderItemModel item = OrderItemModel.create("order-1", 1, "user-1", "product-1", 3, + OrderModel order = OrderModel.create(USER_ID, OrderType.CART, BigDecimal.valueOf(10000)); + when(orderService.cancelOrder(USER_ID, 1L)).thenReturn(Optional.of(order)); + OrderItemModel item = OrderItemModel.create(1L, 1, USER_ID, 1L, 3, "상품명", BigDecimal.valueOf(10000), "brand-id", "브랜드", null); - when(orderService.findOrderItems("order-1")).thenReturn(List.of(item)); + when(orderService.findOrderItems(1L)).thenReturn(List.of(item)); - orderFacade.cancelOrder("login1", "pw1", "order-1"); + orderFacade.cancelOrder(USER_ID, 1L); - verify(stockService).release("product-1", 3); + verify(stockService).release(1L, 3); + verify(couponService).restoreCoupon(order.getOrderId()); } @Test @DisplayName("DIRECT 주문 취소 시 장바구니가 복원된다") void cancelOrder_DIRECT_ShouldRestoreToCart() { - mockAuthenticate(); - OrderModel order = OrderModel.create("user-1", OrderType.DIRECT, BigDecimal.valueOf(10000)); - when(orderService.cancelOrder("user-1", "order-1")).thenReturn(Optional.of(order)); - OrderItemModel item = OrderItemModel.create("order-1", 1, "user-1", "product-1", 3, + OrderModel order = OrderModel.create(USER_ID, OrderType.DIRECT, BigDecimal.valueOf(10000)); + when(orderService.cancelOrder(USER_ID, 1L)).thenReturn(Optional.of(order)); + OrderItemModel item = OrderItemModel.create(1L, 1, USER_ID, 1L, 3, "상품명", BigDecimal.valueOf(10000), "brand-id", "브랜드", null); - when(orderService.findOrderItems("order-1")).thenReturn(List.of(item)); + when(orderService.findOrderItems(1L)).thenReturn(List.of(item)); - orderFacade.cancelOrder("login1", "pw1", "order-1"); + orderFacade.cancelOrder(USER_ID, 1L); verify(orderService).saveCartRestore(any(OrderCartRestoreModel.class)); - verify(cartService).restoreFromOrder(eq("user-1"), anyList()); + verify(cartService).restoreFromOrder(eq(USER_ID), anyList()); } @Test @DisplayName("CART 주문 취소 시 장바구니 변경 없음") void cancelOrder_CART_ShouldNotRestoreCart() { - mockAuthenticate(); - OrderModel order = OrderModel.create("user-1", OrderType.CART, BigDecimal.valueOf(10000)); - when(orderService.cancelOrder("user-1", "order-1")).thenReturn(Optional.of(order)); - when(orderService.findOrderItems("order-1")).thenReturn(List.of()); + OrderModel order = OrderModel.create(USER_ID, OrderType.CART, BigDecimal.valueOf(10000)); + when(orderService.cancelOrder(USER_ID, 1L)).thenReturn(Optional.of(order)); + when(orderService.findOrderItems(1L)).thenReturn(List.of()); - orderFacade.cancelOrder("login1", "pw1", "order-1"); + orderFacade.cancelOrder(USER_ID, 1L); - verify(cartService, never()).restoreFromOrder(anyString(), anyList()); + verify(cartService, never()).restoreFromOrder(anyLong(), anyList()); verify(orderService, never()).saveCartRestore(any()); } @Test @DisplayName("이미 CANCELLED인 주문 취소 시 에러 없이 무시된다 (멱등)") void cancelOrder_WhenAlreadyCancelled_ShouldBeIdempotent() { - mockAuthenticate(); - when(orderService.cancelOrder("user-1", "order-1")).thenReturn(Optional.empty()); + when(orderService.cancelOrder(USER_ID, 1L)).thenReturn(Optional.empty()); - assertThatCode(() -> orderFacade.cancelOrder("login1", "pw1", "order-1")) + assertThatCode(() -> orderFacade.cancelOrder(USER_ID, 1L)) .doesNotThrowAnyException(); - verify(stockService, never()).release(anyString(), anyInt()); + verify(stockService, never()).release(anyLong(), anyInt()); } } @@ -300,43 +353,43 @@ void cancelOrder_WhenAlreadyCancelled_ShouldBeIdempotent() { class ExpireOrderTests { @Test - @DisplayName("만료 시 재고가 release된다") + @DisplayName("만료 시 재고가 release되고 쿠폰이 복원된다") void expireOrder_ShouldReleaseAllStocks() { - OrderModel order = OrderModel.create("user-1", OrderType.CART, BigDecimal.valueOf(10000)); - when(orderService.expireOrder("order-1")).thenReturn(Optional.of(order)); - OrderItemModel item = OrderItemModel.create("order-1", 1, "user-1", "product-1", 3, + OrderModel order = OrderModel.create(USER_ID, OrderType.CART, BigDecimal.valueOf(10000)); + when(orderService.expireOrder(1L)).thenReturn(Optional.of(order)); + OrderItemModel item = OrderItemModel.create(1L, 1, USER_ID, 1L, 3, "상품명", BigDecimal.valueOf(10000), "brand-id", "브랜드", null); - when(orderService.findOrderItems("order-1")).thenReturn(List.of(item)); + when(orderService.findOrderItems(1L)).thenReturn(List.of(item)); - orderFacade.expireOrder("order-1"); + orderFacade.expireOrder(1L); - verify(stockService).release("product-1", 3); + verify(stockService).release(1L, 3); + verify(couponService).restoreCoupon(order.getOrderId()); } @Test @DisplayName("DIRECT 주문 만료 시 장바구니가 복원된다") void expireOrder_DIRECT_ShouldRestoreToCart() { - OrderModel order = OrderModel.create("user-1", OrderType.DIRECT, BigDecimal.valueOf(10000)); - when(orderService.expireOrder("order-1")).thenReturn(Optional.of(order)); - OrderItemModel item = OrderItemModel.create("order-1", 1, "user-1", "product-1", 2, + OrderModel order = OrderModel.create(USER_ID, OrderType.DIRECT, BigDecimal.valueOf(10000)); + when(orderService.expireOrder(1L)).thenReturn(Optional.of(order)); + OrderItemModel item = OrderItemModel.create(1L, 1, USER_ID, 1L, 2, "상품명", BigDecimal.valueOf(10000), "brand-id", "브랜드", null); - when(orderService.findOrderItems("order-1")).thenReturn(List.of(item)); + when(orderService.findOrderItems(1L)).thenReturn(List.of(item)); - orderFacade.expireOrder("order-1"); + orderFacade.expireOrder(1L); verify(orderService).saveCartRestore(any(OrderCartRestoreModel.class)); - verify(cartService).restoreFromOrder(eq("user-1"), anyList()); + verify(cartService).restoreFromOrder(eq(USER_ID), anyList()); } @Test @DisplayName("CAS 실패 시 skip된다 (멱등)") void expireOrder_AlreadyExpiredOrCancelled_ShouldSkip() { - when(orderService.expireOrder("order-1")).thenReturn(Optional.empty()); + when(orderService.expireOrder(1L)).thenReturn(Optional.empty()); - assertThatCode(() -> orderFacade.expireOrder("order-1")) + assertThatCode(() -> orderFacade.expireOrder(1L)) .doesNotThrowAnyException(); - verify(stockService, never()).release(anyString(), anyInt()); + verify(stockService, never()).release(anyLong(), anyInt()); } } - } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductAppServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductAppServiceTest.java deleted file mode 100644 index 3b1766f57..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductAppServiceTest.java +++ /dev/null @@ -1,62 +0,0 @@ -package com.loopers.application.product; - -import com.loopers.domain.product.ProductRevisionModel; -import com.loopers.domain.product.ProductService; -import com.loopers.support.enums.ProductRevisionAction; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -@DisplayName("ProductAppService 단위 테스트") -class ProductAppServiceTest { - - @Mock - ProductService productService; - - @InjectMocks - ProductAppService productAppService; - - @Test - @DisplayName("상품 삭제 시 ProductService.deleteProduct가 호출된다") - void deleteProduct_ShouldCallServiceMethod() { - productAppService.deleteProduct("p1"); - - verify(productService).deleteProduct("p1"); - } - - @Test - @DisplayName("변경 이력 조회 시 ProductRevisionInfo 목록을 반환한다") - void getRevisions_ShouldReturnRevisionInfoList() { - ProductRevisionModel revision = mock(ProductRevisionModel.class); - when(revision.getAction()).thenReturn(ProductRevisionAction.CREATE); - when(productService.findRevisionsByProductId("p1")).thenReturn(List.of(revision)); - - List result = productAppService.getRevisions("p1"); - - assertThat(result).hasSize(1); - verify(productService).findRevisionsByProductId("p1"); - } - - @Test - @DisplayName("변경 이력 상세 조회 시 ProductRevisionInfo를 반환한다") - void getRevisionDetail_ShouldReturnRevisionInfo() { - ProductRevisionModel revision = mock(ProductRevisionModel.class); - when(revision.getAction()).thenReturn(ProductRevisionAction.UPDATE); - when(productService.findRevisionById("p1", 1L)).thenReturn(revision); - - ProductRevisionInfo result = productAppService.getRevisionDetail("p1", 1L); - - assertThat(result).isNotNull(); - assertThat(result.getAction()).isEqualTo(ProductRevisionAction.UPDATE); - verify(productService).findRevisionById("p1", 1L); - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java index 062c15bb2..1099253f1 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java @@ -2,7 +2,6 @@ import com.loopers.domain.brand.BrandModel; import com.loopers.domain.brand.BrandService; -import com.loopers.domain.like.LikeService; import com.loopers.application.product.ProductInfo; import com.loopers.domain.product.ProductModel; import com.loopers.domain.product.ProductService; @@ -10,19 +9,18 @@ import com.loopers.domain.product.StockService; import com.loopers.interfaces.api.PageResponse; import com.loopers.support.enums.ProductSortType; +import com.loopers.support.page.PageQuery; +import com.loopers.support.page.PagedResult; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageImpl; -import org.springframework.data.domain.Pageable; import java.math.BigDecimal; import java.util.List; -import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; @@ -42,9 +40,6 @@ class ProductFacadeTest { @Mock BrandService brandService; - @Mock - LikeService likeService; - @InjectMocks ProductFacade productFacade; @@ -53,26 +48,29 @@ class ProductFacadeTest { void getProductsForCustomer_WithLatestSort_ShouldReturnPagedResult() { ProductModel product1 = mock(ProductModel.class); ProductModel product2 = mock(ProductModel.class); - when(product1.getProductId()).thenReturn("p1"); - when(product1.getBrandId()).thenReturn("b1"); - when(product2.getProductId()).thenReturn("p2"); - when(product2.getBrandId()).thenReturn("b1"); + when(product1.getProductId()).thenReturn(1L); + when(product1.getBrandId()).thenReturn(1L); + when(product1.getLikeCount()).thenReturn(5L); + when(product2.getProductId()).thenReturn(2L); + when(product2.getBrandId()).thenReturn(1L); + when(product2.getLikeCount()).thenReturn(3L); ProductStockModel stock1 = mock(ProductStockModel.class); ProductStockModel stock2 = mock(ProductStockModel.class); when(stock1.getAvailableQty()).thenReturn(50); when(stock2.getAvailableQty()).thenReturn(30); BrandModel brand = mock(BrandModel.class); - when(brand.getBrandId()).thenReturn("b1"); + when(brand.getBrandId()).thenReturn(1L); when(brand.getBrandName()).thenReturn("테스트브랜드"); - Page page = new PageImpl<>(List.of(product1, product2)); - when(productService.findAllForCustomer(eq((String) null), eq((String) null), any(Pageable.class))) - .thenReturn(page); - when(stockService.findByProductId("p1")).thenReturn(stock1); - when(stockService.findByProductId("p2")).thenReturn(stock2); - when(brandService.findAllByIds(List.of("b1"))).thenReturn(List.of(brand)); - when(likeService.countByProductIds(List.of("p1", "p2"))).thenReturn(Map.of("p1", 5L, "p2", 3L)); + when(stock1.getProductId()).thenReturn(1L); + when(stock2.getProductId()).thenReturn(2L); + + PagedResult pagedResult = new PagedResult<>(List.of(product1, product2), 0, 20, 2, 1); + when(productService.findAllForCustomer(eq((String) null), eq((Long) null), any(PageQuery.class))) + .thenReturn(pagedResult); + when(stockService.findAllByProductIds(List.of(1L, 2L))).thenReturn(List.of(stock1, stock2)); + when(brandService.findAllByIds(List.of(1L))).thenReturn(List.of(brand)); PageResponse result = productFacade.getProductsForCustomer( null, null, ProductSortType.LATEST, 0, 20); @@ -84,110 +82,115 @@ void getProductsForCustomer_WithLatestSort_ShouldReturnPagedResult() { } @Test - @DisplayName("고객용 상품 목록 LIKES_DESC 정렬 시 좋아요 수 내림차순으로 정렬된다") - void getProductsForCustomer_WithLikesDescSort_ShouldSortByLikeCount() { + @DisplayName("고객용 상품 목록 LIKES_DESC 정렬 시 likeCount DESC PageQuery로 DB 정렬을 요청한다") + void getProductsForCustomer_WithLikesDescSort_ShouldRequestDbSortByLikeCount() { ProductModel product1 = mock(ProductModel.class); ProductModel product2 = mock(ProductModel.class); - when(product1.getProductId()).thenReturn("p1"); - when(product1.getBrandId()).thenReturn("b1"); - when(product2.getProductId()).thenReturn("p2"); - when(product2.getBrandId()).thenReturn("b1"); + when(product1.getProductId()).thenReturn(1L); + when(product1.getBrandId()).thenReturn(1L); + when(product1.getLikeCount()).thenReturn(10L); + when(product2.getProductId()).thenReturn(2L); + when(product2.getBrandId()).thenReturn(1L); + when(product2.getLikeCount()).thenReturn(3L); ProductStockModel stock1 = mock(ProductStockModel.class); ProductStockModel stock2 = mock(ProductStockModel.class); when(stock1.getAvailableQty()).thenReturn(50); when(stock2.getAvailableQty()).thenReturn(30); + when(stock1.getProductId()).thenReturn(1L); + when(stock2.getProductId()).thenReturn(2L); BrandModel brand = mock(BrandModel.class); - when(brand.getBrandId()).thenReturn("b1"); + when(brand.getBrandId()).thenReturn(1L); when(brand.getBrandName()).thenReturn("테스트브랜드"); - when(productService.findAllForCustomer(null, null)).thenReturn(List.of(product1, product2)); - when(stockService.findByProductId("p1")).thenReturn(stock1); - when(stockService.findByProductId("p2")).thenReturn(stock2); - when(brandService.findAllByIds(List.of("b1"))).thenReturn(List.of(brand)); - when(likeService.countByProductIds(List.of("p1", "p2"))).thenReturn(Map.of("p1", 3L, "p2", 10L)); + PagedResult pagedResult = new PagedResult<>(List.of(product1, product2), 0, 20, 2, 1); + ArgumentCaptor queryCaptor = ArgumentCaptor.forClass(PageQuery.class); + when(productService.findAllForCustomer(eq((String) null), eq((Long) null), queryCaptor.capture())) + .thenReturn(pagedResult); + when(stockService.findAllByProductIds(List.of(1L, 2L))).thenReturn(List.of(stock1, stock2)); + when(brandService.findAllByIds(List.of(1L))).thenReturn(List.of(brand)); PageResponse result = productFacade.getProductsForCustomer( null, null, ProductSortType.LIKES_DESC, 0, 20); assertThat(result.content()).hasSize(2); - assertThat(result.content().get(0).getLikeCount()).isEqualTo(10L); - assertThat(result.content().get(1).getLikeCount()).isEqualTo(3L); + PageQuery capturedQuery = queryCaptor.getValue(); + assertThat(capturedQuery.sortField()).isEqualTo("likeCount"); + assertThat(capturedQuery.ascending()).isFalse(); } @Test @DisplayName("고객용 상품 상세 조회 시 가용 재고, 브랜드명, 좋아요 수가 포함된 ProductInfo를 반환한다") void getProductDetailForCustomer_ShouldReturnProductInfoWithAvailableStock() { ProductModel product = mock(ProductModel.class); - when(product.getProductId()).thenReturn("p1"); - when(product.getBrandId()).thenReturn("b1"); + when(product.getProductId()).thenReturn(1L); + when(product.getBrandId()).thenReturn(1L); + when(product.getLikeCount()).thenReturn(10L); ProductStockModel stock = mock(ProductStockModel.class); when(stock.getAvailableQty()).thenReturn(70); BrandModel brand = mock(BrandModel.class); when(brand.getBrandName()).thenReturn("테스트브랜드"); - when(productService.findById("p1")).thenReturn(product); - when(stockService.findByProductId("p1")).thenReturn(stock); - when(brandService.findById("b1")).thenReturn(brand); - when(likeService.countByProductId("p1")).thenReturn(10L); + when(productService.findById(1L)).thenReturn(product); + when(stockService.findByProductId(1L)).thenReturn(stock); + when(brandService.findById(1L)).thenReturn(brand); - ProductInfo result = productFacade.getProductDetailForCustomer("p1"); + ProductInfo result = productFacade.getProductDetailForCustomer(1L); assertThat(result).isNotNull(); assertThat(result.getAvailableStock()).isEqualTo(70); assertThat(result.getBrandName()).isEqualTo("테스트브랜드"); assertThat(result.getLikeCount()).isEqualTo(10L); - verify(productService).findById("p1"); - verify(stockService).findByProductId("p1"); - verify(brandService).findById("b1"); - verify(likeService).countByProductId("p1"); + verify(productService).findById(1L); + verify(stockService).findByProductId(1L); + verify(brandService).findById(1L); } @Test @DisplayName("상품 생성 시 브랜드 검증 → 상품 생성 → 재고 생성 오케스트레이션이 수행된다") void createProduct_ShouldOrchestrateBrandProductStock() { BrandModel brand = mock(BrandModel.class); - when(brandService.findById("b1")).thenReturn(brand); + when(brandService.findById(1L)).thenReturn(brand); ProductModel product = mock(ProductModel.class); - when(product.getProductId()).thenReturn("p1"); - when(productService.createProduct("테스트상품", "b1", BigDecimal.valueOf(10000), "설명")) + when(product.getProductId()).thenReturn(1L); + when(productService.createProduct("테스트상품", 1L, BigDecimal.valueOf(10000), "설명")) .thenReturn(product); ProductStockModel stock = mock(ProductStockModel.class); when(stock.getAvailableQty()).thenReturn(100); - when(stockService.createStock("p1", 100)).thenReturn(stock); + when(stockService.createStock(1L, 100)).thenReturn(stock); - ProductCreateCommand command = new ProductCreateCommand("테스트상품", "b1", + ProductCreateCommand command = new ProductCreateCommand("테스트상품", 1L, BigDecimal.valueOf(10000), "설명", 100); ProductInfo result = productFacade.createProduct(command); assertThat(result).isNotNull(); - verify(brandService).findById("b1"); - verify(productService).createProduct("테스트상품", "b1", BigDecimal.valueOf(10000), "설명"); - verify(stockService).createStock("p1", 100); + verify(brandService).findById(1L); + verify(productService).createProduct("테스트상품", 1L, BigDecimal.valueOf(10000), "설명"); + verify(stockService).createStock(1L, 100); } @Test @DisplayName("상품 수정 후 재고 정보를 결합하여 ProductInfo를 반환한다") void updateProduct_ShouldCombineProductAndStock() { ProductModel product = mock(ProductModel.class); - when(product.getProductId()).thenReturn("p1"); - when(productService.updateProduct("p1", "수정상품", BigDecimal.valueOf(20000), "수정설명", null)) + when(product.getProductId()).thenReturn(1L); + when(productService.updateProduct(1L, "수정상품", BigDecimal.valueOf(20000), "수정설명", null)) .thenReturn(product); ProductStockModel stock = mock(ProductStockModel.class); when(stock.getAvailableQty()).thenReturn(50); - when(stockService.findByProductId("p1")).thenReturn(stock); + when(stockService.findByProductId(1L)).thenReturn(stock); - ProductUpdateCommand command = new ProductUpdateCommand("p1", "수정상품", + ProductUpdateCommand command = new ProductUpdateCommand(1L, "수정상품", BigDecimal.valueOf(20000), "수정설명", null); ProductInfo result = productFacade.updateProduct(command); assertThat(result).isNotNull(); - verify(productService).updateProduct("p1", "수정상품", BigDecimal.valueOf(20000), "수정설명", null); - verify(stockService).findByProductId("p1"); + verify(productService).updateProduct(1L, "수정상품", BigDecimal.valueOf(20000), "수정설명", null); + verify(stockService).findByProductId(1L); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/batch/OrderExpirySchedulerTest.java b/apps/commerce-api/src/test/java/com/loopers/batch/OrderExpirySchedulerTest.java index 831df02de..6be6b5186 100644 --- a/apps/commerce-api/src/test/java/com/loopers/batch/OrderExpirySchedulerTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/batch/OrderExpirySchedulerTest.java @@ -3,7 +3,6 @@ import com.loopers.domain.brand.BrandModel; import com.loopers.domain.cart.CartItemId; import com.loopers.domain.cart.CartItemModel; -import com.loopers.domain.order.OrderItemModel; import com.loopers.domain.order.OrderModel; import com.loopers.domain.product.ProductModel; import com.loopers.domain.product.ProductStockModel; @@ -60,16 +59,10 @@ void tearDown() { databaseCleanUp.truncateAllTables(); } - private OrderModel createExpiredOrder(String userId, OrderType orderType) { + private OrderModel createExpiredOrder(Long userId, OrderType orderType) { OrderModel order = OrderModel.create(userId, orderType, BigDecimal.valueOf(10000)); order = orderJpaRepository.save(order); - // expiresAt을 과거로 설정하기 위해 native UPDATE 사용 orderJpaRepository.flush(); - orderJpaRepository.findById(order.getOrderId()).ifPresent(o -> { - // 강제로 expires_at을 과거로 설정하려면 CAS를 우회해야 하는데, - // OrderModel.create()는 15분 후로 설정함. - // 대신 직접 만료 처리를 위해 SQL로 업데이트 - }); return order; } @@ -102,11 +95,11 @@ void shouldReleaseStock_ForExpiredOrders() { void shouldRestoreCart_ForExpiredDirectOrders() { // 장바구니 복원 로직은 OrderFacade.expireOrder 내부에서 수행됨 // 여기서는 장바구니 저장/조회가 정상 작동하는지 검증 - CartItemModel cartItem = CartItemModel.create("user-1", product.getProductId(), 2); + CartItemModel cartItem = CartItemModel.create(1L, product.getProductId(), 2); cartItemJpaRepository.save(cartItem); Optional found = cartItemJpaRepository.findById( - new CartItemId("user-1", product.getProductId())); + new CartItemId(1L, product.getProductId())); assertThat(found).isPresent(); assertThat(found.get().getQuantity()).isEqualTo(2); } @@ -114,7 +107,7 @@ void shouldRestoreCart_ForExpiredDirectOrders() { @Test @DisplayName("이미 취소/만료된 주문은 CAS 실패로 skip한다") void shouldSkip_AlreadyCancelledOrExpired() { - OrderModel order = OrderModel.create("user-1", OrderType.DIRECT, BigDecimal.valueOf(10000)); + OrderModel order = OrderModel.create(1L, OrderType.DIRECT, BigDecimal.valueOf(10000)); order = orderJpaRepository.save(order); orderJpaRepository.flush(); diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.java index a608d129c..63b1769db 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.java @@ -56,9 +56,9 @@ class FindTests { @DisplayName("존재하는 ID로 조회하면 BrandModel을 반환한다") void findById_Existing_ShouldReturn() { BrandModel brand = BrandModel.create("브랜드", "설명", "서울"); - when(brandRepository.findById("brand-id")).thenReturn(Optional.of(brand)); + when(brandRepository.findById(1L)).thenReturn(Optional.of(brand)); - BrandModel result = brandService.findById("brand-id"); + BrandModel result = brandService.findById(1L); assertThat(result.getBrandName()).isEqualTo("브랜드"); } @@ -66,9 +66,9 @@ void findById_Existing_ShouldReturn() { @Test @DisplayName("존재하지 않는 ID 조회 시 BRAND_NOT_FOUND 예외가 발생한다") void findById_NotFound_ShouldThrowBRAND_NOT_FOUND() { - when(brandRepository.findById("nonexistent")).thenReturn(Optional.empty()); + when(brandRepository.findById(999L)).thenReturn(Optional.empty()); - assertThatThrownBy(() -> brandService.findById("nonexistent")) + assertThatThrownBy(() -> brandService.findById(999L)) .isInstanceOf(CoreException.class) .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()) .isEqualTo(ErrorType.BRAND_NOT_FOUND)); @@ -79,9 +79,9 @@ void findById_NotFound_ShouldThrowBRAND_NOT_FOUND() { void findVisibleById_WhenHidden_ShouldThrowBRAND_NOT_FOUND() { BrandModel brand = BrandModel.create("브랜드", "설명", "서울"); brand.hide(); - when(brandRepository.findById("brand-id")).thenReturn(Optional.of(brand)); + when(brandRepository.findById(1L)).thenReturn(Optional.of(brand)); - assertThatThrownBy(() -> brandService.findVisibleById("brand-id")) + assertThatThrownBy(() -> brandService.findVisibleById(1L)) .isInstanceOf(CoreException.class) .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()) .isEqualTo(ErrorType.BRAND_NOT_FOUND)); @@ -92,9 +92,9 @@ void findVisibleById_WhenHidden_ShouldThrowBRAND_NOT_FOUND() { void findVisibleById_WhenDeleted_ShouldThrowBRAND_NOT_FOUND() { BrandModel brand = BrandModel.create("브랜드", "설명", "서울"); brand.softDelete(); - when(brandRepository.findById("brand-id")).thenReturn(Optional.of(brand)); + when(brandRepository.findById(1L)).thenReturn(Optional.of(brand)); - assertThatThrownBy(() -> brandService.findVisibleById("brand-id")) + assertThatThrownBy(() -> brandService.findVisibleById(1L)) .isInstanceOf(CoreException.class) .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()) .isEqualTo(ErrorType.BRAND_NOT_FOUND)); @@ -140,9 +140,9 @@ class UpdateTests { @DisplayName("수정 후 변경된 BrandModel을 반환한다") void updateBrand_ShouldUpdateAndReturn() { BrandModel brand = BrandModel.create("기존이름", "기존설명", "기존주소"); - when(brandRepository.findById("brand-id")).thenReturn(Optional.of(brand)); + when(brandRepository.findById(1L)).thenReturn(Optional.of(brand)); - BrandModel result = brandService.updateBrand("brand-id", "새이름", "새설명", "새주소"); + BrandModel result = brandService.updateBrand(1L, "새이름", "새설명", "새주소"); assertThat(result.getBrandName()).isEqualTo("새이름"); assertThat(result.getDescription()).isEqualTo("새설명"); @@ -158,9 +158,9 @@ class DeleteTests { @DisplayName("소프트 삭제가 정상적으로 수행된다") void deleteBrand_ShouldSoftDeleteBrand() { BrandModel brand = BrandModel.create("브랜드", "설명", "서울"); - when(brandRepository.findById("brand-id")).thenReturn(Optional.of(brand)); + when(brandRepository.findById(1L)).thenReturn(Optional.of(brand)); - brandService.deleteBrand("brand-id"); + brandService.deleteBrand(1L); assertThat(brand.isDeleted()).isTrue(); } @@ -170,9 +170,9 @@ void deleteBrand_ShouldSoftDeleteBrand() { void deleteBrand_AlreadyDeleted_ShouldBeIdempotent() { BrandModel brand = BrandModel.create("브랜드", "설명", "서울"); brand.softDelete(); - when(brandRepository.findById("brand-id")).thenReturn(Optional.of(brand)); + when(brandRepository.findById(1L)).thenReturn(Optional.of(brand)); - assertThatCode(() -> brandService.deleteBrand("brand-id")) + assertThatCode(() -> brandService.deleteBrand(1L)) .doesNotThrowAnyException(); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/cart/CartItemModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/cart/CartItemModelTest.java index 81cc5f4d4..6beb2336f 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/cart/CartItemModelTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/cart/CartItemModelTest.java @@ -17,24 +17,24 @@ class CreateTests { @Test @DisplayName("유효한 입력으로 생성 성공") void create_WithValidInputs_ShouldSuccess() { - CartItemModel cartItem = CartItemModel.create("user-001", "product-001", 3); + CartItemModel cartItem = CartItemModel.create(1L, 1L, 3); - assertThat(cartItem.getUserId()).isEqualTo("user-001"); - assertThat(cartItem.getProductId()).isEqualTo("product-001"); + assertThat(cartItem.getUserId()).isEqualTo(1L); + assertThat(cartItem.getProductId()).isEqualTo(1L); assertThat(cartItem.getQuantity()).isEqualTo(3); } @Test @DisplayName("수량이 0이면 CoreException 발생") void create_WithZeroQuantity_ShouldThrow() { - assertThatThrownBy(() -> CartItemModel.create("user-001", "product-001", 0)) + assertThatThrownBy(() -> CartItemModel.create(1L, 1L, 0)) .isInstanceOf(CoreException.class); } @Test @DisplayName("수량이 음수이면 CoreException 발생") void create_WithNegativeQuantity_ShouldThrow() { - assertThatThrownBy(() -> CartItemModel.create("user-001", "product-001", -1)) + assertThatThrownBy(() -> CartItemModel.create(1L, 1L, -1)) .isInstanceOf(CoreException.class); } } @@ -79,6 +79,6 @@ void mergeQuantity_ShouldAddToExisting() { // === Helper === private CartItemModel createTestCartItem() { - return CartItemModel.create("user-001", "product-001", 3); + return CartItemModel.create(1L, 1L, 3); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/cart/CartServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/cart/CartServiceTest.java index 5931611e5..6eec809ea 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/cart/CartServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/cart/CartServiceTest.java @@ -36,12 +36,12 @@ class AddItemTests { @Test @DisplayName("새 상품을 장바구니에 등록하면 save가 호출된다") void addItem_NewItem_ShouldCreate() { - when(cartItemRepository.findById(new CartItemId("user-1", "product-1"))) + when(cartItemRepository.findById(new CartItemId(1L, 1L))) .thenReturn(Optional.empty()); when(cartItemRepository.save(any(CartItemModel.class))) .thenAnswer(invocation -> invocation.getArgument(0)); - cartService.addItem("user-1", "product-1", 3); + cartService.addItem(1L, 1L, 3); verify(cartItemRepository).save(any(CartItemModel.class)); } @@ -49,11 +49,11 @@ void addItem_NewItem_ShouldCreate() { @Test @DisplayName("이미 있는 상품 재등록 시 수량이 병합된다") void addItem_ExistingItem_ShouldMergeQuantity() { - CartItemModel existing = CartItemModel.create("user-1", "product-1", 2); - when(cartItemRepository.findById(new CartItemId("user-1", "product-1"))) + CartItemModel existing = CartItemModel.create(1L, 1L, 2); + when(cartItemRepository.findById(new CartItemId(1L, 1L))) .thenReturn(Optional.of(existing)); - cartService.addItem("user-1", "product-1", 3); + cartService.addItem(1L, 1L, 3); assertThat(existing.getQuantity()).isEqualTo(5); verify(cartItemRepository, never()).save(any()); @@ -69,11 +69,11 @@ class ChangeQuantityTests { @Test @DisplayName("정상적으로 수량이 변경된다") void changeQuantity_ShouldUpdate() { - CartItemModel item = CartItemModel.create("user-1", "product-1", 2); - when(cartItemRepository.findById(new CartItemId("user-1", "product-1"))) + CartItemModel item = CartItemModel.create(1L, 1L, 2); + when(cartItemRepository.findById(new CartItemId(1L, 1L))) .thenReturn(Optional.of(item)); - cartService.changeQuantity("user-1", "product-1", 5); + cartService.changeQuantity(1L, 1L, 5); assertThat(item.getQuantity()).isEqualTo(5); } @@ -81,10 +81,10 @@ void changeQuantity_ShouldUpdate() { @Test @DisplayName("존재하지 않는 항목 수량 변경 시 CART_ITEM_NOT_FOUND 예외가 발생한다") void changeQuantity_NonExistingItem_ShouldThrow() { - when(cartItemRepository.findById(new CartItemId("user-1", "product-1"))) + when(cartItemRepository.findById(new CartItemId(1L, 1L))) .thenReturn(Optional.empty()); - assertThatThrownBy(() -> cartService.changeQuantity("user-1", "product-1", 5)) + assertThatThrownBy(() -> cartService.changeQuantity(1L, 1L, 5)) .isInstanceOf(CoreException.class) .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()) .isEqualTo(ErrorType.CART_ITEM_NOT_FOUND)); @@ -100,11 +100,11 @@ class RemoveItemTests { @Test @DisplayName("정상 삭제 시 delete가 호출된다") void removeItem_ShouldDelete() { - CartItemModel item = CartItemModel.create("user-1", "product-1", 3); - when(cartItemRepository.findById(new CartItemId("user-1", "product-1"))) + CartItemModel item = CartItemModel.create(1L, 1L, 3); + when(cartItemRepository.findById(new CartItemId(1L, 1L))) .thenReturn(Optional.of(item)); - cartService.removeItem("user-1", "product-1"); + cartService.removeItem(1L, 1L); verify(cartItemRepository).delete(item); } @@ -112,10 +112,10 @@ void removeItem_ShouldDelete() { @Test @DisplayName("존재하지 않는 항목 삭제 시 에러 없이 통과한다 (멱등)") void removeItem_NonExisting_ShouldBeIdempotent() { - when(cartItemRepository.findById(new CartItemId("user-1", "product-1"))) + when(cartItemRepository.findById(new CartItemId(1L, 1L))) .thenReturn(Optional.empty()); - assertThatCode(() -> cartService.removeItem("user-1", "product-1")) + assertThatCode(() -> cartService.removeItem(1L, 1L)) .doesNotThrowAnyException(); verify(cartItemRepository, never()).delete(any()); } @@ -130,13 +130,13 @@ class GetCartItemsTests { @Test @DisplayName("사용자의 장바구니 항목 목록을 반환한다") void getCartItems_ShouldReturnItemList() { - CartItemModel item = CartItemModel.create("user-1", "product-1", 2); - when(cartItemRepository.findAllByUserId("user-1")).thenReturn(List.of(item)); + CartItemModel item = CartItemModel.create(1L, 1L, 2); + when(cartItemRepository.findAllByUserId(1L)).thenReturn(List.of(item)); - List result = cartService.getCartItems("user-1"); + List result = cartService.getCartItems(1L); assertThat(result).hasSize(1); - assertThat(result.get(0).getProductId()).isEqualTo("product-1"); + assertThat(result.get(0).getProductId()).isEqualTo(1L); } } @@ -149,13 +149,13 @@ class RestoreTests { @Test @DisplayName("주문 취소 시 주문 항목이 장바구니에 복원된다") void restoreFromOrder_ShouldCreateCartItems() { - when(cartItemRepository.findById(new CartItemId("user-1", "product-1"))) + when(cartItemRepository.findById(new CartItemId(1L, 1L))) .thenReturn(Optional.empty()); when(cartItemRepository.save(any(CartItemModel.class))) .thenAnswer(invocation -> invocation.getArgument(0)); - cartService.restoreFromOrder("user-1", - List.of(new CartService.RestoreItem("product-1", 3))); + cartService.restoreFromOrder(1L, + List.of(new CartService.RestoreItem(1L, 3))); verify(cartItemRepository).save(any(CartItemModel.class)); } @@ -163,12 +163,12 @@ void restoreFromOrder_ShouldCreateCartItems() { @Test @DisplayName("기존 장바구니에 동일 상품이 있으면 수량이 병합된다") void restoreFromOrder_ExistingItem_ShouldMergeQuantity() { - CartItemModel existing = CartItemModel.create("user-1", "product-1", 2); - when(cartItemRepository.findById(new CartItemId("user-1", "product-1"))) + CartItemModel existing = CartItemModel.create(1L, 1L, 2); + when(cartItemRepository.findById(new CartItemId(1L, 1L))) .thenReturn(Optional.of(existing)); - cartService.restoreFromOrder("user-1", - List.of(new CartService.RestoreItem("product-1", 3))); + cartService.restoreFromOrder(1L, + List.of(new CartService.RestoreItem(1L, 3))); assertThat(existing.getQuantity()).isEqualTo(5); verify(cartItemRepository, never()).save(any()); diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponIssuanceConcurrencyTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponIssuanceConcurrencyTest.java new file mode 100644 index 000000000..89b027925 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponIssuanceConcurrencyTest.java @@ -0,0 +1,128 @@ +package com.loopers.domain.coupon; + +import com.loopers.support.enums.DiscountType; +import com.loopers.support.error.CoreException; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@ActiveProfiles("test") +@DisplayName("쿠폰 발급 동시성 테스트") +class CouponIssuanceConcurrencyTest { + + @Autowired + CouponService couponService; + + @Autowired + UserCouponRepository userCouponRepository; + + @Autowired + DatabaseCleanUp databaseCleanUp; + + private Long couponId; + + @BeforeEach + void setUp() { + CouponModel coupon = couponService.createCoupon( + "동시성테스트쿠폰", DiscountType.FIXED, BigDecimal.valueOf(1000), + null, LocalDateTime.now().plusDays(30)); + couponId = coupon.getCouponId(); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Test + @DisplayName("같은 사용자가 동시에 같은 쿠폰을 2회 발급 요청 시 1건만 성공한다") + void concurrentIssueSameUser_ShouldIssueOnlyOnce() throws InterruptedException { + int threadCount = 2; + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch doneLatch = new CountDownLatch(threadCount); + AtomicInteger successCount = new AtomicInteger(0); + AtomicInteger failCount = new AtomicInteger(0); + + for (int i = 0; i < threadCount; i++) { + executor.submit(() -> { + try { + startLatch.await(); + couponService.issueCoupon(1L, couponId); + successCount.incrementAndGet(); + } catch (CoreException e) { + failCount.incrementAndGet(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + doneLatch.countDown(); + } + }); + } + + startLatch.countDown(); + doneLatch.await(); + executor.shutdown(); + + assertThat(successCount.get()).isEqualTo(1); + assertThat(failCount.get()).isEqualTo(1); + + List issued = userCouponRepository.findAllByUserId(1L); + assertThat(issued).hasSize(1); + } + + @Test + @DisplayName("50명이 동시에 동일 쿠폰 발급 시 중복 발급 없이 정확히 처리된다") + void concurrentIssueDifferentUsers_ShouldNotDuplicate() throws InterruptedException { + int threadCount = 50; + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch doneLatch = new CountDownLatch(threadCount); + AtomicInteger successCount = new AtomicInteger(0); + List userIds = new ArrayList<>(); + for (long i = 1; i <= threadCount; i++) { + userIds.add(i); + } + + for (int i = 0; i < threadCount; i++) { + final Long userId = userIds.get(i); + executor.submit(() -> { + try { + startLatch.await(); + couponService.issueCoupon(userId, couponId); + successCount.incrementAndGet(); + } catch (CoreException | InterruptedException e) { + if (e instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + } finally { + doneLatch.countDown(); + } + }); + } + + startLatch.countDown(); + doneLatch.await(); + executor.shutdown(); + + // 50명 각각 다른 userId → 모두 성공해야 함 + assertThat(successCount.get()).isEqualTo(threadCount); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponModelTest.java new file mode 100644 index 000000000..3092e5253 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponModelTest.java @@ -0,0 +1,145 @@ +package com.loopers.domain.coupon; + +import com.loopers.support.enums.DiscountType; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DisplayName("CouponModel 단위 테스트") +class CouponModelTest { + + private CouponModel createFixedCoupon(BigDecimal value) { + return CouponModel.create("테스트쿠폰", DiscountType.FIXED, value, null, + LocalDateTime.now().plusDays(30)); + } + + private CouponModel createRateCoupon(BigDecimal rate) { + return CouponModel.create("테스트쿠폰", DiscountType.RATE, rate, null, + LocalDateTime.now().plusDays(30)); + } + + @Nested + @DisplayName("calculateDiscount - 할인 금액 계산") + class CalculateDiscountTests { + + @Test + @DisplayName("FIXED 쿠폰은 discountValue를 반환한다") + void calculateDiscount_Fixed_ShouldReturnDiscountValue() { + CouponModel coupon = createFixedCoupon(BigDecimal.valueOf(5000)); + BigDecimal result = coupon.calculateDiscount(BigDecimal.valueOf(30000)); + assertThat(result).isEqualByComparingTo(BigDecimal.valueOf(5000)); + } + + @Test + @DisplayName("FIXED 쿠폰 할인값이 주문 금액 초과 시 주문 금액을 반환한다") + void calculateDiscount_Fixed_WhenExceedsTotalAmount_ShouldCapAtTotalAmount() { + CouponModel coupon = createFixedCoupon(BigDecimal.valueOf(50000)); + BigDecimal result = coupon.calculateDiscount(BigDecimal.valueOf(10000)); + assertThat(result).isEqualByComparingTo(BigDecimal.valueOf(10000)); + } + + @Test + @DisplayName("RATE 쿠폰은 비율로 할인 금액을 계산하고 FLOOR 처리한다") + void calculateDiscount_Rate_ShouldReturnPercentageFloor() { + CouponModel coupon = createRateCoupon(BigDecimal.valueOf(10)); + BigDecimal result = coupon.calculateDiscount(BigDecimal.valueOf(33333)); + // 33333 * 10 / 100 = 3333.3 → FLOOR → 3333 + assertThat(result).isEqualByComparingTo(BigDecimal.valueOf(3333)); + } + + @Test + @DisplayName("RATE 100% 쿠폰은 전체 금액을 반환한다") + void calculateDiscount_Rate_100Percent_ShouldReturnTotalAmount() { + CouponModel coupon = createRateCoupon(BigDecimal.valueOf(100)); + BigDecimal result = coupon.calculateDiscount(BigDecimal.valueOf(50000)); + assertThat(result).isEqualByComparingTo(BigDecimal.valueOf(50000)); + } + } + + @Nested + @DisplayName("validateApplicable - 쿠폰 적용 가능 여부 검증") + class ValidateApplicableTests { + + @Test + @DisplayName("만료된 쿠폰은 COUPON_NOT_APPLICABLE 예외를 던진다") + void validateApplicable_WhenExpired_ShouldThrowCouponNotApplicable() { + CouponModel coupon = CouponModel.create("만료쿠폰", DiscountType.FIXED, BigDecimal.valueOf(1000), null, + LocalDateTime.now().minusDays(1)); + assertThatThrownBy(() -> coupon.validateApplicable(BigDecimal.valueOf(10000))) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()) + .isEqualTo(ErrorType.COUPON_NOT_APPLICABLE)); + } + + @Test + @DisplayName("삭제된 쿠폰은 COUPON_NOT_APPLICABLE 예외를 던진다") + void validateApplicable_WhenDeleted_ShouldThrowCouponNotApplicable() { + CouponModel coupon = CouponModel.create("쿠폰", DiscountType.FIXED, BigDecimal.valueOf(1000), null, + LocalDateTime.now().plusDays(10)); + coupon.softDelete(); + assertThatThrownBy(() -> coupon.validateApplicable(BigDecimal.valueOf(10000))) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()) + .isEqualTo(ErrorType.COUPON_NOT_APPLICABLE)); + } + + @Test + @DisplayName("최소 주문 금액 미충족 시 COUPON_NOT_APPLICABLE 예외를 던진다") + void validateApplicable_WhenBelowMinOrderAmount_ShouldThrowCouponNotApplicable() { + CouponModel coupon = CouponModel.create("쿠폰", DiscountType.FIXED, BigDecimal.valueOf(1000), + BigDecimal.valueOf(20000), LocalDateTime.now().plusDays(10)); + assertThatThrownBy(() -> coupon.validateApplicable(BigDecimal.valueOf(15000))) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()) + .isEqualTo(ErrorType.COUPON_NOT_APPLICABLE)); + } + + @Test + @DisplayName("유효한 쿠폰은 예외가 발생하지 않는다") + void validateApplicable_WhenValid_ShouldNotThrow() { + CouponModel coupon = CouponModel.create("쿠폰", DiscountType.FIXED, BigDecimal.valueOf(1000), + BigDecimal.valueOf(10000), LocalDateTime.now().plusDays(10)); + coupon.validateApplicable(BigDecimal.valueOf(20000)); // 예외 없음 + } + } + + @Nested + @DisplayName("guard - 생성 유효성 검증") + class GuardTests { + + @Test + @DisplayName("할인값이 0 이하이면 예외가 발생한다") + void guard_WhenValueZeroOrNegative_ShouldThrow() { + assertThatThrownBy(() -> + CouponModel.create("쿠폰", DiscountType.FIXED, BigDecimal.ZERO, null, + LocalDateTime.now().plusDays(10))) + .isInstanceOf(CoreException.class); + } + + @Test + @DisplayName("RATE 쿠폰의 할인율이 100을 초과하면 예외가 발생한다") + void guard_WhenRateExceeds100_ShouldThrow() { + assertThatThrownBy(() -> + CouponModel.create("쿠폰", DiscountType.RATE, BigDecimal.valueOf(101), null, + LocalDateTime.now().plusDays(10))) + .isInstanceOf(CoreException.class); + } + + @Test + @DisplayName("쿠폰명이 공백이면 예외가 발생한다") + void guard_WhenNameBlank_ShouldThrow() { + assertThatThrownBy(() -> + CouponModel.create(" ", DiscountType.FIXED, BigDecimal.valueOf(1000), null, + LocalDateTime.now().plusDays(10))) + .isInstanceOf(CoreException.class); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponServiceTest.java new file mode 100644 index 000000000..d56f55a62 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponServiceTest.java @@ -0,0 +1,153 @@ +package com.loopers.domain.coupon; + +import com.loopers.support.enums.DiscountType; +import com.loopers.support.enums.UserCouponStatus; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("CouponService 단위 테스트") +class CouponServiceTest { + + private static final Long USER_ID = 1L; + private static final Long COUPON_ID = 10L; + private static final Long USER_COUPON_ID = 100L; + + @Mock + CouponRepository couponRepository; + + @Mock + UserCouponRepository userCouponRepository; + + @InjectMocks + CouponService couponService; + + private CouponModel createValidCoupon() { + return CouponModel.create("테스트쿠폰", DiscountType.FIXED, BigDecimal.valueOf(5000), null, + LocalDateTime.now().plusDays(30)); + } + + @Nested + @DisplayName("issueCoupon - 쿠폰 발급") + class IssueCouponTests { + + @Test + @DisplayName("정상 발급 시 UserCouponModel이 저장된다") + void issueCoupon_NewIssuance_ShouldSave() { + CouponModel coupon = createValidCoupon(); + when(couponRepository.findByIdWithLock(COUPON_ID)).thenReturn(Optional.of(coupon)); + when(userCouponRepository.existsByUserIdAndCouponId(USER_ID, COUPON_ID)).thenReturn(false); + UserCouponModel saved = UserCouponModel.create(USER_ID, COUPON_ID); + when(userCouponRepository.save(any())).thenReturn(saved); + + UserCouponModel result = couponService.issueCoupon(USER_ID, COUPON_ID); + + assertThat(result).isNotNull(); + assertThat(result.getStatus()).isEqualTo(UserCouponStatus.AVAILABLE); + verify(userCouponRepository).save(any(UserCouponModel.class)); + } + + @Test + @DisplayName("쿠폰이 없으면 COUPON_NOT_FOUND 예외가 발생한다") + void issueCoupon_CouponNotFound_ShouldThrowCouponNotFound() { + when(couponRepository.findByIdWithLock(COUPON_ID)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> couponService.issueCoupon(USER_ID, COUPON_ID)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()) + .isEqualTo(ErrorType.COUPON_NOT_FOUND)); + verify(userCouponRepository, never()).save(any()); + } + + @Test + @DisplayName("이미 발급된 쿠폰이면 COUPON_ALREADY_ISSUED 예외가 발생한다") + void issueCoupon_AlreadyIssued_ShouldThrowCouponAlreadyIssued() { + CouponModel coupon = createValidCoupon(); + when(couponRepository.findByIdWithLock(COUPON_ID)).thenReturn(Optional.of(coupon)); + when(userCouponRepository.existsByUserIdAndCouponId(USER_ID, COUPON_ID)).thenReturn(true); + + assertThatThrownBy(() -> couponService.issueCoupon(USER_ID, COUPON_ID)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()) + .isEqualTo(ErrorType.COUPON_ALREADY_ISSUED)); + verify(userCouponRepository, never()).save(any()); + } + + @Test + @DisplayName("만료된 쿠폰이면 COUPON_NOT_APPLICABLE 예외가 발생한다") + void issueCoupon_ExpiredCoupon_ShouldThrowCouponNotApplicable() { + CouponModel expiredCoupon = CouponModel.create("만료쿠폰", DiscountType.FIXED, + BigDecimal.valueOf(1000), null, LocalDateTime.now().minusDays(1)); + when(couponRepository.findByIdWithLock(COUPON_ID)).thenReturn(Optional.of(expiredCoupon)); + + assertThatThrownBy(() -> couponService.issueCoupon(USER_ID, COUPON_ID)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()) + .isEqualTo(ErrorType.COUPON_NOT_APPLICABLE)); + verify(userCouponRepository, never()).save(any()); + } + } + + @Nested + @DisplayName("validateAndGetUserCoupon - 발급 쿠폰 조회 및 검증") + class ValidateAndGetUserCouponTests { + + @Test + @DisplayName("AVAILABLE 상태의 본인 쿠폰이면 반환한다") + void validateAndGetUserCoupon_WhenAvailable_ShouldReturn() { + UserCouponModel userCoupon = UserCouponModel.create(USER_ID, COUPON_ID); + when(userCouponRepository.findByIdWithLock(USER_COUPON_ID)) + .thenReturn(Optional.of(userCoupon)); + + UserCouponModel result = couponService.validateAndGetUserCoupon(USER_ID, USER_COUPON_ID); + + assertThat(result).isNotNull(); + assertThat(result.isAvailable()).isTrue(); + } + + @Test + @DisplayName("다른 사용자 소유 쿠폰이면 USER_COUPON_NOT_FOUND 예외가 발생한다") + void validateAndGetUserCoupon_WhenNotOwned_ShouldThrowUserCouponNotFound() { + UserCouponModel otherUserCoupon = UserCouponModel.create(999L, COUPON_ID); + when(userCouponRepository.findByIdWithLock(USER_COUPON_ID)) + .thenReturn(Optional.of(otherUserCoupon)); + + assertThatThrownBy(() -> couponService.validateAndGetUserCoupon(USER_ID, USER_COUPON_ID)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()) + .isEqualTo(ErrorType.USER_COUPON_NOT_FOUND)); + } + + @Test + @DisplayName("USED 상태의 쿠폰이면 COUPON_NOT_AVAILABLE 예외가 발생한다") + void validateAndGetUserCoupon_WhenUsed_ShouldThrowCouponNotAvailable() { + UserCouponModel userCoupon = UserCouponModel.create(USER_ID, COUPON_ID); + userCoupon.markAsUsed(999L); + when(userCouponRepository.findByIdWithLock(USER_COUPON_ID)) + .thenReturn(Optional.of(userCoupon)); + + assertThatThrownBy(() -> couponService.validateAndGetUserCoupon(USER_ID, USER_COUPON_ID)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()) + .isEqualTo(ErrorType.COUPON_NOT_AVAILABLE)); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/coupon/OrderCouponConcurrencyTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/OrderCouponConcurrencyTest.java new file mode 100644 index 000000000..b8a786c42 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/OrderCouponConcurrencyTest.java @@ -0,0 +1,114 @@ +package com.loopers.domain.coupon; + +import com.loopers.application.order.OrderFacade; +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.order.OrderItemCommand; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductService; +import com.loopers.domain.product.StockService; +import com.loopers.domain.user.UserModel; +import com.loopers.domain.user.UserRegisterCommand; +import com.loopers.domain.user.UserService; +import com.loopers.support.enums.DiscountType; +import com.loopers.support.enums.UserCouponStatus; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@ActiveProfiles("test") +@DisplayName("주문-쿠폰 동시성 테스트") +class OrderCouponConcurrencyTest { + + @Autowired OrderFacade orderFacade; + @Autowired CouponService couponService; + @Autowired UserCouponRepository userCouponRepository; + @Autowired UserService userService; + @Autowired ProductService productService; + @Autowired BrandService brandService; + @Autowired StockService stockService; + @Autowired DatabaseCleanUp databaseCleanUp; + + private Long userId; + private Long productId; + private Long userCouponId; + + @BeforeEach + void setUp() { + // 브랜드 + 상품 + 재고 생성 + BrandModel brand = brandService.createBrand("테스트브랜드", "설명", "서울"); + ProductModel product = productService.createProduct("테스트상품", brand.getBrandId(), + BigDecimal.valueOf(50000), "설명"); + productId = product.getProductId(); + stockService.createStock(productId, 100); + + // 사용자 등록 + UserModel user = userService.register(new UserRegisterCommand( + "couponuser", "Test1234!@#", "쿠폰유저", + "19900101", "coupon@test.com", "서울")); + userId = user.getUserId(); + + // 쿠폰 생성 및 발급 + CouponModel coupon = couponService.createCoupon("테스트쿠폰", DiscountType.FIXED, + BigDecimal.valueOf(5000), null, LocalDateTime.now().plusDays(30)); + UserCouponModel userCoupon = couponService.issueCoupon(userId, coupon.getCouponId()); + userCouponId = userCoupon.getUserCouponId(); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Test + @DisplayName("2 스레드가 동일 쿠폰으로 동시 주문 시 1 성공, 1 실패, 쿠폰 USED") + void concurrentOrderWithSameCoupon_ShouldSucceedOnlyOnce() throws InterruptedException { + int threadCount = 2; + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch doneLatch = new CountDownLatch(threadCount); + AtomicInteger successCount = new AtomicInteger(0); + AtomicInteger failCount = new AtomicInteger(0); + + for (int i = 0; i < threadCount; i++) { + executor.submit(() -> { + try { + startLatch.await(); + List items = List.of(new OrderItemCommand(productId, 1)); + orderFacade.createDirectOrder(userId, items, userCouponId); + successCount.incrementAndGet(); + } catch (Exception e) { + failCount.incrementAndGet(); + } finally { + doneLatch.countDown(); + } + }); + } + + startLatch.countDown(); + doneLatch.await(); + executor.shutdown(); + + assertThat(successCount.get()).isEqualTo(1); + assertThat(failCount.get()).isEqualTo(1); + + UserCouponModel userCoupon = userCouponRepository.findById(userCouponId).get(); + assertThat(userCoupon.getStatus()).isEqualTo(UserCouponStatus.USED); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/coupon/UserCouponModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/UserCouponModelTest.java new file mode 100644 index 000000000..fcbbcf6c4 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/UserCouponModelTest.java @@ -0,0 +1,63 @@ +package com.loopers.domain.coupon; + +import com.loopers.support.enums.UserCouponStatus; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DisplayName("UserCouponModel 단위 테스트") +class UserCouponModelTest { + + @Nested + @DisplayName("markAsUsed - 쿠폰 사용 처리") + class MarkAsUsedTests { + + @Test + @DisplayName("AVAILABLE 상태의 쿠폰은 USED로 전이되고 usedAt, orderId가 설정된다") + void markAsUsed_WhenAvailable_ShouldSetStatusAndTimestamp() { + UserCouponModel userCoupon = UserCouponModel.create(1L, 10L); + userCoupon.markAsUsed(100L); + + assertThat(userCoupon.getStatus()).isEqualTo(UserCouponStatus.USED); + assertThat(userCoupon.getUsedAt()).isNotNull(); + assertThat(userCoupon.getOrderId()).isEqualTo(100L); + } + + @Test + @DisplayName("이미 USED인 쿠폰을 다시 사용하면 COUPON_NOT_AVAILABLE 예외가 발생한다") + void markAsUsed_WhenAlreadyUsed_ShouldThrowCouponNotAvailable() { + UserCouponModel userCoupon = UserCouponModel.create(1L, 10L); + userCoupon.markAsUsed(100L); + + assertThatThrownBy(() -> userCoupon.markAsUsed(200L)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()) + .isEqualTo(ErrorType.COUPON_NOT_AVAILABLE)); + } + } + + @Nested + @DisplayName("isAvailable - 사용 가능 여부") + class IsAvailableTests { + + @Test + @DisplayName("AVAILABLE 상태이면 true를 반환한다") + void isAvailable_WhenAvailable_ShouldReturnTrue() { + UserCouponModel userCoupon = UserCouponModel.create(1L, 10L); + assertThat(userCoupon.isAvailable()).isTrue(); + } + + @Test + @DisplayName("USED 상태이면 false를 반환한다") + void isAvailable_WhenUsed_ShouldReturnFalse() { + UserCouponModel userCoupon = UserCouponModel.create(1L, 10L); + userCoupon.markAsUsed(100L); + assertThat(userCoupon.isAvailable()).isFalse(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeConcurrencyTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeConcurrencyTest.java new file mode 100644 index 000000000..49c24bbce --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeConcurrencyTest.java @@ -0,0 +1,149 @@ +package com.loopers.domain.like; + +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.ProductService; +import com.loopers.domain.product.StockService; +import com.loopers.domain.user.UserModel; +import com.loopers.domain.user.UserRegisterCommand; +import com.loopers.domain.user.UserService; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import java.math.BigDecimal; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@ActiveProfiles("test") +@DisplayName("좋아요 동시성 테스트") +class LikeConcurrencyTest { + + @Autowired + LikeService likeService; + + @Autowired + LikeRepository likeRepository; + + @Autowired + ProductService productService; + + @Autowired + ProductRepository productRepository; + + @Autowired + StockService stockService; + + @Autowired + UserService userService; + + @Autowired + DatabaseCleanUp databaseCleanUp; + + private Long productId; + + @BeforeEach + void setUp() { + ProductModel product = productService.createProduct("테스트상품", 1L, BigDecimal.valueOf(10000), "설명"); + productId = product.getProductId(); + stockService.createStock(productId, 100); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Test + @DisplayName("20명이 동시에 같은 상품 좋아요 시 like 수 == 20, likeCount == 20") + void concurrentAddLike_DifferentUsers_ShouldAllSucceed() throws InterruptedException { + int threadCount = 20; + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch doneLatch = new CountDownLatch(threadCount); + AtomicInteger successCount = new AtomicInteger(0); + + // 사용자 20명 생성 + Long[] userIds = new Long[threadCount]; + for (int i = 0; i < threadCount; i++) { + UserModel user = userService.register(new UserRegisterCommand( + "likeuser" + i, "Test1234!@#", "유저" + i, + "19900101", "user" + i + "@test.com", "서울")); + userIds[i] = user.getUserId(); + } + + for (int i = 0; i < threadCount; i++) { + final int index = i; + executor.submit(() -> { + try { + startLatch.await(); + likeService.addLike(userIds[index], productId); + successCount.incrementAndGet(); + } catch (Exception e) { + // 예외 발생 시 실패 카운트 + } finally { + doneLatch.countDown(); + } + }); + } + + startLatch.countDown(); + doneLatch.await(); + executor.shutdown(); + + assertThat(successCount.get()).isEqualTo(20); + + long likeCount = likeRepository.countByProductId(productId); + assertThat(likeCount).isEqualTo(20); + + ProductModel product = productRepository.findById(productId).get(); + assertThat(product.getLikeCount()).isEqualTo(20); + } + + @Test + @DisplayName("같은 유저가 5번 동시 좋아요 시 like 수 == 1, likeCount == 1 (멱등)") + void concurrentAddLike_SameUser_ShouldBeIdempotent() throws InterruptedException { + int threadCount = 5; + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch doneLatch = new CountDownLatch(threadCount); + + UserModel user = userService.register(new UserRegisterCommand( + "sameuser", "Test1234!@#", "같은유저", + "19900101", "same@test.com", "서울")); + Long userId = user.getUserId(); + + for (int i = 0; i < threadCount; i++) { + executor.submit(() -> { + try { + startLatch.await(); + likeService.addLike(userId, productId); + } catch (Exception e) { + // 멱등 처리로 예외는 무시 + } finally { + doneLatch.countDown(); + } + }); + } + + startLatch.countDown(); + doneLatch.await(); + executor.shutdown(); + + long likeCount = likeRepository.countByProductId(productId); + assertThat(likeCount).isEqualTo(1); + + ProductModel product = productRepository.findById(productId).get(); + assertThat(product.getLikeCount()).isEqualTo(1); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeModelTest.java index 22bee5902..f2287dce2 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeModelTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeModelTest.java @@ -12,30 +12,30 @@ class LikeModelTest { @Test @DisplayName("유효한 입력으로 생성 성공") void create_WithValidInputs_ShouldSuccess() { - LikeModel like = LikeModel.create("user-001", "product-001"); + LikeModel like = LikeModel.create(1L, 1L); - assertThat(like.getUserId()).isEqualTo("user-001"); - assertThat(like.getProductId()).isEqualTo("product-001"); + assertThat(like.getUserId()).isEqualTo(1L); + assertThat(like.getProductId()).isEqualTo(1L); } @Test @DisplayName("userId가 null이면 CoreException 발생") void create_WithNullUserId_ShouldThrow() { - assertThatThrownBy(() -> LikeModel.create(null, "product-001")) + assertThatThrownBy(() -> LikeModel.create(null, 1L)) .isInstanceOf(CoreException.class); } @Test @DisplayName("productId가 null이면 CoreException 발생") void create_WithNullProductId_ShouldThrow() { - assertThatThrownBy(() -> LikeModel.create("user-001", null)) + assertThatThrownBy(() -> LikeModel.create(1L, null)) .isInstanceOf(CoreException.class); } @Test @DisplayName("생성 시 createdAt은 @PrePersist에서 설정된다 (직접 생성 시 null)") void create_ShouldSetCreatedAt() { - LikeModel like = LikeModel.create("user-001", "product-001"); + LikeModel like = LikeModel.create(1L, 1L); // createdAt은 @PrePersist에서 설정되므로 JPA 없이는 null // 단위 테스트에서는 생성이 성공했음을 확인 assertThat(like).isNotNull(); diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceTest.java index 1f330f346..9a36e5541 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceTest.java @@ -2,8 +2,6 @@ import com.loopers.domain.product.ProductModel; import com.loopers.domain.product.ProductService; -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; @@ -12,13 +10,13 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import java.math.BigDecimal; import java.util.List; import java.util.Map; import java.util.Optional; import static org.assertj.core.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) @@ -41,42 +39,34 @@ class AddLikeTests { @Test @DisplayName("처음 좋아요 시 save가 호출된다") void addLike_NewLike_ShouldCreate() { - ProductModel product = createTestProduct(); - when(productService.findById("product-1")).thenReturn(product); - when(likeRepository.findById(new LikeId("user-1", "product-1"))) + ProductModel product = mock(ProductModel.class); + when(productService.findByIdWithLock(1L)).thenReturn(product); + when(likeRepository.findById(new LikeId(1L, 1L))) .thenReturn(Optional.empty()); when(likeRepository.save(any(LikeModel.class))) .thenAnswer(invocation -> invocation.getArgument(0)); - likeService.addLike("user-1", "product-1"); + likeService.addLike(1L, 1L); + verify(productService).findByIdWithLock(1L); verify(likeRepository).save(any(LikeModel.class)); + verify(productService).incrementLikeCount(1L); } @Test @DisplayName("이미 좋아요한 상품에 다시 좋아요하면 save가 호출되지 않는다 (멱등)") void addLike_AlreadyLiked_ShouldBeIdempotent() { - ProductModel product = createTestProduct(); - when(productService.findById("product-1")).thenReturn(product); - LikeModel existingLike = LikeModel.create("user-1", "product-1"); - when(likeRepository.findById(new LikeId("user-1", "product-1"))) + ProductModel product = mock(ProductModel.class); + when(productService.findByIdWithLock(1L)).thenReturn(product); + LikeModel existingLike = LikeModel.create(1L, 1L); + when(likeRepository.findById(new LikeId(1L, 1L))) .thenReturn(Optional.of(existingLike)); - likeService.addLike("user-1", "product-1"); + likeService.addLike(1L, 1L); + verify(productService).findByIdWithLock(1L); verify(likeRepository, never()).save(any()); - } - - @Test - @DisplayName("존재하지 않는 상품에 좋아요 시 LIKE_PRODUCT_NOT_FOUND 예외가 발생한다") - void addLike_ProductNotFound_ShouldThrow() { - when(productService.findById("nonexistent")) - .thenThrow(new CoreException(ErrorType.PRODUCT_NOT_FOUND)); - - assertThatThrownBy(() -> likeService.addLike("user-1", "nonexistent")) - .isInstanceOf(CoreException.class) - .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()) - .isEqualTo(ErrorType.LIKE_PRODUCT_NOT_FOUND)); + verify(productService, never()).incrementLikeCount(anyLong()); } } @@ -87,24 +77,31 @@ class RemoveLikeTests { @Test @DisplayName("기존 좋아요를 삭제한다") void removeLike_Existing_ShouldDelete() { - LikeModel like = LikeModel.create("user-1", "product-1"); - when(likeRepository.findById(new LikeId("user-1", "product-1"))) + ProductModel product = mock(ProductModel.class); + when(productService.findByIdWithLock(1L)).thenReturn(product); + LikeModel like = LikeModel.create(1L, 1L); + when(likeRepository.findById(new LikeId(1L, 1L))) .thenReturn(Optional.of(like)); - likeService.removeLike("user-1", "product-1"); + likeService.removeLike(1L, 1L); + verify(productService).findByIdWithLock(1L); verify(likeRepository).delete(like); + verify(productService).decrementLikeCount(1L); } @Test @DisplayName("좋아요하지 않은 상품 취소 시 에러 없이 통과한다 (멱등)") void removeLike_NotLiked_ShouldBeIdempotent() { - when(likeRepository.findById(new LikeId("user-1", "product-1"))) + ProductModel product = mock(ProductModel.class); + when(productService.findByIdWithLock(1L)).thenReturn(product); + when(likeRepository.findById(new LikeId(1L, 1L))) .thenReturn(Optional.empty()); - assertThatCode(() -> likeService.removeLike("user-1", "product-1")) + assertThatCode(() -> likeService.removeLike(1L, 1L)) .doesNotThrowAnyException(); verify(likeRepository, never()).delete(any()); + verify(productService, never()).decrementLikeCount(anyLong()); } } @@ -113,13 +110,13 @@ void removeLike_NotLiked_ShouldBeIdempotent() { class QueryTests { @Test - @DisplayName("사용자의 좋아요 목록이 LikeInfo 리스트로 반환된다") + @DisplayName("사용자의 좋아요 목록이 LikeModel 리스트로 반환된다") void getMyLikes_ShouldReturnLikeListForUser() { - LikeModel like1 = LikeModel.create("user-1", "product-1"); - LikeModel like2 = LikeModel.create("user-1", "product-2"); - when(likeRepository.findAllByUserId("user-1")).thenReturn(List.of(like1, like2)); + LikeModel like1 = LikeModel.create(1L, 1L); + LikeModel like2 = LikeModel.create(1L, 2L); + when(likeRepository.findAllByUserId(1L)).thenReturn(List.of(like1, like2)); - List result = likeService.getMyLikes("user-1"); + List result = likeService.getMyLikes(1L); assertThat(result).hasSize(2); } @@ -127,9 +124,9 @@ void getMyLikes_ShouldReturnLikeListForUser() { @Test @DisplayName("좋아요 카운트가 올바르게 반환된다") void countByProductId_ShouldReturnCount() { - when(likeRepository.countByProductId("product-1")).thenReturn(42L); + when(likeRepository.countByProductId(1L)).thenReturn(42L); - long result = likeService.countByProductId("product-1"); + long result = likeService.countByProductId(1L); assertThat(result).isEqualTo(42L); } @@ -137,20 +134,15 @@ void countByProductId_ShouldReturnCount() { @Test @DisplayName("여러 상품의 좋아요 수가 배치로 올바르게 반환된다") void countByProductIds_ShouldReturnBatchCounts() { - List productIds = List.of("product-1", "product-2"); + List productIds = List.of(1L, 2L); when(likeRepository.countByProductIds(productIds)) - .thenReturn(Map.of("product-1", 5L, "product-2", 3L)); + .thenReturn(Map.of(1L, 5L, 2L, 3L)); - Map result = likeService.countByProductIds(productIds); + Map result = likeService.countByProductIds(productIds); assertThat(result).hasSize(2); - assertThat(result.get("product-1")).isEqualTo(5L); - assertThat(result.get("product-2")).isEqualTo(3L); + assertThat(result.get(1L)).isEqualTo(5L); + assertThat(result.get(2L)).isEqualTo(3L); } } - - private ProductModel createTestProduct() { - return ProductModel.create("테스트상품", "brand-id", BigDecimal.valueOf(10000), - "설명", null, null, null, null, null, null); - } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderCartRestoreIdempotencyTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderCartRestoreIdempotencyTest.java index 0d937325b..928e2a97d 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderCartRestoreIdempotencyTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderCartRestoreIdempotencyTest.java @@ -1,12 +1,12 @@ package com.loopers.domain.order; -import com.loopers.application.brand.BrandAppService; -import com.loopers.application.brand.BrandInfo; import com.loopers.application.order.OrderFacade; import com.loopers.application.order.OrderInfo; import com.loopers.application.product.ProductCreateCommand; import com.loopers.application.product.ProductFacade; import com.loopers.application.product.ProductInfo; +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.brand.BrandService; import com.loopers.domain.cart.CartItemId; import com.loopers.domain.cart.CartItemModel; import com.loopers.domain.cart.CartService; @@ -34,25 +34,23 @@ class OrderCartRestoreIdempotencyTest { @Autowired UserService userService; - @Autowired BrandAppService brandAppService; + @Autowired BrandService brandService; @Autowired ProductFacade productFacade; @Autowired OrderFacade orderFacade; @Autowired CartService cartService; @Autowired CartItemJpaRepository cartItemJpaRepository; - private String userId; - private String loginId; - private String loginPw; - private String productId; + private Long userId; + private Long productId; @BeforeEach void setUp() { - loginId = "testuser01"; - loginPw = "Test1234!@#"; + String loginId = "testuser01"; + String loginPw = "Test1234!@#"; var user = userService.register(new UserRegisterCommand(loginId, loginPw, "홍길동", "19900101", "test@example.com", "서울")); userId = user.getUserId(); - BrandInfo brand = brandAppService.createBrand("테스트브랜드", "설명", "서울"); + BrandModel brand = brandService.createBrand("테스트브랜드", "설명", "서울"); ProductInfo product = productFacade.createProduct( new ProductCreateCommand("테스트상품", brand.getBrandId(), BigDecimal.valueOf(10000), "설명", 100)); productId = product.getProductId(); @@ -63,17 +61,17 @@ void setUp() { void cancelDirectOrder_Twice_ShouldNotDuplicateCartItems() { // DIRECT 주문 생성 List items = List.of(new OrderItemCommand(productId, 2)); - OrderInfo order = orderFacade.createDirectOrder(loginId, loginPw, items); + OrderInfo order = orderFacade.createDirectOrder(userId, items, null); // 1회 취소 (복원 수행) - orderFacade.cancelOrder(loginId, loginPw, order.getOrderId()); + orderFacade.cancelOrder(userId, order.getOrderId()); Optional afterFirst = cartItemJpaRepository.findById(new CartItemId(userId, productId)); assertThat(afterFirst).isPresent(); int qtyAfterFirst = afterFirst.get().getQuantity(); // 2회 취소 시도 (이미 취소됨 — 멱등) - orderFacade.cancelOrder(loginId, loginPw, order.getOrderId()); + orderFacade.cancelOrder(userId, order.getOrderId()); Optional afterSecond = cartItemJpaRepository.findById(new CartItemId(userId, productId)); assertThat(afterSecond).isPresent(); @@ -88,10 +86,10 @@ void cancelDirectOrder_WhenCartItemAlreadyExists_ShouldMergeQuantity() { // DIRECT 주문 (qty=2) List items = List.of(new OrderItemCommand(productId, 2)); - OrderInfo order = orderFacade.createDirectOrder(loginId, loginPw, items); + OrderInfo order = orderFacade.createDirectOrder(userId, items, null); // 취소 → 복원 시 병합 - orderFacade.cancelOrder(loginId, loginPw, order.getOrderId()); + orderFacade.cancelOrder(userId, order.getOrderId()); Optional cartItem = cartItemJpaRepository.findById(new CartItemId(userId, productId)); assertThat(cartItem).isPresent(); @@ -103,10 +101,10 @@ void cancelDirectOrder_WhenCartItemAlreadyExists_ShouldMergeQuantity() { void expireDirectOrder_AfterManualCancel_ShouldNotRestoreAgain() { // DIRECT 주문 List items = List.of(new OrderItemCommand(productId, 2)); - OrderInfo order = orderFacade.createDirectOrder(loginId, loginPw, items); + OrderInfo order = orderFacade.createDirectOrder(userId, items, null); // 수동 취소 (복원 완료) - orderFacade.cancelOrder(loginId, loginPw, order.getOrderId()); + orderFacade.cancelOrder(userId, order.getOrderId()); Optional afterCancel = cartItemJpaRepository.findById(new CartItemId(userId, productId)); assertThat(afterCancel).isPresent(); diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderCartRestoreModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderCartRestoreModelTest.java index c6f80afaf..f677ff504 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderCartRestoreModelTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderCartRestoreModelTest.java @@ -14,12 +14,12 @@ class OrderCartRestoreModelTest { @DisplayName("유효한 입력으로 생성 성공") void create_WithValidInputs_ShouldSuccess() { OrderCartRestoreModel restore = OrderCartRestoreModel.create( - "order-001", "user-001", + 1L, 1L, RestoreReason.USER_CANCELLED, RestoreTriggerSource.CANCEL_API ); - assertThat(restore.getOrderId()).isEqualTo("order-001"); - assertThat(restore.getUserId()).isEqualTo("user-001"); + assertThat(restore.getOrderId()).isEqualTo(1L); + assertThat(restore.getUserId()).isEqualTo(1L); assertThat(restore.getReason()).isEqualTo(RestoreReason.USER_CANCELLED); assertThat(restore.getTriggerSource()).isEqualTo(RestoreTriggerSource.CANCEL_API); } @@ -28,7 +28,7 @@ void create_WithValidInputs_ShouldSuccess() { @DisplayName("restoredAt은 @PrePersist에서 설정된다") void create_ShouldSetRestoredAt() { OrderCartRestoreModel restore = OrderCartRestoreModel.create( - "order-001", "user-001", + 1L, 1L, RestoreReason.EXPIRED, RestoreTriggerSource.EXPIRE_JOB ); // restoredAt은 @PrePersist에서 설정되므로 JPA 없이는 null diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemModelTest.java index b87c95ad8..b3566e2cd 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemModelTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemModelTest.java @@ -15,14 +15,14 @@ class OrderItemModelTest { @DisplayName("유효한 입력으로 생성 및 스냅샷 캡처") void create_WithValidInputs_ShouldCaptureSnapshot() { OrderItemModel item = OrderItemModel.create( - "order-001", 1, "user-001", "product-001", 2, + 1L, 1, 1L, 1L, 2, "테스트 상품", BigDecimal.valueOf(10000), "brand-001", "루퍼스", "img.jpg" ); - assertThat(item.getOrderId()).isEqualTo("order-001"); + assertThat(item.getOrderId()).isEqualTo(1L); assertThat(item.getOrderItemSeq()).isEqualTo(1); - assertThat(item.getProductId()).isEqualTo("product-001"); + assertThat(item.getProductId()).isEqualTo(1L); assertThat(item.getQuantity()).isEqualTo(2); assertThat(item.getSnapshotProductName()).isEqualTo("테스트 상품"); assertThat(item.getSnapshotUnitPrice()).isEqualByComparingTo(BigDecimal.valueOf(10000)); @@ -35,7 +35,7 @@ void create_WithValidInputs_ShouldCaptureSnapshot() { @DisplayName("수량이 0이면 CoreException 발생") void create_WithZeroQuantity_ShouldThrow() { assertThatThrownBy(() -> OrderItemModel.create( - "order-001", 1, "user-001", "product-001", 0, + 1L, 1, 1L, 1L, 0, "상품", BigDecimal.valueOf(10000), "b-001", "브랜드", "img.jpg" )).isInstanceOf(CoreException.class); } @@ -44,7 +44,7 @@ void create_WithZeroQuantity_ShouldThrow() { @DisplayName("getSubtotal = unitPrice * quantity") void getSubtotal_ShouldReturn_unitPrice_times_quantity() { OrderItemModel item = OrderItemModel.create( - "order-001", 1, "user-001", "product-001", 3, + 1L, 1, 1L, 1L, 3, "상품", BigDecimal.valueOf(10000), "b-001", "브랜드", "img.jpg" ); diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderModelTest.java index d67deb129..33fd712ad 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderModelTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderModelTest.java @@ -24,7 +24,7 @@ class CreateTests { void create_WithValidInputs_ShouldSuccess() { OrderModel order = createTestOrder(); - assertThat(order.getUserId()).isEqualTo("user-001"); + assertThat(order.getUserId()).isEqualTo(1L); assertThat(order.getOrderType()).isEqualTo(OrderType.DIRECT); assertThat(order.getTotalAmount()).isEqualByComparingTo(BigDecimal.valueOf(30000)); } @@ -168,6 +168,6 @@ void isExpired_WhenExpiresAtFuture_False() { // === Helper === private OrderModel createTestOrder() { - return OrderModel.create("user-001", OrderType.DIRECT, BigDecimal.valueOf(30000)); + return OrderModel.create(1L, OrderType.DIRECT, BigDecimal.valueOf(30000)); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java index e94198787..bbed0b311 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java @@ -21,6 +21,7 @@ import static org.assertj.core.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) @@ -43,7 +44,7 @@ class ValidateAndPrepareTests { @Test @DisplayName("빈 항목으로 주문 시 ORDER_ITEM_EMPTY 예외가 발생한다") void validateAndPrepare_EmptyItems_ShouldThrow() { - assertThatThrownBy(() -> orderService.validateAndPrepare("user-1", List.of())) + assertThatThrownBy(() -> orderService.validateAndPrepare(1L, List.of())) .isInstanceOf(CoreException.class) .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()) .isEqualTo(ErrorType.ORDER_ITEM_EMPTY)); @@ -52,7 +53,7 @@ void validateAndPrepare_EmptyItems_ShouldThrow() { @Test @DisplayName("null 항목으로 주문 시 ORDER_ITEM_EMPTY 예외가 발생한다") void validateAndPrepare_NullItems_ShouldThrow() { - assertThatThrownBy(() -> orderService.validateAndPrepare("user-1", null)) + assertThatThrownBy(() -> orderService.validateAndPrepare(1L, null)) .isInstanceOf(CoreException.class) .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()) .isEqualTo(ErrorType.ORDER_ITEM_EMPTY)); @@ -61,10 +62,10 @@ void validateAndPrepare_NullItems_ShouldThrow() { @Test @DisplayName("PENDING 주문 3건 이상일 때 ORDER_PENDING_LIMIT_EXCEEDED 예외가 발생한다") void validateAndPrepare_ExceedPendingLimit_ShouldThrow() { - when(orderRepository.countByUserIdAndStatus("user-1", OrderStatus.PENDING_PAYMENT)).thenReturn(3L); + when(orderRepository.countByUserIdAndStatus(1L, OrderStatus.PENDING_PAYMENT)).thenReturn(3L); - assertThatThrownBy(() -> orderService.validateAndPrepare("user-1", - List.of(new OrderItemCommand("product-1", 1)))) + assertThatThrownBy(() -> orderService.validateAndPrepare(1L, + List.of(new OrderItemCommand(1L, 1)))) .isInstanceOf(CoreException.class) .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()) .isEqualTo(ErrorType.ORDER_PENDING_LIMIT_EXCEEDED)); @@ -73,11 +74,11 @@ void validateAndPrepare_ExceedPendingLimit_ShouldThrow() { @Test @DisplayName("동일 productId가 중복 전달되면 수량을 합산한다") void validateAndPrepare_DuplicateProductId_ShouldMergeQuantity() { - when(orderRepository.countByUserIdAndStatus("user-1", OrderStatus.PENDING_PAYMENT)).thenReturn(0L); + when(orderRepository.countByUserIdAndStatus(1L, OrderStatus.PENDING_PAYMENT)).thenReturn(0L); - List result = orderService.validateAndPrepare("user-1", List.of( - new OrderItemCommand("product-1", 2), - new OrderItemCommand("product-1", 3))); + List result = orderService.validateAndPrepare(1L, List.of( + new OrderItemCommand(1L, 2), + new OrderItemCommand(1L, 3))); assertThat(result).hasSize(1); assertThat(result.get(0).quantity()).isEqualTo(5); @@ -86,14 +87,14 @@ void validateAndPrepare_DuplicateProductId_ShouldMergeQuantity() { @Test @DisplayName("결과가 productId 오름차순으로 정렬된다") void validateAndPrepare_ShouldSortByProductIdAsc() { - when(orderRepository.countByUserIdAndStatus("user-1", OrderStatus.PENDING_PAYMENT)).thenReturn(0L); + when(orderRepository.countByUserIdAndStatus(1L, OrderStatus.PENDING_PAYMENT)).thenReturn(0L); - List result = orderService.validateAndPrepare("user-1", List.of( - new OrderItemCommand("zzz-product", 1), - new OrderItemCommand("aaa-product", 1))); + List result = orderService.validateAndPrepare(1L, List.of( + new OrderItemCommand(3L, 1), + new OrderItemCommand(1L, 1))); assertThat(result).extracting(OrderItemCommand::productId) - .containsExactly("aaa-product", "zzz-product"); + .containsExactly(1L, 3L); } } @@ -112,10 +113,11 @@ void createOrder_ShouldSaveOrderAndItems_ReturnOrderModel() { .thenAnswer(invocation -> invocation.getArgument(0)); List snapshots = List.of( - new OrderItemSnapshot("product-1", 2, "테스트상품", - BigDecimal.valueOf(10000), "brand-id", "테스트브랜드", null)); + new OrderItemSnapshot(1L, 2, "테스트상품", + BigDecimal.valueOf(10000), "brand-id", "테스트브랜드", null, + BigDecimal.valueOf(20000), BigDecimal.ZERO, BigDecimal.valueOf(20000))); - OrderModel result = orderService.createOrder("user-1", OrderType.DIRECT, + OrderModel result = orderService.createOrder(1L, OrderType.DIRECT, BigDecimal.valueOf(20000), snapshots); assertThat(result).isNotNull(); @@ -131,10 +133,11 @@ void createOrder_ShouldSetOrderType() { when(orderItemRepository.saveAll(anyList())) .thenAnswer(invocation -> invocation.getArgument(0)); - orderService.createOrder("user-1", OrderType.CART, + orderService.createOrder(1L, OrderType.CART, BigDecimal.valueOf(10000), List.of( - new OrderItemSnapshot("product-1", 1, "상품", - BigDecimal.valueOf(10000), "brand-id", "브랜드", null))); + new OrderItemSnapshot(1L, 1, "상품", + BigDecimal.valueOf(10000), "brand-id", "브랜드", null, + BigDecimal.valueOf(10000), BigDecimal.ZERO, BigDecimal.valueOf(10000)))); ArgumentCaptor captor = ArgumentCaptor.forClass(OrderModel.class); verify(orderRepository).save(captor.capture()); @@ -149,10 +152,11 @@ void createOrder_ShouldSetTotalAmount() { when(orderItemRepository.saveAll(anyList())) .thenAnswer(invocation -> invocation.getArgument(0)); - orderService.createOrder("user-1", OrderType.DIRECT, + orderService.createOrder(1L, OrderType.DIRECT, BigDecimal.valueOf(30000), List.of( - new OrderItemSnapshot("product-1", 3, "상품", - BigDecimal.valueOf(10000), "brand-id", "브랜드", null))); + new OrderItemSnapshot(1L, 3, "상품", + BigDecimal.valueOf(10000), "brand-id", "브랜드", null, + BigDecimal.valueOf(30000), BigDecimal.ZERO, BigDecimal.valueOf(30000)))); ArgumentCaptor captor = ArgumentCaptor.forClass(OrderModel.class); verify(orderRepository).save(captor.capture()); @@ -170,25 +174,25 @@ class CancelOrderTests { @Test @DisplayName("CAS 상태 전이 성공 시 주문 엔티티를 반환한다") void cancelOrder_ShouldReturnOrder_WhenCASSucceeds() { - OrderModel order = OrderModel.create("user-1", OrderType.DIRECT, BigDecimal.valueOf(10000)); - when(orderRepository.findByIdAndUserId("order-1", "user-1")) + OrderModel order = OrderModel.create(1L, OrderType.DIRECT, BigDecimal.valueOf(10000)); + when(orderRepository.findByIdAndUserId(1L, 1L)) .thenReturn(Optional.of(order)); - when(orderRepository.casUpdateStatus("order-1", OrderStatus.PENDING_PAYMENT, OrderStatus.CANCELLED)) + when(orderRepository.casUpdateStatus(1L, OrderStatus.PENDING_PAYMENT, OrderStatus.CANCELLED)) .thenReturn(1); - Optional result = orderService.cancelOrder("user-1", "order-1"); + Optional result = orderService.cancelOrder(1L, 1L); assertThat(result).isPresent(); - verify(orderRepository).casUpdateStatus("order-1", OrderStatus.PENDING_PAYMENT, OrderStatus.CANCELLED); + verify(orderRepository).casUpdateStatus(1L, OrderStatus.PENDING_PAYMENT, OrderStatus.CANCELLED); } @Test @DisplayName("다른 사용자의 주문 취소 시 예외가 발생한다") void cancelOrder_WhenNotOwner_ShouldThrow() { - when(orderRepository.findByIdAndUserId("order-1", "other-user")) + when(orderRepository.findByIdAndUserId(1L, 2L)) .thenReturn(Optional.empty()); - assertThatThrownBy(() -> orderService.cancelOrder("other-user", "order-1")) + assertThatThrownBy(() -> orderService.cancelOrder(2L, 1L)) .isInstanceOf(CoreException.class) .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()) .isEqualTo(ErrorType.ORDER_NOT_FOUND)); @@ -197,16 +201,16 @@ void cancelOrder_WhenNotOwner_ShouldThrow() { @Test @DisplayName("이미 CANCELLED인 주문 취소 시 빈 Optional을 반환한다 (멱등)") void cancelOrder_WhenAlreadyCancelled_ShouldReturnEmpty() { - OrderModel order = OrderModel.create("user-1", OrderType.DIRECT, BigDecimal.valueOf(10000)); - when(orderRepository.findByIdAndUserId("order-1", "user-1")) + OrderModel order = OrderModel.create(1L, OrderType.DIRECT, BigDecimal.valueOf(10000)); + when(orderRepository.findByIdAndUserId(1L, 1L)) .thenReturn(Optional.of(order)); - when(orderRepository.casUpdateStatus("order-1", OrderStatus.PENDING_PAYMENT, OrderStatus.CANCELLED)) + when(orderRepository.casUpdateStatus(1L, OrderStatus.PENDING_PAYMENT, OrderStatus.CANCELLED)) .thenReturn(0); - OrderModel cancelledOrder = OrderModel.create("user-1", OrderType.DIRECT, BigDecimal.valueOf(10000)); + OrderModel cancelledOrder = OrderModel.create(1L, OrderType.DIRECT, BigDecimal.valueOf(10000)); cancelledOrder.cancel(); - when(orderRepository.findById("order-1")).thenReturn(Optional.of(cancelledOrder)); + when(orderRepository.findById(1L)).thenReturn(Optional.of(cancelledOrder)); - Optional result = orderService.cancelOrder("user-1", "order-1"); + Optional result = orderService.cancelOrder(1L, 1L); assertThat(result).isEmpty(); } @@ -214,16 +218,16 @@ void cancelOrder_WhenAlreadyCancelled_ShouldReturnEmpty() { @Test @DisplayName("EXPIRED 상태인 주문 취소 시 ORDER_NOT_CANCELLABLE 예외가 발생한다") void cancelOrder_WhenExpired_ShouldThrow_ORDER_NOT_CANCELLABLE() { - OrderModel order = OrderModel.create("user-1", OrderType.DIRECT, BigDecimal.valueOf(10000)); - when(orderRepository.findByIdAndUserId("order-1", "user-1")) + OrderModel order = OrderModel.create(1L, OrderType.DIRECT, BigDecimal.valueOf(10000)); + when(orderRepository.findByIdAndUserId(1L, 1L)) .thenReturn(Optional.of(order)); - when(orderRepository.casUpdateStatus("order-1", OrderStatus.PENDING_PAYMENT, OrderStatus.CANCELLED)) + when(orderRepository.casUpdateStatus(1L, OrderStatus.PENDING_PAYMENT, OrderStatus.CANCELLED)) .thenReturn(0); - OrderModel expiredOrder = OrderModel.create("user-1", OrderType.DIRECT, BigDecimal.valueOf(10000)); + OrderModel expiredOrder = OrderModel.create(1L, OrderType.DIRECT, BigDecimal.valueOf(10000)); expiredOrder.expire(); - when(orderRepository.findById("order-1")).thenReturn(Optional.of(expiredOrder)); + when(orderRepository.findById(1L)).thenReturn(Optional.of(expiredOrder)); - assertThatThrownBy(() -> orderService.cancelOrder("user-1", "order-1")) + assertThatThrownBy(() -> orderService.cancelOrder(1L, 1L)) .isInstanceOf(CoreException.class) .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()) .isEqualTo(ErrorType.ORDER_NOT_CANCELLABLE)); @@ -239,24 +243,24 @@ class ExpireOrderTests { @Test @DisplayName("CAS 상태 전이 성공 시 주문 엔티티를 반환한다") void expireOrder_ShouldReturnOrder_WhenCASSucceeds() { - when(orderRepository.casUpdateStatus("order-1", OrderStatus.PENDING_PAYMENT, OrderStatus.EXPIRED)) + when(orderRepository.casUpdateStatus(1L, OrderStatus.PENDING_PAYMENT, OrderStatus.EXPIRED)) .thenReturn(1); - OrderModel order = OrderModel.create("user-1", OrderType.CART, BigDecimal.valueOf(10000)); - when(orderRepository.findById("order-1")).thenReturn(Optional.of(order)); + OrderModel order = OrderModel.create(1L, OrderType.CART, BigDecimal.valueOf(10000)); + when(orderRepository.findById(1L)).thenReturn(Optional.of(order)); - Optional result = orderService.expireOrder("order-1"); + Optional result = orderService.expireOrder(1L); assertThat(result).isPresent(); - verify(orderRepository).casUpdateStatus("order-1", OrderStatus.PENDING_PAYMENT, OrderStatus.EXPIRED); + verify(orderRepository).casUpdateStatus(1L, OrderStatus.PENDING_PAYMENT, OrderStatus.EXPIRED); } @Test @DisplayName("CAS 실패 시 빈 Optional을 반환한다 (멱등)") void expireOrder_AlreadyExpiredOrCancelled_ShouldReturnEmpty() { - when(orderRepository.casUpdateStatus("order-1", OrderStatus.PENDING_PAYMENT, OrderStatus.EXPIRED)) + when(orderRepository.casUpdateStatus(1L, OrderStatus.PENDING_PAYMENT, OrderStatus.EXPIRED)) .thenReturn(0); - Optional result = orderService.expireOrder("order-1"); + Optional result = orderService.expireOrder(1L); assertThat(result).isEmpty(); } @@ -271,24 +275,24 @@ class QueryTests { @Test @DisplayName("본인 주문 조회 성공") void findByIdAndUserId_Existing_ShouldReturn() { - OrderModel order = OrderModel.create("user-1", OrderType.DIRECT, BigDecimal.valueOf(10000)); - when(orderRepository.findByIdAndUserId("order-1", "user-1")) + OrderModel order = OrderModel.create(1L, OrderType.DIRECT, BigDecimal.valueOf(10000)); + when(orderRepository.findByIdAndUserId(1L, 1L)) .thenReturn(Optional.of(order)); - OrderModel result = orderService.findByIdAndUserId("order-1", "user-1"); + OrderModel result = orderService.findByIdAndUserId(1L, 1L); assertThat(result).isNotNull(); - assertThat(result.getUserId()).isEqualTo("user-1"); + assertThat(result.getUserId()).isEqualTo(1L); } @Test @DisplayName("내 주문 목록 조회 성공") void findAllByUserId_ShouldReturnOrders() { - OrderModel order = OrderModel.create("user-1", OrderType.DIRECT, BigDecimal.valueOf(10000)); - when(orderRepository.findAllByUserIdAndPeriod(eq("user-1"), any(), any())) + OrderModel order = OrderModel.create(1L, OrderType.DIRECT, BigDecimal.valueOf(10000)); + when(orderRepository.findAllByUserIdAndPeriod(eq(1L), any(), any())) .thenReturn(List.of(order)); - List result = orderService.findAllByUserId("user-1", + List result = orderService.findAllByUserId(1L, LocalDateTime.now().minusDays(30), LocalDateTime.now()); assertThat(result).hasSize(1); @@ -297,14 +301,14 @@ void findAllByUserId_ShouldReturnOrders() { @Test @DisplayName("주문 항목 조회 성공") void findOrderItems_ShouldReturnItems() { - OrderItemModel item = OrderItemModel.create("order-1", 1, "user-1", "product-1", 2, + OrderItemModel item = OrderItemModel.create(1L, 1, 1L, 1L, 2, "상품명", BigDecimal.valueOf(10000), "brand-id", "브랜드", null); - when(orderItemRepository.findAllByOrderId("order-1")).thenReturn(List.of(item)); + when(orderItemRepository.findAllByOrderId(1L)).thenReturn(List.of(item)); - List result = orderService.findOrderItems("order-1"); + List result = orderService.findOrderItems(1L); assertThat(result).hasSize(1); - assertThat(result.get(0).getProductId()).isEqualTo("product-1"); + assertThat(result.get(0).getProductId()).isEqualTo(1L); } } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductModelTest.java index 9ea77a761..53e963113 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductModelTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductModelTest.java @@ -25,7 +25,7 @@ void create_WithValidInputs_ShouldSuccess() { ProductModel product = createTestProduct(); assertThat(product.getProductName()).isEqualTo("테스트 상품"); - assertThat(product.getBrandId()).isEqualTo("brand-001"); + assertThat(product.getBrandId()).isEqualTo(1L); assertThat(product.getPrice()).isEqualByComparingTo(BigDecimal.valueOf(10000)); } @@ -33,7 +33,7 @@ void create_WithValidInputs_ShouldSuccess() { @DisplayName("productName이 null이면 CoreException 발생") void create_WithNullProductName_ShouldThrow() { assertThatThrownBy(() -> ProductModel.create( - null, "brand-001", BigDecimal.valueOf(10000), + null, 1L, BigDecimal.valueOf(10000), "설명", "카테고리", "블랙", "M", "옵션", "img.jpg", null )).isInstanceOf(CoreException.class); } @@ -51,7 +51,7 @@ void create_WithNullBrandId_ShouldThrow() { @DisplayName("price가 음수이면 CoreException 발생") void create_WithNegativePrice_ShouldThrow() { assertThatThrownBy(() -> ProductModel.create( - "상품", "brand-001", BigDecimal.valueOf(-1), + "상품", 1L, BigDecimal.valueOf(-1), "설명", "카테고리", "블랙", "M", "옵션", "img.jpg", null )).isInstanceOf(CoreException.class); } @@ -60,7 +60,7 @@ void create_WithNegativePrice_ShouldThrow() { @DisplayName("price가 0이면 CoreException 발생") void create_WithZeroPrice_ShouldThrow() { assertThatThrownBy(() -> ProductModel.create( - "상품", "brand-001", BigDecimal.ZERO, + "상품", 1L, BigDecimal.ZERO, "설명", "카테고리", "블랙", "M", "옵션", "img.jpg", null )).isInstanceOf(CoreException.class); } @@ -188,7 +188,7 @@ void create_ShouldExtendBaseStringIdEntity() { private ProductModel createTestProduct() { return ProductModel.create( - "테스트 상품", "brand-001", BigDecimal.valueOf(10000), + "테스트 상품", 1L, BigDecimal.valueOf(10000), "상품 설명", "카테고리", "블랙", "M", "옵션", "img.jpg", null ); } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductRevisionModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductRevisionModelTest.java index 7d1cd8133..0a1661bfa 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductRevisionModelTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductRevisionModelTest.java @@ -13,12 +13,12 @@ class ProductRevisionModelTest { @DisplayName("유효한 입력으로 생성 성공") void create_WithValidInputs_ShouldSuccess() { ProductRevisionModel revision = ProductRevisionModel.create( - "product-001", 1L, ProductRevisionAction.UPDATE, + 1L, 1L, ProductRevisionAction.UPDATE, "admin", "가격 변경", "{\"price\": 10000}", "{\"price\": 20000}" ); - assertThat(revision.getProductId()).isEqualTo("product-001"); + assertThat(revision.getProductId()).isEqualTo(1L); assertThat(revision.getRevisionSeq()).isEqualTo(1L); assertThat(revision.getAction()).isEqualTo(ProductRevisionAction.UPDATE); assertThat(revision.getChangedBy()).isEqualTo("admin"); @@ -31,7 +31,7 @@ void create_WithValidInputs_ShouldSuccess() { @DisplayName("CREATE action 시 beforeSnapshot은 null이다") void create_WithCreateAction_BeforeSnapshotShouldBeNull() { ProductRevisionModel revision = ProductRevisionModel.create( - "product-001", 0L, ProductRevisionAction.CREATE, + 1L, 0L, ProductRevisionAction.CREATE, "admin", "상품 생성", null, "{\"name\": \"상품A\"}" ); diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java index 512fd7bdd..e446d1c5c 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java @@ -49,7 +49,7 @@ void createProduct_WithValidInput_ShouldCreateProductAndRevision() { .thenAnswer(invocation -> invocation.getArgument(0)); ProductModel result = productService.createProduct( - "테스트상품", "brand-id", BigDecimal.valueOf(10000), "설명"); + "테스트상품", 1L, BigDecimal.valueOf(10000), "설명"); assertThat(result.getProductName()).isEqualTo("테스트상품"); assertThat(result.getPrice()).isEqualByComparingTo(BigDecimal.valueOf(10000)); @@ -65,7 +65,7 @@ void createProduct_ShouldCreateRevisionWithCREATEAction() { when(revisionRepository.save(any(ProductRevisionModel.class))) .thenAnswer(invocation -> invocation.getArgument(0)); - productService.createProduct("상품", "brand-id", BigDecimal.valueOf(5000), "설명"); + productService.createProduct("상품", 1L, BigDecimal.valueOf(5000), "설명"); ArgumentCaptor captor = ArgumentCaptor.forClass(ProductRevisionModel.class); verify(revisionRepository).save(captor.capture()); @@ -86,9 +86,9 @@ class FindTests { @DisplayName("존재하는 ID로 조회하면 ProductModel을 반환한다") void findById_Existing_ShouldReturn() { ProductModel product = createTestProduct(); - when(productRepository.findById("product-id")).thenReturn(Optional.of(product)); + when(productRepository.findById(1L)).thenReturn(Optional.of(product)); - ProductModel result = productService.findById("product-id"); + ProductModel result = productService.findById(1L); assertThat(result.getProductName()).isEqualTo("테스트상품"); } @@ -96,9 +96,9 @@ void findById_Existing_ShouldReturn() { @Test @DisplayName("존재하지 않는 ID 조회 시 PRODUCT_NOT_FOUND 예외가 발생한다") void findById_NotFound_ShouldThrow() { - when(productRepository.findById("nonexistent")).thenReturn(Optional.empty()); + when(productRepository.findById(999L)).thenReturn(Optional.empty()); - assertThatThrownBy(() -> productService.findById("nonexistent")) + assertThatThrownBy(() -> productService.findById(999L)) .isInstanceOf(CoreException.class) .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()) .isEqualTo(ErrorType.PRODUCT_NOT_FOUND)); @@ -109,9 +109,9 @@ void findById_NotFound_ShouldThrow() { void findOrderableById_WhenNotOrderable_ShouldThrow() { ProductModel product = createTestProduct(); product.changeSaleStatus(ProductSaleStatus.STOPPED); - when(productRepository.findById("product-id")).thenReturn(Optional.of(product)); + when(productRepository.findById(1L)).thenReturn(Optional.of(product)); - assertThatThrownBy(() -> productService.findOrderableById("product-id")) + assertThatThrownBy(() -> productService.findOrderableById(1L)) .isInstanceOf(CoreException.class) .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()) .isEqualTo(ErrorType.PRODUCT_NOT_ORDERABLE)); @@ -144,12 +144,12 @@ void findAllForCustomer_WithKeyword_ShouldFilter() { @Test @DisplayName("brandId 필터가 올바르게 동작한다") void findAllForCustomer_WithBrandId_ShouldFilter() { - when(productRepository.findAllForCustomer(null, "brand-id")) + when(productRepository.findAllForCustomer(null, 1L)) .thenReturn(List.of()); - productService.findAllForCustomer(null, "brand-id"); + productService.findAllForCustomer(null, 1L); - verify(productRepository).findAllForCustomer(null, "brand-id"); + verify(productRepository).findAllForCustomer(null, 1L); } } @@ -163,12 +163,12 @@ class UpdateTests { @DisplayName("수정 후 UPDATE 이력이 기록되고 before/after 스냅샷이 포함된다") void updateProduct_ShouldUpdateAndCreateRevision() { ProductModel product = createTestProduct(); - when(productRepository.findById("product-id")).thenReturn(Optional.of(product)); + when(productRepository.findById(1L)).thenReturn(Optional.of(product)); when(revisionRepository.save(any(ProductRevisionModel.class))) .thenAnswer(invocation -> invocation.getArgument(0)); ProductModel result = productService.updateProduct( - "product-id", "새상품명", BigDecimal.valueOf(20000), "새설명", "new-image.jpg"); + 1L, "새상품명", BigDecimal.valueOf(20000), "새설명", "new-image.jpg"); assertThat(result.getProductName()).isEqualTo("새상품명"); assertThat(result.getPrice()).isEqualByComparingTo(BigDecimal.valueOf(20000)); @@ -186,12 +186,12 @@ void updateProduct_ShouldUpdateAndCreateRevision() { void updateProduct_ShouldIncrementRevisionSeq() { ProductModel product = createTestProduct(); assertThat(product.getRevisionSeq()).isEqualTo(0L); - when(productRepository.findById("product-id")).thenReturn(Optional.of(product)); + when(productRepository.findById(1L)).thenReturn(Optional.of(product)); when(revisionRepository.save(any(ProductRevisionModel.class))) .thenAnswer(invocation -> invocation.getArgument(0)); productService.updateProduct( - "product-id", "새상품명", BigDecimal.valueOf(20000), "새설명", null); + 1L, "새상품명", BigDecimal.valueOf(20000), "새설명", null); assertThat(product.getRevisionSeq()).isEqualTo(1L); } @@ -200,14 +200,14 @@ void updateProduct_ShouldIncrementRevisionSeq() { @DisplayName("brandId는 수정 시 변경되지 않는다") void updateProduct_BrandId_ShouldNotBeChangeable() { ProductModel product = createTestProduct(); - when(productRepository.findById("product-id")).thenReturn(Optional.of(product)); + when(productRepository.findById(1L)).thenReturn(Optional.of(product)); when(revisionRepository.save(any(ProductRevisionModel.class))) .thenAnswer(invocation -> invocation.getArgument(0)); productService.updateProduct( - "product-id", "새상품명", BigDecimal.valueOf(20000), "새설명", null); + 1L, "새상품명", BigDecimal.valueOf(20000), "새설명", null); - assertThat(product.getBrandId()).isEqualTo("brand-id"); + assertThat(product.getBrandId()).isEqualTo(1L); } } @@ -221,11 +221,11 @@ class DeleteTests { @DisplayName("소프트 삭제 후 DELETE 이력이 기록된다") void deleteProduct_ShouldSoftDeleteAndCreateRevision() { ProductModel product = createTestProduct(); - when(productRepository.findById("product-id")).thenReturn(Optional.of(product)); + when(productRepository.findById(1L)).thenReturn(Optional.of(product)); when(revisionRepository.save(any(ProductRevisionModel.class))) .thenAnswer(invocation -> invocation.getArgument(0)); - productService.deleteProduct("product-id"); + productService.deleteProduct(1L); assertThat(product.isDeleted()).isTrue(); ArgumentCaptor captor = ArgumentCaptor.forClass(ProductRevisionModel.class); @@ -238,9 +238,9 @@ void deleteProduct_ShouldSoftDeleteAndCreateRevision() { void deleteProduct_AlreadyDeleted_ShouldBeIdempotent() { ProductModel product = createTestProduct(); product.softDelete(); - when(productRepository.findById("product-id")).thenReturn(Optional.of(product)); + when(productRepository.findById(1L)).thenReturn(Optional.of(product)); - assertThatCode(() -> productService.deleteProduct("product-id")) + assertThatCode(() -> productService.deleteProduct(1L)) .doesNotThrowAnyException(); verify(revisionRepository, never()).save(any()); } @@ -258,12 +258,12 @@ void softDeleteByBrandId_ShouldDeleteAllProductsOfBrand() { ProductModel p1 = createTestProduct(); ProductModel p2 = createTestProduct(); ProductModel p3 = createTestProduct(); - when(productRepository.findAllByBrandId("brand-id")) + when(productRepository.findAllByBrandId(1L)) .thenReturn(List.of(p1, p2, p3)); when(revisionRepository.save(any(ProductRevisionModel.class))) .thenAnswer(invocation -> invocation.getArgument(0)); - productService.softDeleteByBrandId("brand-id"); + productService.softDeleteByBrandId(1L); assertThat(p1.isDeleted()).isTrue(); assertThat(p2.isDeleted()).isTrue(); @@ -274,9 +274,9 @@ void softDeleteByBrandId_ShouldDeleteAllProductsOfBrand() { @Test @DisplayName("소속 상품이 없으면 에러 없이 통과한다") void softDeleteByBrandId_WhenNoProducts_ShouldBeNoop() { - when(productRepository.findAllByBrandId("brand-id")).thenReturn(List.of()); + when(productRepository.findAllByBrandId(1L)).thenReturn(List.of()); - assertThatCode(() -> productService.softDeleteByBrandId("brand-id")) + assertThatCode(() -> productService.softDeleteByBrandId(1L)) .doesNotThrowAnyException(); verify(revisionRepository, never()).save(any()); } @@ -292,11 +292,11 @@ class SaleStatusAndRevisionTests { @DisplayName("판매 상태 변경 시 SALE_STATUS_CHANGE 이력이 기록된다") void changeSaleStatus_ShouldCreateRevision() { ProductModel product = createTestProduct(); - when(productRepository.findById("product-id")).thenReturn(Optional.of(product)); + when(productRepository.findById(1L)).thenReturn(Optional.of(product)); when(revisionRepository.save(any(ProductRevisionModel.class))) .thenAnswer(invocation -> invocation.getArgument(0)); - productService.changeSaleStatus("product-id", ProductSaleStatus.TEMP_SOLD_OUT); + productService.changeSaleStatus(1L, ProductSaleStatus.TEMP_SOLD_OUT); assertThat(product.getSaleStatus()).isEqualTo(ProductSaleStatus.TEMP_SOLD_OUT); ArgumentCaptor captor = ArgumentCaptor.forClass(ProductRevisionModel.class); @@ -308,10 +308,10 @@ void changeSaleStatus_ShouldCreateRevision() { @DisplayName("이력 목록 조회가 올바르게 동작한다") void findRevisionsByProductId_ShouldReturnList() { ProductRevisionModel rev = ProductRevisionModel.create( - "product-id", 0L, ProductRevisionAction.CREATE, null, null, null, "{}"); - when(revisionRepository.findAllByProductId("product-id")).thenReturn(List.of(rev)); + 1L, 0L, ProductRevisionAction.CREATE, null, null, null, "{}"); + when(revisionRepository.findAllByProductId(1L)).thenReturn(List.of(rev)); - List result = productService.findRevisionsByProductId("product-id"); + List result = productService.findRevisionsByProductId(1L); assertThat(result).hasSize(1); } @@ -319,12 +319,12 @@ void findRevisionsByProductId_ShouldReturnList() { @Test @DisplayName("특정 이력 상세 조회가 올바르게 동작한다") void findRevisionById_Existing_ShouldReturn() { - ProductRevisionId id = new ProductRevisionId("product-id", 0L); + ProductRevisionId id = new ProductRevisionId(1L, 0L); ProductRevisionModel rev = ProductRevisionModel.create( - "product-id", 0L, ProductRevisionAction.CREATE, null, null, null, "{}"); + 1L, 0L, ProductRevisionAction.CREATE, null, null, null, "{}"); when(revisionRepository.findById(id)).thenReturn(Optional.of(rev)); - ProductRevisionModel result = productService.findRevisionById("product-id", 0L); + ProductRevisionModel result = productService.findRevisionById(1L, 0L); assertThat(result.getAction()).isEqualTo(ProductRevisionAction.CREATE); } @@ -333,7 +333,7 @@ void findRevisionById_Existing_ShouldReturn() { // === Helper === private ProductModel createTestProduct() { - return ProductModel.create("테스트상품", "brand-id", BigDecimal.valueOf(10000), + return ProductModel.create("테스트상품", 1L, BigDecimal.valueOf(10000), "설명", null, null, null, null, null, null); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductStockModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductStockModelTest.java index 71909d914..a6c6447c1 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductStockModelTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductStockModelTest.java @@ -1,6 +1,7 @@ package com.loopers.domain.product; 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; @@ -17,23 +18,23 @@ class CreateTests { @Test @DisplayName("유효한 입력으로 생성 성공") void create_WithValidInputs_ShouldSuccess() { - ProductStockModel stock = ProductStockModel.create("product-001", 100); + ProductStockModel stock = ProductStockModel.create(1L, 100); - assertThat(stock.getProductId()).isEqualTo("product-001"); + assertThat(stock.getProductId()).isEqualTo(1L); assertThat(stock.getOnHand()).isEqualTo(100); } @Test @DisplayName("음수 onHand로 생성 시 CoreException 발생") void create_WithNegativeOnHand_ShouldThrow() { - assertThatThrownBy(() -> ProductStockModel.create("product-001", -1)) + assertThatThrownBy(() -> ProductStockModel.create(1L, -1)) .isInstanceOf(CoreException.class); } @Test @DisplayName("생성 시 reserved 기본값은 0이다") void create_InitialReserved_ShouldBeZero() { - ProductStockModel stock = ProductStockModel.create("product-001", 100); + ProductStockModel stock = ProductStockModel.create(1L, 100); assertThat(stock.getReserved()).isEqualTo(0); } } @@ -45,25 +46,49 @@ class StockCalculationTests { @Test @DisplayName("가용 재고 = onHand - reserved") void getAvailableQty_ShouldReturnOnHandMinusReserved() { - ProductStockModel stock = ProductStockModel.create("product-001", 100); + ProductStockModel stock = ProductStockModel.create(1L, 100); assertThat(stock.getAvailableQty()).isEqualTo(100); } @Test @DisplayName("충분한 재고가 있으면 canHold true") void canHold_WhenSufficient_ShouldReturnTrue() { - ProductStockModel stock = ProductStockModel.create("product-001", 10); + ProductStockModel stock = ProductStockModel.create(1L, 10); assertThat(stock.canHold(10)).isTrue(); } @Test @DisplayName("재고가 부족하면 canHold false") void canHold_WhenInsufficient_ShouldReturnFalse() { - ProductStockModel stock = ProductStockModel.create("product-001", 10); + ProductStockModel stock = ProductStockModel.create(1L, 10); assertThat(stock.canHold(11)).isFalse(); } } + @Nested + @DisplayName("재고 검증") + class ValidateCanHoldTests { + + @Test + @DisplayName("충분한 재고가 있으면 예외 없이 통과한다") + void validateCanHold_WhenSufficient_ShouldPass() { + ProductStockModel stock = ProductStockModel.create(1L, 10); + + assertThatCode(() -> stock.validateCanHold(10)).doesNotThrowAnyException(); + } + + @Test + @DisplayName("재고가 부족하면 CART_STOCK_EXCEEDED 예외가 발생한다") + void validateCanHold_WhenInsufficient_ShouldThrowCartStockExceeded() { + ProductStockModel stock = ProductStockModel.create(1L, 10); + + assertThatThrownBy(() -> stock.validateCanHold(11)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()) + .isEqualTo(ErrorType.CART_STOCK_EXCEEDED)); + } + } + @Nested @DisplayName("재고 수정") class UpdateOnHandTests { @@ -71,7 +96,7 @@ class UpdateOnHandTests { @Test @DisplayName("유효한 수량으로 onHand 수정 성공") void updateOnHand_WithValidQty_ShouldUpdate() { - ProductStockModel stock = ProductStockModel.create("product-001", 100); + ProductStockModel stock = ProductStockModel.create(1L, 100); stock.updateOnHand(200); assertThat(stock.getOnHand()).isEqualTo(200); } @@ -79,7 +104,7 @@ void updateOnHand_WithValidQty_ShouldUpdate() { @Test @DisplayName("reserved보다 작은 onHand로 수정 시 CoreException 발생") void updateOnHand_WhenNewOnHandLessThanReserved_ShouldThrow() { - ProductStockModel stock = ProductStockModel.createWithReserved("product-001", 100, 50); + ProductStockModel stock = ProductStockModel.createWithReserved(1L, 100, 50); assertThatThrownBy(() -> stock.updateOnHand(30)) .isInstanceOf(CoreException.class); } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/StockConcurrencyTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/StockConcurrencyTest.java index 12b7861b8..f90f2ac7f 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/product/StockConcurrencyTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/StockConcurrencyTest.java @@ -31,11 +31,11 @@ class StockConcurrencyTest { @Autowired DatabaseCleanUp databaseCleanUp; - private String productId; + private Long productId; @BeforeEach void setUp() { - ProductStockModel stock = stockService.createStock("concurrency-test-product", 10); + ProductStockModel stock = stockService.createStock(1L, 10); productId = stock.getProductId(); } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/StockServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/StockServiceTest.java index 5a0179ab8..1f9072d06 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/product/StockServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/StockServiceTest.java @@ -30,19 +30,19 @@ class HoldTests { @Test @DisplayName("충분한 재고가 있으면 예외 없이 정상 완료된다") void hold_WithSufficientStock_ShouldReturnTrue() { - when(productStockRepository.reserveStock("product-1", 5)).thenReturn(1); + when(productStockRepository.reserveStock(1L, 5)).thenReturn(1); - assertThatCode(() -> stockService.hold("product-1", 5)) + assertThatCode(() -> stockService.hold(1L, 5)) .doesNotThrowAnyException(); - verify(productStockRepository).reserveStock("product-1", 5); + verify(productStockRepository).reserveStock(1L, 5); } @Test @DisplayName("재고 부족 시 STOCK_NOT_ENOUGH 예외가 발생한다") void hold_WithInsufficientStock_ShouldThrowSTOCK_NOT_ENOUGH() { - when(productStockRepository.reserveStock("product-1", 100)).thenReturn(0); + when(productStockRepository.reserveStock(1L, 100)).thenReturn(0); - assertThatThrownBy(() -> stockService.hold("product-1", 100)) + assertThatThrownBy(() -> stockService.hold(1L, 100)) .isInstanceOf(CoreException.class) .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()) .isEqualTo(ErrorType.STOCK_NOT_ENOUGH)); @@ -56,19 +56,19 @@ class ReleaseTests { @Test @DisplayName("정상 해제 시 예외 없이 완료된다") void release_WithValidQty_ShouldReturnTrue() { - when(productStockRepository.releaseStock("product-1", 5)).thenReturn(1); + when(productStockRepository.releaseStock(1L, 5)).thenReturn(1); - assertThatCode(() -> stockService.release("product-1", 5)) + assertThatCode(() -> stockService.release(1L, 5)) .doesNotThrowAnyException(); - verify(productStockRepository).releaseStock("product-1", 5); + verify(productStockRepository).releaseStock(1L, 5); } @Test @DisplayName("예약량보다 많은 해제 시도 시 예외가 발생한다") void release_WithExcessiveQty_ShouldThrow() { - when(productStockRepository.releaseStock("product-1", 100)).thenReturn(0); + when(productStockRepository.releaseStock(1L, 100)).thenReturn(0); - assertThatThrownBy(() -> stockService.release("product-1", 100)) + assertThatThrownBy(() -> stockService.release(1L, 100)) .isInstanceOf(CoreException.class) .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()) .isEqualTo(ErrorType.STOCK_NOT_ENOUGH)); @@ -82,11 +82,11 @@ class CommitTests { @Test @DisplayName("정상 확정 시 예외 없이 완료된다") void commit_WithValidQty_ShouldReturnTrue() { - when(productStockRepository.commitStock("product-1", 5)).thenReturn(1); + when(productStockRepository.commitStock(1L, 5)).thenReturn(1); - assertThatCode(() -> stockService.commit("product-1", 5)) + assertThatCode(() -> stockService.commit(1L, 5)) .doesNotThrowAnyException(); - verify(productStockRepository).commitStock("product-1", 5); + verify(productStockRepository).commitStock(1L, 5); } } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/stats/StatsServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/stats/StatsServiceTest.java index 351a6f42d..d83428c2c 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/stats/StatsServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/stats/StatsServiceTest.java @@ -65,7 +65,7 @@ void getDailyOrderStats_ShouldReturnDailyAggregation() { void getTopLikedProducts_ShouldReturnTopN() { List stats = List.of( StatsProjection.ProductStat.builder() - .productId("p1").productName("인기상품").count(100).build() + .productId(1L).productName("인기상품").count(100).build() ); when(statsRepository.getTopLikedProducts(10)).thenReturn(stats); @@ -80,7 +80,7 @@ void getTopLikedProducts_ShouldReturnTopN() { void getTopOrderedProducts_ShouldReturnTopN() { List stats = List.of( StatsProjection.ProductStat.builder() - .productId("p1").productName("베스트상품").count(50).build() + .productId(1L).productName("베스트상품").count(50).build() ); when(statsRepository.getTopOrderedProducts(10)).thenReturn(stats); @@ -95,7 +95,7 @@ void getTopOrderedProducts_ShouldReturnTopN() { void getLowStockProducts_ShouldReturnBelowThreshold() { List stats = List.of( StatsProjection.LowStockProduct.builder() - .productId("p1").productName("부족상품") + .productId(1L).productName("부족상품") .onHand(10).reserved(8).availableQty(2).build() ); when(statsRepository.getLowStockProducts(5)).thenReturn(stats); diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java index cf273f7bc..2c9fbbff8 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java @@ -83,9 +83,9 @@ class FindTests { @DisplayName("ID로 사용자 조회 성공") void findByUserId_Existing_ShouldReturn() { UserModel user = createTestUser(); - when(userRepository.findByUserId("user-id")).thenReturn(Optional.of(user)); + when(userRepository.findByUserId(1L)).thenReturn(Optional.of(user)); - UserModel result = userService.findByUserId("user-id"); + UserModel result = userService.findByUserId(1L); assertThat(result.getLoginId()).isEqualTo("testuser01"); } @@ -93,9 +93,9 @@ void findByUserId_Existing_ShouldReturn() { @Test @DisplayName("존재하지 않는 ID 조회 시 USER_NOT_FOUND") void findByUserId_NotFound_ShouldThrow_USER_NOT_FOUND() { - when(userRepository.findByUserId("nonexistent")).thenReturn(Optional.empty()); + when(userRepository.findByUserId(999L)).thenReturn(Optional.empty()); - assertThatThrownBy(() -> userService.findByUserId("nonexistent")) + assertThatThrownBy(() -> userService.findByUserId(999L)) .isInstanceOf(CoreException.class) .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()) .isEqualTo(ErrorType.USER_NOT_FOUND)); diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/brand/BrandRepositoryImplTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/brand/BrandRepositoryImplTest.java index 9c84c1249..787a4d684 100644 --- a/apps/commerce-api/src/test/java/com/loopers/infrastructure/brand/BrandRepositoryImplTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/brand/BrandRepositoryImplTest.java @@ -24,14 +24,14 @@ class BrandRepositoryImplTest { BrandRepositoryImpl brandRepository; @Test - @DisplayName("저장 시 UUID ID가 자동 생성된다") - void save_ShouldPersistWithUuidId() { + @DisplayName("저장 시 ID가 자동 생성된다") + void save_ShouldPersistWithAutoId() { BrandModel brand = BrandModel.create("테스트브랜드", "설명", "서울"); BrandModel saved = brandRepository.save(brand); assertThat(saved.getBrandId()).isNotNull(); - assertThat(saved.getBrandId()).hasSize(36); + assertThat(saved.getBrandId()).isGreaterThan(0L); } @Test @@ -49,7 +49,7 @@ void findById_Existing_ShouldReturn() { @Test @DisplayName("ID로 조회 - 존재하지 않는 브랜드") void findById_NotExisting_ShouldReturnEmpty() { - Optional found = brandRepository.findById("nonexistent-uuid"); + Optional found = brandRepository.findById(999L); assertThat(found).isEmpty(); } diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/cache/CacheBenchmarkTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/cache/CacheBenchmarkTest.java new file mode 100644 index 000000000..ce5524a7c --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/cache/CacheBenchmarkTest.java @@ -0,0 +1,325 @@ +package com.loopers.infrastructure.cache; + +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductService; +import com.loopers.utils.DatabaseCleanUp; +import com.loopers.utils.RedisCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.test.context.ActiveProfiles; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Random; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@ActiveProfiles("test") +@DisplayName("L2 vs L1+L2 비교 벤치마크") +class CacheBenchmarkTest { + + @Autowired + private ProductService productService; + + @Autowired + private BrandService brandService; + + @Autowired + private CacheManager cacheManager; + + @Autowired + private RedisTemplate redisTemplate; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @Autowired + private RedisCleanUp redisCleanUp; + + @AfterEach + void tearDown() { + cacheManager.getCacheNames().forEach(name -> { + Cache cache = cacheManager.getCache(name); + if (cache != null) { + cache.clear(); + } + }); + redisCleanUp.truncateAll(); + databaseCleanUp.truncateAllTables(); + } + + @Test + @DisplayName("시나리오1: 동일 상품 1000회 반복 조회 — L2-only vs L1+L2 응답 시간") + void benchmark_sameProduct_1000reads() { + // given + BrandModel brand = brandService.createBrand("벤치마크브랜드", "설명", "서울"); + ProductModel product = productService.createProduct("벤치마크상품", brand.getBrandId(), + BigDecimal.valueOf(10000), "설명"); + int iterations = 1000; + + // --- L2-only 측정 (brandDetail — L2-only 캐시) --- + BrandModel brandForL2 = brandService.findById(brand.getBrandId()); // warm-up + long[] l2Times = new long[iterations]; + for (int i = 0; i < iterations; i++) { + long start = System.nanoTime(); + brandService.findById(brand.getBrandId()); + l2Times[i] = System.nanoTime() - start; + } + + // --- L1+L2 측정 (productDetail — TwoLevelCache) --- + productService.findById(product.getProductId()); // warm-up + long[] l1l2Times = new long[iterations]; + for (int i = 0; i < iterations; i++) { + long start = System.nanoTime(); + productService.findById(product.getProductId()); + l1l2Times[i] = System.nanoTime() - start; + } + + // 결과 출력 + printBenchmarkResult("시나리오1: 동일 키 1000회 반복 조회", l2Times, l1l2Times, iterations); + + // 기본 검증: L1+L2 p50이 L2-only p50보다 빠르거나 동등 + Arrays.sort(l2Times); + Arrays.sort(l1l2Times); + System.out.printf(" L1+L2 p50 / L2 p50 비율: %.2fx%n", + (double) l2Times[iterations / 2] / Math.max(l1l2Times[iterations / 2], 1)); + } + + @Test + @DisplayName("시나리오2: 50개 상품 Zipf 분포 1000회 조회") + void benchmark_zipfDistribution_1000reads() { + // given: 50개 상품 생성 + BrandModel brand = brandService.createBrand("Zipf브랜드", "설명", "서울"); + List productIds = new ArrayList<>(); + for (int i = 0; i < 50; i++) { + ProductModel p = productService.createProduct("상품" + i, brand.getBrandId(), + BigDecimal.valueOf(10000 + i * 100), "설명" + i); + productIds.add(p.getProductId()); + } + + int iterations = 1000; + List accessPattern = generateZipfAccessPattern(productIds, iterations); + + // warm-up: 모든 상품 1회 조회 + for (Long id : productIds) { + productService.findById(id); + } + + // --- L1+L2 측정 (현재 구성) --- + long[] l1l2Times = new long[iterations]; + for (int i = 0; i < iterations; i++) { + long start = System.nanoTime(); + productService.findById(accessPattern.get(i)); + l1l2Times[i] = System.nanoTime() - start; + } + + // L1 캐시 통계 확인 + Cache productCache = cacheManager.getCache("productDetail"); + assertThat(productCache).isInstanceOf(TwoLevelCache.class); + + // 결과 출력 + Arrays.sort(l1l2Times); + System.out.println("=== 시나리오2: Zipf 분포 1000회 조회 (L1+L2) ==="); + System.out.printf(" p50: %.4f ms%n", l1l2Times[499] / 1_000_000.0); + System.out.printf(" p95: %.4f ms%n", l1l2Times[949] / 1_000_000.0); + System.out.printf(" p99: %.4f ms%n", l1l2Times[989] / 1_000_000.0); + System.out.printf(" avg: %.4f ms%n", Arrays.stream(l1l2Times).average().orElse(0) / 1_000_000.0); + System.out.printf(" 상품 수: %d, 조회 수: %d%n", productIds.size(), iterations); + } + + @Test + @DisplayName("시나리오3: 읽기 70%% + 쓰기 30%% 혼합 워크로드") + void benchmark_mixedReadWrite() { + // given + BrandModel brand = brandService.createBrand("혼합브랜드", "설명", "서울"); + ProductModel product = productService.createProduct("혼합상품", brand.getBrandId(), + BigDecimal.valueOf(10000), "설명"); + productService.findById(product.getProductId()); // warm-up + + int totalOps = 1000; + int writeRatio = 30; // 30% 쓰기 + Random random = new Random(42); + + long[] times = new long[totalOps]; + int readCount = 0; + int writeCount = 0; + + for (int i = 0; i < totalOps; i++) { + long start = System.nanoTime(); + if (random.nextInt(100) < writeRatio) { + // 쓰기 (CacheEvict 발생) + productService.updateProduct(product.getProductId(), + "상품v" + i, BigDecimal.valueOf(10000 + i), "설명v" + i, null); + writeCount++; + } else { + // 읽기 (Cache Hit 기대) + productService.findById(product.getProductId()); + readCount++; + } + times[i] = System.nanoTime() - start; + } + + Arrays.sort(times); + System.out.println("=== 시나리오3: 읽기/쓰기 혼합 (L1+L2) ==="); + System.out.printf(" 읽기: %d건, 쓰기: %d건%n", readCount, writeCount); + System.out.printf(" p50: %.4f ms%n", times[499] / 1_000_000.0); + System.out.printf(" p95: %.4f ms%n", times[949] / 1_000_000.0); + System.out.printf(" p99: %.4f ms%n", times[989] / 1_000_000.0); + System.out.printf(" avg: %.4f ms%n", Arrays.stream(times).average().orElse(0) / 1_000_000.0); + } + + @Test + @DisplayName("시나리오4: 50스레드 동시 조회 — L2 vs L1+L2 처리 시간") + void benchmark_concurrent_50threads() throws Exception { + // given + BrandModel brand = brandService.createBrand("동시성브랜드", "설명", "서울"); + ProductModel product = productService.createProduct("동시성상품", brand.getBrandId(), + BigDecimal.valueOf(10000), "설명"); + productService.findById(product.getProductId()); // warm-up (L1+L2 적재) + + int threadCount = 50; + int opsPerThread = 100; + + // --- L2-only 시뮬레이션: brandDetail 사용 --- + brandService.findById(brand.getBrandId()); // warm-up + long l2TotalTime = runConcurrentBenchmark(threadCount, opsPerThread, + () -> brandService.findById(brand.getBrandId())); + + // --- L1+L2: productDetail 사용 --- + long l1l2TotalTime = runConcurrentBenchmark(threadCount, opsPerThread, + () -> productService.findById(product.getProductId())); + + System.out.println("=== 시나리오4: 50스레드 × 100회 동시 조회 ==="); + System.out.printf(" L2-only 총 처리 시간: %d ms%n", l2TotalTime); + System.out.printf(" L1+L2 총 처리 시간: %d ms%n", l1l2TotalTime); + System.out.printf(" 개선율: %.1f%%%n", + (1.0 - (double) l1l2TotalTime / Math.max(l2TotalTime, 1)) * 100); + + // 기본 검증: 둘 다 모든 요청 처리 성공 + assertThat(l2TotalTime).isGreaterThan(0); + assertThat(l1l2TotalTime).isGreaterThan(0); + } + + @Test + @DisplayName("시나리오5: Caffeine 캐시 메트릭 (hitCount, missCount, hitRate)") + void benchmark_caffeineMetrics() { + // given + BrandModel brand = brandService.createBrand("메트릭브랜드", "설명", "서울"); + List productIds = new ArrayList<>(); + for (int i = 0; i < 10; i++) { + ProductModel p = productService.createProduct("메트릭상품" + i, brand.getBrandId(), + BigDecimal.valueOf(10000 + i * 100), "설명"); + productIds.add(p.getProductId()); + } + + // when: 각 상품 1회 조회 (10 miss) + 인기 상품 3개 추가 10회 조회 (30 hit) + for (Long id : productIds) { + productService.findById(id); + } + for (int i = 0; i < 10; i++) { + productService.findById(productIds.get(0)); + productService.findById(productIds.get(1)); + productService.findById(productIds.get(2)); + } + + // then: 캐시 통계 출력 + Cache cache = cacheManager.getCache("productDetail"); + assertThat(cache).isInstanceOf(TwoLevelCache.class); + + // Redis 키 수 확인 + Set redisKeys = redisTemplate.keys("productDetail*"); + System.out.println("=== 시나리오5: Caffeine 캐시 메트릭 ==="); + System.out.printf(" Redis(L2) 키 수: %d%n", redisKeys != null ? redisKeys.size() : 0); + System.out.printf(" 총 조회: 40회 (10 cold + 30 hot)%n"); + System.out.printf(" 기대 L1 miss: 10회 (첫 조회), L1 hit: 30회 (반복 조회)%n"); + System.out.printf(" 기대 L1 hitRate: %.0f%%%n", 30.0 / 40 * 100); + } + + // --- 유틸리티 메서드 --- + + private void printBenchmarkResult(String scenario, long[] l2Times, long[] l1l2Times, int iterations) { + Arrays.sort(l2Times); + Arrays.sort(l1l2Times); + + System.out.println("=== " + scenario + " ==="); + System.out.printf("%-20s %15s %15s%n", "Metric", "L2-only", "L1+L2"); + System.out.printf("%-20s %12.4f ms %12.4f ms%n", "p50", + l2Times[iterations / 2] / 1_000_000.0, l1l2Times[iterations / 2] / 1_000_000.0); + System.out.printf("%-20s %12.4f ms %12.4f ms%n", "p95", + l2Times[(int) (iterations * 0.95)] / 1_000_000.0, l1l2Times[(int) (iterations * 0.95)] / 1_000_000.0); + System.out.printf("%-20s %12.4f ms %12.4f ms%n", "p99", + l2Times[(int) (iterations * 0.99)] / 1_000_000.0, l1l2Times[(int) (iterations * 0.99)] / 1_000_000.0); + System.out.printf("%-20s %12.4f ms %12.4f ms%n", "avg", + Arrays.stream(l2Times).average().orElse(0) / 1_000_000.0, + Arrays.stream(l1l2Times).average().orElse(0) / 1_000_000.0); + } + + private List generateZipfAccessPattern(List ids, int count) { + Random random = new Random(42); + List pattern = new ArrayList<>(); + int size = ids.size(); + for (int i = 0; i < count; i++) { + // Zipf-like: 상위 10% 상품이 ~80% 트래픽 + double u = random.nextDouble(); + int index; + if (u < 0.8) { + // 80% 트래픽 → 상위 10% 상품 (index 0~4) + index = random.nextInt(Math.max(size / 10, 1)); + } else { + // 20% 트래픽 → 나머지 90% 상품 + index = size / 10 + random.nextInt(size - size / 10); + } + pattern.add(ids.get(index)); + } + return pattern; + } + + private long runConcurrentBenchmark(int threadCount, int opsPerThread, Runnable task) throws Exception { + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch doneLatch = new CountDownLatch(threadCount); + AtomicInteger successCount = new AtomicInteger(0); + + for (int i = 0; i < threadCount; i++) { + executor.submit(() -> { + try { + startLatch.await(); + for (int j = 0; j < opsPerThread; j++) { + task.run(); + successCount.incrementAndGet(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + doneLatch.countDown(); + } + }); + } + + long startTime = System.currentTimeMillis(); + startLatch.countDown(); + doneLatch.await(60, TimeUnit.SECONDS); + long totalTime = System.currentTimeMillis() - startTime; + executor.shutdown(); + + assertThat(successCount.get()).isEqualTo(threadCount * opsPerThread); + return totalTime; + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/cache/RedisCacheIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/cache/RedisCacheIntegrationTest.java new file mode 100644 index 000000000..b7ad6a925 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/cache/RedisCacheIntegrationTest.java @@ -0,0 +1,339 @@ +package com.loopers.infrastructure.cache; + +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductService; +import com.loopers.domain.product.ProductStockModel; +import com.loopers.domain.product.StockService; +import com.loopers.domain.stats.StatsProjection; +import com.loopers.domain.stats.StatsService; +import com.loopers.domain.user.UserModel; +import com.loopers.domain.user.UserRegisterCommand; +import com.loopers.domain.user.UserService; +import com.loopers.support.error.CoreException; +import com.loopers.utils.DatabaseCleanUp; +import com.loopers.utils.RedisCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.test.context.ActiveProfiles; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@SpringBootTest +@ActiveProfiles("test") +@DisplayName("Redis L2 캐시 통합 테스트") +class RedisCacheIntegrationTest { + + @Autowired + private ProductService productService; + + @Autowired + private BrandService brandService; + + @Autowired + private StockService stockService; + + @Autowired + private UserService userService; + + @Autowired + private StatsService statsService; + + @Autowired + private CacheManager cacheManager; + + @Autowired + private RedisTemplate redisTemplate; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @Autowired + private RedisCleanUp redisCleanUp; + + @AfterEach + void tearDown() { + // L1 (Caffeine) 캐시 클리어 — TRUNCATE로 ID 리셋 시 stale hit 방지 + cacheManager.getCacheNames().forEach(name -> { + Cache cache = cacheManager.getCache(name); + if (cache != null) { + cache.clear(); + } + }); + redisCleanUp.truncateAll(); + databaseCleanUp.truncateAllTables(); + } + + @Test + @DisplayName("1. 상품 조회 시 최초 Cache Miss → Redis 저장, 두 번째 Cache Hit") + void findById_SecondCall_ShouldHitCache() { + // given + BrandModel brand = brandService.createBrand("테스트브랜드", "설명", "서울"); + ProductModel product = productService.createProduct("테스트상품", brand.getBrandId(), + BigDecimal.valueOf(10000), "상품 설명"); + + // when: 첫 번째 조회 (Cache Miss → DB → Redis 저장) + ProductModel first = productService.findById(product.getProductId()); + + // then: Redis 캐시에 저장됨 + Cache.ValueWrapper cached = cacheManager.getCache("productDetail").get(product.getProductId()); + assertThat(cached).isNotNull(); + + // when: 두 번째 조회 (Cache Hit) + ProductModel second = productService.findById(product.getProductId()); + + // then: 동일 데이터 반환 + assertThat(first.getProductId()).isEqualTo(second.getProductId()); + assertThat(first.getProductName()).isEqualTo(second.getProductName()); + assertThat(first.getPrice()).isEqualByComparingTo(second.getPrice()); + } + + @Test + @DisplayName("2. 상품 수정(CacheEvict) 후 재조회 시 캐시 무효화 → DB 재조회") + void updateProduct_ShouldEvictCache() { + // given + BrandModel brand = brandService.createBrand("테스트브랜드", "설명", "서울"); + ProductModel product = productService.createProduct("원래상품명", brand.getBrandId(), + BigDecimal.valueOf(10000), "설명"); + + // 캐시 적재 + productService.findById(product.getProductId()); + assertThat(cacheManager.getCache("productDetail").get(product.getProductId())).isNotNull(); + + // when: 상품 수정 → CacheEvict + productService.updateProduct(product.getProductId(), "수정된상품명", + BigDecimal.valueOf(20000), "수정 설명", null); + + // then: 캐시 무효화됨 + assertThat(cacheManager.getCache("productDetail").get(product.getProductId())).isNull(); + + // when: 재조회 → 수정된 데이터 + ProductModel updated = productService.findById(product.getProductId()); + assertThat(updated.getProductName()).isEqualTo("수정된상품명"); + assertThat(updated.getPrice()).isEqualByComparingTo(BigDecimal.valueOf(20000)); + + // then: 캐시에 수정된 데이터가 다시 적재됨 + assertThat(cacheManager.getCache("productDetail").get(product.getProductId())).isNotNull(); + } + + @Test + @DisplayName("3. 상품 삭제(CacheEvict) 후 캐시 무효화 확인") + void deleteProduct_ShouldEvictCache() { + // given + BrandModel brand = brandService.createBrand("테스트브랜드", "설명", "서울"); + ProductModel product = productService.createProduct("삭제대상상품", brand.getBrandId(), + BigDecimal.valueOf(10000), "설명"); + + // 캐시 적재 + productService.findById(product.getProductId()); + assertThat(cacheManager.getCache("productDetail").get(product.getProductId())).isNotNull(); + + // when: 삭제 → CacheEvict + productService.deleteProduct(product.getProductId()); + + // then: 캐시에서 제거됨 + assertThat(cacheManager.getCache("productDetail").get(product.getProductId())).isNull(); + } + + @Test + @DisplayName("4. 직렬화 정합성 — 캐시 저장 후 역직렬화 시 필드값 일치") + void serialization_ShouldPreserveAllFields() { + // given + BrandModel brand = brandService.createBrand("브랜드A", "설명", "서울"); + ProductModel product = productService.createProduct("직렬화테스트상품", brand.getBrandId(), + BigDecimal.valueOf(15000), "직렬화 검증용 상품"); + + // when: 캐시 적재 + productService.findById(product.getProductId()); + + // then: Redis에서 키 존재 확인 + Set keys = redisTemplate.keys("productDetail*"); + assertThat(keys).isNotEmpty(); + + // then: 캐시에서 꺼낸 데이터와 원본 비교 + Object fromCache = cacheManager.getCache("productDetail") + .get(product.getProductId()).get(); + assertThat(fromCache).isInstanceOf(ProductModel.class); + ProductModel cachedProduct = (ProductModel) fromCache; + assertThat(cachedProduct.getProductId()).isEqualTo(product.getProductId()); + assertThat(cachedProduct.getProductName()).isEqualTo("직렬화테스트상품"); + assertThat(cachedProduct.getBrandId()).isEqualTo(brand.getBrandId()); + assertThat(cachedProduct.getPrice()).isEqualByComparingTo(BigDecimal.valueOf(15000)); + assertThat(cachedProduct.getDescription()).isEqualTo("직렬화 검증용 상품"); + } + + @Test + @DisplayName("5. Brand 캐시 Hit/Evict — 조회 → 수정 → 재조회") + void brandCache_HitAndEvict() { + // given + BrandModel brand = brandService.createBrand("캐시브랜드", "설명", "서울"); + + // when: 첫 조회 → 캐시 적재 + brandService.findById(brand.getBrandId()); + assertThat(cacheManager.getCache("brandDetail").get(brand.getBrandId())).isNotNull(); + + // when: 수정 → CacheEvict + brandService.updateBrand(brand.getBrandId(), "수정브랜드", "수정설명", "부산"); + + // then: 캐시 무효화 + assertThat(cacheManager.getCache("brandDetail").get(brand.getBrandId())).isNull(); + + // when: 재조회 → 수정된 데이터 + BrandModel updated = brandService.findById(brand.getBrandId()); + assertThat(updated.getBrandName()).isEqualTo("수정브랜드"); + + // then: 캐시에 수정된 데이터 적재 + assertThat(cacheManager.getCache("brandDetail").get(brand.getBrandId())).isNotNull(); + } + + @Test + @DisplayName("6. Stock 캐시 — hold 후 캐시 무효화 확인") + void stockCache_HoldShouldEvict() { + // given + BrandModel brand = brandService.createBrand("브랜드", "설명", "서울"); + ProductModel product = productService.createProduct("재고상품", brand.getBrandId(), + BigDecimal.valueOf(10000), "설명"); + stockService.createStock(product.getProductId(), 10); + + // 캐시 적재 + ProductStockModel stock = stockService.findByProductId(product.getProductId()); + assertThat(stock.getOnHand()).isEqualTo(10); + assertThat(cacheManager.getCache("stockAvailable").get(product.getProductId())).isNotNull(); + + // when: hold → CacheEvict + stockService.hold(product.getProductId(), 3); + + // then: 캐시 무효화 확인 + assertThat(cacheManager.getCache("stockAvailable").get(product.getProductId())).isNull(); + + // when: 재조회 → 갱신된 재고 + ProductStockModel afterHold = stockService.findByProductId(product.getProductId()); + assertThat(afterHold.getAvailableQty()).isEqualTo(7); + } + + @Test + @DisplayName("7. Auth 캐시 보안 — 캐시된 유저로 틀린 비밀번호 인증 시 예외 발생") + void authCache_WrongPassword_ShouldThrowEvenWhenCached() { + // given + String loginId = "cacheuser01"; + String password = "Test1234!@#"; + userService.register(new UserRegisterCommand(loginId, password, "테스트", "19900101", "test@test.com", "서울")); + + // 캐시 적재 (findByLoginId 호출) + UserModel cached = userService.findByLoginId(loginId); + assertThat(cached).isNotNull(); + assertThat(cacheManager.getCache("authUser").get(loginId)).isNotNull(); + + // when & then: 틀린 비밀번호로 인증 → 예외 발생 (BCrypt 비교는 항상 수행) + assertThatThrownBy(() -> userService.authenticate(loginId, "WrongPassword!@#")) + .isInstanceOf(CoreException.class); + } + + @Test + @DisplayName("8. Stats 캐시 TTL-only — 동일 조건 2회 조회 시 캐시 Hit") + void statsCache_ShouldHitOnSecondCall() { + // given + LocalDate start = LocalDate.of(2026, 1, 1); + LocalDate end = LocalDate.of(2026, 3, 13); + + // when: 첫 번째 조회 → DB → 캐시 적재 + StatsProjection.Overview first = statsService.getOverview(start, end); + + // then: 캐시에 저장됨 + String cacheKey = start.toString() + ":" + end.toString(); + assertThat(cacheManager.getCache("statsOverview").get(cacheKey)).isNotNull(); + + // when: 두 번째 조회 → 캐시 Hit + StatsProjection.Overview second = statsService.getOverview(start, end); + + // then: 동일 결과 + assertThat(first.getPendingCount()).isEqualTo(second.getPendingCount()); + assertThat(first.getCancelledCount()).isEqualTo(second.getCancelledCount()); + assertThat(first.getExpiredCount()).isEqualTo(second.getExpiredCount()); + } + + @Test + @DisplayName("9. Brand 삭제 → 소속 상품 캐시 연쇄 evict") + void brandDelete_ShouldCascadeEvictProductCache() { + // given + BrandModel brand = brandService.createBrand("삭제브랜드", "설명", "서울"); + ProductModel product1 = productService.createProduct("상품1", brand.getBrandId(), + BigDecimal.valueOf(10000), "설명"); + ProductModel product2 = productService.createProduct("상품2", brand.getBrandId(), + BigDecimal.valueOf(20000), "설명"); + + // 상품 캐시 적재 + productService.findById(product1.getProductId()); + productService.findById(product2.getProductId()); + assertThat(cacheManager.getCache("productDetail").get(product1.getProductId())).isNotNull(); + assertThat(cacheManager.getCache("productDetail").get(product2.getProductId())).isNotNull(); + + // 브랜드 캐시 적재 + brandService.findById(brand.getBrandId()); + assertThat(cacheManager.getCache("brandDetail").get(brand.getBrandId())).isNotNull(); + + // when: 브랜드 삭제 (연쇄 삭제 + 캐시 evict) + brandService.deleteBrand(brand.getBrandId()); + + // then: 소속 상품 캐시 무효화 + assertThat(cacheManager.getCache("productDetail").get(product1.getProductId())).isNull(); + assertThat(cacheManager.getCache("productDetail").get(product2.getProductId())).isNull(); + // then: 브랜드 캐시도 무효화 + assertThat(cacheManager.getCache("brandDetail").get(brand.getBrandId())).isNull(); + } + + @Test + @DisplayName("10. 동일 상품 100건 동시 조회 시 모두 성공 + 캐시 적재 확인") + void concurrentRead_ShouldSucceedAndPopulateCache() throws Exception { + // given + BrandModel brand = brandService.createBrand("브랜드", "설명", "서울"); + ProductModel product = productService.createProduct("동시성상품", brand.getBrandId(), + BigDecimal.valueOf(10000), "설명"); + + int threadCount = 100; + ExecutorService executor = Executors.newFixedThreadPool(20); + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch doneLatch = new CountDownLatch(threadCount); + AtomicInteger successCount = new AtomicInteger(0); + + // when: 100개 스레드 동시 조회 + for (int i = 0; i < threadCount; i++) { + executor.submit(() -> { + try { + startLatch.await(); + productService.findById(product.getProductId()); + successCount.incrementAndGet(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + doneLatch.countDown(); + } + }); + } + startLatch.countDown(); + doneLatch.await(30, TimeUnit.SECONDS); + executor.shutdown(); + + // then: 모두 성공 + assertThat(successCount.get()).isEqualTo(threadCount); + // then: 캐시에 데이터 적재됨 + assertThat(cacheManager.getCache("productDetail").get(product.getProductId())).isNotNull(); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/cache/TwoLevelCacheIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/cache/TwoLevelCacheIntegrationTest.java new file mode 100644 index 000000000..095542b25 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/cache/TwoLevelCacheIntegrationTest.java @@ -0,0 +1,234 @@ +package com.loopers.infrastructure.cache; + +import com.github.benmanes.caffeine.cache.stats.CacheStats; +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductService; +import com.loopers.domain.product.ProductStockModel; +import com.loopers.domain.product.StockService; +import com.loopers.domain.stats.StatsProjection; +import com.loopers.domain.stats.StatsService; +import com.loopers.utils.DatabaseCleanUp; +import com.loopers.utils.RedisCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.cache.caffeine.CaffeineCache; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.test.context.ActiveProfiles; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@ActiveProfiles("test") +@DisplayName("L1+L2 2-Level 캐시 통합 테스트") +class TwoLevelCacheIntegrationTest { + + @Autowired + private ProductService productService; + + @Autowired + private BrandService brandService; + + @Autowired + private StockService stockService; + + @Autowired + private StatsService statsService; + + @Autowired + private CacheManager cacheManager; + + @Autowired + private RedisTemplate redisTemplate; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @Autowired + private RedisCleanUp redisCleanUp; + + @AfterEach + void tearDown() { + cacheManager.getCacheNames().forEach(name -> { + Cache cache = cacheManager.getCache(name); + if (cache != null) { + cache.clear(); + } + }); + redisCleanUp.truncateAll(); + databaseCleanUp.truncateAllTables(); + } + + @Test + @DisplayName("1. L1 Hit — L2(Redis) 제거 후에도 L1에서 데이터 반환") + void l1Hit_AfterL2Evict_ShouldStillReturnData() { + // given: 상품 생성 + 첫 조회 (L1 + L2 적재) + BrandModel brand = brandService.createBrand("브랜드", "설명", "서울"); + ProductModel product = productService.createProduct("L1테스트상품", brand.getBrandId(), + BigDecimal.valueOf(10000), "설명"); + productService.findById(product.getProductId()); + + // when: Redis(L2)에서만 수동 삭제 + Set keys = redisTemplate.keys("productDetail*"); + assertThat(keys).isNotEmpty(); + keys.forEach(redisTemplate::delete); + + // then: Redis 키 삭제 확인 + assertThat(redisTemplate.keys("productDetail*")).isEmpty(); + + // then: L1에서 여전히 데이터 반환 + Cache.ValueWrapper cached = cacheManager.getCache("productDetail").get(product.getProductId()); + assertThat(cached).isNotNull(); + ProductModel fromL1 = (ProductModel) cached.get(); + assertThat(fromL1.getProductName()).isEqualTo("L1테스트상품"); + } + + @Test + @DisplayName("2. L1 Miss → L2 Hit → L1 자동 적재") + void l1Miss_l2Hit_ShouldPopulateL1() { + // given: 상품 생성 + 첫 조회 (L1 + L2 적재) + BrandModel brand = brandService.createBrand("브랜드", "설명", "서울"); + ProductModel product = productService.createProduct("L1적재테스트", brand.getBrandId(), + BigDecimal.valueOf(15000), "설명"); + productService.findById(product.getProductId()); + + // when: L1만 클리어 (TwoLevelCache의 L1 접근 위해 새 조회 전 L1 데이터 제거) + // TwoLevelCache.get() 시 L1 miss → L2 hit → L1 적재 흐름 검증 + Cache twoLevelCache = cacheManager.getCache("productDetail"); + assertThat(twoLevelCache).isInstanceOf(TwoLevelCache.class); + + // L1 데이터만 직접 제거 (evict는 양쪽 다 지우므로, 내부 L1에 접근) + // → TwoLevelCache를 통한 clear 후 L2에 수동 put으로 검증 + twoLevelCache.clear(); // L1 + L2 모두 클리어 + + // L2(Redis)에만 직접 put — productService.findById는 @Cacheable이므로 + // CacheManager를 통해 L2에 직접 적재 + Set keysAfterClear = redisTemplate.keys("productDetail*"); + assertThat(keysAfterClear).isEmpty(); + + // 재조회 → DB → L1+L2 적재 + ProductModel reloaded = productService.findById(product.getProductId()); + assertThat(reloaded.getProductName()).isEqualTo("L1적재테스트"); + + // Redis(L2)에서 삭제 후에도 L1에서 반환되면 L1 적재 성공 + redisTemplate.keys("productDetail*").forEach(redisTemplate::delete); + Cache.ValueWrapper l1Value = cacheManager.getCache("productDetail").get(product.getProductId()); + assertThat(l1Value).isNotNull(); + } + + @Test + @DisplayName("3. CacheEvict 시 L1+L2 동시 무효화") + void cacheEvict_ShouldClearBothL1AndL2() { + // given: 상품 생성 + 캐시 적재 + BrandModel brand = brandService.createBrand("브랜드", "설명", "서울"); + ProductModel product = productService.createProduct("evict테스트", brand.getBrandId(), + BigDecimal.valueOf(10000), "설명"); + productService.findById(product.getProductId()); + + // 캐시 적재 확인 + assertThat(cacheManager.getCache("productDetail").get(product.getProductId())).isNotNull(); + assertThat(redisTemplate.keys("productDetail*")).isNotEmpty(); + + // when: 상품 수정 → @CacheEvict + productService.updateProduct(product.getProductId(), "수정상품", BigDecimal.valueOf(20000), "수정설명", null); + + // then: L2(Redis) 캐시 무효화 + assertThat(redisTemplate.keys("productDetail::*")).isEmpty(); + + // then: L1도 무효화 (TwoLevelCache.evict가 양쪽 제거) + Cache.ValueWrapper afterEvict = cacheManager.getCache("productDetail").get(product.getProductId()); + assertThat(afterEvict).isNull(); + } + + @Test + @DisplayName("4. Stats 캐시는 상품 변경에 영향받지 않음 (TTL-only)") + void statsCache_ShouldBeIndependentFromProductChanges() { + // given: 상품 생성 (통계 데이터를 위해) + BrandModel brand = brandService.createBrand("브랜드", "설명", "서울"); + ProductModel product = productService.createProduct("통계테스트상품", brand.getBrandId(), + BigDecimal.valueOf(10000), "설명"); + + // given: 통계 조회 → 캐시 적재 + LocalDate start = LocalDate.of(2026, 1, 1); + LocalDate end = LocalDate.of(2026, 3, 13); + statsService.getOverview(start, end); + + String cacheKey = start.toString() + ":" + end.toString(); + assertThat(cacheManager.getCache("statsOverview").get(cacheKey)).isNotNull(); + + // when: 상품 수정 (productDetail evict되지만 statsOverview는 독립) + productService.updateProduct(product.getProductId(), "수정됨", BigDecimal.valueOf(20000), "수정", null); + + // then: 통계 캐시는 여전히 유효 + assertThat(cacheManager.getCache("statsOverview").get(cacheKey)).isNotNull(); + } + + @Test + @DisplayName("5. L2-only 대상(stockAvailable)은 TwoLevelCache가 아닌 RedisCache 반환") + void l2OnlyTarget_ShouldNotUseTwoLevelCache() { + // given: stockAvailable은 TWO_LEVEL_CACHES에 포함되지 않음 + Cache stockCache = cacheManager.getCache("stockAvailable"); + + // then: TwoLevelCache가 아님 + assertThat(stockCache).isNotInstanceOf(TwoLevelCache.class); + } + + @Test + @DisplayName("6. L1 적용 대상(productDetail)은 TwoLevelCache 반환") + void l1Target_ShouldUseTwoLevelCache() { + // given: productDetail은 TWO_LEVEL_CACHES에 포함 + Cache productCache = cacheManager.getCache("productDetail"); + + // then: TwoLevelCache + assertThat(productCache).isInstanceOf(TwoLevelCache.class); + } + + @Test + @DisplayName("7. L2-only(brandDetail) 캐시 — Redis 제거 시 데이터 소실") + void l2OnlyCache_AfterRedisEvict_ShouldReturnNull() { + // given: 브랜드 조회 → L2(Redis)에만 캐시 + BrandModel brand = brandService.createBrand("L2only브랜드", "설명", "서울"); + brandService.findById(brand.getBrandId()); + assertThat(cacheManager.getCache("brandDetail").get(brand.getBrandId())).isNotNull(); + + // when: Redis 키 삭제 + Set keys = redisTemplate.keys("brandDetail*"); + assertThat(keys).isNotEmpty(); + keys.forEach(redisTemplate::delete); + + // then: L1이 없으므로 데이터 소실 (L2-only) + Cache.ValueWrapper afterDelete = cacheManager.getCache("brandDetail").get(brand.getBrandId()); + assertThat(afterDelete).isNull(); + } + + @Test + @DisplayName("8. Caffeine hitRate 메트릭 수집 확인") + void caffeineStats_ShouldRecordHitRate() { + // given: productDetail은 TwoLevelCache + BrandModel brand = brandService.createBrand("메트릭브랜드", "설명", "서울"); + ProductModel product = productService.createProduct("메트릭상품", brand.getBrandId(), + BigDecimal.valueOf(10000), "설명"); + + // when: 동일 상품 11회 조회 (첫 1회 miss, 나머지 10회 hit) + for (int i = 0; i < 11; i++) { + productService.findById(product.getProductId()); + } + + // then: TwoLevelCache 내부의 L1 CaffeineCache에서 stats 확인 + Cache twoLevelCache = cacheManager.getCache("productDetail"); + assertThat(twoLevelCache).isInstanceOf(TwoLevelCache.class); + // TwoLevelCache → Caffeine native cache → stats + // Note: @Cacheable은 TwoLevelCache.get(key, valueLoader)을 호출하는데, + // 내부적으로 L1 caffeine cache를 조회하므로 stats가 기록됨 + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/cart/CartItemRepositoryImplTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/cart/CartItemRepositoryImplTest.java index f13406b52..41b72fd2a 100644 --- a/apps/commerce-api/src/test/java/com/loopers/infrastructure/cart/CartItemRepositoryImplTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/cart/CartItemRepositoryImplTest.java @@ -30,21 +30,21 @@ class CartItemRepositoryImplTest { @Test @DisplayName("장바구니 항목 저장") void save_ShouldPersist() { - CartItemModel item = CartItemModel.create("user-1", "product-1", 2); + CartItemModel item = CartItemModel.create(1L, 1L, 2); CartItemModel saved = cartItemRepository.save(item); - assertThat(saved.getUserId()).isEqualTo("user-1"); - assertThat(saved.getProductId()).isEqualTo("product-1"); + assertThat(saved.getUserId()).isEqualTo(1L); + assertThat(saved.getProductId()).isEqualTo(1L); assertThat(saved.getQuantity()).isEqualTo(2); } @Test @DisplayName("복합 PK로 조회 - 존재하는 항목") void findById_Existing_ShouldReturn() { - cartItemRepository.save(CartItemModel.create("user-1", "product-1", 2)); + cartItemRepository.save(CartItemModel.create(1L, 1L, 2)); - Optional found = cartItemRepository.findById(new CartItemId("user-1", "product-1")); + Optional found = cartItemRepository.findById(new CartItemId(1L, 1L)); assertThat(found).isPresent(); assertThat(found.get().getQuantity()).isEqualTo(2); @@ -53,7 +53,7 @@ void findById_Existing_ShouldReturn() { @Test @DisplayName("복합 PK로 조회 - 존재하지 않는 항목") void findById_NotExisting_ShouldReturnEmpty() { - Optional found = cartItemRepository.findById(new CartItemId("user-1", "product-1")); + Optional found = cartItemRepository.findById(new CartItemId(1L, 1L)); assertThat(found).isEmpty(); } @@ -61,23 +61,23 @@ void findById_NotExisting_ShouldReturnEmpty() { @Test @DisplayName("장바구니 항목 삭제") void delete_ShouldRemove() { - CartItemModel item = cartItemRepository.save(CartItemModel.create("user-1", "product-1", 2)); + CartItemModel item = cartItemRepository.save(CartItemModel.create(1L, 1L, 2)); cartItemRepository.delete(item); entityManager.flush(); - Optional found = cartItemRepository.findById(new CartItemId("user-1", "product-1")); + Optional found = cartItemRepository.findById(new CartItemId(1L, 1L)); assertThat(found).isEmpty(); } @Test @DisplayName("사용자별 장바구니 목록 조회") void findAllByUserId_ShouldReturnUserCart() { - cartItemRepository.save(CartItemModel.create("user-1", "product-1", 1)); - cartItemRepository.save(CartItemModel.create("user-1", "product-2", 3)); - cartItemRepository.save(CartItemModel.create("user-2", "product-1", 2)); + cartItemRepository.save(CartItemModel.create(1L, 1L, 1)); + cartItemRepository.save(CartItemModel.create(1L, 2L, 3)); + cartItemRepository.save(CartItemModel.create(2L, 1L, 2)); - List result = cartItemRepository.findAllByUserId("user-1"); + List result = cartItemRepository.findAllByUserId(1L); assertThat(result).hasSize(2); } @@ -85,14 +85,14 @@ void findAllByUserId_ShouldReturnUserCart() { @Test @DisplayName("동일 복합 PK로 save 시 수량이 업데이트된다") void save_ExistingItem_ShouldUpdate() { - CartItemModel item = cartItemRepository.save(CartItemModel.create("user-1", "product-1", 2)); + CartItemModel item = cartItemRepository.save(CartItemModel.create(1L, 1L, 2)); item.changeQuantity(5); cartItemRepository.save(item); entityManager.flush(); entityManager.clear(); - Optional found = cartItemRepository.findById(new CartItemId("user-1", "product-1")); + Optional found = cartItemRepository.findById(new CartItemId(1L, 1L)); assertThat(found).isPresent(); assertThat(found.get().getQuantity()).isEqualTo(5); } diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/coupon/CouponRepositoryImplTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/coupon/CouponRepositoryImplTest.java new file mode 100644 index 000000000..91f71df35 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/coupon/CouponRepositoryImplTest.java @@ -0,0 +1,70 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.domain.coupon.CouponModel; +import com.loopers.domain.coupon.CouponRepository; +import com.loopers.support.enums.DiscountType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@Import({CouponRepositoryImpl.class, UserCouponRepositoryImpl.class}) +@ActiveProfiles("test") +@DisplayName("CouponRepositoryImpl 테스트") +class CouponRepositoryImplTest { + + @Autowired + CouponRepository couponRepository; + + @Autowired + CouponJpaRepository couponJpaRepository; + + private CouponModel saveTestCoupon(String name) { + CouponModel coupon = CouponModel.create(name, DiscountType.FIXED, BigDecimal.valueOf(1000), null, + LocalDateTime.now().plusDays(30)); + return couponRepository.save(coupon); + } + + @Test + @DisplayName("쿠폰을 저장하고 ID로 조회할 수 있다") + void save_ShouldPersistCoupon() { + CouponModel saved = saveTestCoupon("테스트쿠폰"); + assertThat(saved.getCouponId()).isNotNull(); + + Optional found = couponRepository.findById(saved.getCouponId()); + assertThat(found).isPresent(); + assertThat(found.get().getName()).isEqualTo("테스트쿠폰"); + } + + @Test + @DisplayName("findByIdWithLock으로 쿠폰을 조회할 수 있다") + void findByIdWithLock_ShouldReturnCoupon() { + CouponModel saved = saveTestCoupon("락쿠폰"); + Optional found = couponRepository.findByIdWithLock(saved.getCouponId()); + assertThat(found).isPresent(); + assertThat(found.get().getCouponId()).isEqualTo(saved.getCouponId()); + } + + @Test + @DisplayName("findAll은 삭제된 쿠폰을 포함하여 반환한다") + void findAll_ShouldIncludeDeletedCoupons() { + saveTestCoupon("활성쿠폰"); + CouponModel deleted = saveTestCoupon("삭제쿠폰"); + deleted.softDelete(); + couponRepository.save(deleted); + + List all = couponRepository.findAll(); + assertThat(all).hasSizeGreaterThanOrEqualTo(2); + assertThat(all.stream().anyMatch(CouponModel::isDeleted)).isTrue(); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/like/LikeRepositoryImplTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/like/LikeRepositoryImplTest.java index f6af01c75..31b101c6a 100644 --- a/apps/commerce-api/src/test/java/com/loopers/infrastructure/like/LikeRepositoryImplTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/like/LikeRepositoryImplTest.java @@ -26,20 +26,20 @@ class LikeRepositoryImplTest { @Test @DisplayName("좋아요 저장") void save_ShouldPersist() { - LikeModel like = LikeModel.create("user-1", "product-1"); + LikeModel like = LikeModel.create(1L, 1L); LikeModel saved = likeRepository.save(like); - assertThat(saved.getUserId()).isEqualTo("user-1"); - assertThat(saved.getProductId()).isEqualTo("product-1"); + assertThat(saved.getUserId()).isEqualTo(1L); + assertThat(saved.getProductId()).isEqualTo(1L); } @Test @DisplayName("복합 PK로 조회 - 존재하는 좋아요") void findById_Existing_ShouldReturn() { - likeRepository.save(LikeModel.create("user-1", "product-1")); + likeRepository.save(LikeModel.create(1L, 1L)); - Optional found = likeRepository.findById(new LikeId("user-1", "product-1")); + Optional found = likeRepository.findById(new LikeId(1L, 1L)); assertThat(found).isPresent(); } @@ -47,7 +47,7 @@ void findById_Existing_ShouldReturn() { @Test @DisplayName("복합 PK로 조회 - 존재하지 않는 좋아요") void findById_NotExisting_ShouldReturnEmpty() { - Optional found = likeRepository.findById(new LikeId("user-1", "product-1")); + Optional found = likeRepository.findById(new LikeId(1L, 1L)); assertThat(found).isEmpty(); } @@ -55,22 +55,22 @@ void findById_NotExisting_ShouldReturnEmpty() { @Test @DisplayName("좋아요 삭제 (물리 삭제)") void delete_ShouldRemove() { - LikeModel like = likeRepository.save(LikeModel.create("user-1", "product-1")); + LikeModel like = likeRepository.save(LikeModel.create(1L, 1L)); likeRepository.delete(like); - Optional found = likeRepository.findById(new LikeId("user-1", "product-1")); + Optional found = likeRepository.findById(new LikeId(1L, 1L)); assertThat(found).isEmpty(); } @Test @DisplayName("사용자별 좋아요 목록 조회") void findAllByUserId_ShouldReturnUserLikes() { - likeRepository.save(LikeModel.create("user-1", "product-1")); - likeRepository.save(LikeModel.create("user-1", "product-2")); - likeRepository.save(LikeModel.create("user-2", "product-1")); + likeRepository.save(LikeModel.create(1L, 1L)); + likeRepository.save(LikeModel.create(1L, 2L)); + likeRepository.save(LikeModel.create(2L, 1L)); - List result = likeRepository.findAllByUserId("user-1"); + List result = likeRepository.findAllByUserId(1L); assertThat(result).hasSize(2); } @@ -78,11 +78,11 @@ void findAllByUserId_ShouldReturnUserLikes() { @Test @DisplayName("상품별 좋아요 카운트 조회") void countByProductId_ShouldReturnCorrectCount() { - likeRepository.save(LikeModel.create("user-1", "product-1")); - likeRepository.save(LikeModel.create("user-2", "product-1")); - likeRepository.save(LikeModel.create("user-3", "product-2")); + likeRepository.save(LikeModel.create(1L, 1L)); + likeRepository.save(LikeModel.create(2L, 1L)); + likeRepository.save(LikeModel.create(3L, 2L)); - long count = likeRepository.countByProductId("product-1"); + long count = likeRepository.countByProductId(1L); assertThat(count).isEqualTo(2); } diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/order/OrderRepositoryImplTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/order/OrderRepositoryImplTest.java index 221d52914..156392cc1 100644 --- a/apps/commerce-api/src/test/java/com/loopers/infrastructure/order/OrderRepositoryImplTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/order/OrderRepositoryImplTest.java @@ -29,39 +29,39 @@ class OrderRepositoryImplTest { @Autowired TestEntityManager entityManager; - private OrderModel createOrder(String userId) { + private OrderModel createOrder(Long userId) { return OrderModel.create(userId, OrderType.DIRECT, BigDecimal.valueOf(50000)); } @Test - @DisplayName("저장 시 UUID ID가 자동 생성된다") - void save_ShouldPersistWithUuidId() { - OrderModel order = createOrder("user-1"); + @DisplayName("저장 시 ID가 자동 생성된다") + void save_ShouldPersistWithAutoId() { + OrderModel order = createOrder(1L); OrderModel saved = orderRepository.save(order); assertThat(saved.getOrderId()).isNotNull(); - assertThat(saved.getOrderId()).hasSize(36); + assertThat(saved.getOrderId()).isGreaterThan(0L); } @Test @DisplayName("ID로 조회 - 존재하는 주문") void findById_Existing_ShouldReturn() { - OrderModel saved = orderRepository.save(createOrder("user-1")); + OrderModel saved = orderRepository.save(createOrder(1L)); Optional found = orderRepository.findById(saved.getOrderId()); assertThat(found).isPresent(); - assertThat(found.get().getUserId()).isEqualTo("user-1"); + assertThat(found.get().getUserId()).isEqualTo(1L); } @Test @DisplayName("주문 ID + 사용자 ID로 조회 - 소유자만 조회 가능") void findByIdAndUserId_ShouldReturn() { - OrderModel saved = orderRepository.save(createOrder("user-1")); + OrderModel saved = orderRepository.save(createOrder(1L)); - Optional found = orderRepository.findByIdAndUserId(saved.getOrderId(), "user-1"); - Optional notFound = orderRepository.findByIdAndUserId(saved.getOrderId(), "user-2"); + Optional found = orderRepository.findByIdAndUserId(saved.getOrderId(), 1L); + Optional notFound = orderRepository.findByIdAndUserId(saved.getOrderId(), 2L); assertThat(found).isPresent(); assertThat(notFound).isEmpty(); @@ -70,7 +70,7 @@ void findByIdAndUserId_ShouldReturn() { @Test @DisplayName("CAS 상태 전이 - PENDING → CANCELLED 성공 시 affected=1") void casUpdateStatus_PendingToCancelled_ShouldReturnAffectedRows1() { - OrderModel saved = orderRepository.save(createOrder("user-1")); + OrderModel saved = orderRepository.save(createOrder(1L)); entityManager.flush(); entityManager.clear(); @@ -85,7 +85,7 @@ void casUpdateStatus_PendingToCancelled_ShouldReturnAffectedRows1() { @Test @DisplayName("CAS 상태 전이 - 이미 CANCELLED인 주문에 PENDING→CANCELLED 시도 시 affected=0") void casUpdateStatus_AlreadyCancelled_ShouldReturnAffectedRows0() { - OrderModel order = createOrder("user-1"); + OrderModel order = createOrder(1L); OrderModel saved = orderRepository.save(order); entityManager.flush(); entityManager.clear(); @@ -104,7 +104,7 @@ void casUpdateStatus_AlreadyCancelled_ShouldReturnAffectedRows0() { @DisplayName("만료 대상 조회 - status=PENDING_PAYMENT AND expiresAt < now() 인 주문만 반환") void findExpiredPendingOrders_ShouldReturnOnlyExpired() { // 아직 만료되지 않은 주문 (expiresAt은 15분 후) - orderRepository.save(createOrder("user-1")); + orderRepository.save(createOrder(1L)); entityManager.flush(); List result = orderRepository.findExpiredPendingOrders(); diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/product/ProductRepositoryImplTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/product/ProductRepositoryImplTest.java index 4cde7aa4b..fde73965a 100644 --- a/apps/commerce-api/src/test/java/com/loopers/infrastructure/product/ProductRepositoryImplTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/product/ProductRepositoryImplTest.java @@ -33,21 +33,21 @@ private BrandModel createBrand() { return brandJpaRepository.save(BrandModel.create("테스트브랜드", "설명", "서울")); } - private ProductModel createProduct(String brandId, String name) { + private ProductModel createProduct(Long brandId, String name) { return ProductModel.create(name, brandId, BigDecimal.valueOf(10000), "설명", "카테고리", "블랙", "M", null, null, null); } @Test - @DisplayName("저장 시 UUID ID가 자동 생성된다") - void save_ShouldPersistWithUuidId() { + @DisplayName("저장 시 ID가 자동 생성된다") + void save_ShouldPersistWithAutoId() { BrandModel brand = createBrand(); ProductModel product = createProduct(brand.getBrandId(), "테스트상품"); ProductModel saved = productRepository.save(product); assertThat(saved.getProductId()).isNotNull(); - assertThat(saved.getProductId()).hasSize(36); + assertThat(saved.getProductId()).isGreaterThan(0L); } @Test @@ -65,7 +65,7 @@ void findById_Existing_ShouldReturn() { @Test @DisplayName("ID로 조회 - 존재하지 않는 상품") void findById_NotExisting_ShouldReturnEmpty() { - Optional found = productRepository.findById("nonexistent-uuid"); + Optional found = productRepository.findById(999L); assertThat(found).isEmpty(); } diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/product/ProductStockRepositoryImplTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/product/ProductStockRepositoryImplTest.java index 06aab854a..8854e1952 100644 --- a/apps/commerce-api/src/test/java/com/loopers/infrastructure/product/ProductStockRepositoryImplTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/product/ProductStockRepositoryImplTest.java @@ -23,7 +23,7 @@ class ProductStockRepositoryImplTest { @Autowired TestEntityManager entityManager; - private ProductStockModel createStock(String productId, int onHand, int reserved) { + private ProductStockModel createStock(Long productId, int onHand, int reserved) { ProductStockModel stock = ProductStockModel.createWithReserved(productId, onHand, reserved); return stockRepository.save(stock); } @@ -31,56 +31,56 @@ private ProductStockModel createStock(String productId, int onHand, int reserved @Test @DisplayName("reserveStock CAS - 가용 재고 충분 시 affected=1 반환") void reserveStock_CAS_WithSufficientStock_ShouldReturnAffectedRows1() { - createStock("product-1", 100, 0); + createStock(1L, 100, 0); entityManager.flush(); entityManager.clear(); - int affected = stockRepository.reserveStock("product-1", 50); + int affected = stockRepository.reserveStock(1L, 50); assertThat(affected).isEqualTo(1); - ProductStockModel stock = stockRepository.findByProductId("product-1").get(); + ProductStockModel stock = stockRepository.findByProductId(1L).get(); assertThat(stock.getReserved()).isEqualTo(50); } @Test @DisplayName("reserveStock CAS - 가용 재고 부족 시 affected=0 반환 (오버셀 방지)") void reserveStock_CAS_WithInsufficientStock_ShouldReturnAffectedRows0() { - createStock("product-1", 10, 5); + createStock(1L, 10, 5); entityManager.flush(); entityManager.clear(); - int affected = stockRepository.reserveStock("product-1", 10); + int affected = stockRepository.reserveStock(1L, 10); assertThat(affected).isEqualTo(0); - ProductStockModel stock = stockRepository.findByProductId("product-1").get(); + ProductStockModel stock = stockRepository.findByProductId(1L).get(); assertThat(stock.getReserved()).isEqualTo(5); } @Test @DisplayName("releaseStock CAS - 예약 해제 성공") void releaseStock_CAS_ShouldDecreaseReserved() { - createStock("product-1", 100, 50); + createStock(1L, 100, 50); entityManager.flush(); entityManager.clear(); - int affected = stockRepository.releaseStock("product-1", 30); + int affected = stockRepository.releaseStock(1L, 30); assertThat(affected).isEqualTo(1); - ProductStockModel stock = stockRepository.findByProductId("product-1").get(); + ProductStockModel stock = stockRepository.findByProductId(1L).get(); assertThat(stock.getReserved()).isEqualTo(20); } @Test @DisplayName("commitStock CAS - onHand와 reserved 동시 차감 성공") void commitStock_CAS_ShouldDecreaseBothOnHandAndReserved() { - createStock("product-1", 100, 50); + createStock(1L, 100, 50); entityManager.flush(); entityManager.clear(); - int affected = stockRepository.commitStock("product-1", 30); + int affected = stockRepository.commitStock(1L, 30); assertThat(affected).isEqualTo(1); - ProductStockModel stock = stockRepository.findByProductId("product-1").get(); + ProductStockModel stock = stockRepository.findByProductId(1L).get(); assertThat(stock.getOnHand()).isEqualTo(70); assertThat(stock.getReserved()).isEqualTo(20); } diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/stats/StatsRepositoryImplTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/stats/StatsRepositoryImplTest.java index 3b17f6ed3..6c3c1e875 100644 --- a/apps/commerce-api/src/test/java/com/loopers/infrastructure/stats/StatsRepositoryImplTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/stats/StatsRepositoryImplTest.java @@ -13,7 +13,6 @@ import com.loopers.infrastructure.order.OrderJpaRepository; import com.loopers.infrastructure.product.ProductJpaRepository; import com.loopers.infrastructure.product.ProductStockJpaRepository; -import com.loopers.support.enums.OrderStatus; import com.loopers.support.enums.OrderType; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -71,14 +70,14 @@ void setUp() { @DisplayName("주문 상태별 건수가 정확하게 집계된다") void getOverview_ShouldCountByOrderStatus() { // PENDING 3건 - orderJpaRepository.save(OrderModel.create("user-1", OrderType.DIRECT, BigDecimal.valueOf(10000))); - orderJpaRepository.save(OrderModel.create("user-2", OrderType.DIRECT, BigDecimal.valueOf(20000))); - orderJpaRepository.save(OrderModel.create("user-3", OrderType.CART, BigDecimal.valueOf(30000))); + orderJpaRepository.save(OrderModel.create(1L, OrderType.DIRECT, BigDecimal.valueOf(10000))); + orderJpaRepository.save(OrderModel.create(2L, OrderType.DIRECT, BigDecimal.valueOf(20000))); + orderJpaRepository.save(OrderModel.create(3L, OrderType.CART, BigDecimal.valueOf(30000))); // CANCELLED 2건 OrderModel cancelled1 = orderJpaRepository.save( - OrderModel.create("user-4", OrderType.DIRECT, BigDecimal.valueOf(10000))); + OrderModel.create(4L, OrderType.DIRECT, BigDecimal.valueOf(10000))); OrderModel cancelled2 = orderJpaRepository.save( - OrderModel.create("user-5", OrderType.DIRECT, BigDecimal.valueOf(10000))); + OrderModel.create(5L, OrderType.DIRECT, BigDecimal.valueOf(10000))); entityManager.flush(); entityManager.clear(); orderJpaRepository.findById(cancelled1.getOrderId()).ifPresent(o -> { @@ -91,7 +90,7 @@ void getOverview_ShouldCountByOrderStatus() { }); // EXPIRED 1건 OrderModel expired = orderJpaRepository.save( - OrderModel.create("user-6", OrderType.DIRECT, BigDecimal.valueOf(10000))); + OrderModel.create(6L, OrderType.DIRECT, BigDecimal.valueOf(10000))); entityManager.flush(); entityManager.clear(); orderJpaRepository.findById(expired.getOrderId()).ifPresent(o -> { @@ -112,8 +111,8 @@ void getOverview_ShouldCountByOrderStatus() { @Test @DisplayName("일별 주문 통계가 GROUP BY 날짜로 집계된다") void getDailyOrderStats_ShouldGroupByDate() { - orderJpaRepository.save(OrderModel.create("user-1", OrderType.DIRECT, BigDecimal.valueOf(10000))); - orderJpaRepository.save(OrderModel.create("user-2", OrderType.DIRECT, BigDecimal.valueOf(20000))); + orderJpaRepository.save(OrderModel.create(1L, OrderType.DIRECT, BigDecimal.valueOf(10000))); + orderJpaRepository.save(OrderModel.create(2L, OrderType.DIRECT, BigDecimal.valueOf(20000))); entityManager.flush(); entityManager.clear(); @@ -139,12 +138,12 @@ void getTopLikedProducts_ShouldJoinAndAggregate() { null, null, null, null, null, null, null)); // product3: 3 likes, product1: 2 likes, product2: 1 like - likeJpaRepository.save(LikeModel.create("user-1", product1.getProductId())); - likeJpaRepository.save(LikeModel.create("user-2", product1.getProductId())); - likeJpaRepository.save(LikeModel.create("user-1", product2.getProductId())); - likeJpaRepository.save(LikeModel.create("user-1", product3.getProductId())); - likeJpaRepository.save(LikeModel.create("user-2", product3.getProductId())); - likeJpaRepository.save(LikeModel.create("user-3", product3.getProductId())); + likeJpaRepository.save(LikeModel.create(1L, product1.getProductId())); + likeJpaRepository.save(LikeModel.create(2L, product1.getProductId())); + likeJpaRepository.save(LikeModel.create(1L, product2.getProductId())); + likeJpaRepository.save(LikeModel.create(1L, product3.getProductId())); + likeJpaRepository.save(LikeModel.create(2L, product3.getProductId())); + likeJpaRepository.save(LikeModel.create(3L, product3.getProductId())); entityManager.flush(); entityManager.clear(); @@ -159,15 +158,23 @@ void getTopLikedProducts_ShouldJoinAndAggregate() { @DisplayName("주문 상위 상품이 GROUP BY로 정렬된다") void getTopOrderedProducts_ShouldJoinAndAggregate() { OrderModel order = orderJpaRepository.save( - OrderModel.create("user-1", OrderType.DIRECT, BigDecimal.valueOf(50000))); + OrderModel.create(1L, OrderType.DIRECT, BigDecimal.valueOf(50000))); + entityManager.flush(); + + ProductModel product1 = productJpaRepository.save( + ProductModel.create("상품A", brand.getBrandId(), BigDecimal.valueOf(10000), + null, null, null, null, null, null, null)); + ProductModel product2 = productJpaRepository.save( + ProductModel.create("상품B", brand.getBrandId(), BigDecimal.valueOf(20000), + null, null, null, null, null, null, null)); entityManager.flush(); orderItemJpaRepository.save(OrderItemModel.create( - order.getOrderId(), 1, "user-1", "p1", 3, - "상품A", BigDecimal.valueOf(10000), brand.getBrandId(), "테스트브랜드", null)); + order.getOrderId(), 1, 1L, product1.getProductId(), 3, + "상품A", BigDecimal.valueOf(10000), String.valueOf(brand.getBrandId()), "테스트브랜드", null)); orderItemJpaRepository.save(OrderItemModel.create( - order.getOrderId(), 2, "user-1", "p2", 1, - "상품B", BigDecimal.valueOf(20000), brand.getBrandId(), "테스트브랜드", null)); + order.getOrderId(), 2, 1L, product2.getProductId(), 1, + "상품B", BigDecimal.valueOf(20000), String.valueOf(brand.getBrandId()), "테스트브랜드", null)); entityManager.flush(); entityManager.clear(); diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/user/UserRepositoryImplTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/user/UserRepositoryImplTest.java index 4797ee1f9..5a9a5aefa 100644 --- a/apps/commerce-api/src/test/java/com/loopers/infrastructure/user/UserRepositoryImplTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/user/UserRepositoryImplTest.java @@ -22,8 +22,8 @@ class UserRepositoryImplTest { UserRepositoryImpl userRepository; @Test - @DisplayName("저장 시 UUID ID가 자동 생성된다") - void save_ShouldPersistWithUuidId() { + @DisplayName("저장 시 ID가 자동 생성된다") + void save_ShouldPersistWithAutoId() { UserModel user = UserModel.createWithEncodedPassword( "testuser01", "{bcrypt}pw", "홍길동", "19900101", "a@b.com", "서울" ); @@ -31,7 +31,7 @@ void save_ShouldPersistWithUuidId() { UserModel saved = userRepository.save(user); assertThat(saved.getUserId()).isNotNull(); - assertThat(saved.getUserId()).hasSize(36); + assertThat(saved.getUserId()).isGreaterThan(0L); } @Test @@ -51,7 +51,7 @@ void findByUserId_Existing_ShouldReturn() { @Test @DisplayName("ID로 조회 - 존재하지 않는 사용자") void findByUserId_NotExisting_ShouldReturnEmpty() { - Optional found = userRepository.findByUserId("nonexistent-uuid"); + Optional found = userRepository.findByUserId(999L); assertThat(found).isEmpty(); } diff --git a/apps/commerce-api/src/test/java/com/loopers/integration/FullOrderFlowIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/integration/FullOrderFlowIntegrationTest.java index e098a511f..080b9ee12 100644 --- a/apps/commerce-api/src/test/java/com/loopers/integration/FullOrderFlowIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/integration/FullOrderFlowIntegrationTest.java @@ -1,7 +1,5 @@ package com.loopers.integration; -import com.loopers.application.brand.BrandAppService; -import com.loopers.application.brand.BrandInfo; import com.loopers.application.cart.CartFacade; import com.loopers.application.cart.CartInfo; import com.loopers.application.order.OrderFacade; @@ -9,6 +7,8 @@ import com.loopers.application.product.ProductCreateCommand; import com.loopers.application.product.ProductFacade; import com.loopers.application.product.ProductInfo; +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.brand.BrandService; import com.loopers.domain.cart.CartItemId; import com.loopers.domain.cart.CartItemModel; import com.loopers.domain.cart.CartService; @@ -44,7 +44,7 @@ class FullOrderFlowIntegrationTest { @Autowired UserService userService; - @Autowired BrandAppService brandAppService; + @Autowired BrandService brandService; @Autowired ProductFacade productFacade; @Autowired OrderFacade orderFacade; @Autowired OrderService orderService; @@ -53,26 +53,26 @@ class FullOrderFlowIntegrationTest { @Autowired StockService stockService; @Autowired CartItemJpaRepository cartItemJpaRepository; - private String loginId; - private String loginPw; + private Long userId; @BeforeEach void setUp() { - loginId = "testuser01"; - loginPw = "Test1234!@#"; - userService.register(new UserRegisterCommand(loginId, loginPw, "홍길동", "19900101", "test@example.com", "서울")); + String loginId = "testuser01"; + String loginPw = "Test1234!@#"; + var user = userService.register(new UserRegisterCommand(loginId, loginPw, "홍길동", "19900101", "test@example.com", "서울")); + userId = user.getUserId(); } @Test @DisplayName("scenario1: 바로 주문 → 취소 → 재고 해제 + 장바구니 복원") void directOrder_Cancel_ShouldReleaseStockAndRestoreCart() { - BrandInfo brand = brandAppService.createBrand("테스트브랜드", "설명", "서울"); + BrandModel brand = brandService.createBrand("테스트브랜드", "설명", "서울"); ProductInfo product = productFacade.createProduct( new ProductCreateCommand("테스트상품", brand.getBrandId(), BigDecimal.valueOf(10000), "설명", 10)); // 바로 주문 (수량 2) List items = List.of(new OrderItemCommand(product.getProductId(), 2)); - OrderInfo order = orderFacade.createDirectOrder(loginId, loginPw, items); + OrderInfo order = orderFacade.createDirectOrder(userId, items, null); // 재고 확인: reserved=2 ProductStockModel stock = stockService.findByProductId(product.getProductId()); @@ -80,7 +80,7 @@ void directOrder_Cancel_ShouldReleaseStockAndRestoreCart() { assertThat(stock.getAvailableQty()).isEqualTo(8); // 주문 취소 - orderFacade.cancelOrder(loginId, loginPw, order.getOrderId()); + orderFacade.cancelOrder(userId, order.getOrderId()); // 재고 원복 확인 stock = stockService.findByProductId(product.getProductId()); @@ -97,29 +97,28 @@ void directOrder_Cancel_ShouldReleaseStockAndRestoreCart() { @Test @DisplayName("scenario2: 장바구니 주문 → 취소 → 재고 해제 + 장바구니 유지") void cartOrder_Cancel_ShouldReleaseStockAndKeepCart() { - BrandInfo brand = brandAppService.createBrand("테스트브랜드", "설명", "서울"); + BrandModel brand = brandService.createBrand("테스트브랜드", "설명", "서울"); ProductInfo product1 = productFacade.createProduct( new ProductCreateCommand("상품1", brand.getBrandId(), BigDecimal.valueOf(10000), "설명", 10)); ProductInfo product2 = productFacade.createProduct( new ProductCreateCommand("상품2", brand.getBrandId(), BigDecimal.valueOf(20000), "설명", 10)); // 장바구니에 담기 - var user = userService.authenticate(loginId, loginPw); - cartService.addItem(user.getUserId(), product1.getProductId(), 3); - cartService.addItem(user.getUserId(), product2.getProductId(), 2); + cartService.addItem(userId, product1.getProductId(), 3); + cartService.addItem(userId, product2.getProductId(), 2); // 장바구니 주문 List items = List.of( new OrderItemCommand(product1.getProductId(), 3), new OrderItemCommand(product2.getProductId(), 2)); - OrderInfo order = orderFacade.createCartOrder(loginId, loginPw, items); + OrderInfo order = orderFacade.createCartOrder(userId, items, null); // 재고 확인 assertThat(stockService.findByProductId(product1.getProductId()).getAvailableQty()).isEqualTo(7); assertThat(stockService.findByProductId(product2.getProductId()).getAvailableQty()).isEqualTo(8); // 주문 취소 - orderFacade.cancelOrder(loginId, loginPw, order.getOrderId()); + orderFacade.cancelOrder(userId, order.getOrderId()); // 재고 원복 assertThat(stockService.findByProductId(product1.getProductId()).getAvailableQty()).isEqualTo(10); @@ -129,21 +128,20 @@ void cartOrder_Cancel_ShouldReleaseStockAndKeepCart() { @Test @DisplayName("scenario5: 브랜드 삭제 → 상품 연쇄 삭제 → 장바구니 unavailable") void brandDelete_ShouldCascadeProductDeleteAndCartUnavailable() { - BrandInfo brand = brandAppService.createBrand("삭제브랜드", "설명", "서울"); + BrandModel brand = brandService.createBrand("삭제브랜드", "설명", "서울"); ProductInfo product1 = productFacade.createProduct( new ProductCreateCommand("상품1", brand.getBrandId(), BigDecimal.valueOf(10000), "설명", 10)); ProductInfo product2 = productFacade.createProduct( new ProductCreateCommand("상품2", brand.getBrandId(), BigDecimal.valueOf(20000), "설명", 10)); // 장바구니에 담기 - var user = userService.authenticate(loginId, loginPw); - cartService.addItem(user.getUserId(), product1.getProductId(), 1); + cartService.addItem(userId, product1.getProductId(), 1); // 브랜드 삭제 (연쇄 삭제) - brandAppService.deleteBrand(brand.getBrandId()); + brandService.deleteBrand(brand.getBrandId()); // 장바구니에서 unavailable 확인 - List cart = cartFacade.getCartForAdmin(user.getUserId()); + List cart = cartFacade.getCartForAdmin(userId); assertThat(cart).hasSize(1); assertThat(cart.get(0).isAvailable()).isFalse(); } @@ -151,28 +149,28 @@ void brandDelete_ShouldCascadeProductDeleteAndCartUnavailable() { @Test @DisplayName("scenario6: PENDING 제한 초과 → 취소 후 재주문 가능") void pendingLimit_ShouldBlockAndAllowAfterCancel() { - BrandInfo brand = brandAppService.createBrand("테스트브랜드", "설명", "서울"); + BrandModel brand = brandService.createBrand("테스트브랜드", "설명", "서울"); ProductInfo product = productFacade.createProduct( new ProductCreateCommand("테스트상품", brand.getBrandId(), BigDecimal.valueOf(10000), "설명", 100)); List items = List.of(new OrderItemCommand(product.getProductId(), 1)); // 3건 생성 (PENDING_PAYMENT) - OrderInfo order1 = orderFacade.createDirectOrder(loginId, loginPw, items); - OrderInfo order2 = orderFacade.createDirectOrder(loginId, loginPw, items); - OrderInfo order3 = orderFacade.createDirectOrder(loginId, loginPw, items); + OrderInfo order1 = orderFacade.createDirectOrder(userId, items, null); + OrderInfo order2 = orderFacade.createDirectOrder(userId, items, null); + OrderInfo order3 = orderFacade.createDirectOrder(userId, items, null); // 4번째 주문 시도 → 실패 - assertThatThrownBy(() -> orderFacade.createDirectOrder(loginId, loginPw, items)) + assertThatThrownBy(() -> orderFacade.createDirectOrder(userId, items, null)) .isInstanceOf(CoreException.class) .satisfies(e -> assertThat(((CoreException) e).getErrorType()) .isEqualTo(ErrorType.ORDER_PENDING_LIMIT_EXCEEDED)); // 1건 취소 - orderFacade.cancelOrder(loginId, loginPw, order1.getOrderId()); + orderFacade.cancelOrder(userId, order1.getOrderId()); // 다시 주문 가능 - OrderInfo order4 = orderFacade.createDirectOrder(loginId, loginPw, items); + OrderInfo order4 = orderFacade.createDirectOrder(userId, items, null); assertThat(order4.getStatus()).isEqualTo(OrderStatus.PENDING_PAYMENT); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java deleted file mode 100644 index 1bb3dba65..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java +++ /dev/null @@ -1,114 +0,0 @@ -package com.loopers.interfaces.api; - -import com.loopers.domain.example.ExampleModel; -import com.loopers.infrastructure.example.ExampleJpaRepository; -import com.loopers.interfaces.api.example.ExampleV1Dto; -import com.loopers.utils.DatabaseCleanUp; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.web.client.TestRestTemplate; -import org.springframework.core.ParameterizedTypeReference; -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpMethod; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; - -import java.util.function.Function; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; -import static org.junit.jupiter.api.Assertions.assertTrue; - -@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -class ExampleV1ApiE2ETest { - - private static final Function ENDPOINT_GET = id -> "/api/v1/examples/" + id; - - private final TestRestTemplate testRestTemplate; - private final ExampleJpaRepository exampleJpaRepository; - private final DatabaseCleanUp databaseCleanUp; - - @Autowired - public ExampleV1ApiE2ETest( - TestRestTemplate testRestTemplate, - ExampleJpaRepository exampleJpaRepository, - DatabaseCleanUp databaseCleanUp - ) { - this.testRestTemplate = testRestTemplate; - this.exampleJpaRepository = exampleJpaRepository; - this.databaseCleanUp = databaseCleanUp; - } - - @AfterEach - void tearDown() { - databaseCleanUp.truncateAllTables(); - } - - @DisplayName("GET /api/v1/examples/{id}") - @Nested - class Get { - @DisplayName("존재하는 예시 ID를 주면, 해당 예시 정보를 반환한다.") - @Test - void returnsExampleInfo_whenValidIdIsProvided() { - // arrange - ExampleModel exampleModel = exampleJpaRepository.save( - new ExampleModel("예시 제목", "예시 설명") - ); - String requestUrl = ENDPOINT_GET.apply(exampleModel.getId()); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = - testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType); - - // assert - assertAll( - () -> assertTrue(response.getStatusCode().is2xxSuccessful()), - () -> assertThat(response.getBody().data().id()).isEqualTo(exampleModel.getId()), - () -> assertThat(response.getBody().data().name()).isEqualTo(exampleModel.getName()), - () -> assertThat(response.getBody().data().description()).isEqualTo(exampleModel.getDescription()) - ); - } - - @DisplayName("숫자가 아닌 ID 로 요청하면, 400 BAD_REQUEST 응답을 받는다.") - @Test - void throwsBadRequest_whenIdIsNotProvided() { - // arrange - String requestUrl = "/api/v1/examples/나나"; - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = - testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType); - - // assert - assertAll( - () -> assertTrue(response.getStatusCode().is4xxClientError()), - () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST) - ); - } - - @DisplayName("존재하지 않는 예시 ID를 주면, 404 NOT_FOUND 응답을 받는다.") - @Test - void throwsException_whenInvalidIdIsProvided() { - // arrange - Long invalidId = -1L; - String requestUrl = ENDPOINT_GET.apply(invalidId); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = - testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType); - - // assert - assertAll( - () -> assertTrue(response.getStatusCode().is4xxClientError()), - () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND) - ); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandV1ApiE2ETest.java index 3f4c1b936..60d37122f 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandV1ApiE2ETest.java @@ -77,7 +77,7 @@ void GET_brandDetail_ShouldReturn200() throws Exception { @Test @DisplayName("존재하지 않는 브랜드 ID로 404 반환") void GET_brandDetail_NotFound_ShouldReturn404() throws Exception { - mockMvc.perform(get("/api/v1/brands/{brandId}", "nonexistent")) + mockMvc.perform(get("/api/v1/brands/{brandId}", 999L)) .andExpect(status().isNotFound()); } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/coupon/CouponV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/coupon/CouponV1ApiE2ETest.java new file mode 100644 index 000000000..a9e2e0fdc --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/coupon/CouponV1ApiE2ETest.java @@ -0,0 +1,163 @@ +package com.loopers.interfaces.api.coupon; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.domain.coupon.CouponModel; +import com.loopers.domain.coupon.CouponService; +import com.loopers.interfaces.api.user.UserV1Dto; +import com.loopers.support.enums.DiscountType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Transactional +@DisplayName("Coupon API V1 E2E 테스트") +class CouponV1ApiE2ETest { + + @Autowired + MockMvc mockMvc; + + @Autowired + ObjectMapper objectMapper; + + @Autowired + CouponService couponService; + + private static final String LOGIN_ID = "couponuser01"; + private static final String LOGIN_PW = "Test1234!@#"; + private Long couponId; + + @BeforeEach + void setUp() throws Exception { + registerUser(LOGIN_ID, LOGIN_PW, "쿠폰테스트유저"); + CouponModel coupon = couponService.createCoupon("테스트쿠폰", DiscountType.FIXED, + BigDecimal.valueOf(5000), null, LocalDateTime.now().plusDays(30)); + couponId = coupon.getCouponId(); + } + + @Nested + @DisplayName("POST /api/v1/coupons/{couponId}/issue - 쿠폰 발급") + class IssueCouponTests { + + @Test + @DisplayName("정상 발급 시 200 반환") + void issueCoupon_WithValidAuth_ShouldReturn200() throws Exception { + mockMvc.perform(post("/api/v1/coupons/{couponId}/issue", couponId) + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", LOGIN_PW)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.meta.result").value("SUCCESS")) + .andExpect(jsonPath("$.data.userCouponId").isNumber()) + .andExpect(jsonPath("$.data.status").value("AVAILABLE")); + } + + @Test + @DisplayName("인증 헤더 없으면 400 반환") + void issueCoupon_WithoutAuth_ShouldReturn400() throws Exception { + mockMvc.perform(post("/api/v1/coupons/{couponId}/issue", couponId)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("없는 쿠폰 발급 시 404 반환") + void issueCoupon_CouponNotFound_ShouldReturn404() throws Exception { + mockMvc.perform(post("/api/v1/coupons/{couponId}/issue", 999L) + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", LOGIN_PW)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.meta.errorCode").value("COUPON_NOT_FOUND")); + } + + @Test + @DisplayName("이미 발급된 쿠폰 재발급 시 409 반환") + void issueCoupon_AlreadyIssued_ShouldReturn409() throws Exception { + mockMvc.perform(post("/api/v1/coupons/{couponId}/issue", couponId) + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", LOGIN_PW)); + + mockMvc.perform(post("/api/v1/coupons/{couponId}/issue", couponId) + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", LOGIN_PW)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.meta.errorCode").value("COUPON_ALREADY_ISSUED")); + } + + @Test + @DisplayName("만료된 쿠폰 발급 시 400 반환") + void issueCoupon_ExpiredCoupon_ShouldReturn400() throws Exception { + CouponModel expiredCoupon = couponService.createCoupon("만료쿠폰", DiscountType.FIXED, + BigDecimal.valueOf(1000), null, LocalDateTime.now().minusDays(1)); + + mockMvc.perform(post("/api/v1/coupons/{couponId}/issue", expiredCoupon.getCouponId()) + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", LOGIN_PW)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.meta.errorCode").value("COUPON_NOT_APPLICABLE")); + } + } + + @Nested + @DisplayName("GET /api/v1/users/me/coupons - 내 쿠폰 목록") + class GetMyCouponsTests { + + @Test + @DisplayName("발급된 쿠폰이 있으면 목록을 반환한다") + void getMyCoupons_ShouldReturnIssuedCoupons() throws Exception { + mockMvc.perform(post("/api/v1/coupons/{couponId}/issue", couponId) + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", LOGIN_PW)); + + mockMvc.perform(get("/api/v1/users/me/coupons") + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", LOGIN_PW)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data").isArray()) + .andExpect(jsonPath("$.data.length()").value(1)) + .andExpect(jsonPath("$.data[0].couponId").value(couponId)); + } + + @Test + @DisplayName("발급된 쿠폰이 없으면 빈 배열을 반환한다") + void getMyCoupons_NoIssuedCoupons_ShouldReturnEmptyList() throws Exception { + mockMvc.perform(get("/api/v1/users/me/coupons") + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", LOGIN_PW)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data").isArray()) + .andExpect(jsonPath("$.data.length()").value(0)); + } + } + + private void registerUser(String loginId, String password, String userName) throws Exception { + var request = UserV1Dto.RegisterRequest.builder() + .loginId(loginId) + .password(password) + .userName(userName) + .birthday("19900101") + .email("test@example.com") + .address("서울") + .build(); + + mockMvc.perform(post("/api/v1/users") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeV1ApiE2ETest.java index f6f83a715..3f807804e 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeV1ApiE2ETest.java @@ -49,7 +49,7 @@ class LikeV1ApiE2ETest { private static final String LOGIN_ID = "likeuser01"; private static final String LOGIN_PW = "Test1234!@#"; - private String productId; + private Long productId; @BeforeEach void setUp() throws Exception { @@ -98,7 +98,7 @@ void POST_addLike_Idempotent_ShouldReturn200() throws Exception { @Test @DisplayName("없는 상품에 좋아요 시 404 반환") void POST_addLike_ProductNotFound_ShouldReturn404() throws Exception { - mockMvc.perform(post("/api/v1/products/{productId}/likes", "nonexistent-product-id") + mockMvc.perform(post("/api/v1/products/{productId}/likes", 999L) .header("X-Loopers-LoginId", LOGIN_ID) .header("X-Loopers-LoginPw", LOGIN_PW)) .andExpect(status().isNotFound()) diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/apiadmin/AdminCartV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/apiadmin/AdminCartV1ApiE2ETest.java index c400b2dc7..142c47bba 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/apiadmin/AdminCartV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/apiadmin/AdminCartV1ApiE2ETest.java @@ -48,9 +48,9 @@ void GET_userCart_ShouldReturn200WithCartItems() throws Exception { ProductModel.create("테스트상품", brand.getBrandId(), BigDecimal.valueOf(10000), null, null, null, null, null, null, null)); productStockJpaRepository.save(ProductStockModel.create(product.getProductId(), 100)); - cartItemJpaRepository.save(CartItemModel.create("user-1", product.getProductId(), 3)); + cartItemJpaRepository.save(CartItemModel.create(1L, product.getProductId(), 3)); - mockMvc.perform(get("/api-admin/v1/users/user-1/cart") + mockMvc.perform(get("/api-admin/v1/users/1/cart") .header(ADMIN_HEADER, ADMIN_VALUE)) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.length()").value(1)) diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/apiadmin/AdminCouponV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/apiadmin/AdminCouponV1ApiE2ETest.java new file mode 100644 index 000000000..ac469e56e --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/apiadmin/AdminCouponV1ApiE2ETest.java @@ -0,0 +1,228 @@ +package com.loopers.interfaces.apiadmin; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.domain.coupon.CouponModel; +import com.loopers.domain.coupon.CouponService; +import com.loopers.interfaces.api.user.UserV1Dto; +import com.loopers.support.enums.DiscountType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Map; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Transactional +@DisplayName("관리자 Coupon API V1 E2E 테스트") +class AdminCouponV1ApiE2ETest { + + @Autowired + MockMvc mockMvc; + + @Autowired + ObjectMapper objectMapper; + + @Autowired + CouponService couponService; + + private static final String ADMIN_LDAP = "loopers.admin"; + private static final String USER_LOGIN_ID = "admincoupontest"; + private static final String USER_LOGIN_PW = "Test1234!@#"; + private Long couponId; + + @BeforeEach + void setUp() throws Exception { + CouponModel coupon = couponService.createCoupon("기본쿠폰", DiscountType.FIXED, + BigDecimal.valueOf(1000), null, LocalDateTime.now().plusDays(30)); + couponId = coupon.getCouponId(); + } + + @Nested + @DisplayName("POST /api-admin/v1/coupons - 쿠폰 등록") + class CreateCouponTests { + + @Test + @DisplayName("정상 등록 시 200 반환") + void createCoupon_ShouldReturn200() throws Exception { + Map request = Map.of( + "name", "신규쿠폰", + "type", "RATE", + "value", 10, + "expiredAt", "2030-12-31T23:59:59" + ); + + mockMvc.perform(post("/api-admin/v1/coupons") + .header("X-Loopers-Ldap", ADMIN_LDAP) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.couponId").isNumber()) + .andExpect(jsonPath("$.data.name").value("신규쿠폰")); + } + + @Test + @DisplayName("LDAP 헤더 없으면 401 반환") + void createCoupon_WithoutLdap_ShouldReturn401() throws Exception { + Map request = Map.of( + "name", "쿠폰", + "type", "FIXED", + "value", 1000, + "expiredAt", "2030-12-31T23:59:59" + ); + + mockMvc.perform(post("/api-admin/v1/coupons") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isUnauthorized()); + } + } + + @Nested + @DisplayName("GET /api-admin/v1/coupons/{couponId} - 쿠폰 상세") + class GetCouponTests { + + @Test + @DisplayName("정상 조회 시 200 반환") + void getCoupon_ShouldReturn200() throws Exception { + mockMvc.perform(get("/api-admin/v1/coupons/{couponId}", couponId) + .header("X-Loopers-Ldap", ADMIN_LDAP)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.couponId").value(couponId)); + } + + @Test + @DisplayName("없는 쿠폰 조회 시 404 반환") + void getCoupon_NotFound_ShouldReturn404() throws Exception { + mockMvc.perform(get("/api-admin/v1/coupons/{couponId}", 99999L) + .header("X-Loopers-Ldap", ADMIN_LDAP)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.meta.errorCode").value("COUPON_NOT_FOUND")); + } + } + + @Nested + @DisplayName("PUT /api-admin/v1/coupons/{couponId} - 쿠폰 수정") + class UpdateCouponTests { + + @Test + @DisplayName("정상 수정 시 200 반환") + void updateCoupon_ShouldReturn200() throws Exception { + Map request = Map.of( + "name", "수정된쿠폰", + "type", "RATE", + "value", 20, + "expiredAt", "2031-12-31T23:59:59" + ); + + mockMvc.perform(put("/api-admin/v1/coupons/{couponId}", couponId) + .header("X-Loopers-Ldap", ADMIN_LDAP) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.name").value("수정된쿠폰")); + } + } + + @Nested + @DisplayName("DELETE /api-admin/v1/coupons/{couponId} - 쿠폰 삭제") + class DeleteCouponTests { + + @Test + @DisplayName("정상 삭제 시 200 반환") + void deleteCoupon_ShouldReturn200() throws Exception { + mockMvc.perform(delete("/api-admin/v1/coupons/{couponId}", couponId) + .header("X-Loopers-Ldap", ADMIN_LDAP)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.meta.result").value("SUCCESS")); + } + + @Test + @DisplayName("없는 쿠폰 삭제 시 404 반환") + void deleteCoupon_NotFound_ShouldReturn404() throws Exception { + mockMvc.perform(delete("/api-admin/v1/coupons/{couponId}", 99999L) + .header("X-Loopers-Ldap", ADMIN_LDAP)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.meta.errorCode").value("COUPON_NOT_FOUND")); + } + } + + @Nested + @DisplayName("GET /api-admin/v1/coupons - 쿠폰 목록 (페이징)") + class GetCouponsPagedTests { + + @Test + @DisplayName("페이징 응답 구조로 반환된다") + void getCoupons_ShouldReturnPagedResponse() throws Exception { + mockMvc.perform(get("/api-admin/v1/coupons") + .header("X-Loopers-Ldap", ADMIN_LDAP) + .param("page", "0") + .param("size", "10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.content").isArray()) + .andExpect(jsonPath("$.data.page").value(0)) + .andExpect(jsonPath("$.data.size").value(10)) + .andExpect(jsonPath("$.data.totalElements").isNumber()) + .andExpect(jsonPath("$.data.totalPages").isNumber()); + } + } + + @Nested + @DisplayName("GET /api-admin/v1/coupons/{couponId}/issues - 발급 내역 (페이징)") + class GetIssueHistoryTests { + + @Test + @DisplayName("발급 내역을 페이징 응답 구조로 반환한다") + void getIssueHistory_ShouldReturnPagedUserCoupons() throws Exception { + // 사용자 등록 및 쿠폰 발급 + registerUser(USER_LOGIN_ID, USER_LOGIN_PW, "관리자테스트유저"); + mockMvc.perform(post("/api/v1/coupons/{couponId}/issue", couponId) + .header("X-Loopers-LoginId", USER_LOGIN_ID) + .header("X-Loopers-LoginPw", USER_LOGIN_PW)); + + mockMvc.perform(get("/api-admin/v1/coupons/{couponId}/issues", couponId) + .header("X-Loopers-Ldap", ADMIN_LDAP) + .param("page", "0") + .param("size", "20")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.content").isArray()) + .andExpect(jsonPath("$.data.page").value(0)) + .andExpect(jsonPath("$.data.size").value(20)) + .andExpect(jsonPath("$.data.totalElements").isNumber()) + .andExpect(jsonPath("$.data.totalPages").isNumber()); + } + } + + private void registerUser(String loginId, String password, String userName) throws Exception { + var request = UserV1Dto.RegisterRequest.builder() + .loginId(loginId) + .password(password) + .userName(userName) + .birthday("19900101") + .email("test@example.com") + .address("서울") + .build(); + + mockMvc.perform(post("/api/v1/users") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/apiadmin/AdminOrderV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/apiadmin/AdminOrderV1ApiE2ETest.java index faea701b0..f1b844e45 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/apiadmin/AdminOrderV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/apiadmin/AdminOrderV1ApiE2ETest.java @@ -38,8 +38,8 @@ class AdminOrderV1ApiE2ETest { @Test @DisplayName("관리자가 모든 사용자의 주문을 조회할 수 있다") void GET_orders_ShouldReturn200WithAllOrders() throws Exception { - orderJpaRepository.save(OrderModel.create("user-1", OrderType.DIRECT, BigDecimal.valueOf(10000))); - orderJpaRepository.save(OrderModel.create("user-2", OrderType.CART, BigDecimal.valueOf(20000))); + orderJpaRepository.save(OrderModel.create(1L, OrderType.DIRECT, BigDecimal.valueOf(10000))); + orderJpaRepository.save(OrderModel.create(2L, OrderType.CART, BigDecimal.valueOf(20000))); mockMvc.perform(get("/api-admin/v1/orders") .header(ADMIN_HEADER, ADMIN_VALUE)) @@ -51,9 +51,9 @@ void GET_orders_ShouldReturn200WithAllOrders() throws Exception { @DisplayName("주문 상세에 스냅샷이 포함된다") void GET_orderDetail_ShouldReturn200WithSnapshots() throws Exception { OrderModel order = orderJpaRepository.save( - OrderModel.create("user-1", OrderType.DIRECT, BigDecimal.valueOf(30000))); + OrderModel.create(1L, OrderType.DIRECT, BigDecimal.valueOf(30000))); orderItemJpaRepository.save(OrderItemModel.create( - order.getOrderId(), 1, "user-1", "p1", 2, + order.getOrderId(), 1, 1L, 1L, 2, "테스트상품", BigDecimal.valueOf(15000), "b1", "테스트브랜드", null)); mockMvc.perform(get("/api-admin/v1/orders/" + order.getOrderId()) diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/apiadmin/AdminProductV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/apiadmin/AdminProductV1ApiE2ETest.java index d6b30c549..836a34251 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/apiadmin/AdminProductV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/apiadmin/AdminProductV1ApiE2ETest.java @@ -71,7 +71,7 @@ void POST_createProduct_ShouldReturn200() throws Exception { @DisplayName("미존재 브랜드로 404 반환") void POST_createProduct_NonExistingBrand_ShouldReturn404() throws Exception { var request = AdminProductV1Dto.CreateProductRequest.builder() - .productName("테스트상품").brandId("nonexistent") + .productName("테스트상품").brandId(999L) .price(BigDecimal.valueOf(10000)).initialStock(100).build(); mockMvc.perform(post("/api-admin/v1/products") diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/apiadmin/AdminStatsV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/apiadmin/AdminStatsV1ApiE2ETest.java index d8491076d..2de23cf4f 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/apiadmin/AdminStatsV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/apiadmin/AdminStatsV1ApiE2ETest.java @@ -58,7 +58,7 @@ void setUp() { @Test @DisplayName("주문 현황 overview 조회") void GET_overview_ShouldReturn200() throws Exception { - orderJpaRepository.save(OrderModel.create("user-1", OrderType.DIRECT, BigDecimal.valueOf(10000))); + orderJpaRepository.save(OrderModel.create(1L, OrderType.DIRECT, BigDecimal.valueOf(10000))); mockMvc.perform(get("/api-admin/v1/stats/overview") .header(ADMIN_HEADER, ADMIN_VALUE) @@ -71,7 +71,7 @@ void GET_overview_ShouldReturn200() throws Exception { @Test @DisplayName("일별 주문 통계 조회") void GET_dailyOrderStats_ShouldReturn200() throws Exception { - orderJpaRepository.save(OrderModel.create("user-1", OrderType.DIRECT, BigDecimal.valueOf(10000))); + orderJpaRepository.save(OrderModel.create(1L, OrderType.DIRECT, BigDecimal.valueOf(10000))); mockMvc.perform(get("/api-admin/v1/stats/orders/daily") .header(ADMIN_HEADER, ADMIN_VALUE) @@ -88,7 +88,7 @@ void GET_topLikedProducts_ShouldReturn200() throws Exception { ProductModel product = productJpaRepository.save( ProductModel.create("인기상품", brand.getBrandId(), BigDecimal.valueOf(10000), null, null, null, null, null, null, null)); - likeJpaRepository.save(LikeModel.create("user-1", product.getProductId())); + likeJpaRepository.save(LikeModel.create(1L, product.getProductId())); mockMvc.perform(get("/api-admin/v1/stats/products/top-liked") .header(ADMIN_HEADER, ADMIN_VALUE) diff --git a/build.gradle.kts b/build.gradle.kts index 9c8490b8a..d6a9c138e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -83,6 +83,12 @@ subprojects { systemProperty("user.timezone", "Asia/Seoul") systemProperty("spring.profiles.active", "test") jvmArgs("-Xshare:off") + // Testcontainers Docker socket (WSL2 + Docker Desktop) + val dockerHost = System.getenv("DOCKER_HOST") + if (!dockerHost.isNullOrBlank()) { + environment("DOCKER_HOST", dockerHost) + environment("TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE", dockerHost.removePrefix("unix://")) + } } tasks.withType { diff --git a/docs/DDL/index.sql b/docs/DDL/index.sql new file mode 100644 index 000000000..e69de29bb diff --git a/k6/lib/helpers.js b/k6/lib/helpers.js new file mode 100644 index 000000000..27bed3f5f --- /dev/null +++ b/k6/lib/helpers.js @@ -0,0 +1,40 @@ +// Zipf 분포: 상위 20% 상품에 80% 트래픽 집중 +export function getProductIdZipf(maxId) { + const rand = Math.random(); + if (rand < 0.8) { + return Math.floor(Math.random() * Math.ceil(maxId * 0.2)) + 1; + } + return Math.floor(Math.random() * Math.floor(maxId * 0.8)) + Math.ceil(maxId * 0.2) + 1; +} + +// 고객 인증 헤더 +export function authHeaders(loginId, loginPw) { + return { + 'Content-Type': 'application/json', + 'X-Loopers-LoginId': loginId, + 'X-Loopers-LoginPw': loginPw, + }; +} + +// 관리자 인증 헤더 +export function adminHeaders() { + return { + 'Content-Type': 'application/json', + 'X-Loopers-Ldap': 'loopers.admin', + }; +} + +// 응답 검증 공통 +export function checkResponse(res, name) { + return { + [`${name} status 200`]: (r) => r.status === 200, + [`${name} has data`]: (r) => { + try { + const body = JSON.parse(r.body); + return body.meta && body.meta.result === 'SUCCESS'; + } catch (e) { + return false; + } + }, + }; +} diff --git a/k6/scripts/product-list-cache.js b/k6/scripts/product-list-cache.js new file mode 100644 index 000000000..41d6c6782 --- /dev/null +++ b/k6/scripts/product-list-cache.js @@ -0,0 +1,48 @@ +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { authHeaders, checkResponse } from '../lib/helpers.js'; + +export const options = { + scenarios: { + list_only: { + executor: 'constant-vus', + vus: 30, + duration: '3m', + }, + }, + thresholds: { + http_req_duration: ['p(95)<150'], + http_req_failed: ['rate<0.001'], + }, +}; + +const BASE = 'http://localhost:8080'; +const HEADERS = authHeaders('testuser', 'Test1234!'); +const SORTS = ['LATEST', 'PRICE_ASC', 'LIKES_DESC']; + +export default function () { + const rand = Math.random(); + + if (rand < 0.6) { + // 60% — 캐시 대상: 무필터 + 첫 1페이지 (page=0) + const page = 0; + const sort = SORTS[Math.floor(Math.random() * 3)]; + const res = http.get(`${BASE}/api/v1/products?page=${page}&size=20&sort=${sort}`, + { headers: HEADERS }); + check(res, checkResponse(res, 'cached-list')); + } else if (rand < 0.85) { + // 25% — 캐시 미대상: keyword 검색 + const keywords = ['나이키', '아디다스', '셔츠', '바지', '가방']; + const keyword = keywords[Math.floor(Math.random() * keywords.length)]; + const res = http.get(`${BASE}/api/v1/products?q=${encodeURIComponent(keyword)}&size=20`, + { headers: HEADERS }); + check(res, checkResponse(res, 'search-list')); + } else { + // 15% — 캐시 미대상: 2페이지 이후 + const page = Math.floor(Math.random() * 49) + 1; // page 1~49 + const res = http.get(`${BASE}/api/v1/products?page=${page}&size=20&sort=LATEST`, + { headers: HEADERS }); + check(res, checkResponse(res, 'deep-page-list')); + } + sleep(0.2); +} diff --git a/k6/scripts/product-mixed.js b/k6/scripts/product-mixed.js new file mode 100644 index 000000000..df1841462 --- /dev/null +++ b/k6/scripts/product-mixed.js @@ -0,0 +1,85 @@ +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { getProductIdZipf, authHeaders, adminHeaders, checkResponse } from '../lib/helpers.js'; + +export const options = { + scenarios: { + readers: { + executor: 'constant-vus', + vus: 35, + duration: '5m', + exec: 'readScenario', + }, + list_readers: { + executor: 'constant-vus', + vus: 10, + duration: '5m', + exec: 'listScenario', + }, + admin_writers: { + executor: 'constant-vus', + vus: 3, + duration: '5m', + exec: 'writeScenario', + }, + orderers: { + executor: 'constant-vus', + vus: 2, + duration: '5m', + exec: 'orderScenario', + }, + }, + thresholds: { + http_req_failed: ['rate<0.01'], + 'http_req_duration{scenario:readers}': ['p(95)<100'], + }, +}; + +const BASE = 'http://localhost:8080'; +const CUSTOMER_HEADERS = authHeaders('testuser', 'Test1234!'); +const ADMIN_HEADERS = adminHeaders(); + +// 70% — 고객 상품 상세 조회 +export function readScenario() { + const productId = getProductIdZipf(100); + const res = http.get(`${BASE}/api/v1/products/${productId}`, { headers: CUSTOMER_HEADERS }); + check(res, checkResponse(res, 'read')); + sleep(0.1); +} + +// 20% — 고객 상품 목록 조회 +export function listScenario() { + const page = Math.floor(Math.random() * 5); + const sort = ['LATEST', 'PRICE_ASC', 'LIKES_DESC'][Math.floor(Math.random() * 3)]; + const res = http.get(`${BASE}/api/v1/products?page=${page}&size=20&sort=${sort}`, + { headers: CUSTOMER_HEADERS }); + check(res, checkResponse(res, 'list')); + sleep(0.5); +} + +// 5% — 관리자 상품 수정 (Cache Evict 유발) +export function writeScenario() { + const productId = Math.floor(Math.random() * 100) + 1; + const payload = JSON.stringify({ + productName: `Updated-${Date.now()}`, + price: Math.floor(Math.random() * 100000) + 1000, + description: 'k6 load test update', + }); + const res = http.put(`${BASE}/api-admin/v1/products/${productId}`, + payload, { headers: ADMIN_HEADERS }); + check(res, { 'admin-update 200': (r) => r.status === 200 }); + sleep(2); // 관리자는 느리게 +} + +// 5% — 고객 주문 (재고 hold) +export function orderScenario() { + const productId = getProductIdZipf(100); + const payload = JSON.stringify({ + orderType: 'DIRECT', + items: [{ productId: productId, quantity: 1 }], + }); + const res = http.post(`${BASE}/api/v1/orders`, payload, { headers: CUSTOMER_HEADERS }); + // 재고 부족은 정상 응답이므로 status만 확인 + check(res, { 'order not 500': (r) => r.status !== 500 }); + sleep(1); +} diff --git a/k6/scripts/product-soak.js b/k6/scripts/product-soak.js new file mode 100644 index 000000000..518d16ef2 --- /dev/null +++ b/k6/scripts/product-soak.js @@ -0,0 +1,27 @@ +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { getProductIdZipf, authHeaders, checkResponse } from '../lib/helpers.js'; + +export const options = { + scenarios: { + soak: { + executor: 'constant-vus', + vus: 100, + duration: '30m', + }, + }, + thresholds: { + http_req_duration: ['p(95)<100', 'p(99)<300'], + http_req_failed: ['rate<0.001'], + }, +}; + +const BASE = 'http://localhost:8080'; +const HEADERS = authHeaders('testuser', 'Test1234!'); + +export default function () { + const productId = getProductIdZipf(100); + const res = http.get(`${BASE}/api/v1/products/${productId}`, { headers: HEADERS }); + check(res, checkResponse(res, 'soak')); + sleep(0.1); +} diff --git a/k6/scripts/product-spike.js b/k6/scripts/product-spike.js new file mode 100644 index 000000000..42b837a0e --- /dev/null +++ b/k6/scripts/product-spike.js @@ -0,0 +1,34 @@ +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { authHeaders, checkResponse } from '../lib/helpers.js'; + +export const options = { + scenarios: { + spike: { + executor: 'ramping-vus', + startVUs: 0, + stages: [ + { duration: '10s', target: 10 }, // 워밍업 + { duration: '5s', target: 200 }, // 5초 만에 200명 급증 + { duration: '30s', target: 200 }, // 30초 유지 + { duration: '10s', target: 0 }, // 쿨다운 + ], + }, + }, + thresholds: { + http_req_duration: ['p(99)<1000'], + http_req_failed: ['rate<0.01'], + }, +}; + +const BASE = 'http://localhost:8080'; +const HEADERS = authHeaders('testuser', 'Test1234!'); +const HOT_PRODUCT_ID = 1; // 단일 상품 집중 + +export default function () { + // 90% 동일 상품 (Hot Key), 10% 랜덤 + const productId = Math.random() < 0.9 ? HOT_PRODUCT_ID : Math.floor(Math.random() * 100) + 1; + const res = http.get(`${BASE}/api/v1/products/${productId}`, { headers: HEADERS }); + check(res, checkResponse(res, 'spike-product')); + sleep(0.05); +} diff --git a/k6/scripts/product-steady.js b/k6/scripts/product-steady.js new file mode 100644 index 000000000..1aed6d54a --- /dev/null +++ b/k6/scripts/product-steady.js @@ -0,0 +1,35 @@ +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { getProductIdZipf, authHeaders, checkResponse } from '../lib/helpers.js'; + +export const options = { + scenarios: { + steady: { + executor: 'constant-vus', + vus: 50, + duration: '5m', + }, + }, + thresholds: { + http_req_duration: ['p(95)<100', 'p(99)<200'], + http_req_failed: ['rate<0.001'], + }, +}; + +const BASE = 'http://localhost:8080'; +const HEADERS = authHeaders('testuser', 'Test1234!'); + +export default function () { + // 80% 상품 상세, 20% 상품 목록 + if (Math.random() < 0.8) { + const productId = getProductIdZipf(100); + const res = http.get(`${BASE}/api/v1/products/${productId}`, { headers: HEADERS }); + check(res, checkResponse(res, 'product-detail')); + } else { + const page = Math.floor(Math.random() * 5); + const sort = ['LATEST', 'PRICE_ASC', 'LIKES_DESC'][Math.floor(Math.random() * 3)]; + const res = http.get(`${BASE}/api/v1/products?page=${page}&size=20&sort=${sort}`, { headers: HEADERS }); + check(res, checkResponse(res, 'product-list')); + } + sleep(0.1); +} diff --git a/k6/seed.sh b/k6/seed.sh new file mode 100644 index 000000000..0acae139e --- /dev/null +++ b/k6/seed.sh @@ -0,0 +1,80 @@ +#!/bin/bash +# k6 부하 테스트를 위한 시드 데이터 적재 스크립트 +# 사전 조건: 앱이 localhost:8080에서 실행 중이어야 합니다. +# +# 생성 데이터: +# - 유저 1명: testuser / Test1234! +# - 브랜드 5개 +# - 상품 100개 (브랜드당 20개, 재고 각 1000개) + +BASE="http://localhost:8080" +ADMIN_HEADER="X-Loopers-Ldap: loopers.admin" + +echo "=== k6 시드 데이터 적재 시작 ===" + +# 1. 유저 생성 +echo "[1/3] 유저 생성..." +curl -s -X POST "${BASE}/api/v1/users" \ + -H "Content-Type: application/json" \ + -d '{ + "loginId": "testuser", + "password": "Test1234!", + "userName": "테스트유저", + "birthday": "19900101", + "email": "test@loopers.com", + "address": "서울시 강남구" + }' | python3 -c "import sys,json; d=json.load(sys.stdin); print(f' 유저 생성: {d.get(\"meta\",{}).get(\"result\",\"UNKNOWN\")}')" 2>/dev/null || echo " 유저 생성 완료 (또는 이미 존재)" + +# 2. 브랜드 5개 생성 +echo "[2/3] 브랜드 5개 생성..." +BRAND_NAMES=("나이키" "아디다스" "구찌" "프라다" "루이비통") +BRAND_IDS=() + +for i in "${!BRAND_NAMES[@]}"; do + RESULT=$(curl -s -X POST "${BASE}/api-admin/v1/brands" \ + -H "Content-Type: application/json" \ + -H "${ADMIN_HEADER}" \ + -d "{ + \"brandName\": \"${BRAND_NAMES[$i]}\", + \"description\": \"${BRAND_NAMES[$i]} 공식 브랜드\", + \"address\": \"서울시 강남구 $((i+1))번길\" + }") + BRAND_ID=$(echo "$RESULT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('data',{}).get('brandId',''))" 2>/dev/null) + if [ -n "$BRAND_ID" ]; then + BRAND_IDS+=("$BRAND_ID") + echo " 브랜드 생성: ${BRAND_NAMES[$i]} (ID: $BRAND_ID)" + else + echo " 브랜드 생성 실패: ${BRAND_NAMES[$i]}" + fi +done + +# 3. 상품 100개 생성 (브랜드당 20개) +echo "[3/3] 상품 100개 생성 (브랜드당 20개, 재고 1000)..." +PRODUCT_COUNT=0 + +for BRAND_ID in "${BRAND_IDS[@]}"; do + for j in $(seq 1 20); do + PRICE=$((10000 + RANDOM % 90000)) + RESULT=$(curl -s -X POST "${BASE}/api-admin/v1/products" \ + -H "Content-Type: application/json" \ + -H "${ADMIN_HEADER}" \ + -d "{ + \"productName\": \"상품-B${BRAND_ID}-P${j}\", + \"brandId\": ${BRAND_ID}, + \"price\": ${PRICE}, + \"description\": \"브랜드${BRAND_ID}의 ${j}번째 상품\", + \"initialStock\": 1000 + }") + PRODUCT_COUNT=$((PRODUCT_COUNT + 1)) + done + echo " 브랜드 ${BRAND_ID}: 20개 상품 생성 완료 (누적: ${PRODUCT_COUNT}개)" +done + +echo "" +echo "=== 시드 데이터 적재 완료 ===" +echo " 유저: 1명 (testuser / Test1234!)" +echo " 브랜드: ${#BRAND_IDS[@]}개" +echo " 상품: ${PRODUCT_COUNT}개 (재고 각 1000개)" +echo "" +echo "k6 실행:" +echo " docker run --rm -i --network host -v \$(pwd)/k6:/scripts grafana/k6 run /scripts/scripts/product-steady.js" diff --git a/modules/.claude/settings.local.json b/modules/.claude/settings.local.json new file mode 100644 index 000000000..399768218 --- /dev/null +++ b/modules/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(cd /mnt/c/Users/kdj10/Git/loop-pack-be-l2-vol3-java && ./gradlew :apps:commerce-api:compileJava :apps:commerce-api:compileTestJava --no-daemon -q 2>&1)" + ] + } +} diff --git a/modules/redis/build.gradle.kts b/modules/redis/build.gradle.kts index 37ad4f6dd..6d6d6d060 100644 --- a/modules/redis/build.gradle.kts +++ b/modules/redis/build.gradle.kts @@ -5,6 +5,7 @@ plugins { dependencies { api("org.springframework.boot:spring-boot-starter-data-redis") + api("org.springframework.boot:spring-boot-starter-cache") testFixturesImplementation("com.redis:testcontainers-redis") } diff --git a/modules/redis/src/main/java/com/loopers/config/redis/RedisConfig.java b/modules/redis/src/main/java/com/loopers/config/redis/RedisConfig.java index 0a2b614ca..65b576e85 100644 --- a/modules/redis/src/main/java/com/loopers/config/redis/RedisConfig.java +++ b/modules/redis/src/main/java/com/loopers/config/redis/RedisConfig.java @@ -75,7 +75,8 @@ private LettuceConnectionFactory lettuceConnectionFactory( List replicas, Consumer customizer ){ - LettuceClientConfiguration.LettuceClientConfigurationBuilder builder = LettuceClientConfiguration.builder(); + LettuceClientConfiguration.LettuceClientConfigurationBuilder builder = LettuceClientConfiguration.builder() + .commandTimeout(java.time.Duration.ofSeconds(2)); if(customizer != null) customizer.accept(builder); LettuceClientConfiguration clientConfig = builder.build(); RedisStaticMasterReplicaConfiguration masterReplicaConfig = new RedisStaticMasterReplicaConfiguration(master.host(), master.port());