diff --git a/.gitignore b/.gitignore index f982a079f..5b2c3eef9 100644 --- a/.gitignore +++ b/.gitignore @@ -45,6 +45,9 @@ claude/* .claude/settings.local.json .interview-state/ +### Static Resources ### +**/src/main/resources/static/ + ### Documentation ### docs/* !docs/design/ diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java index 7ba37e7f2..67e7396e6 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java @@ -5,7 +5,11 @@ import com.loopers.domain.brand.BrandModel; import com.loopers.domain.brand.BrandService; import com.loopers.domain.product.ProductService; +import com.loopers.support.cache.CacheType; import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.cache.annotation.Caching; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; @@ -18,6 +22,7 @@ public class BrandFacade { private final BrandService brandService; private final ProductService productService; + @CacheEvict(cacheNames = CacheType.Names.BRAND_LIST, allEntries = true) @Transactional public void registerBrand(BrandCriteria.Register criteria) { brandService.register(criteria.name()); @@ -29,19 +34,31 @@ public BrandResult getBrand(Long id) { return BrandResult.from(brandModel); } + @Caching(evict = { + @CacheEvict(cacheNames = CacheType.Names.BRAND_LIST, allEntries = true), + @CacheEvict(cacheNames = CacheType.Names.PRODUCT_LIST_LATEST, allEntries = true), + @CacheEvict(cacheNames = CacheType.Names.PRODUCT_LIST_PRICE, allEntries = true), + @CacheEvict(cacheNames = CacheType.Names.PRODUCT_LIST_LIKES, allEntries = true)}) @Transactional public void updateBrand(Long id, BrandCriteria.Update criteria) { brandService.update(id, criteria.name()); } + @Caching(evict = { + @CacheEvict(cacheNames = CacheType.Names.BRAND_LIST, allEntries = true), + @CacheEvict(cacheNames = CacheType.Names.PRODUCT_LIST_LATEST, allEntries = true), + @CacheEvict(cacheNames = CacheType.Names.PRODUCT_LIST_PRICE, allEntries = true), + @CacheEvict(cacheNames = CacheType.Names.PRODUCT_LIST_LIKES, allEntries = true)}) @Transactional public void deleteBrand(Long id) { brandService.delete(id); productService.deleteAllByBrandId(id); } + @Cacheable(cacheNames = CacheType.Names.BRAND_LIST, key = "'all'") @Transactional(readOnly = true) - public Page getBrands(Pageable pageable) { - return brandService.getAll(pageable).map(BrandResult::from); + public BrandResult.ListPage getBrands(Pageable pageable) { + return BrandResult.ListPage.from( + brandService.getAll(pageable).map(BrandResult::from)); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/dto/BrandResult.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/dto/BrandResult.java index fc6540cef..ab15125c5 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/dto/BrandResult.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/dto/BrandResult.java @@ -2,9 +2,26 @@ import com.loopers.domain.brand.BrandModel; import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.List; +import org.springframework.data.domain.Page; public record BrandResult(Long id, String name, ZonedDateTime createdAt, ZonedDateTime updatedAt, ZonedDateTime deletedAt) { public static BrandResult from(BrandModel model) { return new BrandResult(model.getId(), model.getName(), model.getCreatedAt(), model.getUpdatedAt(), model.getDeletedAt()); } + + public record ListPage( + int page, int size, long totalElements, int totalPages, + List items + ) { + public static ListPage from(Page resultPage) { + return new ListPage( + resultPage.getNumber(), + resultPage.getSize(), + resultPage.getTotalElements(), + resultPage.getTotalPages(), + new ArrayList<>(resultPage.getContent())); + } + } } 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 7ccfea241..d9be0efb3 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 @@ -3,17 +3,21 @@ import com.loopers.application.product.dto.ProductCriteria; import com.loopers.application.product.dto.ProductResult; import com.loopers.domain.brand.BrandService; -import com.loopers.domain.product.ProductLikeService; +import com.loopers.domain.product.ImageType; +import com.loopers.domain.product.ProductImageService; import com.loopers.domain.product.ProductModel; import com.loopers.domain.product.ProductService; -import com.loopers.support.util.PaginationUtils; -import java.util.Comparator; -import java.util.List; +import com.loopers.support.cache.CacheType; import java.util.Map; import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.cache.annotation.Caching; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -23,8 +27,12 @@ public class ProductFacade { private final ProductService productService; private final BrandService brandService; - private final ProductLikeService productLikeService; + private final ProductImageService productImageService; + @Caching(evict = { + @CacheEvict(cacheNames = CacheType.Names.PRODUCT_LIST_LATEST, allEntries = true), + @CacheEvict(cacheNames = CacheType.Names.PRODUCT_LIST_PRICE, allEntries = true), + @CacheEvict(cacheNames = CacheType.Names.PRODUCT_LIST_LIKES, allEntries = true)}) @Transactional public void registerProduct(ProductCriteria.Register criteria) { brandService.validateExists(criteria.brandId()); @@ -36,15 +44,37 @@ public ProductResult getProduct(Long id) { ProductModel product = productService.getById(id); return ProductResult.of( product, - brandService.getById(product.getBrandId()).getName(), - productLikeService.countLikes(id)); + brandService.getById(product.getBrandId()).getName()); } + @Transactional(readOnly = true) + public ProductResult.DetailWithImages getProductDetail(Long id) { + ProductModel product = productService.getById(id); + return new ProductResult.DetailWithImages( + ProductResult.of( + product, + brandService.getById(product.getBrandId()).getName()), + productImageService.getImagesByProductIdAndType(id, ImageType.MAIN).stream() + .map(ProductResult.ImageResult::from) + .toList(), + productImageService.getImagesByProductIdAndType(id, ImageType.DETAIL).stream() + .map(ProductResult.ImageResult::from) + .toList()); + } + + @Caching(evict = { + @CacheEvict(cacheNames = CacheType.Names.PRODUCT_LIST_LATEST, allEntries = true), + @CacheEvict(cacheNames = CacheType.Names.PRODUCT_LIST_PRICE, allEntries = true), + @CacheEvict(cacheNames = CacheType.Names.PRODUCT_LIST_LIKES, allEntries = true)}) @Transactional public void updateProduct(Long id, ProductCriteria.Update criteria) { productService.update(id, criteria.name(), criteria.price(), criteria.stock()); } + @Caching(evict = { + @CacheEvict(cacheNames = CacheType.Names.PRODUCT_LIST_LATEST, allEntries = true), + @CacheEvict(cacheNames = CacheType.Names.PRODUCT_LIST_PRICE, allEntries = true), + @CacheEvict(cacheNames = CacheType.Names.PRODUCT_LIST_LIKES, allEntries = true)}) @Transactional public void deleteProduct(Long id) { productService.delete(id); @@ -69,7 +99,10 @@ public Page getProductsByBrandId(Long brandId, Pageable pageable) public Page getProductsWithActiveBrand(Pageable pageable) { Page products = productService.getAll(pageable); return new PageImpl<>( - toResultsWithActiveBrand(products.getContent()), + ProductResult.fromWithActiveBrand( + products.getContent(), + brandService.getActiveNameMapByIds( + ProductModel.extractDistinctBrandIds(products.getContent()))), products.getPageable(), products.getTotalElements()); } @@ -78,34 +111,70 @@ public Page getProductsWithActiveBrand(Pageable pageable) { public Page getProductsWithActiveBrandByBrandId(Long brandId, Pageable pageable) { Page products = productService.getAllByBrandId(brandId, pageable); return new PageImpl<>( - toResultsWithActiveBrand(products.getContent()), + ProductResult.fromWithActiveBrand( + products.getContent(), + brandService.getActiveNameMapByIds( + ProductModel.extractDistinctBrandIds(products.getContent()))), products.getPageable(), products.getTotalElements()); } @Transactional(readOnly = true) public Page getProductsWithActiveBrandSortedByLikes(int page, int size) { - return toPageSortedByLikes(productService.getAll(), page, size); + Page products = productService.getAllSortedByLikeCountDesc( + PageRequest.of(page, size)); + return new PageImpl<>( + ProductResult.fromWithActiveBrand( + products.getContent(), + brandService.getActiveNameMapByIds( + ProductModel.extractDistinctBrandIds(products.getContent()))), + products.getPageable(), + products.getTotalElements()); } @Transactional(readOnly = true) - public Page getProductsWithActiveBrandByBrandIdSortedByLikes(Long brandId, int page, int size) { - return toPageSortedByLikes(productService.getAllByBrandId(brandId), page, size); + public Page getProductsWithActiveBrandByBrandIdSortedByLikes( + Long brandId, int page, int size) { + Page products = productService.getAllByBrandIdSortedByLikeCountDesc( + brandId, PageRequest.of(page, size)); + return new PageImpl<>( + ProductResult.fromWithActiveBrand( + products.getContent(), + brandService.getActiveNameMapByIds( + ProductModel.extractDistinctBrandIds(products.getContent()))), + products.getPageable(), + products.getTotalElements()); } - private Page toPageSortedByLikes(List products, int page, int size) { - List sorted = toResultsWithActiveBrand(products).stream() - .sorted(Comparator.comparingLong(ProductResult::likeCount).reversed()) - .toList(); - return PaginationUtils.toPage(sorted, page, size); + @Cacheable(cacheNames = CacheType.Names.PRODUCT_LIST_LATEST, + key = "(#brandId ?: 'all') + ':' + #page + ':' + #size") + @Transactional(readOnly = true) + public ProductResult.ListPage getProductListLatest(Long brandId, int page, int size) { + Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")); + return ProductResult.ListPage.from(brandId != null + ? getProductsWithActiveBrandByBrandId(brandId, pageable) + : getProductsWithActiveBrand(pageable)); } - private List toResultsWithActiveBrand(List products) { - return ProductResult.fromWithActiveBrand( - products, - brandService.getActiveNameMapByIds( - ProductModel.extractDistinctBrandIds(products)), - productLikeService.countLikesByProductIds( - ProductModel.extractIds(products))); + @Cacheable(cacheNames = CacheType.Names.PRODUCT_LIST_PRICE, + key = "#sort + ':' + (#brandId ?: 'all') + ':' + #page + ':' + #size") + @Transactional(readOnly = true) + public ProductResult.ListPage getProductListByPrice( + Long brandId, String sort, int page, int size) { + Sort sortOrder = "price_asc".equals(sort) + ? Sort.by(Sort.Direction.ASC, "price") + : Sort.by(Sort.Direction.DESC, "price"); + return ProductResult.ListPage.from(brandId != null + ? getProductsWithActiveBrandByBrandId(brandId, PageRequest.of(page, size, sortOrder)) + : getProductsWithActiveBrand(PageRequest.of(page, size, sortOrder))); + } + + @Cacheable(cacheNames = CacheType.Names.PRODUCT_LIST_LIKES, + key = "(#brandId ?: 'all') + ':' + #page + ':' + #size") + @Transactional(readOnly = true) + public ProductResult.ListPage getProductListByLikes(Long brandId, int page, int size) { + return ProductResult.ListPage.from(brandId != null + ? getProductsWithActiveBrandByBrandIdSortedByLikes(brandId, page, size) + : getProductsWithActiveBrandSortedByLikes(page, size)); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductLikeFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductLikeFacade.java index 9cbd03099..bcc847306 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductLikeFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductLikeFacade.java @@ -18,11 +18,13 @@ public class ProductLikeFacade { public void like(Long userId, Long productId) { productService.validateExists(productId); productLikeService.like(userId, productId); + productService.incrementLikeCount(productId); } @Transactional public void unlike(Long userId, Long productId) { productLikeService.unlike(userId, productId); + productService.decrementLikeCount(productId); } @Transactional(readOnly = true) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/dto/ProductResult.java b/apps/commerce-api/src/main/java/com/loopers/application/product/dto/ProductResult.java index a92008208..e9a7e9bdf 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/dto/ProductResult.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/dto/ProductResult.java @@ -1,9 +1,13 @@ package com.loopers.application.product.dto; +import com.loopers.domain.product.ImageType; +import com.loopers.domain.product.ProductImageModel; import com.loopers.domain.product.ProductModel; import java.time.ZonedDateTime; +import java.util.ArrayList; import java.util.List; import java.util.Map; +import org.springframework.data.domain.Page; public record ProductResult( Long id, @@ -13,15 +17,12 @@ public record ProductResult( int price, int stock, long likeCount, + String thumbnailUrl, ZonedDateTime createdAt, ZonedDateTime updatedAt, ZonedDateTime deletedAt ) { public static ProductResult of(ProductModel model, String brandName) { - return of(model, brandName, 0L); - } - - public static ProductResult of(ProductModel model, String brandName, long likeCount) { return new ProductResult( model.getId(), model.getBrandId(), @@ -29,21 +30,47 @@ public static ProductResult of(ProductModel model, String brandName, long likeCo model.getName(), model.getPrice(), model.getStock(), - likeCount, + model.getLikeCount(), + model.getThumbnailUrl(), model.getCreatedAt(), model.getUpdatedAt(), model.getDeletedAt()); } public static List fromWithActiveBrand( - List products, Map brandNameMap, - Map likeCountMap) { + List products, Map brandNameMap) { return products.stream() .filter(product -> brandNameMap.containsKey(product.getBrandId())) .map(product -> ProductResult.of( - product, - brandNameMap.get(product.getBrandId()), - likeCountMap.getOrDefault(product.getId(), 0L))) + product, brandNameMap.get(product.getBrandId()))) .toList(); } + + public record ImageResult(Long id, String imageUrl, ImageType imageType, int sortOrder) { + public static ImageResult from(ProductImageModel model) { + return new ImageResult( + model.getId(), model.getImageUrl(), model.getImageType(), model.getSortOrder()); + } + } + + public record ListPage( + int page, int size, long totalElements, int totalPages, + List items + ) { + public static ListPage from(Page resultPage) { + return new ListPage( + resultPage.getNumber(), + resultPage.getSize(), + resultPage.getTotalElements(), + resultPage.getTotalPages(), + new ArrayList<>(resultPage.getContent())); + } + } + + public record DetailWithImages( + ProductResult product, + List mainImages, + List detailImages + ) { + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/dto/UserResult.java b/apps/commerce-api/src/main/java/com/loopers/application/user/dto/UserResult.java index 6c530ddc1..f1f24a288 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/user/dto/UserResult.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/dto/UserResult.java @@ -7,7 +7,8 @@ public record UserResult( String loginId, String name, String birthDate, - String email + String email, + long point ) { public static UserResult from(UserModel model) { return new UserResult( @@ -15,6 +16,7 @@ public static UserResult from(UserModel model) { model.getLoginId(), model.getName(), model.getBirthDateString(), - model.getEmail()); + model.getEmail(), + model.getPoint()); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ImageType.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ImageType.java new file mode 100644 index 000000000..f5fa177f0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ImageType.java @@ -0,0 +1,6 @@ +package com.loopers.domain.product; + +public enum ImageType { + MAIN, + DETAIL +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductImageModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductImageModel.java new file mode 100644 index 000000000..78cae67fc --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductImageModel.java @@ -0,0 +1,78 @@ +package com.loopers.domain.product; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "product_images", indexes = { + @Index(name = "idx_product_images_product_id", columnList = "product_id")}) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ProductImageModel extends BaseEntity { + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "image_url", nullable = false) + private String imageUrl; + + @Enumerated(EnumType.STRING) + @Column(name = "image_type", nullable = false) + private ImageType imageType; + + @Column(name = "sort_order", nullable = false) + private int sortOrder; + + // === 생성 === // + + private ProductImageModel(Long productId, String imageUrl, ImageType imageType, int sortOrder) { + this.productId = productId; + this.imageUrl = imageUrl; + this.imageType = imageType; + this.sortOrder = sortOrder; + } + + public static ProductImageModel create(Long productId, String imageUrl, ImageType imageType, int sortOrder) { + validateProductId(productId); + validateImageUrl(imageUrl); + validateImageType(imageType); + validateSortOrder(sortOrder); + return new ProductImageModel(productId, imageUrl, imageType, sortOrder); + } + + // === 검증 === // + + private static void validateProductId(Long productId) { + if (productId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 ID는 필수값입니다."); + } + } + + private static void validateImageUrl(String imageUrl) { + if (imageUrl == null || imageUrl.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "이미지 URL은 필수값입니다."); + } + } + + private static void validateImageType(ImageType imageType) { + if (imageType == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "이미지 타입은 필수값입니다."); + } + } + + private static void validateSortOrder(int sortOrder) { + if (sortOrder < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "정렬 순서는 0 이상이어야 합니다."); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductImageRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductImageRepository.java new file mode 100644 index 000000000..9bd028971 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductImageRepository.java @@ -0,0 +1,14 @@ +package com.loopers.domain.product; + +import java.util.List; + +public interface ProductImageRepository { + + ProductImageModel save(ProductImageModel image); + + List findAllByProductId(Long productId); + + List findAllByProductIdAndImageType(Long productId, ImageType imageType); + + void deleteAllByProductId(Long productId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductImageService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductImageService.java new file mode 100644 index 000000000..51a657248 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductImageService.java @@ -0,0 +1,34 @@ +package com.loopers.domain.product; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class ProductImageService { + + private final ProductImageRepository productImageRepository; + + @Transactional + public ProductImageModel addImage(Long productId, String imageUrl, ImageType imageType, int sortOrder) { + return productImageRepository.save( + ProductImageModel.create(productId, imageUrl, imageType, sortOrder)); + } + + @Transactional(readOnly = true) + public List getImagesByProductId(Long productId) { + return productImageRepository.findAllByProductId(productId); + } + + @Transactional(readOnly = true) + public List getImagesByProductIdAndType(Long productId, ImageType imageType) { + return productImageRepository.findAllByProductIdAndImageType(productId, imageType); + } + + @Transactional + public void deleteAllByProductId(Long productId) { + productImageRepository.deleteAllByProductId(productId); + } +} 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 c85166c52..73531e840 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 @@ -5,6 +5,7 @@ import com.loopers.support.error.ErrorType; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.Index; import jakarta.persistence.Table; import jakarta.persistence.Version; import java.util.List; @@ -14,7 +15,13 @@ @Getter @Entity -@Table(name = "products") +@Table(name = "products", indexes = { + @Index(name = "idx_products_deleted_like", columnList = "deleted_at, like_count"), + @Index(name = "idx_products_deleted_price", columnList = "deleted_at, price"), + @Index(name = "idx_products_deleted_created", columnList = "deleted_at, created_at DESC"), + @Index(name = "idx_products_brand_deleted_like", columnList = "brand_id, deleted_at, like_count"), + @Index(name = "idx_products_brand_deleted_price", columnList = "brand_id, deleted_at, price"), + @Index(name = "idx_products_brand_deleted_created", columnList = "brand_id, deleted_at, created_at DESC")}) @NoArgsConstructor(access = AccessLevel.PROTECTED) public class ProductModel extends BaseEntity { @@ -30,10 +37,15 @@ public class ProductModel extends BaseEntity { @Column(name = "stock", nullable = false) private int stock; + @Column(name = "like_count", nullable = false) + private int likeCount; + + @Column(name = "thumbnail_url") + private String thumbnailUrl; + @Version private Long version; - // === 생성 === // private ProductModel(Long brandId, String name, int price, int stock) { @@ -89,6 +101,20 @@ public boolean isSoldOut() { return this.stock == 0; } + public void addLikeCount() { + this.likeCount++; + } + + public void subtractLikeCount() { + if (this.likeCount > 0) { + this.likeCount--; + } + } + + public void updateThumbnailUrl(String thumbnailUrl) { + this.thumbnailUrl = thumbnailUrl; + } + // === 컬렉션 유틸 === // public static List extractDistinctBrandIds(List products) { 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 fafe25406..1b87eb677 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 @@ -19,4 +19,12 @@ public interface ProductRepository { List findAllByIdIn(List ids); List findAll(); + + Page findAllSortedByLikeCountDesc(Pageable pageable); + + Page findAllByBrandIdSortedByLikeCountDesc(Long brandId, Pageable pageable); + + void incrementLikeCount(Long id); + + void decrementLikeCount(Long id); } 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 caf676bd9..fad495c99 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 @@ -90,6 +90,26 @@ public void increaseStock(Long productId, int quantity) { getById(productId).increaseStock(quantity); } + @Transactional(readOnly = true) + public Page getAllSortedByLikeCountDesc(Pageable pageable) { + return productRepository.findAllSortedByLikeCountDesc(pageable); + } + + @Transactional(readOnly = true) + public Page getAllByBrandIdSortedByLikeCountDesc(Long brandId, Pageable pageable) { + return productRepository.findAllByBrandIdSortedByLikeCountDesc(brandId, pageable); + } + + @Transactional + public void incrementLikeCount(Long id) { + productRepository.incrementLikeCount(id); + } + + @Transactional + public void decrementLikeCount(Long id) { + productRepository.decrementLikeCount(id); + } + @Transactional public List validateAndDeductStock( List commands) { 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 0aa7638cd..ed0956388 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 @@ -1,6 +1,9 @@ package com.loopers.domain.user; +import java.util.List; import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; public interface UserRepository { UserModel save(UserModel userModel); @@ -8,4 +11,8 @@ public interface UserRepository { Optional findById(Long id); Optional findByLoginId(String loginId); + + Page findAll(Pageable pageable); + + List findAll(); } 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 2d57eb789..d46a90ee8 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 @@ -4,8 +4,11 @@ import com.loopers.support.error.ErrorType; import java.time.LocalDate; import java.time.format.DateTimeFormatter; +import java.util.List; import java.util.regex.Pattern; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -58,6 +61,13 @@ public void changePassword(String loginId, String rawCurrentPassword, String raw userRepository.save(user); } + @Transactional + public void addPoint(Long userId, long amount) { + UserModel user = userRepository.findById(userId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다.")); + user.addPoint(amount); + } + @Transactional public void deductPoint(Long userId, long amount) { UserModel user = userRepository.findById(userId) @@ -65,6 +75,22 @@ public void deductPoint(Long userId, long amount) { user.deductPoint(amount); } + @Transactional(readOnly = true) + public UserModel getById(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다.")); + } + + @Transactional(readOnly = true) + public Page getUsers(Pageable pageable) { + return userRepository.findAll(pageable); + } + + @Transactional(readOnly = true) + public List getAllUsers() { + return userRepository.findAll(); + } + private void validatePasswordFormat(String rawPassword) { if (rawPassword == null || !PASSWORD_PATTERN.matcher(rawPassword).matches()) { throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 8~16자의 영문 대소문자, 숫자, 특수문자 조합이어야 합니다."); diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/datagenerator/BulkDataGeneratorProperties.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/datagenerator/BulkDataGeneratorProperties.java new file mode 100644 index 000000000..c60b4300f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/datagenerator/BulkDataGeneratorProperties.java @@ -0,0 +1,35 @@ +package com.loopers.infrastructure.datagenerator; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "app.data-generator") +public record BulkDataGeneratorProperties( + boolean enabled, + int brandCount, + int productCount, + int userCount, + int likeCount, + int orderCount, + SoftDelete softDelete) { + + public BulkDataGeneratorProperties { + if (brandCount <= 0) brandCount = 100; + if (productCount <= 0) productCount = 100_000; + if (userCount <= 0) userCount = 10_000; + if (likeCount <= 0) likeCount = 500_000; + if (orderCount <= 0) orderCount = 100_000; + if (softDelete == null) softDelete = new SoftDelete(10, 3, 2); + } + + public record SoftDelete( + int brandPercent, + int productWithoutLikePercent, + int productWithLikePercent) { + + public SoftDelete { + if (brandPercent <= 0) brandPercent = 10; + if (productWithoutLikePercent <= 0) productWithoutLikePercent = 3; + if (productWithLikePercent <= 0) productWithLikePercent = 2; + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/datagenerator/BulkDataGeneratorRunner.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/datagenerator/BulkDataGeneratorRunner.java new file mode 100644 index 000000000..4d07c566c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/datagenerator/BulkDataGeneratorRunner.java @@ -0,0 +1,20 @@ +package com.loopers.infrastructure.datagenerator; + +import lombok.RequiredArgsConstructor; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@ConditionalOnProperty(name = "app.data-generator.enabled", havingValue = "true") +public class BulkDataGeneratorRunner implements ApplicationRunner { + + private final BulkDataGeneratorService bulkDataGeneratorService; + + @Override + public void run(ApplicationArguments args) { + bulkDataGeneratorService.generateAll(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/datagenerator/BulkDataGeneratorService.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/datagenerator/BulkDataGeneratorService.java new file mode 100644 index 000000000..cdb1e379c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/datagenerator/BulkDataGeneratorService.java @@ -0,0 +1,467 @@ +package com.loopers.infrastructure.datagenerator; + +import com.loopers.domain.user.PasswordEncoder; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class BulkDataGeneratorService { + + private final DataGeneratorRepository dataGeneratorRepository; + private final PasswordEncoder passwordEncoder; + private final BulkDataGeneratorProperties properties; + + private volatile boolean running = false; + + public boolean isRunning() { + return running; + } + + public void generateAll() { + if (running) { + log.warn("BulkDataGenerator is already running. Skipping."); + return; + } + running = true; + try { + log.info("=== BulkDataGenerator START === brands={}, products={}, users={}, likes={}, orders={}", + properties.brandCount(), properties.productCount(), + properties.userCount(), properties.likeCount(), properties.orderCount()); + + long totalStart = System.currentTimeMillis(); + + long totalBrands = dataGeneratorRepository.countAllInTable("brands"); + long totalProducts = dataGeneratorRepository.countAllInTable("products"); + long totalUsers = dataGeneratorRepository.countAllInTable("users"); + long totalLikes = dataGeneratorRepository.countAllInTable("likes"); + long totalOrders = dataGeneratorRepository.countAllInTable("orders"); + + // Phase 1: Brands + if (totalBrands < properties.brandCount()) { + generateBrands(); + totalBrands = dataGeneratorRepository.countAllInTable("brands"); + } else { + log.info(" Phase 1: Brands {} already exist. Skipping.", totalBrands); + } + + // Phase 1.5: Soft delete 10% brands + if (dataGeneratorRepository.findDeletedBrandIds().isEmpty() && totalBrands >= properties.brandCount()) { + List activeBrandIds = dataGeneratorRepository.findAllBrandIds(); + softDeleteBrands(activeBrandIds); + } else { + log.info(" Phase 1.5: Brand soft-delete already applied. Skipping."); + } + + // Phase 2: Products + if (totalProducts < properties.productCount()) { + generateProducts(dataGeneratorRepository.findAllBrandIds()); + totalProducts = dataGeneratorRepository.countAllInTable("products"); + } else { + log.info(" Phase 2: Products {} already exist. Skipping.", totalProducts); + } + + // Phase 2.5: Soft delete products Wave 1 + if (totalProducts >= properties.productCount() + && dataGeneratorRepository.getStats().get("productCount") >= properties.productCount()) { + softDeleteProductsWave1(); + } else if (totalProducts >= properties.productCount()) { + log.info(" Phase 2.5: Product soft-delete Wave 1 already applied. Skipping."); + } + + // Phase 2.7: Product Images + long totalProductImages = dataGeneratorRepository.countAllInTable("product_images"); + if (totalProductImages == 0 && totalProducts >= properties.productCount()) { + generateProductImages(); + } else { + log.info(" Phase 2.7: Product images {} already exist. Skipping.", totalProductImages); + } + + // Phase 3: Users + if (totalUsers < properties.userCount()) { + generateUsers(); + } else { + log.info(" Phase 3: Users {} already exist. Skipping.", totalUsers); + } + + // Phase 4: Likes + if (totalLikes < properties.likeCount()) { + generateLikes(); + } else { + log.info(" Phase 4: Likes {} already exist. Skipping.", totalLikes); + } + + // Phase 4.5: Soft delete products Wave 2 + long deletedProducts = totalProducts - dataGeneratorRepository.getStats().get("productCount"); + // Wave 1 삭제 ~13K, Wave 2 추가 2K = 총 ~15K + if (totalProducts >= properties.productCount() && deletedProducts > 0 && deletedProducts < 14_000) { + softDeleteProductsWave2(); + } else if (deletedProducts >= 14_000) { + log.info(" Phase 4.5: Product soft-delete Wave 2 already applied. Skipping."); + } + + // Phase 5: Orders + if (totalOrders < properties.orderCount()) { + generateOrders(); + } else { + log.info(" Phase 5: Orders {} already exist. Skipping.", totalOrders); + } + + // Phase 6: Sync like_count + syncLikeCounts(); + + Map finalStats = dataGeneratorRepository.getStats(); + log.info("=== BulkDataGenerator DONE === elapsed={}s | brands={}, products={}, users={}, likes={}, orders={}", + elapsed(totalStart), + finalStats.get("brandCount"), finalStats.get("productCount"), + finalStats.get("userCount"), finalStats.get("likeCount"), finalStats.get("orderCount")); + } finally { + running = false; + } + } + + private List generateBrands() { + long start = System.currentTimeMillis(); + dataGeneratorRepository.batchInsertBrands( + FashionDataPool.BRAND_NAMES.subList( + 0, Math.min(properties.brandCount(), FashionDataPool.BRAND_NAMES.size()))); + List brandIds = dataGeneratorRepository.findAllBrandIds(); + log.info(" Phase 1: Brands {} created ({}s)", brandIds.size(), elapsed(start)); + return brandIds; + } + + private void softDeleteBrands(List allBrandIds) { + long start = System.currentTimeMillis(); + int deleteCount = allBrandIds.size() * properties.softDelete().brandPercent() / 100; + dataGeneratorRepository.batchSoftDeleteBrands( + allBrandIds.subList(allBrandIds.size() - deleteCount, allBrandIds.size())); + log.info(" Phase 1.5: Brands {} soft-deleted ({}s)", deleteCount, elapsed(start)); + } + + private void generateProducts(List activeBrandIds) { + if (activeBrandIds.isEmpty()) { + log.warn(" Phase 2: No active brands available. Skipping product generation."); + return; + } + + long start = System.currentTimeMillis(); + Random random = new Random(42); + int totalProducts = properties.productCount(); + int batchSize = 10_000; + + // 브랜드별 상품 할당량 계산 + int[] productsPerBrand = new int[activeBrandIds.size()]; + int allocated = 0; + for (int i = 0; i < activeBrandIds.size(); i++) { + productsPerBrand[i] = FashionDataPool.productsPerBrand(i, totalProducts, random); + allocated += productsPerBrand[i]; + } + + // 총합을 totalProducts에 맞춤 + double scale = (double) totalProducts / allocated; + allocated = 0; + for (int i = 0; i < productsPerBrand.length; i++) { + productsPerBrand[i] = Math.max(1, (int) (productsPerBrand[i] * scale)); + allocated += productsPerBrand[i]; + } + // 나머지를 첫 번째 브랜드에 조정 + productsPerBrand[0] += totalProducts - allocated; + + List batch = new ArrayList<>(batchSize); + int created = 0; + + for (int brandIdx = 0; brandIdx < activeBrandIds.size(); brandIdx++) { + Long brandId = activeBrandIds.get(brandIdx); + int count = productsPerBrand[brandIdx]; + + for (int j = 0; j < count; j++) { + FashionDataPool.Category category = FashionDataPool.pickCategory(random); + String name = FashionDataPool.generateProductName(category, random); + int price = FashionDataPool.generatePrice(category, random); + int stock = FashionDataPool.generateStock(category, random); + + batch.add(new Object[]{brandId, name, price, stock, "/image.png"}); + + if (batch.size() >= batchSize) { + dataGeneratorRepository.batchInsertProducts(batch); + created += batch.size(); + batch.clear(); + log.info(" Phase 2: Products {}/{} ({}s)", created, totalProducts, elapsed(start)); + } + } + } + + if (!batch.isEmpty()) { + dataGeneratorRepository.batchInsertProducts(batch); + created += batch.size(); + batch.clear(); + } + + log.info(" Phase 2: Products {} created ({}s)", created, elapsed(start)); + } + + private void generateProductImages() { + long start = System.currentTimeMillis(); + List productIds = dataGeneratorRepository.findAllProductIds(); + if (productIds.isEmpty()) { + log.warn(" Phase 2.7: No active products. Skipping image generation."); + return; + } + + String imageUrl = "/image.png"; + int batchSize = 10_000; + List batch = new ArrayList<>(batchSize); + int created = 0; + + for (Long productId : productIds) { + // MAIN 이미지 2장 + for (int i = 0; i < 2; i++) { + batch.add(new Object[]{productId, imageUrl, "MAIN", i}); + } + // DETAIL 이미지 3장 + for (int i = 0; i < 3; i++) { + batch.add(new Object[]{productId, imageUrl, "DETAIL", i}); + } + + if (batch.size() >= batchSize) { + dataGeneratorRepository.batchInsertProductImages(batch); + created += batch.size(); + batch.clear(); + log.info(" Phase 2.7: Product images {}/{} ({}s)", + created, productIds.size() * 5, elapsed(start)); + } + } + + if (!batch.isEmpty()) { + dataGeneratorRepository.batchInsertProductImages(batch); + created += batch.size(); + batch.clear(); + } + + log.info(" Phase 2.7: Product images {} created ({}s)", created, elapsed(start)); + } + + private void softDeleteProductsWave1() { + long start = System.currentTimeMillis(); + + // 삭제된 브랜드 소속 상품 연쇄 삭제 + List deletedBrandProductIds = findDeletedBrandProductIds(); + if (!deletedBrandProductIds.isEmpty()) { + dataGeneratorRepository.batchSoftDeleteProducts(deletedBrandProductIds); + log.info(" Phase 2.5: Cascade-deleted {} products from deleted brands ({}s)", + deletedBrandProductIds.size(), elapsed(start)); + } + + // 활성 브랜드의 오래된 시즌 상품 단종 + int beforeLikeCount = properties.productCount() * properties.softDelete().productWithoutLikePercent() / 100; + List oldProductIds = dataGeneratorRepository.findOldestActiveProductIds(beforeLikeCount); + if (!oldProductIds.isEmpty()) { + dataGeneratorRepository.batchSoftDeleteProducts(oldProductIds); + log.info(" Phase 2.5: Discontinued {} old season products ({}s)", + oldProductIds.size(), elapsed(start)); + } + + log.info(" Phase 2.5: Product soft-delete Wave 1 completed ({}s)", elapsed(start)); + } + + private List findDeletedBrandProductIds() { + // 삭제된 브랜드의 ID 조회 → 해당 브랜드 상품 ID 조회 + List deletedBrandIds = dataGeneratorRepository.findDeletedBrandIds(); + if (deletedBrandIds.isEmpty()) return List.of(); + return dataGeneratorRepository.findProductIdsByBrandIds(deletedBrandIds); + } + + private void softDeleteProductsWave2() { + long start = System.currentTimeMillis(); + // 좋아요가 있지만 단종된 상품 (like_count 낮은 순) + int afterLikeCount = properties.productCount() * properties.softDelete().productWithLikePercent() / 100; + List productsWithLikes = dataGeneratorRepository.findActiveProductIdsWithLikes(afterLikeCount); + if (!productsWithLikes.isEmpty()) { + dataGeneratorRepository.batchSoftDeleteProducts(productsWithLikes); + log.info(" Phase 4.5: Discontinued {} products with likes ({}s)", + productsWithLikes.size(), elapsed(start)); + } + log.info(" Phase 4.5: Product soft-delete Wave 2 completed ({}s)", elapsed(start)); + } + + private void generateUsers() { + long start = System.currentTimeMillis(); + String encodedPassword = passwordEncoder.encode("Test1234!"); + int created = dataGeneratorRepository.batchInsertUsers( + "bulk", properties.userCount(), encodedPassword, 1_000_000L); + log.info(" Phase 3: Users {} created ({}s)", created, elapsed(start)); + } + + private void generateLikes() { + long start = System.currentTimeMillis(); + List productIds = dataGeneratorRepository.findAllProductIds(); + List userIds = dataGeneratorRepository.findAllUserIds(); + + if (productIds.isEmpty() || userIds.isEmpty()) { + log.warn(" Phase 4: No products or users. Skipping like generation."); + return; + } + + int totalLikes = properties.likeCount(); + + // Zipf distribution (exponent 1.2): 상위 1% 상품이 ~70% 좋아요 차지 + double[] weights = new double[productIds.size()]; + double sumWeights = 0; + for (int i = 0; i < productIds.size(); i++) { + weights[i] = 1.0 / Math.pow(i + 1, 1.2); + sumWeights += weights[i]; + } + + int[] likesPerProduct = new int[productIds.size()]; + int assigned = 0; + for (int i = 0; i < productIds.size() && assigned < totalLikes; i++) { + likesPerProduct[i] = Math.min( + (int) (weights[i] / sumWeights * totalLikes), + userIds.size()); + assigned += likesPerProduct[i]; + } + for (int i = 0; assigned < totalLikes && i < productIds.size(); i++) { + if (likesPerProduct[i] < userIds.size()) { + likesPerProduct[i]++; + assigned++; + } + } + + // Generate pairs in chunks + Random random = new Random(42); + int chunkSize = 50_000; + List chunk = new ArrayList<>(chunkSize); + int totalCreated = 0; + + for (int i = 0; i < productIds.size(); i++) { + int count = likesPerProduct[i]; + if (count == 0) continue; + + long productId = productIds.get(i); + Set selectedUserIndices = new HashSet<>(); + int attempts = 0; + while (selectedUserIndices.size() < count && attempts < count * 3) { + selectedUserIndices.add(random.nextInt(userIds.size())); + attempts++; + } + + for (int userIndex : selectedUserIndices) { + chunk.add(new long[]{userIds.get(userIndex), productId}); + + if (chunk.size() >= chunkSize) { + dataGeneratorRepository.batchInsertLikes(chunk); + totalCreated += chunk.size(); + chunk.clear(); + log.info(" Phase 4: Likes {}/{} ({}s)", totalCreated, totalLikes, elapsed(start)); + } + } + } + + if (!chunk.isEmpty()) { + dataGeneratorRepository.batchInsertLikes(chunk); + totalCreated += chunk.size(); + chunk.clear(); + } + + log.info(" Phase 4: Likes {} created ({}s)", totalCreated, elapsed(start)); + } + + private void generateOrders() { + long start = System.currentTimeMillis(); + List userIds = dataGeneratorRepository.findAllUserIds(); + // 좋아요 순 정렬된 상품 풀 (인덱스 0 = 가장 인기 많은 상품) + List> productPool = dataGeneratorRepository.findProductsForOrders(5000); + + if (userIds.isEmpty() || productPool.isEmpty()) { + log.warn(" Phase 5: No users or products. Skipping order generation."); + return; + } + + Random random = new Random(42); + int totalOrders = properties.orderCount(); + int ordersPerUser = Math.max(1, totalOrders / userIds.size()); + int chunkSize = 1000; + int created = 0; + int poolSize = productPool.size(); + + List orderBatch = new ArrayList<>(chunkSize); + List> itemsPerOrder = new ArrayList<>(chunkSize); + + for (Long userId : userIds) { + if (created + orderBatch.size() >= totalOrders) break; + + for (int o = 0; o < ordersPerUser && created + orderBatch.size() < totalOrders; o++) { + int itemCount = 1 + random.nextInt(3); + int totalPrice = 0; + List items = new ArrayList<>(itemCount); + + for (int i = 0; i < itemCount; i++) { + // Power-law: 인기 상품(낮은 인덱스)일수록 더 자주 선택 + int productIndex = (int) (Math.pow(random.nextDouble(), 1.5) * poolSize); + Map product = productPool.get(productIndex); + int price = ((Number) product.get("price")).intValue(); + int qty = 1 + random.nextInt(3); + totalPrice += price * qty; + items.add(new Object[]{ + ((Number) product.get("id")).longValue(), + price, qty, + (String) product.get("product_name"), + (String) product.get("brand_name"), + "ORDERED"}); + } + + orderBatch.add(new Object[]{userId, totalPrice, totalPrice, "ORDERED", 0}); + itemsPerOrder.add(items); + } + + if (orderBatch.size() >= chunkSize) { + created += flushOrderBatch(orderBatch, itemsPerOrder); + log.info(" Phase 5: Orders {}/{} ({}s)", created, totalOrders, elapsed(start)); + } + } + + if (!orderBatch.isEmpty()) { + created += flushOrderBatch(orderBatch, itemsPerOrder); + } + + log.info(" Phase 5: Orders {} created ({}s)", created, elapsed(start)); + } + + private void syncLikeCounts() { + long start = System.currentTimeMillis(); + dataGeneratorRepository.syncLikeCounts(); + log.info(" Phase 6: like_count synced from likes table ({}s)", elapsed(start)); + } + + private int flushOrderBatch(List orderBatch, List> itemsPerOrder) { + long maxId = dataGeneratorRepository.getMaxOrderId(); + dataGeneratorRepository.batchInsertOrders(orderBatch); + + List allItems = new ArrayList<>(); + for (int i = 0; i < orderBatch.size(); i++) { + long orderId = maxId + 1 + i; + for (Object[] item : itemsPerOrder.get(i)) { + allItems.add(new Object[]{ + orderId, item[0], item[1], item[2], item[3], item[4], item[5]}); + } + } + dataGeneratorRepository.batchInsertOrderItems(allItems); + + int size = orderBatch.size(); + orderBatch.clear(); + itemsPerOrder.clear(); + return size; + } + + private String elapsed(long startMs) { + return String.format("%.1f", (System.currentTimeMillis() - startMs) / 1000.0); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/datagenerator/DataGeneratorRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/datagenerator/DataGeneratorRepository.java new file mode 100644 index 000000000..6ef67bc44 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/datagenerator/DataGeneratorRepository.java @@ -0,0 +1,295 @@ +package com.loopers.infrastructure.datagenerator; + +import java.sql.PreparedStatement; +import java.sql.Timestamp; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZonedDateTime; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class DataGeneratorRepository { + + private final JdbcTemplate jdbcTemplate; + + public Map getStats() { + Map stats = new HashMap<>(); + stats.put("brandCount", countTable("brands", true)); + stats.put("productCount", countTable("products", true)); + stats.put("likeCount", countTable("likes", false)); + stats.put("userCount", countTable("users", false)); + stats.put("orderCount", countTable("orders", false)); + stats.put("couponCount", countTable("coupons", true)); + stats.put("ownedCouponCount", countTable("owned_coupons", false)); + return stats; + } + + public int batchInsertLikes(List userProductPairs) { + if (userProductPairs.isEmpty()) { + return 0; + } + String sql = "INSERT IGNORE INTO likes (user_id, product_id, created_at) VALUES (?, ?, NOW())"; + jdbcTemplate.batchUpdate(sql, userProductPairs, 1000, + (PreparedStatement ps, long[] pair) -> { + ps.setLong(1, pair[0]); + ps.setLong(2, pair[1]); + }); + return userProductPairs.size(); + } + + public int batchInsertUsers(String prefix, int count, String encodedPassword, long defaultPoint) { + String sql = "INSERT INTO users (login_id, password, name, birth_date, email, point, version, created_at, updated_at) " + + "VALUES (?, ?, ?, ?, ?, ?, 0, NOW(), NOW())"; + LocalDate baseBirthDate = LocalDate.of(1995, 1, 1); + LocalDateTime now = LocalDateTime.now(); + + int created = 0; + for (int i = 1; i <= count; i++) { + String loginId = prefix + i; + String name = prefix.substring(0, Math.min(prefix.length(), 4)) + i; + if (name.length() < 2) name = "user" + i; + if (name.length() > 10) name = name.substring(0, 10); + String email = loginId + "@test.com"; + LocalDate birthDate = baseBirthDate.plusDays(i % 3650); + + try { + jdbcTemplate.update(sql, loginId, encodedPassword, name, birthDate, email, defaultPoint); + created++; + } catch (Exception ignored) { + // duplicate login_id, skip + } + } + return created; + } + + public List findAllUserIds() { + return jdbcTemplate.queryForList("SELECT id FROM users", Long.class); + } + + public List> findRandomProducts(int limit) { + return jdbcTemplate.queryForList( + "SELECT id, price, stock FROM products WHERE deleted_at IS NULL AND stock > 0 ORDER BY RAND() LIMIT ?", + limit); + } + + public int batchInsertOwnedCoupons(Long couponId, String couponName, String discountType, + long discountValue, Long minOrderAmount, + ZonedDateTime expiredAt, List userIds) { + if (userIds.isEmpty()) return 0; + + String sql = "INSERT IGNORE INTO owned_coupons " + + "(coupon_id, coupon_name, discount_type, discount_value, min_order_amount, " + + "expired_at, user_id, created_at, updated_at) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, NOW(), NOW())"; + + Timestamp expiredTimestamp = Timestamp.from(expiredAt.toInstant()); + + jdbcTemplate.batchUpdate(sql, userIds, 500, + (PreparedStatement ps, Long userId) -> { + ps.setLong(1, couponId); + ps.setString(2, couponName); + ps.setString(3, discountType); + ps.setLong(4, discountValue); + if (minOrderAmount != null) { + ps.setLong(5, minOrderAmount); + } else { + ps.setNull(5, java.sql.Types.BIGINT); + } + ps.setTimestamp(6, expiredTimestamp); + ps.setLong(7, userId); + }); + return userIds.size(); + } + + public void updateCouponIssuedQuantity(Long couponId, int quantity) { + jdbcTemplate.update( + "UPDATE coupons SET issued_quantity = issued_quantity + ? WHERE id = ?", + quantity, couponId); + } + + public List findAllProductIds() { + return jdbcTemplate.queryForList( + "SELECT id FROM products WHERE deleted_at IS NULL", Long.class); + } + + public List findAllBrandIds() { + return jdbcTemplate.queryForList( + "SELECT id FROM brands WHERE deleted_at IS NULL", Long.class); + } + + public List findDeletedBrandIds() { + return jdbcTemplate.queryForList( + "SELECT id FROM brands WHERE deleted_at IS NOT NULL", Long.class); + } + + public int batchInsertBrands(List brandNames) { + if (brandNames.isEmpty()) return 0; + String sql = "INSERT IGNORE INTO brands (name, created_at, updated_at) VALUES (?, NOW(), NOW())"; + jdbcTemplate.batchUpdate(sql, brandNames, 100, + (PreparedStatement ps, String name) -> ps.setString(1, name)); + return brandNames.size(); + } + + public int batchInsertProducts(List products) { + if (products.isEmpty()) return 0; + String sql = "INSERT INTO products (brand_id, name, price, stock, like_count, thumbnail_url, version, created_at, updated_at) " + + "VALUES (?, ?, ?, ?, 0, ?, 0, NOW(), NOW())"; + jdbcTemplate.batchUpdate(sql, products, 1000, + (PreparedStatement ps, Object[] p) -> { + ps.setLong(1, (Long) p[0]); + ps.setString(2, (String) p[1]); + ps.setInt(3, (int) p[2]); + ps.setInt(4, (int) p[3]); + ps.setString(5, (String) p[4]); + }); + return products.size(); + } + + public int batchInsertProductImages(List images) { + if (images.isEmpty()) return 0; + String sql = "INSERT INTO product_images (product_id, image_url, image_type, sort_order, created_at, updated_at) " + + "VALUES (?, ?, ?, ?, NOW(), NOW())"; + jdbcTemplate.batchUpdate(sql, images, 1000, + (PreparedStatement ps, Object[] img) -> { + ps.setLong(1, (Long) img[0]); + ps.setString(2, (String) img[1]); + ps.setString(3, (String) img[2]); + ps.setInt(4, (int) img[3]); + }); + return images.size(); + } + + public long getMaxUserId() { + Long maxId = jdbcTemplate.queryForObject( + "SELECT COALESCE(MAX(user_id), 0) FROM likes", Long.class); + return maxId != null ? maxId : 0L; + } + + public long getMaxOrderId() { + Long maxId = jdbcTemplate.queryForObject( + "SELECT COALESCE(MAX(id), 0) FROM orders", Long.class); + return maxId != null ? maxId : 0L; + } + + public List> findProductsForOrders(int limit) { + return jdbcTemplate.queryForList( + "SELECT p.id, p.name AS product_name, p.price, b.name AS brand_name " + + "FROM products p JOIN brands b ON p.brand_id = b.id " + + "LEFT JOIN (SELECT product_id, COUNT(*) AS cnt FROM likes GROUP BY product_id) lc " + + "ON p.id = lc.product_id " + + "WHERE p.deleted_at IS NULL AND p.stock > 0 " + + "ORDER BY COALESCE(lc.cnt, 0) DESC LIMIT ?", + limit); + } + + public void batchInsertOrders(List orders) { + if (orders.isEmpty()) return; + String sql = "INSERT INTO orders " + + "(user_id, total_price, original_total_price, status, discount_amount, version, created_at, updated_at) " + + "VALUES (?, ?, ?, ?, ?, 0, NOW(), NOW())"; + jdbcTemplate.batchUpdate(sql, orders, 1000, + (PreparedStatement ps, Object[] o) -> { + ps.setLong(1, (Long) o[0]); + ps.setInt(2, (int) o[1]); + ps.setInt(3, (int) o[2]); + ps.setString(4, (String) o[3]); + ps.setInt(5, (int) o[4]); + }); + } + + public void batchInsertOrderItems(List items) { + if (items.isEmpty()) return; + String sql = "INSERT INTO order_items " + + "(order_id, product_id, order_price, quantity, product_name, brand_name, status, created_at, updated_at) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, NOW(), NOW())"; + jdbcTemplate.batchUpdate(sql, items, 1000, + (PreparedStatement ps, Object[] item) -> { + ps.setLong(1, (Long) item[0]); + ps.setLong(2, (Long) item[1]); + ps.setInt(3, (int) item[2]); + ps.setInt(4, (int) item[3]); + ps.setString(5, (String) item[4]); + ps.setString(6, (String) item[5]); + ps.setString(7, (String) item[6]); + }); + } + + public long countAllInTable(String tableName) { + Long count = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM " + tableName, Long.class); + return count != null ? count : 0L; + } + + public void batchSoftDeleteBrands(List brandIds) { + if (brandIds.isEmpty()) return; + String placeholders = String.join(",", brandIds.stream().map(id -> "?").toList()); + jdbcTemplate.update( + "UPDATE brands SET deleted_at = NOW() WHERE id IN (" + placeholders + ")", + brandIds.toArray()); + } + + public void batchSoftDeleteProducts(List productIds) { + if (productIds.isEmpty()) return; + int batchSize = 10_000; + for (int i = 0; i < productIds.size(); i += batchSize) { + List batch = productIds.subList(i, Math.min(i + batchSize, productIds.size())); + String placeholders = String.join(",", batch.stream().map(id -> "?").toList()); + jdbcTemplate.update( + "UPDATE products SET deleted_at = NOW() WHERE id IN (" + placeholders + ")", + batch.toArray()); + } + } + + public List findProductIdsByBrandIds(List brandIds) { + if (brandIds.isEmpty()) return List.of(); + String placeholders = String.join(",", brandIds.stream().map(id -> "?").toList()); + return jdbcTemplate.queryForList( + "SELECT id FROM products WHERE brand_id IN (" + placeholders + ")", + Long.class, + brandIds.toArray()); + } + + public List findActiveProductIdsSample(int limit, long seed) { + return jdbcTemplate.queryForList( + "SELECT id FROM products WHERE deleted_at IS NULL ORDER BY id LIMIT ? OFFSET ?", + Long.class, + limit, seed % 1000); + } + + public List findOldestActiveProductIds(int limit) { + return jdbcTemplate.queryForList( + "SELECT id FROM products WHERE deleted_at IS NULL ORDER BY id ASC LIMIT ?", + Long.class, + limit); + } + + public List findActiveProductIdsWithLikes(int limit) { + return jdbcTemplate.queryForList( + "SELECT id FROM products WHERE deleted_at IS NULL AND like_count > 0 " + + "ORDER BY like_count ASC LIMIT ?", + Long.class, + limit); + } + + public void syncLikeCounts() { + jdbcTemplate.update( + "UPDATE products p LEFT JOIN (" + + "SELECT product_id, COUNT(*) AS cnt FROM likes GROUP BY product_id" + + ") lc ON p.id = lc.product_id " + + "SET p.like_count = COALESCE(lc.cnt, 0)"); + } + + private long countTable(String tableName, boolean hasSoftDelete) { + String sql = hasSoftDelete + ? "SELECT COUNT(*) FROM " + tableName + " WHERE deleted_at IS NULL" + : "SELECT COUNT(*) FROM " + tableName; + Long count = jdbcTemplate.queryForObject(sql, Long.class); + return count != null ? count : 0L; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/datagenerator/FashionDataPool.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/datagenerator/FashionDataPool.java new file mode 100644 index 000000000..201b9dead --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/datagenerator/FashionDataPool.java @@ -0,0 +1,185 @@ +package com.loopers.infrastructure.datagenerator; + +import java.util.List; +import java.util.Random; + +public final class FashionDataPool { + + private FashionDataPool() { + } + + // === 브랜드 100개 === + static final List BRAND_NAMES = List.of( + // 글로벌 스포츠 (15) + "나이키", "아디다스", "뉴발란스", "푸마", "리복", + "컨버스", "아식스", "미즈노", "언더아머", "데상트", + "휠라", "엘레세", "르꼬끄", "챔피온", "카파", + // 글로벌 SPA (10) + "자라", "유니클로", "H&M", "코스", "망고", + "갭", "올드네이비", "포에버21", "프라이마크", "몬키", + // 국내 스트릿 (15) + "커버낫", "디스이즈네버댓", "마뗑킴", "아더에러", "엠엘비", + "널디", "키르시", "그루브라임", "LMC", "인사일런스", + "플랙", "비바스튜디오", "앤더슨벨", "이미스", "마르디메크르디", + // 국내 베이직 (10) + "무신사스탠다드", "탑텐", "스파오", "에잇세컨즈", "폴햄", + "지오다노", "닉스", "프로젝트엠", "앤드지", "올젠", + // 럭셔리 스포츠 (5) + "톰브라운", "메종키츠네", "아미", "우영미", "르메르", + // 아웃도어 (10) + "노스페이스", "파타고니아", "아크테릭스", "컬럼비아", "살로몬", + "호카", "메렐", "티엠비", "블랙야크", "코오롱스포츠", + // 캐주얼/컨템포러리 (10) + "폴로랄프로렌", "타미힐피거", "캘빈클라인", "라코스테", "게스", + "빈폴", "헤지스", "닥스", "브룩스브라더스", "제이크루", + // 스니커즈/슈즈 (5) + "반스", "닥터마틴", "크록스", "버켄스탁", "타임버랜드", + // 디자이너 (10) + "이자벨마랑", "아크네스튜디오", "겐조", "오프화이트", "스투시", + "팔라스", "슈프림", "베이프", "휴먼메이드", "갤러리디파트먼트", + // 국내 여성 (10) + "미샤", "잇미샤", "시스템", "지컷", "랩", + "올리브데올리브", "듀엘", "로엠", "쏘울", "나인"); + + // === 카테고리 === + enum Category { + TOP("상의", 25, 19_900, 89_900, 100, 500, 0.05), + BOTTOM("하의", 18, 29_900, 129_900, 100, 500, 0.05), + OUTER("아우터", 12, 69_900, 399_900, 30, 200, 0.08), + DRESS("원피스/세트", 8, 39_900, 199_900, 30, 200, 0.06), + SHOES("슈즈", 15, 59_900, 299_900, 20, 150, 0.10), + BAG("가방", 12, 39_900, 249_900, 30, 200, 0.06), + ACCESSORY("악세서리", 10, 9_900, 149_900, 200, 1000, 0.03); + + final String label; + final int weightPercent; + final int minPrice; + final int maxPrice; + final int minStock; + final int maxStock; + final double soldOutRate; + + Category(String label, int weightPercent, int minPrice, int maxPrice, + int minStock, int maxStock, double soldOutRate) { + this.label = label; + this.weightPercent = weightPercent; + this.minPrice = minPrice; + this.maxPrice = maxPrice; + this.minStock = minStock; + this.maxStock = maxStock; + this.soldOutRate = soldOutRate; + } + } + + // 카테고리별 아이템 타입 + private static final List TOP_ITEMS = List.of( + "반팔 티셔츠", "긴팔 티셔츠", "크루넥 맨투맨", "후드 스웨트셔츠", "옥스포드 셔츠", + "오버사이즈 셔츠", "헨리넥 티", "피케 폴로", "크롭 티셔츠", "니트 스웨터", + "카라 니트", "터틀넥 니트", "린넨 셔츠", "스트라이프 티셔츠", "그래픽 티셔츠"); + private static final List BOTTOM_ITEMS = List.of( + "스트레이트 데님", "슬림핏 데님", "와이드 데님", "치노 팬츠", "슬랙스", + "조거 팬츠", "카고 팬츠", "숏 팬츠", "플리츠 스커트", "밴딩 팬츠", + "부츠컷 데님", "코듀로이 팬츠"); + private static final List OUTER_ITEMS = List.of( + "라이더 자켓", "코치 자켓", "블레이저", "트렌치 코트", "숏패딩", + "롱패딩", "바람막이", "플리스 자켓", "카디건", "데님 자켓", + "무스탕", "볼버 자켓"); + private static final List DRESS_ITEMS = List.of( + "미니 원피스", "롱 원피스", "셔츠 원피스", "니트 원피스", "점프수트", + "투피스 셋업", "블라우스 셋업"); + private static final List SHOES_ITEMS = List.of( + "캔버스 스니커즈", "러닝화", "로퍼", "첼시 부츠", "워커", + "슬리퍼", "뮬", "플랫슈즈", "하이탑 스니커즈", "트레일 러닝화", + "더비슈즈", "레이스업 부츠"); + private static final List BAG_ITEMS = List.of( + "미니 크로스백", "토트백", "백팩", "숄더백", "에코백", + "메신저백", "클러치백", "버킷백", "웨이스트백", "노트북 백팩"); + private static final List ACCESSORY_ITEMS = List.of( + "볼캡", "비니", "버킷햇", "양말 세트", "벨트", + "선글라스", "목걸이", "팔찌", "반지", "머플러", + "장갑", "키링"); + + private static final List MODIFIERS = List.of( + "오버핏", "슬림핏", "에센셜", "클래식", "프리미엄", + "빈티지", "모던", "레트로", "시그니처", "베이직", + "라이트", "헤비웨이트", "소프트", "워시드", "리버시블"); + + private static final List COLORS = List.of( + "블랙", "화이트", "네이비", "차콜", "베이지", + "카키", "인디고", "버건디", "올리브", "크림", + "그레이", "스카이블루", "머스타드", "딥그린", "코랄"); + + // === 브랜드 티어 (인기도) === + // S티어: index 0~9 (10개), A티어: 10~29 (20개), B티어: 30~99 (70개) + static final int S_TIER_END = 10; + static final int A_TIER_END = 30; + + // === 브랜드별 상품 수 분포 === + // 인기 브랜드(0~19): 2000~3000, 중간(20~49): 500~1000, 소규모(50~99): 100~300 + + static int productsPerBrand(int brandIndex, int totalProducts, Random random) { + if (brandIndex < 20) { + return 2000 + random.nextInt(1001); // 2000~3000 + } else if (brandIndex < 50) { + return 500 + random.nextInt(501); // 500~1000 + } else { + return 100 + random.nextInt(201); // 100~300 + } + } + + static Category pickCategory(Random random) { + int roll = random.nextInt(100); + int cumulative = 0; + for (Category cat : Category.values()) { + cumulative += cat.weightPercent; + if (roll < cumulative) { + return cat; + } + } + return Category.ACCESSORY; + } + + static String generateProductName(Category category, Random random) { + List items = switch (category) { + case TOP -> TOP_ITEMS; + case BOTTOM -> BOTTOM_ITEMS; + case OUTER -> OUTER_ITEMS; + case DRESS -> DRESS_ITEMS; + case SHOES -> SHOES_ITEMS; + case BAG -> BAG_ITEMS; + case ACCESSORY -> ACCESSORY_ITEMS; + }; + + String item = items.get(random.nextInt(items.size())); + String modifier = MODIFIERS.get(random.nextInt(MODIFIERS.size())); + String color = COLORS.get(random.nextInt(COLORS.size())); + + String fullName = modifier + " " + item + " " + color; + if (fullName.length() <= 99) { + return fullName; + } + + String withoutColor = modifier + " " + item; + if (withoutColor.length() <= 99) { + return withoutColor; + } + return item; + } + + static int generatePrice(Category category, Random random) { + // 로그정규분포: 저가 쪽에 더 많이 분포 + double logMin = Math.log(category.minPrice); + double logMax = Math.log(category.maxPrice); + double logPrice = logMin + random.nextGaussian() * (logMax - logMin) * 0.3 + (logMax - logMin) * 0.4; + logPrice = Math.max(logMin, Math.min(logMax, logPrice)); + int price = (int) Math.exp(logPrice); + return (price / 100) * 100; // 100원 단위 반올림 + } + + static int generateStock(Category category, Random random) { + if (random.nextDouble() < category.soldOutRate) { + return 0; + } + return category.minStock + random.nextInt(category.maxStock - category.minStock + 1); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductImageJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductImageJpaRepository.java new file mode 100644 index 000000000..72e4e2951 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductImageJpaRepository.java @@ -0,0 +1,16 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.ImageType; +import com.loopers.domain.product.ProductImageModel; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ProductImageJpaRepository extends JpaRepository { + + List findAllByProductIdAndDeletedAtIsNullOrderBySortOrder(Long productId); + + List findAllByProductIdAndImageTypeAndDeletedAtIsNullOrderBySortOrder( + Long productId, ImageType imageType); + + List findAllByProductIdAndDeletedAtIsNull(Long productId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductImageRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductImageRepositoryImpl.java new file mode 100644 index 000000000..517c92938 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductImageRepositoryImpl.java @@ -0,0 +1,37 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.ImageType; +import com.loopers.domain.product.ProductImageModel; +import com.loopers.domain.product.ProductImageRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class ProductImageRepositoryImpl implements ProductImageRepository { + + private final ProductImageJpaRepository productImageJpaRepository; + + @Override + public ProductImageModel save(ProductImageModel image) { + return productImageJpaRepository.save(image); + } + + @Override + public List findAllByProductId(Long productId) { + return productImageJpaRepository.findAllByProductIdAndDeletedAtIsNullOrderBySortOrder(productId); + } + + @Override + public List findAllByProductIdAndImageType(Long productId, ImageType imageType) { + return productImageJpaRepository + .findAllByProductIdAndImageTypeAndDeletedAtIsNullOrderBySortOrder(productId, imageType); + } + + @Override + public void deleteAllByProductId(Long productId) { + productImageJpaRepository.findAllByProductIdAndDeletedAtIsNull(productId) + .forEach(ProductImageModel::delete); + } +} 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 40a228055..0cb28f1e2 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 @@ -6,6 +6,9 @@ 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; public interface ProductJpaRepository extends JpaRepository { Optional findByIdAndDeletedAtIsNull(Long id); @@ -19,4 +22,18 @@ public interface ProductJpaRepository extends JpaRepository List findAllByBrandIdAndDeletedAtIsNull(Long brandId); List findAllByIdInAndDeletedAtIsNull(List ids); + + Page findAllByDeletedAtIsNullOrderByLikeCountDesc(Pageable pageable); + + Page findAllByBrandIdAndDeletedAtIsNullOrderByLikeCountDesc( + Long brandId, Pageable pageable); + + @Modifying(flushAutomatically = true, clearAutomatically = true) + @Query("UPDATE ProductModel p SET p.likeCount = p.likeCount + 1 WHERE p.id = :id") + int incrementLikeCount(@Param("id") Long id); + + @Modifying(flushAutomatically = true, clearAutomatically = true) + @Query("UPDATE ProductModel p SET p.likeCount = p.likeCount - 1" + + " WHERE p.id = :id AND p.likeCount > 0") + int decrementLikeCount(@Param("id") Long id); } 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 8d84aad98..c513099ab 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 @@ -48,4 +48,25 @@ public List findAllByBrandId(Long brandId) { public List findAllByIdIn(List ids) { return productJpaRepository.findAllByIdInAndDeletedAtIsNull(ids); } + + @Override + public Page findAllSortedByLikeCountDesc(Pageable pageable) { + return productJpaRepository.findAllByDeletedAtIsNullOrderByLikeCountDesc(pageable); + } + + @Override + public Page findAllByBrandIdSortedByLikeCountDesc(Long brandId, Pageable pageable) { + return productJpaRepository.findAllByBrandIdAndDeletedAtIsNullOrderByLikeCountDesc( + brandId, pageable); + } + + @Override + public void incrementLikeCount(Long id) { + productJpaRepository.incrementLikeCount(id); + } + + @Override + public void decrementLikeCount(Long id) { + productJpaRepository.decrementLikeCount(id); + } } 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 52ebbd2ae..8900dd124 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 @@ -1,11 +1,16 @@ package com.loopers.infrastructure.user; import com.loopers.domain.user.UserModel; +import java.util.List; import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; public interface UserJpaRepository extends JpaRepository { Optional findByLoginId(String loginId); Optional findByLoginIdAndDeletedAtIsNull(String loginId); Optional findByIdAndDeletedAtIsNull(Long id); + Page findAllByDeletedAtIsNull(Pageable pageable); + List findAllByDeletedAtIsNull(); } 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 8f77b8f48..eacdd646a 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 @@ -2,8 +2,11 @@ import com.loopers.domain.user.UserModel; import com.loopers.domain.user.UserRepository; +import java.util.List; import java.util.Optional; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Repository; @RequiredArgsConstructor @@ -25,4 +28,14 @@ public Optional findById(Long id) { public Optional findByLoginId(String loginId) { return userJpaRepository.findByLoginIdAndDeletedAtIsNull(loginId); } + + @Override + public Page findAll(Pageable pageable) { + return userJpaRepository.findAllByDeletedAtIsNull(pageable); + } + + @Override + public List findAll() { + return userJpaRepository.findAllByDeletedAtIsNull(); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AuthFilter.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AuthFilter.java index 79edc117a..1735afd94 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AuthFilter.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AuthFilter.java @@ -28,7 +28,8 @@ public class AuthFilter extends OncePerRequestFilter { "/api/v1/users/me", "/api/v1/users/password", "/api/v1/users/me/likes", - "/api/v1/users/me/coupons" + "/api/v1/users/me/coupons", + "/api/v1/users/me/point" ); private static final String AUTH_REQUIRED_SUFFIX = "/likes"; private static final String AUTH_REQUIRED_PREFIX_ORDERS = "/api/v1/orders"; diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/brand/AdminBrandV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/brand/AdminBrandV1Controller.java index e3bd22b77..723128d2c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/brand/AdminBrandV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/brand/AdminBrandV1Controller.java @@ -6,7 +6,6 @@ import com.loopers.interfaces.brand.dto.AdminBrandV1Dto; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.web.bind.annotation.*; @@ -32,14 +31,14 @@ public ApiResponse list( @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size ) { - Page brandInfoPage = brandFacade.getBrands(PageRequest.of(page, size)); + BrandResult.ListPage listPage = brandFacade.getBrands(PageRequest.of(page, size)); return ApiResponse.success( new AdminBrandV1Dto.ListResponse( - brandInfoPage.getNumber(), - brandInfoPage.getSize(), - brandInfoPage.getTotalElements(), - brandInfoPage.getTotalPages(), - brandInfoPage.getContent().stream() + listPage.page(), + listPage.size(), + listPage.totalElements(), + listPage.totalPages(), + listPage.items().stream() .map(AdminBrandV1Dto.ListResponse.ListItem::from) .toList())); } @@ -49,8 +48,8 @@ public ApiResponse list( public ApiResponse getById( @PathVariable Long brandId ) { - BrandResult brandInfo = brandFacade.getBrand(brandId); - return ApiResponse.success(AdminBrandV1Dto.DetailResponse.from(brandInfo)); + return ApiResponse.success( + AdminBrandV1Dto.DetailResponse.from(brandFacade.getBrand(brandId))); } @PutMapping("/{brandId}") diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/datagenerator/AdminDataGeneratorV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/datagenerator/AdminDataGeneratorV1Controller.java new file mode 100644 index 000000000..36118e541 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/datagenerator/AdminDataGeneratorV1Controller.java @@ -0,0 +1,225 @@ +package com.loopers.interfaces.datagenerator; + +import com.loopers.application.coupon.CouponFacade; +import com.loopers.application.coupon.dto.CouponCriteria; +import com.loopers.application.coupon.dto.CouponResult; +import com.loopers.application.order.OrderFacade; +import com.loopers.application.order.dto.OrderCriteria; +import com.loopers.domain.coupon.CouponDiscountType; +import com.loopers.domain.product.ProductService; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.user.PasswordEncoder; +import com.loopers.domain.user.UserService; +import com.loopers.infrastructure.datagenerator.BulkDataGeneratorService; +import com.loopers.infrastructure.datagenerator.DataGeneratorRepository; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.datagenerator.dto.AdminDataGeneratorV1Dto; +import jakarta.validation.Valid; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ThreadLocalRandom; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; + +@Slf4j +@RequiredArgsConstructor +@RestController +@RequestMapping("/api-admin/v1/data-generator") +public class AdminDataGeneratorV1Controller { + + private final DataGeneratorRepository dataGeneratorRepository; + private final BulkDataGeneratorService bulkDataGeneratorService; + private final PasswordEncoder passwordEncoder; + private final UserService userService; + private final ProductService productService; + private final OrderFacade orderFacade; + private final CouponFacade couponFacade; + + @PostMapping("/bulk-init") + public ApiResponse> bulkInit() { + if (bulkDataGeneratorService.isRunning()) { + return ApiResponse.success(Map.of("message", "이미 실행 중입니다. Stats API로 진행 상황을 확인하세요.")); + } + CompletableFuture.runAsync(bulkDataGeneratorService::generateAll); + return ApiResponse.success(Map.of("message", "데이터 생성이 시작되었습니다. Stats API로 진행 상황을 확인하세요.")); + } + + @GetMapping("/stats") + public ApiResponse getStats() { + Map stats = dataGeneratorRepository.getStats(); + return ApiResponse.success(new AdminDataGeneratorV1Dto.StatsResponse( + stats.get("brandCount"), + stats.get("productCount"), + stats.get("likeCount"), + stats.get("userCount"), + stats.get("orderCount"), + stats.get("couponCount"), + stats.get("ownedCouponCount"), + Collections.emptyMap())); + } + + @PostMapping("/likes") + public ApiResponse generateLikes( + @Valid @RequestBody AdminDataGeneratorV1Dto.GenerateLikesRequest request + ) { + long startUserId = dataGeneratorRepository.getMaxUserId() + 1; + List pairs = new ArrayList<>(); + + for (Long productId : request.productIds()) { + for (int i = 0; i < request.likesPerProduct(); i++) { + pairs.add(new long[]{startUserId + i, productId}); + } + } + + int created = dataGeneratorRepository.batchInsertLikes(pairs); + return ApiResponse.success(new AdminDataGeneratorV1Dto.GenerateLikesResponse( + created, 0, + request.productIds().size() + "개 상품에 각 " + + request.likesPerProduct() + "개 좋아요 생성 완료")); + } + + @PostMapping("/users") + public ApiResponse generateUsers( + @Valid @RequestBody AdminDataGeneratorV1Dto.GenerateUsersRequest request + ) { + long defaultPoint = request.defaultPoint() != null ? request.defaultPoint() : 0L; + String encodedPassword = passwordEncoder.encode("Test1234!"); + + int created = dataGeneratorRepository.batchInsertUsers( + request.prefix(), request.count(), encodedPassword, defaultPoint); + + return ApiResponse.success(new AdminDataGeneratorV1Dto.GenerateUsersResponse( + created, + created + "명 유저 생성 완료 (비밀번호: Test1234!)")); + } + + @PostMapping("/orders") + public ApiResponse generateOrders( + @Valid @RequestBody AdminDataGeneratorV1Dto.GenerateOrdersRequest request + ) { + boolean isSpecificMode = "specific".equals(request.mode()) + && request.items() != null && !request.items().isEmpty(); + + List specifiedItems = null; + + if (isSpecificMode) { + specifiedItems = new ArrayList<>(); + for (AdminDataGeneratorV1Dto.GenerateOrdersRequest.OrderItemSpec spec : request.items()) { + ProductModel product = productService.getById(spec.productId()); + specifiedItems.add(new OrderCriteria.Create.CreateItem( + product.getId(), spec.quantity(), product.getPrice())); + } + } else { + List> products = dataGeneratorRepository.findRandomProducts(100); + if (products.isEmpty()) { + return ApiResponse.success(new AdminDataGeneratorV1Dto.GenerateOrdersResponse( + 0, 0, "주문 가능한 상품이 없습니다.")); + } + specifiedItems = pickRandomItems(products, + request.itemsPerOrder() != null ? request.itemsPerOrder() : 3); + } + + int created = 0; + int failed = 0; + + for (Long userId : request.userIds()) { + try { + List orderItems = isSpecificMode + ? specifiedItems + : pickRandomItems(dataGeneratorRepository.findRandomProducts(100), + request.itemsPerOrder() != null ? request.itemsPerOrder() : 3); + + int totalCost = orderItems.stream() + .mapToInt(item -> item.expectedPrice() * item.quantity()) + .sum(); + + userService.addPoint(userId, totalCost + 1000L); + + orderFacade.createOrder(userId, new OrderCriteria.Create(orderItems)); + created++; + } catch (Exception e) { + log.warn("주문 생성 실패 (userId={}): {}", userId, e.getMessage()); + failed++; + } + } + + return ApiResponse.success(new AdminDataGeneratorV1Dto.GenerateOrdersResponse( + created, failed, + created + "건 주문 생성 완료, " + failed + "건 실패")); + } + + @PostMapping("/coupons") + public ApiResponse generateCoupons( + @Valid @RequestBody AdminDataGeneratorV1Dto.GenerateCouponsRequest request + ) { + CouponDiscountType discountType = CouponDiscountType.valueOf(request.discountType()); + ZonedDateTime expiredAt = ZonedDateTime.now().plusMonths(3); + int couponsCreated = 0; + int totalIssued = 0; + + List createdCoupons = new ArrayList<>(); + + for (int i = 0; i < request.count(); i++) { + String couponName = discountType == CouponDiscountType.RATE + ? request.discountValue() + "% 할인 쿠폰 #" + (i + 1) + : request.discountValue() + "원 할인 쿠폰 #" + (i + 1); + + CouponResult.Detail detail = couponFacade.registerCoupon(new CouponCriteria.Create( + couponName, + discountType, + request.discountValue(), + request.minOrderAmount(), + request.totalQuantityPerCoupon(), + expiredAt)); + createdCoupons.add(detail); + couponsCreated++; + } + + if (request.issueToAllUsers()) { + List userIds = dataGeneratorRepository.findAllUserIds(); + for (CouponResult.Detail coupon : createdCoupons) { + int issued = dataGeneratorRepository.batchInsertOwnedCoupons( + coupon.id(), coupon.name(), discountType.name(), + request.discountValue(), request.minOrderAmount(), + expiredAt, userIds); + dataGeneratorRepository.updateCouponIssuedQuantity(coupon.id(), issued); + totalIssued += issued; + } + } + + return ApiResponse.success(new AdminDataGeneratorV1Dto.GenerateCouponsResponse( + couponsCreated, totalIssued, + couponsCreated + "개 쿠폰 생성, " + totalIssued + "개 발급 완료")); + } + + private List pickRandomItems( + List> products, int count) { + List> shuffled = new ArrayList<>(products); + Collections.shuffle(shuffled); + + List items = new ArrayList<>(); + for (int i = 0; i < Math.min(count, shuffled.size()); i++) { + Map p = shuffled.get(i); + int price = ((Number) p.get("price")).intValue(); + int stock = ((Number) p.get("stock")).intValue(); + int qty = Math.min(ThreadLocalRandom.current().nextInt(1, 4), stock); + if (qty <= 0) continue; + + items.add(new OrderCriteria.Create.CreateItem( + ((Number) p.get("id")).longValue(), qty, price)); + } + + if (items.isEmpty() && !products.isEmpty()) { + Map p = products.get(0); + items.add(new OrderCriteria.Create.CreateItem( + ((Number) p.get("id")).longValue(), 1, + ((Number) p.get("price")).intValue())); + } + return items; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/datagenerator/dto/AdminDataGeneratorV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/datagenerator/dto/AdminDataGeneratorV1Dto.java new file mode 100644 index 000000000..92da8fa63 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/datagenerator/dto/AdminDataGeneratorV1Dto.java @@ -0,0 +1,93 @@ +package com.loopers.interfaces.datagenerator.dto; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import java.util.List; +import java.util.Map; + +public class AdminDataGeneratorV1Dto { + + public record GenerateLikesRequest( + @NotNull(message = "상품 ID 목록은 필수입니다.") + List productIds, + @NotNull(message = "상품당 좋아요 수는 필수입니다.") + @Min(value = 1, message = "상품당 좋아요 수는 1 이상이어야 합니다.") + Integer likesPerProduct + ) {} + + public record GenerateLikesResponse( + int totalCreated, + int skipped, + String message + ) {} + + public record StatsResponse( + long brandCount, + long productCount, + long likeCount, + long userCount, + long orderCount, + long couponCount, + long ownedCouponCount, + Map details + ) {} + + // === User Generation === + + public record GenerateUsersRequest( + @NotBlank(message = "접두사는 필수입니다.") + String prefix, + @NotNull @Min(value = 1, message = "생성 수는 1 이상이어야 합니다.") + Integer count, + @Min(value = 0, message = "기본 포인트는 0 이상이어야 합니다.") + Long defaultPoint + ) {} + + public record GenerateUsersResponse( + int totalCreated, + String message + ) {} + + // === Order Generation === + + public record GenerateOrdersRequest( + @NotNull(message = "유저 ID 목록은 필수입니다.") + List userIds, + String mode, + List items, + Integer itemsPerOrder + ) { + public record OrderItemSpec( + @NotNull Long productId, + @Min(1) int quantity + ) {} + } + + public record GenerateOrdersResponse( + int totalCreated, + int totalFailed, + String message + ) {} + + // === Coupon Generation === + + public record GenerateCouponsRequest( + @NotNull @Min(value = 1, message = "쿠폰 수는 1 이상이어야 합니다.") + Integer count, + @NotBlank(message = "할인 타입은 필수입니다.") + String discountType, + @NotNull @Min(value = 1, message = "할인 값은 1 이상이어야 합니다.") + Long discountValue, + Long minOrderAmount, + @NotNull @Min(value = 1, message = "총 수량은 1 이상이어야 합니다.") + Integer totalQuantityPerCoupon, + boolean issueToAllUsers + ) {} + + public record GenerateCouponsResponse( + int couponsCreated, + int totalIssued, + String message + ) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/product/ProductV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/product/ProductV1Controller.java index 479509e7b..b9699b1c1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/product/ProductV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/product/ProductV1Controller.java @@ -1,13 +1,9 @@ package com.loopers.interfaces.product; import com.loopers.application.product.ProductFacade; -import com.loopers.application.product.dto.ProductResult; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.product.dto.ProductV1Dto; import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Sort; import org.springframework.web.bind.annotation.*; @RequiredArgsConstructor @@ -25,29 +21,16 @@ public ApiResponse list( @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size ) { - Page productPage; - if ("likes_desc".equals(sort)) { - productPage = brandId != null - ? productFacade.getProductsWithActiveBrandByBrandIdSortedByLikes(brandId, page, size) - : productFacade.getProductsWithActiveBrandSortedByLikes(page, size); - } else { - PageRequest pageable = PageRequest.of(page, size, "price_asc".equals(sort) - ? Sort.by(Sort.Direction.ASC, "price.value") - : Sort.by(Sort.Direction.DESC, "createdAt")); - productPage = brandId != null - ? productFacade.getProductsWithActiveBrandByBrandId(brandId, pageable) - : productFacade.getProductsWithActiveBrand(pageable); - } - return ApiResponse.success( - new ProductV1Dto.ListResponse( - productPage.getNumber(), - productPage.getSize(), - productPage.getTotalElements(), - productPage.getTotalPages(), - productPage.getContent().stream() - .map(ProductV1Dto.ListResponse.ListItem::from) - .toList())); + ProductV1Dto.ListResponse.from( + switch (sort) { + case "price_asc", "price_desc" -> + productFacade.getProductListByPrice(brandId, sort, page, size); + case "likes_desc" -> + productFacade.getProductListByLikes(brandId, page, size); + default -> + productFacade.getProductListLatest(brandId, page, size); + })); } @GetMapping("/{productId}") @@ -55,7 +38,7 @@ public ApiResponse list( public ApiResponse getById( @PathVariable Long productId ) { - ProductResult result = productFacade.getProduct(productId); - return ApiResponse.success(ProductV1Dto.DetailResponse.from(result)); + return ApiResponse.success( + ProductV1Dto.DetailResponse.from(productFacade.getProductDetail(productId))); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/product/dto/ProductV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/product/dto/ProductV1Dto.java index 3fa370eac..9f2a86fc3 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/product/dto/ProductV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/product/dto/ProductV1Dto.java @@ -1,6 +1,7 @@ package com.loopers.interfaces.product.dto; import com.loopers.application.product.dto.ProductResult; +import com.loopers.application.product.dto.ProductResult.ListPage; import java.util.List; public class ProductV1Dto { @@ -12,17 +13,31 @@ public record DetailResponse( String name, int price, int stock, - long likeCount + long likeCount, + String thumbnailUrl, + List mainImages, + List detailImages ) { - public static DetailResponse from(ProductResult info) { + public static DetailResponse from(ProductResult.DetailWithImages detail) { + ProductResult product = detail.product(); return new DetailResponse( - info.id(), - info.brandId(), - info.brandName(), - info.name(), - info.price(), - info.stock(), - info.likeCount()); + product.id(), + product.brandId(), + product.brandName(), + product.name(), + product.price(), + product.stock(), + product.likeCount(), + product.thumbnailUrl(), + detail.mainImages().stream().map(ImageResponse::from).toList(), + detail.detailImages().stream().map(ImageResponse::from).toList()); + } + } + + public record ImageResponse(Long id, String imageUrl, String imageType, int sortOrder) { + public static ImageResponse from(ProductResult.ImageResult image) { + return new ImageResponse( + image.id(), image.imageUrl(), image.imageType().name(), image.sortOrder()); } } @@ -33,13 +48,25 @@ public record ListResponse( int totalPages, List items ) { + public static ListResponse from(ListPage listPage) { + return new ListResponse( + listPage.page(), + listPage.size(), + listPage.totalElements(), + listPage.totalPages(), + listPage.items().stream() + .map(ListItem::from) + .toList()); + } + public record ListItem( Long id, Long brandId, String brandName, String name, int price, - long likeCount + long likeCount, + String thumbnailUrl ) { public static ListItem from(ProductResult info) { return new ListItem( @@ -48,7 +75,8 @@ public static ListItem from(ProductResult info) { info.brandName(), info.name(), info.price(), - info.likeCount()); + info.likeCount(), + info.thumbnailUrl()); } } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/user/AdminUserV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/user/AdminUserV1Controller.java new file mode 100644 index 000000000..926d47516 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/user/AdminUserV1Controller.java @@ -0,0 +1,61 @@ +package com.loopers.interfaces.user; + +import com.loopers.domain.user.UserModel; +import com.loopers.domain.user.UserService; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.user.dto.AdminUserV1Dto; +import jakarta.validation.Valid; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.web.bind.annotation.*; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api-admin/v1/users") +public class AdminUserV1Controller { + + private final UserService userService; + + @GetMapping + public ApiResponse list( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + Page userPage = userService.getUsers(PageRequest.of(page, size)); + return ApiResponse.success( + new AdminUserV1Dto.ListResponse( + userPage.getNumber(), + userPage.getSize(), + userPage.getTotalElements(), + userPage.getTotalPages(), + userPage.getContent().stream() + .map(AdminUserV1Dto.ListResponse.ListItem::from) + .toList())); + } + + @PostMapping("/{userId}/point") + public ApiResponse addPoint( + @PathVariable Long userId, + @Valid @RequestBody AdminUserV1Dto.AddPointRequest request + ) { + userService.addPoint(userId, request.amount()); + return ApiResponse.success( + new AdminUserV1Dto.AddPointResponse(1, request.amount(), + "유저 " + userId + "에게 " + request.amount() + " 포인트를 지급했습니다.")); + } + + @PostMapping("/point") + public ApiResponse addPointToAll( + @Valid @RequestBody AdminUserV1Dto.AddPointAllRequest request + ) { + List users = userService.getAllUsers(); + for (UserModel user : users) { + userService.addPoint(user.getId(), request.amount()); + } + return ApiResponse.success( + new AdminUserV1Dto.AddPointResponse(users.size(), request.amount(), + "전체 " + users.size() + "명에게 " + request.amount() + " 포인트를 지급했습니다.")); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/user/UserV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/user/UserV1Controller.java index b84bbe192..88d9c451b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/user/UserV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/user/UserV1Controller.java @@ -2,6 +2,7 @@ import com.loopers.application.user.UserFacade; import com.loopers.application.user.dto.UserResult; +import com.loopers.domain.user.UserService; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.auth.Login; import com.loopers.interfaces.auth.LoginUser; @@ -16,6 +17,7 @@ public class UserV1Controller implements UserV1ApiSpec { private final UserFacade userFacade; + private final UserService userService; @PostMapping("/signup") @Override @@ -45,4 +47,20 @@ public ApiResponse changePassword( return ApiResponse.success(UserV1Dto.ChangePasswordResponse.success()); } + + @PostMapping("/me/point") + public ApiResponse chargePoint( + @Login LoginUser loginUser, + @Valid @RequestBody UserV1Dto.ChargePointRequest request + ) { + userService.addPoint(loginUser.id(), request.amount()); + long currentPoint = userService.getById(loginUser.id()).getPoint(); + return ApiResponse.success(new UserV1Dto.PointResponse(currentPoint)); + } + + @GetMapping("/me/point") + public ApiResponse getMyPoint(@Login LoginUser loginUser) { + long currentPoint = userService.getById(loginUser.id()).getPoint(); + return ApiResponse.success(new UserV1Dto.PointResponse(currentPoint)); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/user/dto/AdminUserV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/user/dto/AdminUserV1Dto.java new file mode 100644 index 000000000..634a73fde --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/user/dto/AdminUserV1Dto.java @@ -0,0 +1,55 @@ +package com.loopers.interfaces.user.dto; + +import com.loopers.domain.user.UserModel; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import java.time.ZonedDateTime; +import java.util.List; + +public class AdminUserV1Dto { + + public record ListResponse( + int page, + int size, + long totalElements, + int totalPages, + List items + ) { + public record ListItem( + Long id, + String loginId, + String name, + String email, + long point, + ZonedDateTime createdAt + ) { + public static ListItem from(UserModel model) { + return new ListItem( + model.getId(), + model.getLoginId(), + model.getName(), + model.getEmail(), + model.getPoint(), + model.getCreatedAt()); + } + } + } + + public record AddPointRequest( + @NotNull(message = "금액은 필수입니다.") + @Min(value = 1, message = "금액은 1 이상이어야 합니다.") + Long amount + ) {} + + public record AddPointAllRequest( + @NotNull(message = "금액은 필수입니다.") + @Min(value = 1, message = "금액은 1 이상이어야 합니다.") + Long amount + ) {} + + public record AddPointResponse( + int updatedCount, + long amountPerUser, + String message + ) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/user/dto/UserV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/user/dto/UserV1Dto.java index 5e36e9cae..f469fa627 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/user/dto/UserV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/user/dto/UserV1Dto.java @@ -60,14 +60,16 @@ public record MyInfoResponse( String loginId, String name, String birthDate, - String email + String email, + long point ) { public static MyInfoResponse from(UserResult info) { return new MyInfoResponse( info.loginId(), maskName(info.name()), info.birthDate(), - info.email()); + info.email(), + info.point()); } } @@ -95,6 +97,15 @@ public static ChangePasswordResponse success() { } } + public record ChargePointRequest( + @jakarta.validation.constraints.Min(value = 1, message = "충전 금액은 1 이상이어야 합니다.") + long amount + ) {} + + public record PointResponse( + long point + ) {} + private static String maskName(String name) { if (name == null || name.isEmpty()) return name; return name.substring(0, name.length() - 1) + "*"; diff --git a/apps/commerce-api/src/main/java/com/loopers/support/cache/CacheConfig.java b/apps/commerce-api/src/main/java/com/loopers/support/cache/CacheConfig.java new file mode 100644 index 000000000..46be202ce --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/support/cache/CacheConfig.java @@ -0,0 +1,51 @@ +package com.loopers.support.cache; + +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.ObjectMapper.DefaultTyping; +import com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator; +import java.util.Arrays; +import java.util.Map; +import java.util.stream.Collectors; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.cache.annotation.EnableCaching; +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; + +@Configuration +@EnableCaching +public class CacheConfig { + + @Bean + public RedisCacheManager cacheManager( + @Qualifier("redisConnectionMaster") LettuceConnectionFactory connectionFactory, + ObjectMapper objectMapper) { + ObjectMapper cacheMapper = objectMapper.copy() + .activateDefaultTyping( + BasicPolymorphicTypeValidator.builder() + .allowIfBaseType(Object.class) + .build(), + DefaultTyping.EVERYTHING, + JsonTypeInfo.As.PROPERTY); + + RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig() + .serializeValuesWith( + RedisSerializationContext.SerializationPair + .fromSerializer(new GenericJackson2JsonRedisSerializer(cacheMapper))); + + Map cacheConfigs = Arrays.stream(CacheType.values()) + .collect(Collectors.toMap( + CacheType::getCacheName, + type -> defaultConfig.entryTtl(type.getTtl()))); + + return RedisCacheManager.builder(connectionFactory) + .cacheDefaults(defaultConfig) + .withInitialCacheConfigurations(cacheConfigs) + .build(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/support/cache/CacheType.java b/apps/commerce-api/src/main/java/com/loopers/support/cache/CacheType.java new file mode 100644 index 000000000..2659be07e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/support/cache/CacheType.java @@ -0,0 +1,27 @@ +package com.loopers.support.cache; + +import java.time.Duration; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum CacheType { + + BRAND_LIST(Names.BRAND_LIST, Duration.ofHours(24)), + PRODUCT_DETAIL(Names.PRODUCT_DETAIL, Duration.ofMinutes(10)), + PRODUCT_LIST_LATEST(Names.PRODUCT_LIST_LATEST, Duration.ofMinutes(3)), + PRODUCT_LIST_PRICE(Names.PRODUCT_LIST_PRICE, Duration.ofMinutes(5)), + PRODUCT_LIST_LIKES(Names.PRODUCT_LIST_LIKES, Duration.ofMinutes(10)); + + private final String cacheName; + private final Duration ttl; + + public static class Names { + public static final String BRAND_LIST = "brand:list"; + public static final String PRODUCT_DETAIL = "product:detail"; + public static final String PRODUCT_LIST_LATEST = "product:list:latest"; + public static final String PRODUCT_LIST_PRICE = "product:list:price"; + public static final String PRODUCT_LIST_LIKES = "product:list:likes"; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/support/cache/RedisCacheHelper.java b/apps/commerce-api/src/main/java/com/loopers/support/cache/RedisCacheHelper.java new file mode 100644 index 000000000..5694da641 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/support/cache/RedisCacheHelper.java @@ -0,0 +1,66 @@ +package com.loopers.support.cache; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.config.redis.RedisConfig; +import java.time.Duration; +import java.util.Optional; +import java.util.Set; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class RedisCacheHelper { + + private final RedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + + public RedisCacheHelper( + @Qualifier(RedisConfig.REDIS_TEMPLATE_MASTER) RedisTemplate redisTemplate, + ObjectMapper objectMapper) { + this.redisTemplate = redisTemplate; + this.objectMapper = objectMapper; + } + + public Optional get(String key, TypeReference typeRef) { + try { + String json = redisTemplate.opsForValue().get(key); + if (json == null) return Optional.empty(); + return Optional.of(objectMapper.readValue(json, typeRef)); + } catch (Exception e) { + log.warn("Redis GET 실패, DB 폴백: key={}", key, e); + return Optional.empty(); + } + } + + public void set(String key, Object value, Duration ttl) { + try { + redisTemplate.opsForValue().set( + key, objectMapper.writeValueAsString(value), ttl); + } catch (Exception e) { + log.warn("Redis SET 실패, 무시: key={}", key, e); + } + } + + public void delete(String key) { + try { + redisTemplate.delete(key); + } catch (Exception e) { + log.warn("Redis DELETE 실패, 무시: key={}", key, e); + } + } + + public void deleteByPattern(String pattern) { + try { + Set keys = redisTemplate.keys(pattern); + if (keys != null && !keys.isEmpty()) { + redisTemplate.delete(keys); + } + } catch (Exception e) { + log.warn("Redis DELETE 패턴 실패, 무시: pattern={}", pattern, e); + } + } +} diff --git a/apps/commerce-api/src/main/resources/application.yml b/apps/commerce-api/src/main/resources/application.yml index 484c070d0..5743d25c9 100644 --- a/apps/commerce-api/src/main/resources/application.yml +++ b/apps/commerce-api/src/main/resources/application.yml @@ -34,6 +34,24 @@ spring: config: activate: on-profile: local, test + web: + resources: + static-locations: + - file:apps/commerce-api/src/main/resources/static/ + - classpath:/static/ + +app: + data-generator: + enabled: false + brand-count: 100 + product-count: 100000 + user-count: 10000 + like-count: 500000 + order-count: 100000 + soft-delete: + brand-percent: 10 + product-without-like-percent: 3 + product-with-like-percent: 2 --- spring: diff --git a/apps/commerce-api/src/main/resources/static/admin/css/styles.css b/apps/commerce-api/src/main/resources/static/admin/css/styles.css new file mode 100644 index 000000000..618625d47 --- /dev/null +++ b/apps/commerce-api/src/main/resources/static/admin/css/styles.css @@ -0,0 +1,317 @@ +/* === Reset & Base === */ +* { margin: 0; padding: 0; box-sizing: border-box; } +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: #f5f5f5; + color: #333; + display: flex; + min-height: 100vh; +} + +/* === Sidebar === */ +.sidebar { + width: 220px; + background: #1e293b; + color: #fff; + position: fixed; + height: 100vh; + overflow-y: auto; +} +.sidebar-header { + padding: 20px; + border-bottom: 1px solid #334155; +} +.sidebar-header h2 { font-size: 18px; font-weight: 700; } +.nav-menu { list-style: none; padding: 10px 0; } +.nav-link { + display: block; + padding: 12px 20px; + color: #94a3b8; + text-decoration: none; + font-size: 14px; + transition: all 0.2s; +} +.nav-link:hover { color: #fff; background: #334155; } +.nav-link.active { color: #fff; background: #3b82f6; font-weight: 600; } +.nav-link.highlight { color: #fbbf24; } +.nav-link.highlight.active { background: #d97706; color: #fff; } +.nav-divider { border-top: 1px solid #334155; margin: 8px 0; } + +/* === Content === */ +.content { + margin-left: 220px; + padding: 24px; + flex: 1; + min-width: 0; +} +.tab-panel { display: none; } +.tab-panel.active { display: block; } + +/* === Cards === */ +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 16px; + margin-bottom: 24px; +} +.stat-card { + background: #fff; + border-radius: 8px; + padding: 20px; + box-shadow: 0 1px 3px rgba(0,0,0,0.1); +} +.stat-card .label { font-size: 12px; color: #64748b; text-transform: uppercase; letter-spacing: 0.5px; } +.stat-card .value { font-size: 28px; font-weight: 700; margin-top: 4px; } + +/* === Panel === */ +.panel { + background: #fff; + border-radius: 8px; + padding: 20px; + box-shadow: 0 1px 3px rgba(0,0,0,0.1); + margin-bottom: 20px; +} +.panel-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; +} +.panel-header h2 { font-size: 18px; font-weight: 600; } + +/* === Table === */ +.table-wrap { overflow-x: auto; } +table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} +th, td { + text-align: left; + padding: 10px 12px; + border-bottom: 1px solid #e2e8f0; + white-space: nowrap; +} +th { background: #f8fafc; font-weight: 600; color: #475569; font-size: 12px; text-transform: uppercase; } +tr:hover td { background: #f1f5f9; } + +/* === Buttons === */ +.btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + border: none; + border-radius: 6px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; +} +.btn-primary { background: #3b82f6; color: #fff; } +.btn-primary:hover { background: #2563eb; } +.btn-danger { background: #ef4444; color: #fff; } +.btn-danger:hover { background: #dc2626; } +.btn-secondary { background: #e2e8f0; color: #475569; } +.btn-secondary:hover { background: #cbd5e1; } +.btn-success { background: #22c55e; color: #fff; } +.btn-success:hover { background: #16a34a; } +.btn-sm { padding: 4px 10px; font-size: 12px; } +.btn:disabled { opacity: 0.5; cursor: not-allowed; } + +/* === Forms === */ +.form-group { margin-bottom: 14px; } +.form-group label { display: block; font-size: 13px; font-weight: 500; margin-bottom: 4px; color: #475569; } +.form-group input, .form-group select, .form-group textarea { + width: 100%; + padding: 8px 12px; + border: 1px solid #d1d5db; + border-radius: 6px; + font-size: 14px; + outline: none; + transition: border 0.2s; +} +.form-group input:focus, .form-group select:focus { border-color: #3b82f6; } +.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } + +/* === Pagination === */ +.pagination { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + margin-top: 16px; +} +.pagination button { + padding: 6px 12px; + border: 1px solid #d1d5db; + border-radius: 4px; + background: #fff; + font-size: 13px; + cursor: pointer; +} +.pagination button.active { background: #3b82f6; color: #fff; border-color: #3b82f6; } +.pagination button:disabled { opacity: 0.4; cursor: not-allowed; } +.pagination .page-info { font-size: 13px; color: #64748b; } + +/* === Toolbar === */ +.toolbar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; } +.toolbar input, .toolbar select { + padding: 8px 12px; + border: 1px solid #d1d5db; + border-radius: 6px; + font-size: 13px; +} + +/* === Progress === */ +.progress-bar { + width: 100%; + height: 24px; + background: #e2e8f0; + border-radius: 12px; + overflow: hidden; + margin: 12px 0; +} +.progress-fill { + height: 100%; + background: linear-gradient(90deg, #3b82f6, #2563eb); + border-radius: 12px; + transition: width 0.3s; + display: flex; + align-items: center; + justify-content: center; + color: #fff; + font-size: 12px; + font-weight: 600; + min-width: 40px; +} + +/* === Log === */ +.log-area { + background: #1e293b; + color: #a5f3fc; + font-family: 'Menlo', 'Monaco', monospace; + font-size: 12px; + padding: 16px; + border-radius: 8px; + max-height: 300px; + overflow-y: auto; + line-height: 1.6; +} +.log-area .log-error { color: #fca5a5; } +.log-area .log-success { color: #86efac; } +.log-area .log-warn { color: #fde68a; } + +/* === Modal === */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} +.modal-overlay.hidden { display: none; } +.modal { + background: #fff; + border-radius: 12px; + width: 480px; + max-height: 80vh; + overflow-y: auto; + box-shadow: 0 20px 60px rgba(0,0,0,0.3); +} +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 20px; + border-bottom: 1px solid #e2e8f0; +} +.modal-header h3 { font-size: 16px; } +.modal-close { + background: none; + border: none; + font-size: 24px; + cursor: pointer; + color: #64748b; +} +.modal-body { padding: 20px; } + +/* === Toast === */ +.toast-container { position: fixed; bottom: 20px; right: 20px; z-index: 2000; } +.toast { + padding: 12px 20px; + border-radius: 8px; + color: #fff; + font-size: 13px; + margin-top: 8px; + animation: slideIn 0.3s ease; + box-shadow: 0 4px 12px rgba(0,0,0,0.15); +} +.toast.success { background: #22c55e; } +.toast.error { background: #ef4444; } +.toast.info { background: #3b82f6; } +@keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } } + +/* === Generator specific === */ +.gen-section { border: 1px solid #e2e8f0; border-radius: 8px; padding: 16px; margin-bottom: 16px; } +.gen-section h3 { font-size: 15px; margin-bottom: 12px; color: #1e293b; } +.gen-config { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px; margin-bottom: 12px; } +.gen-config label { font-size: 12px; color: #64748b; } +.gen-config input { width: 100%; padding: 8px; border: 1px solid #d1d5db; border-radius: 6px; font-size: 14px; } + +/* === Badge === */ +.badge { + display: inline-block; + padding: 2px 8px; + border-radius: 10px; + font-size: 11px; + font-weight: 600; +} +.badge-blue { background: #dbeafe; color: #1d4ed8; } +.badge-green { background: #dcfce7; color: #166534; } +.badge-red { background: #fee2e2; color: #991b1b; } +.badge-gray { background: #f1f5f9; color: #475569; } + +/* === Mode Tabs === */ +.mode-tabs { display: flex; gap: 4px; margin-bottom: 14px; } +.mode-tab { + padding: 8px 16px; + border: 1px solid #d1d5db; + border-radius: 6px; + background: #fff; + font-size: 13px; + cursor: pointer; + transition: all 0.2s; + color: #475569; +} +.mode-tab:hover { background: #f1f5f9; } +.mode-tab.active { background: #3b82f6; color: #fff; border-color: #3b82f6; font-weight: 600; } +.mode-panel { display: none; } +.mode-panel.active { display: block; } +.mode-desc { font-size: 12px; color: #64748b; margin-bottom: 10px; } + +/* === Checkbox Grid === */ +.brand-checklist { margin-top: 8px; } +.checkbox-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 4px; + max-height: 200px; + overflow-y: auto; + border: 1px solid #e2e8f0; + border-radius: 6px; + padding: 8px; +} +.checkbox-item { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + padding: 4px 8px; + border-radius: 4px; + cursor: pointer; +} +.checkbox-item:hover { background: #f1f5f9; } +.checkbox-item input { cursor: pointer; } diff --git a/apps/commerce-api/src/main/resources/static/admin/index.html b/apps/commerce-api/src/main/resources/static/admin/index.html new file mode 100644 index 000000000..853d47b40 --- /dev/null +++ b/apps/commerce-api/src/main/resources/static/admin/index.html @@ -0,0 +1,52 @@ + + + + + + Loopers 관리자 콘솔 + + + + + +
+
+
+
+
+
+
+
+
+ + + + + +
+ + + + diff --git a/apps/commerce-api/src/main/resources/static/admin/js/api.js b/apps/commerce-api/src/main/resources/static/admin/js/api.js new file mode 100644 index 000000000..8afa3b9e9 --- /dev/null +++ b/apps/commerce-api/src/main/resources/static/admin/js/api.js @@ -0,0 +1,76 @@ +const ADMIN_HEADER = { 'X-Loopers-Ldap': 'loopers.admin' }; + +async function request(method, url, body = null) { + const opts = { + method, + headers: { ...ADMIN_HEADER, 'Content-Type': 'application/json' }, + }; + if (body) opts.body = JSON.stringify(body); + + const res = await fetch(url, opts); + const json = await res.json(); + + if (json.meta.result !== 'SUCCESS') { + throw new Error(json.meta.message || json.meta.errorCode || 'API 요청 실패'); + } + return json.data; +} + +// === Brands === +export const BrandApi = { + list: (page = 0, size = 20) => request('GET', `/api-admin/v1/brands?page=${page}&size=${size}`), + get: (id) => request('GET', `/api-admin/v1/brands/${id}`), + create: (name) => request('POST', '/api-admin/v1/brands', { name }), + update: (id, name) => request('PUT', `/api-admin/v1/brands/${id}`, { name }), + delete: (id) => request('DELETE', `/api-admin/v1/brands/${id}`), +}; + +// === Products === +export const ProductApi = { + list: (page = 0, size = 20, brandId = null) => { + let url = `/api-admin/v1/products?page=${page}&size=${size}`; + if (brandId) url += `&brandId=${brandId}`; + return request('GET', url); + }, + get: (id) => request('GET', `/api-admin/v1/products/${id}`), + create: (data) => request('POST', '/api-admin/v1/products', data), + update: (id, data) => request('PUT', `/api-admin/v1/products/${id}`, data), + delete: (id) => request('DELETE', `/api-admin/v1/products/${id}`), +}; + +// === Orders === +export const OrderApi = { + list: (page = 0, size = 20) => request('GET', `/api-admin/v1/orders?page=${page}&size=${size}`), + get: (id) => request('GET', `/api-admin/v1/orders/${id}`), +}; + +// === Coupons === +export const CouponApi = { + list: (page = 0, size = 20) => request('GET', `/api-admin/v1/coupons?page=${page}&size=${size}`), + get: (id) => request('GET', `/api-admin/v1/coupons/${id}`), + create: (data) => request('POST', '/api-admin/v1/coupons', data), + update: (id, data) => request('PUT', `/api-admin/v1/coupons/${id}`, data), + delete: (id) => request('DELETE', `/api-admin/v1/coupons/${id}`), + issues: (id, page = 0, size = 20) => request('GET', `/api-admin/v1/coupons/${id}/issues?page=${page}&size=${size}`), +}; + +// === Users === +export const UserApi = { + list: (page = 0, size = 20) => request('GET', `/api-admin/v1/users?page=${page}&size=${size}`), + addPoint: (userId, amount) => request('POST', `/api-admin/v1/users/${userId}/point`, { amount }), + addPointAll: (amount) => request('POST', '/api-admin/v1/users/point', { amount }), +}; + +// === Data Generator === +export const DataGenApi = { + stats: () => request('GET', '/api-admin/v1/data-generator/stats'), + bulkInit: () => request('POST', '/api-admin/v1/data-generator/bulk-init'), + generateLikes: (productIds, likesPerProduct) => + request('POST', '/api-admin/v1/data-generator/likes', { productIds, likesPerProduct }), + generateUsers: (prefix, count, defaultPoint) => + request('POST', '/api-admin/v1/data-generator/users', { prefix, count, defaultPoint }), + generateOrders: (data) => + request('POST', '/api-admin/v1/data-generator/orders', data), + generateCoupons: (data) => + request('POST', '/api-admin/v1/data-generator/coupons', data), +}; diff --git a/apps/commerce-api/src/main/resources/static/admin/js/app.js b/apps/commerce-api/src/main/resources/static/admin/js/app.js new file mode 100644 index 000000000..66575294b --- /dev/null +++ b/apps/commerce-api/src/main/resources/static/admin/js/app.js @@ -0,0 +1,85 @@ +import { initDashboard } from './components/dashboard.js'; +import { initBrands } from './components/brands.js'; +import { initProducts } from './components/products.js'; +import { initOrders } from './components/orders.js'; +import { initCoupons } from './components/coupons.js'; +import { initDataGenerator } from './components/data-generator.js'; +import { initUsers } from './components/users.js'; + +// === Tab Router === +const tabs = { dashboard: initDashboard, brands: initBrands, products: initProducts, + orders: initOrders, coupons: initCoupons, users: initUsers, 'data-generator': initDataGenerator }; + +function switchTab(tabName) { + document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active')); + document.querySelectorAll('.nav-link').forEach(l => l.classList.remove('active')); + + const panel = document.getElementById(`tab-${tabName}`); + const link = document.querySelector(`[data-tab="${tabName}"]`); + if (panel) panel.classList.add('active'); + if (link) link.classList.add('active'); + + if (tabs[tabName]) tabs[tabName](); +} + +// === Modal === +window.Modal = { + open(title, bodyHtml) { + document.getElementById('modal-title').textContent = title; + document.getElementById('modal-body').innerHTML = bodyHtml; + document.getElementById('modal-overlay').classList.remove('hidden'); + }, + close() { + document.getElementById('modal-overlay').classList.add('hidden'); + } +}; + +// === Toast === +window.Toast = { + show(message, type = 'info') { + const container = document.getElementById('toast-container'); + const toast = document.createElement('div'); + toast.className = `toast ${type}`; + toast.textContent = message; + container.appendChild(toast); + setTimeout(() => toast.remove(), 3000); + }, + success(msg) { this.show(msg, 'success'); }, + error(msg) { this.show(msg, 'error'); }, + info(msg) { this.show(msg, 'info'); }, +}; + +// === Pagination Helper === +window.Pagination = { + render(containerId, currentPage, totalPages, onChange) { + const container = document.getElementById(containerId); + if (!container || totalPages <= 1) { if (container) container.innerHTML = ''; return; } + + let html = ``; + html += `${currentPage + 1} / ${totalPages}`; + html += ``; + container.innerHTML = html; + + container.querySelector('button:first-child')._prev = () => onChange(currentPage - 1); + container.querySelector('button:last-child')._next = () => onChange(currentPage + 1); + + container.querySelectorAll('button').forEach(btn => { + const fn = btn._prev || btn._next; + if (fn) btn.addEventListener('click', fn); + }); + } +}; + +// === Init === +document.querySelectorAll('.nav-link').forEach(link => { + link.addEventListener('click', (e) => { + e.preventDefault(); + switchTab(link.dataset.tab); + }); +}); + +document.getElementById('modal-overlay').addEventListener('click', (e) => { + if (e.target === e.currentTarget) Modal.close(); +}); + +switchTab('dashboard'); diff --git a/apps/commerce-api/src/main/resources/static/admin/js/components/brands.js b/apps/commerce-api/src/main/resources/static/admin/js/components/brands.js new file mode 100644 index 000000000..da8b61ebd --- /dev/null +++ b/apps/commerce-api/src/main/resources/static/admin/js/components/brands.js @@ -0,0 +1,88 @@ +import { BrandApi } from '../api.js'; + +let currentPage = 0; + +export async function initBrands() { + const panel = document.getElementById('tab-brands'); + panel.innerHTML = ` +
+
+

브랜드 관리

+ +
+
+ + +
ID이름생성일액션
+ +
`; + + document.getElementById('brand-add-btn').addEventListener('click', showCreateModal); + await loadBrands(0); +} + +async function loadBrands(page) { + currentPage = page; + try { + const data = await BrandApi.list(page, 20); + const tbody = document.getElementById('brand-tbody'); + tbody.innerHTML = data.items.length === 0 + ? '브랜드가 없습니다' + : data.items.map(b => ` + ${b.id} + ${b.name} + ${formatDate(b.createdAt)} + + + + + `).join(''); + + renderPagination('brand-pagination', data, loadBrands); + } catch (e) { Toast.error(e.message); } +} + +function showCreateModal() { + Modal.open('브랜드 등록', ` +
+ `); + document.getElementById('brand-save-btn').addEventListener('click', async () => { + const name = document.getElementById('brand-name-input').value.trim(); + if (!name) { Toast.error('브랜드명을 입력해주세요'); return; } + try { await BrandApi.create(name); Modal.close(); Toast.success('브랜드가 등록되었습니다'); await loadBrands(currentPage); } + catch (e) { Toast.error(e.message); } + }); +} + +window._brandEdit = (id, name) => { + Modal.open('브랜드 수정', ` +
+ `); + document.getElementById('brand-save-btn').addEventListener('click', async () => { + const newName = document.getElementById('brand-name-input').value.trim(); + if (!newName) { Toast.error('브랜드명을 입력해주세요'); return; } + try { await BrandApi.update(id, newName); Modal.close(); Toast.success('브랜드가 수정되었습니다'); await loadBrands(currentPage); } + catch (e) { Toast.error(e.message); } + }); +}; + +window._brandDel = async (id, name) => { + if (!confirm(`"${name}" 브랜드를 삭제하시겠습니까?`)) return; + try { await BrandApi.delete(id); Toast.success('브랜드가 삭제되었습니다'); await loadBrands(currentPage); } + catch (e) { Toast.error(e.message); } +}; + +function renderPagination(containerId, data, loadFn) { + const c = document.getElementById(containerId); + if (!c || data.totalPages <= 1) { if (c) c.innerHTML = ''; return; } + c.innerHTML = ` + + ${data.page + 1} / ${data.totalPages} (총 ${data.totalElements}건) + `; + const btns = c.querySelectorAll('button'); + btns[0].addEventListener('click', () => loadFn(data.page - 1)); + btns[1].addEventListener('click', () => loadFn(data.page + 1)); +} + +function formatDate(d) { return d ? new Date(d).toLocaleDateString('ko-KR') : '-'; } +function esc(s) { return (s || '').replace(/'/g, "\\'").replace(/"/g, '"'); } diff --git a/apps/commerce-api/src/main/resources/static/admin/js/components/coupons.js b/apps/commerce-api/src/main/resources/static/admin/js/components/coupons.js new file mode 100644 index 000000000..3f156a352 --- /dev/null +++ b/apps/commerce-api/src/main/resources/static/admin/js/components/coupons.js @@ -0,0 +1,114 @@ +import { CouponApi } from '../api.js'; + +let currentPage = 0; + +export async function initCoupons() { + const panel = document.getElementById('tab-coupons'); + panel.innerHTML = ` +
+
+

쿠폰 관리

+ +
+
+ + +
ID이름할인 유형할인 값발급/총수량만료일액션
+ +
`; + + document.getElementById('coupon-add-btn').addEventListener('click', showCreateModal); + await loadCoupons(0); +} + +async function loadCoupons(page) { + currentPage = page; + try { + const data = await CouponApi.list(page, 20); + const tbody = document.getElementById('coupon-tbody'); + tbody.innerHTML = data.items.length === 0 + ? '쿠폰이 없습니다' + : data.items.map(c => ` + ${c.id} + ${esc(c.name)} + ${c.discountType} + ${c.discountValue.toLocaleString()} + ${c.issuedQuantity} / ${c.totalQuantity} + ${formatDate(c.expiredAt)} + + + + + `).join(''); + + renderPagination('coupon-pagination', data, loadCoupons); + } catch (e) { Toast.error(e.message); } +} + +function showCreateModal() { + Modal.open('쿠폰 등록', ` +
+
+
+ +
+
+
+
+
+
+
+
+ `); + + const exp = document.getElementById('cpn-exp'); + const d = new Date(); d.setMonth(d.getMonth() + 1); + exp.value = d.toISOString().slice(0, 16); + + document.getElementById('cpn-save-btn').addEventListener('click', async () => { + try { + await CouponApi.create({ + name: document.getElementById('cpn-name').value.trim(), + discountType: document.getElementById('cpn-type').value, + discountValue: +document.getElementById('cpn-value').value, + minOrderAmount: +document.getElementById('cpn-min').value, + totalQuantity: +document.getElementById('cpn-qty').value, + expiredAt: new Date(document.getElementById('cpn-exp').value).toISOString(), + }); + Modal.close(); Toast.success('쿠폰이 등록되었습니다'); await loadCoupons(currentPage); + } catch (e) { Toast.error(e.message); } + }); +} + +window._couponIssues = async (id, name) => { + try { + const data = await CouponApi.issues(id, 0, 50); + const rows = (data.items || []).map(i => ` + ${i.ownedCouponId}${i.userId}${i.status} + ${formatDate(i.issuedAt)}`).join(''); + + Modal.open(`${name} - 발급 내역`, rows + ? `${rows}
ID유저상태발급일
` + : '

발급 내역이 없습니다

'); + } catch (e) { Toast.error(e.message); } +}; + +window._couponDel = async (id, name) => { + if (!confirm(`"${name}" 쿠폰을 삭제하시겠습니까?`)) return; + try { await CouponApi.delete(id); Toast.success('쿠폰이 삭제되었습니다'); await loadCoupons(currentPage); } + catch (e) { Toast.error(e.message); } +}; + +function renderPagination(containerId, data, loadFn) { + const c = document.getElementById(containerId); + if (!c || data.totalPages <= 1) { if (c) c.innerHTML = ''; return; } + c.innerHTML = ` + + ${data.page + 1} / ${data.totalPages} + `; + const btns = c.querySelectorAll('button'); + btns[0].addEventListener('click', () => loadFn(data.page - 1)); + btns[1].addEventListener('click', () => loadFn(data.page + 1)); +} +function formatDate(d) { return d ? new Date(d).toLocaleDateString('ko-KR') : '-'; } +function esc(s) { return (s || '').replace(/'/g, "\\'").replace(/"/g, '"'); } diff --git a/apps/commerce-api/src/main/resources/static/admin/js/components/dashboard.js b/apps/commerce-api/src/main/resources/static/admin/js/components/dashboard.js new file mode 100644 index 000000000..12f5284b8 --- /dev/null +++ b/apps/commerce-api/src/main/resources/static/admin/js/components/dashboard.js @@ -0,0 +1,69 @@ +import { DataGenApi } from '../api.js'; + +export async function initDashboard() { + const panel = document.getElementById('tab-dashboard'); + panel.innerHTML = ` +

대시보드

+
+
로딩 중...
-
+
+
+

바로가기

+
+ + + +
+
+
+

대용량 데이터 초기화

+

+ 100 브랜드, 10만 상품, 1만 유저, ~50만 좋아요, 10만 주문을 일괄 생성합니다. + 이미 데이터가 있으면 해당 Phase는 건너뜁니다. +

+
+ + +
+
`; + + document.getElementById('dash-bulk-init-btn').addEventListener('click', async () => { + const btn = document.getElementById('dash-bulk-init-btn'); + const status = document.getElementById('dash-bulk-init-status'); + btn.disabled = true; + status.textContent = '시작 중...'; + try { + const result = await DataGenApi.bulkInit(); + status.textContent = result.message; + // 3초마다 stats 갱신 + const interval = setInterval(async () => { + try { + await refreshDashStats(); + } catch (e) { /* ignore */ } + }, 3000); + // 3분 후 자동 중지 + setTimeout(() => { clearInterval(interval); btn.disabled = false; status.textContent = '완료 (Stats 확인)'; }, 180000); + } catch (e) { + status.textContent = '에러: ' + e.message; + btn.disabled = false; + } + }); + + await refreshDashStats(); +} + +async function refreshDashStats() { + try { + const stats = await DataGenApi.stats(); + document.getElementById('dash-stats').innerHTML = ` +
유저
${stats.userCount.toLocaleString()}
+
브랜드
${stats.brandCount.toLocaleString()}
+
상품
${stats.productCount.toLocaleString()}
+
좋아요
${stats.likeCount.toLocaleString()}
+
주문
${stats.orderCount.toLocaleString()}
+
쿠폰
${stats.couponCount.toLocaleString()}
+
발급 쿠폰
${stats.ownedCouponCount.toLocaleString()}
`; + } catch (e) { + document.getElementById('dash-stats').innerHTML = `
에러
${e.message}
`; + } +} diff --git a/apps/commerce-api/src/main/resources/static/admin/js/components/data-generator.js b/apps/commerce-api/src/main/resources/static/admin/js/components/data-generator.js new file mode 100644 index 000000000..99166be24 --- /dev/null +++ b/apps/commerce-api/src/main/resources/static/admin/js/components/data-generator.js @@ -0,0 +1,854 @@ +import { BrandApi, ProductApi, DataGenApi, UserApi } from '../api.js'; + +let isRunning = false; +let shouldStop = false; +let cachedBrands = []; +let cachedProducts = []; + +export async function initDataGenerator() { + const panel = document.getElementById('tab-data-generator'); + panel.innerHTML = ` +

Data Generator

+
+ + +
+

1. User 생성

+

테스트용 유저를 대량 생성합니다. (비밀번호: Test1234!)

+
+
+
+
+
+
+ +
+ + +
+

2. Brand 생성

+
+
+
+
+
+ +
+ + +
+

3. Product 생성

+
+ + + +
+ + +
+

모든 브랜드에 균등 분산하여 상품을 생성합니다.

+
+
+
+
+
+
+ + +
+

선택한 하나의 브랜드에 상품을 집중 생성합니다.

+
+
+
+
+
+ + +
+
+
+
+ + +
+

선택한 브랜드들에 각각 지정 수량의 상품을 생성합니다.

+
+
+
+
+ + +
+
+
+
+
+ +
+
+
+ + ~ + +
+
+
+ +
+ +
+ + +
+

4. Like 생성

+
+ + + +
+ + +
+

모든 상품에 좋아요를 분산 생성합니다.

+
+
+
+
+
+ + +
+

특정 상품 하나에 좋아요를 집중 생성합니다.

+
+
+
+
+
+ + +
+

상품 ID 범위 또는 목록을 지정하여 좋아요를 생성합니다.

+
+
+ +
+
+
+

형식: 1,2,3 (개별 지정) / 1-100 (범위 지정) / 혼합 가능: 1,5,10-20

+
+ +
+ +
+ +
+ + +
+

5. Order 생성

+

가상 유저들이 주문을 생성합니다. (포인트 자동 충전 → 정상 주문 플로우)

+
+ + + +
+ + +
+

모든 유저가 동일한 하나의 상품을 주문합니다.

+
+
+
+
+
+ + +
+

모든 유저가 지정한 여러 상품을 한 주문에 담습니다.

+
+
+
+
+
+
+
+ +
+ + +
+

유저별로 랜덤 상품을 선택해 주문합니다.

+
+
+
+
+ +
+
+
+
+
+ +
+ + +
+

6. Coupon 생성

+

쿠폰을 대량 생성하고, 선택 시 전체 유저에게 발급합니다.

+
+
+
+ +
+
+
+
+
+
+
+ +
+
+
+ +
+ + +
+ +
+ + +
+
+

실행 로그

+ +
+
+
`; + + // Mode tab switching + setupModeTabs('product-mode-tabs', 'product-mode'); + setupModeTabs('likes-mode-tabs', 'likes-mode'); + setupModeTabs('order-mode-tabs', 'order-mode'); + + // Button events + document.getElementById('gen-user-btn').addEventListener('click', generateUsers); + document.getElementById('gen-brand-btn').addEventListener('click', generateBrands); + document.getElementById('gen-product-btn').addEventListener('click', generateProducts); + document.getElementById('gen-likes-btn').addEventListener('click', generateLikes); + document.getElementById('gen-order-btn').addEventListener('click', generateOrders); + document.getElementById('gen-coupon-btn').addEventListener('click', generateCoupons); + document.getElementById('gen-stop-btn').addEventListener('click', () => { shouldStop = true; }); + + // Order multi-product add/remove + document.getElementById('gen-order-multi-add').addEventListener('click', addOrderMultiRow); + document.getElementById('gen-order-multi-items').addEventListener('click', (e) => { + if (e.target.classList.contains('order-multi-remove')) { + const rows = document.querySelectorAll('#gen-order-multi-items .order-item-row'); + if (rows.length > 1) e.target.closest('.order-item-row').remove(); + } + }); + + await loadBrandsCache(); + await loadProductsCache(); + await refreshStats(); +} + +// === Mode Tab Switching === +function setupModeTabs(tabsId, panelPrefix) { + const container = document.getElementById(tabsId); + container.querySelectorAll('.mode-tab').forEach(tab => { + tab.addEventListener('click', () => { + container.querySelectorAll('.mode-tab').forEach(t => t.classList.remove('active')); + tab.classList.add('active'); + document.querySelectorAll(`[id^="${panelPrefix}-"]`).forEach(p => p.classList.remove('active')); + document.getElementById(`${panelPrefix}-${tab.dataset.mode}`).classList.add('active'); + }); + }); +} + +function getActiveMode(tabsId) { + return document.querySelector(`#${tabsId} .mode-tab.active`).dataset.mode; +} + +// === Brand Cache === +async function loadBrandsCache() { + try { + const d = await BrandApi.list(0, 500); + cachedBrands = d.items; + populateBrandSelectors(); + } catch (e) { log(`브랜드 로드 실패: ${e.message}`, 'error'); } +} + +function populateBrandSelectors() { + // Single brand select + const sel = document.getElementById('gen-prod-single-brand'); + if (sel) { + sel.innerHTML = cachedBrands.map(b => ``).join(''); + } + // Multi brand checklist + const checklist = document.getElementById('gen-prod-multi-brands'); + if (checklist) { + if (cachedBrands.length === 0) { + checklist.innerHTML = '

브랜드가 없습니다. 먼저 생성해주세요.

'; + return; + } + checklist.innerHTML = ` +
+ + + 0개 선택 +
+
${cachedBrands.map(b => + `` + ).join('')}
`; + + document.getElementById('brand-check-all').addEventListener('click', () => { + checklist.querySelectorAll('.brand-cb').forEach(cb => cb.checked = true); + updateBrandCheckCount(); + }); + document.getElementById('brand-uncheck-all').addEventListener('click', () => { + checklist.querySelectorAll('.brand-cb').forEach(cb => cb.checked = false); + updateBrandCheckCount(); + }); + checklist.querySelectorAll('.brand-cb').forEach(cb => + cb.addEventListener('change', updateBrandCheckCount)); + } +} + +function updateBrandCheckCount() { + const count = document.querySelectorAll('.brand-cb:checked').length; + const el = document.getElementById('brand-check-count'); + if (el) el.textContent = `${count}개 선택`; +} + +function getSelectedBrandIds() { + return Array.from(document.querySelectorAll('.brand-cb:checked')).map(cb => +cb.value); +} + +// === Product Cache (for Order section) === +async function loadProductsCache() { + try { + const d = await ProductApi.list(0, 200); + cachedProducts = d.items || []; + populateOrderProductSelectors(); + } catch (e) { log(`상품 로드 실패: ${e.message}`, 'error'); } +} + +function populateOrderProductSelectors() { + const options = cachedProducts.length === 0 + ? '' + : cachedProducts.map(p => + `` + ).join(''); + + const singleSel = document.getElementById('gen-order-single-product'); + if (singleSel) singleSel.innerHTML = options; + + document.querySelectorAll('.order-multi-product').forEach(sel => sel.innerHTML = options); +} + +function addOrderMultiRow() { + const container = document.getElementById('gen-order-multi-items'); + const options = cachedProducts.length === 0 + ? '' + : cachedProducts.map(p => + `` + ).join(''); + + const row = document.createElement('div'); + row.className = 'order-item-row'; + row.style.cssText = 'display:flex;gap:8px;margin-bottom:8px;align-items:end'; + row.innerHTML = ` +
+
+
`; + container.appendChild(row); +} + +// === Stats === +async function refreshStats() { + try { + const stats = await DataGenApi.stats(); + document.getElementById('gen-stats').innerHTML = ` +
Users
${stats.userCount.toLocaleString()}
+
Brands
${stats.brandCount.toLocaleString()}
+
Products
${stats.productCount.toLocaleString()}
+
Likes
${stats.likeCount.toLocaleString()}
+
Orders
${stats.orderCount.toLocaleString()}
+
Coupons
${stats.couponCount.toLocaleString()}
+
Issued
${stats.ownedCouponCount.toLocaleString()}
`; + } catch (e) { log(`Stats 에러: ${e.message}`, 'error'); } +} + +// ============================== +// User Generation +// ============================== +async function generateUsers() { + if (isRunning) { Toast.error('이미 실행 중입니다'); return; } + const prefix = document.getElementById('gen-user-prefix').value || 'testuser'; + const count = +document.getElementById('gen-user-count').value; + const defaultPoint = +document.getElementById('gen-user-point').value; + if (count <= 0) return; + + startRun('gen-user-btn'); + showProgress('gen-user-progress-wrap'); + log(`유저 ${count}명 생성 시작 (접두사: ${prefix}, 기본 포인트: ${defaultPoint.toLocaleString()})...`); + + const t = Date.now(); + try { + updateProgress('gen-user-progress', 50, 100); + const result = await DataGenApi.generateUsers(prefix, count, defaultPoint); + updateProgress('gen-user-progress', 100, 100); + log(`완료: ${result.message} (${elapsed(t)})`, 'success'); + } catch (e) { + log(`유저 생성 실패: ${e.message}`, 'error'); + } + + endRun('gen-user-btn'); + await refreshStats(); +} + +// ============================== +// Brand Generation +// ============================== +async function generateBrands() { + if (isRunning) { Toast.error('이미 실행 중입니다'); return; } + const count = +document.getElementById('gen-brand-count').value; + const prefix = document.getElementById('gen-brand-prefix').value || 'Brand'; + if (count <= 0) return; + + startRun('gen-brand-btn'); + showProgress('gen-brand-progress-wrap'); + log(`브랜드 ${count}개 생성 시작...`); + + let created = 0, failed = 0; + const t = Date.now(); + + for (let i = 1; i <= count; i++) { + if (shouldStop) { log('사용자에 의해 중지됨', 'warn'); break; } + try { await BrandApi.create(`${prefix}_${Date.now()}_${i}`); created++; } + catch (e) { failed++; } + updateProgress('gen-brand-progress', i, count); + } + + log(`완료: ${created}개 생성, ${failed}개 실패 (${elapsed(t)})`, 'success'); + endRun('gen-brand-btn'); + await loadBrandsCache(); + await refreshStats(); +} + +// ============================== +// Product Generation +// ============================== +async function generateProducts() { + if (isRunning) { Toast.error('이미 실행 중입니다'); return; } + const mode = getActiveMode('product-mode-tabs'); + const stockMin = +document.getElementById('gen-prod-stockmin').value; + const stockMax = +document.getElementById('gen-prod-stockmax').value; + + let tasks = []; // [{brandId, count, priceMin, priceMax}] + + if (mode === 'all') { + const total = +document.getElementById('gen-prod-all-count').value; + const priceMin = +document.getElementById('gen-prod-all-pricemin').value; + const priceMax = +document.getElementById('gen-prod-all-pricemax').value; + if (total <= 0) return; + if (cachedBrands.length === 0) { Toast.error('브랜드가 없습니다. 먼저 생성해주세요.'); return; } + const perBrand = Math.ceil(total / cachedBrands.length); + let remaining = total; + for (const b of cachedBrands) { + const cnt = Math.min(perBrand, remaining); + if (cnt <= 0) break; + tasks.push({ brandId: b.id, brandName: b.name, count: cnt, priceMin, priceMax }); + remaining -= cnt; + } + } else if (mode === 'single') { + const brandId = +document.getElementById('gen-prod-single-brand').value; + const count = +document.getElementById('gen-prod-single-count').value; + const priceMin = +document.getElementById('gen-prod-single-pricemin').value; + const priceMax = +document.getElementById('gen-prod-single-pricemax').value; + if (!brandId || count <= 0) return; + const brand = cachedBrands.find(b => b.id === brandId); + tasks.push({ brandId, brandName: brand?.name || `ID:${brandId}`, count, priceMin, priceMax }); + } else if (mode === 'multi') { + const brandIds = getSelectedBrandIds(); + const countPerBrand = +document.getElementById('gen-prod-multi-count').value; + const priceMin = +document.getElementById('gen-prod-multi-pricemin').value; + const priceMax = +document.getElementById('gen-prod-multi-pricemax').value; + if (brandIds.length === 0) { Toast.error('브랜드를 선택해주세요.'); return; } + if (countPerBrand <= 0) return; + for (const id of brandIds) { + const brand = cachedBrands.find(b => b.id === id); + tasks.push({ brandId: id, brandName: brand?.name || `ID:${id}`, count: countPerBrand, priceMin, priceMax }); + } + } + + const totalCount = tasks.reduce((sum, t) => sum + t.count, 0); + if (totalCount === 0) return; + + startRun('gen-product-btn'); + showProgress('gen-product-progress-wrap'); + log(`상품 생성 시작 (모드: ${modeLabel('product', mode)}, 총 ${totalCount.toLocaleString()}개, ${tasks.length}개 브랜드)`); + + let created = 0, failed = 0, done = 0; + const t = Date.now(); + const concurrency = 10; + + for (const task of tasks) { + if (shouldStop) break; + log(` → ${task.brandName}: ${task.count}개 생성 중...`); + + for (let i = 0; i < task.count; i += concurrency) { + if (shouldStop) { log('사용자에 의해 중지됨', 'warn'); break; } + const batch = []; + for (let j = i; j < Math.min(i + concurrency, task.count); j++) { + const price = Math.round(randInt(task.priceMin, task.priceMax) / 100) * 100; + batch.push( + ProductApi.create({ + brandId: task.brandId, + name: `Product_${task.brandName}_${j + 1}_${randStr(4)}`, + price, + stock: randInt(stockMin, stockMax), + }).then(() => { created++; }).catch(() => { failed++; }) + ); + } + await Promise.all(batch); + done += batch.length; + updateProgress('gen-product-progress', done, totalCount); + + if (done % 500 === 0) { + log(` 진행: ${done.toLocaleString()}/${totalCount.toLocaleString()} (성공: ${created}, 실패: ${failed})`); + } + } + } + + log(`완료: ${created.toLocaleString()}개 생성, ${failed}개 실패 (${elapsed(t)})`, 'success'); + endRun('gen-product-btn'); + await refreshStats(); +} + +// ============================== +// Like Generation +// ============================== +async function generateLikes() { + if (isRunning) { Toast.error('이미 실행 중입니다'); return; } + const mode = getActiveMode('likes-mode-tabs'); + + if (mode === 'all') { + await generateLikesAll(); + } else if (mode === 'single') { + await generateLikesSingle(); + } else if (mode === 'range') { + await generateLikesRange(); + } +} + +async function generateLikesAll() { + const likesPerProduct = +document.getElementById('gen-likes-all-per').value; + const batchSize = +document.getElementById('gen-likes-all-batch').value; + if (likesPerProduct <= 0 || batchSize <= 0) return; + + startRun('gen-likes-btn'); + showProgress('gen-likes-progress-wrap'); + + let stats; + try { stats = await DataGenApi.stats(); } catch (e) { Toast.error(e.message); endRun('gen-likes-btn'); return; } + if (stats.productCount === 0) { Toast.error('상품이 없습니다.'); endRun('gen-likes-btn'); return; } + + const total = stats.productCount; + log(`전체 상품 ${total.toLocaleString()}개에 각 ~${likesPerProduct}개 좋아요 생성 시작...`); + + let totalCreated = 0, processed = 0; + const t = Date.now(); + const totalPages = Math.ceil(total / batchSize); + + for (let page = 0; page < totalPages; page++) { + if (shouldStop) { log('사용자에 의해 중지됨', 'warn'); break; } + try { + const productPage = await ProductApi.list(page, batchSize); + const productIds = productPage.items.map(p => p.id); + if (productIds.length === 0) break; + + const randomLikes = Math.max(1, likesPerProduct + randInt( + -Math.floor(likesPerProduct * 0.3), Math.floor(likesPerProduct * 0.3))); + const result = await DataGenApi.generateLikes(productIds, randomLikes); + totalCreated += result.totalCreated; + processed += productIds.length; + } catch (e) { + log(`배치 ${page + 1} 에러: ${e.message}`, 'error'); + processed += batchSize; + } + updateProgress('gen-likes-progress', processed, total); + if ((page + 1) % 10 === 0) log(` 진행: ${processed.toLocaleString()}/${total.toLocaleString()} 상품, ${totalCreated.toLocaleString()} 좋아요`); + } + + log(`완료: ${totalCreated.toLocaleString()}개 좋아요 생성 (${elapsed(t)})`, 'success'); + endRun('gen-likes-btn'); + await refreshStats(); +} + +async function generateLikesSingle() { + const productId = +document.getElementById('gen-likes-single-id').value; + const count = +document.getElementById('gen-likes-single-count').value; + if (!productId || count <= 0) { Toast.error('상품 ID와 좋아요 수를 입력해주세요.'); return; } + + startRun('gen-likes-btn'); + showProgress('gen-likes-progress-wrap'); + log(`상품 #${productId}에 좋아요 ${count.toLocaleString()}개 생성 시작...`); + + const t = Date.now(); + const batchSize = 1000; + let totalCreated = 0; + + for (let offset = 0; offset < count; offset += batchSize) { + if (shouldStop) { log('사용자에 의해 중지됨', 'warn'); break; } + const thisBatch = Math.min(batchSize, count - offset); + try { + const result = await DataGenApi.generateLikes([productId], thisBatch); + totalCreated += result.totalCreated; + } catch (e) { log(`에러: ${e.message}`, 'error'); } + updateProgress('gen-likes-progress', offset + thisBatch, count); + } + + log(`완료: 상품 #${productId}에 ${totalCreated.toLocaleString()}개 좋아요 생성 (${elapsed(t)})`, 'success'); + endRun('gen-likes-btn'); + await refreshStats(); +} + +async function generateLikesRange() { + const input = document.getElementById('gen-likes-range-ids').value.trim(); + const countPer = +document.getElementById('gen-likes-range-count').value; + if (!input || countPer <= 0) { Toast.error('상품 ID와 좋아요 수를 입력해주세요.'); return; } + + const productIds = parseIdRange(input); + if (productIds.length === 0) { Toast.error('유효한 상품 ID를 입력해주세요.'); return; } + + startRun('gen-likes-btn'); + showProgress('gen-likes-progress-wrap'); + log(`상품 ${productIds.length}개에 각 ${countPer.toLocaleString()}개 좋아요 생성 시작... (IDs: ${productIds.length <= 10 ? productIds.join(',') : productIds.slice(0, 10).join(',') + '...'})`); + + const t = Date.now(); + let totalCreated = 0; + const batchSize = 500; + + for (let i = 0; i < productIds.length; i += batchSize) { + if (shouldStop) { log('사용자에 의해 중지됨', 'warn'); break; } + const chunk = productIds.slice(i, i + batchSize); + try { + const result = await DataGenApi.generateLikes(chunk, countPer); + totalCreated += result.totalCreated; + } catch (e) { log(`배치 에러: ${e.message}`, 'error'); } + updateProgress('gen-likes-progress', Math.min(i + batchSize, productIds.length), productIds.length); + } + + log(`완료: ${totalCreated.toLocaleString()}개 좋아요 생성 (${elapsed(t)})`, 'success'); + endRun('gen-likes-btn'); + await refreshStats(); +} + +// ============================== +// Order Generation +// ============================== +async function generateOrders() { + if (isRunning) { Toast.error('이미 실행 중입니다'); return; } + const mode = getActiveMode('order-mode-tabs'); + const ordersPerUser = +document.getElementById('gen-order-per-user').value; + const maxUsers = +document.getElementById('gen-order-max-users').value; + if (ordersPerUser <= 0 || maxUsers <= 0) return; + + // Build request payload based on mode + let items = null; + let itemsPerOrder = null; + let requestMode = 'random'; + let modeDesc = ''; + + if (mode === 'single') { + const productId = +document.getElementById('gen-order-single-product').value; + const qty = +document.getElementById('gen-order-single-qty').value; + if (!productId) { log('상품을 선택해주세요.', 'error'); return; } + items = [{ productId, quantity: qty }]; + requestMode = 'specific'; + const product = cachedProducts.find(p => p.id === productId); + modeDesc = `단일 상품 (${product?.name || 'ID:' + productId}, 수량:${qty})`; + } else if (mode === 'multi') { + const rows = document.querySelectorAll('#gen-order-multi-items .order-item-row'); + items = []; + for (const row of rows) { + const productId = +row.querySelector('.order-multi-product').value; + const qty = +row.querySelector('.order-multi-qty').value; + if (productId && qty > 0) items.push({ productId, quantity: qty }); + } + if (items.length === 0) { log('상품을 추가해주세요.', 'error'); return; } + requestMode = 'specific'; + modeDesc = `다중 상품 (${items.length}개 상품)`; + } else { + itemsPerOrder = +document.getElementById('gen-order-random-items').value || 3; + requestMode = 'random'; + modeDesc = `랜덤 상품 (주문당 ${itemsPerOrder}개)`; + } + + startRun('gen-order-btn'); + showProgress('gen-order-progress-wrap'); + log(`주문 생성 시작 — ${modeDesc}, 유저당 ${ordersPerUser}건, 최대 ${maxUsers}명`); + + const t = Date.now(); + let totalCreated = 0, totalFailed = 0; + + try { + const userData = await UserApi.list(0, maxUsers); + const users = userData.items || []; + if (users.length === 0) { log('유저가 없습니다. 먼저 유저를 생성해주세요.', 'error'); endRun('gen-order-btn'); return; } + + log(` 대상 유저: ${users.length}명`); + const totalOrders = users.length * ordersPerUser; + let processed = 0; + const batchSize = 10; + + for (let i = 0; i < users.length; i += batchSize) { + if (shouldStop) { log('사용자에 의해 중지됨', 'warn'); break; } + const batch = users.slice(i, i + batchSize); + + for (let round = 0; round < ordersPerUser; round++) { + if (shouldStop) break; + const userIds = batch.map(u => u.id); + const body = { userIds, mode: requestMode }; + if (requestMode === 'specific') body.items = items; + if (requestMode === 'random') body.itemsPerOrder = itemsPerOrder; + + try { + const result = await DataGenApi.generateOrders(body); + totalCreated += result.totalCreated; + totalFailed += result.totalFailed; + } catch (e) { + log(`배치 에러: ${e.message}`, 'error'); + totalFailed += userIds.length; + } + processed += batch.length; + updateProgress('gen-order-progress', processed, totalOrders); + } + + if ((i + batchSize) % 50 === 0 || i + batchSize >= users.length) { + log(` 진행: ${processed}/${totalOrders} (성공: ${totalCreated}, 실패: ${totalFailed})`); + } + } + } catch (e) { + log(`주문 생성 에러: ${e.message}`, 'error'); + } + + log(`완료: ${totalCreated}건 생성, ${totalFailed}건 실패 (${elapsed(t)})`, 'success'); + endRun('gen-order-btn'); + await refreshStats(); +} + +// ============================== +// Coupon Generation +// ============================== +async function generateCoupons() { + if (isRunning) { Toast.error('이미 실행 중입니다'); return; } + const count = +document.getElementById('gen-coupon-count').value; + const discountType = document.getElementById('gen-coupon-type').value; + const discountValue = +document.getElementById('gen-coupon-value').value; + const minOrderAmount = +document.getElementById('gen-coupon-min-order').value || null; + const totalQuantityPerCoupon = +document.getElementById('gen-coupon-qty').value; + const issueToAllUsers = document.getElementById('gen-coupon-issue-all').checked; + + if (count <= 0 || discountValue <= 0 || totalQuantityPerCoupon <= 0) return; + + startRun('gen-coupon-btn'); + showProgress('gen-coupon-progress-wrap'); + log(`쿠폰 ${count}개 생성 시작 (${discountType} ${discountValue}${discountType === 'RATE' ? '%' : '원'}, 전체 발급: ${issueToAllUsers ? 'O' : 'X'})...`); + + const t = Date.now(); + try { + updateProgress('gen-coupon-progress', 30, 100); + const result = await DataGenApi.generateCoupons({ + count, discountType, discountValue, + minOrderAmount, totalQuantityPerCoupon, issueToAllUsers + }); + updateProgress('gen-coupon-progress', 100, 100); + log(`완료: ${result.message} (${elapsed(t)})`, 'success'); + } catch (e) { + log(`쿠폰 생성 실패: ${e.message}`, 'error'); + } + + endRun('gen-coupon-btn'); + await refreshStats(); +} + +// === ID Range Parser === +// "1,2,3" → [1,2,3] +// "1-5" → [1,2,3,4,5] +// "1,3,10-15" → [1,3,10,11,12,13,14,15] +function parseIdRange(input) { + const ids = new Set(); + input.split(',').forEach(part => { + part = part.trim(); + if (part.includes('-')) { + const [start, end] = part.split('-').map(Number); + if (!isNaN(start) && !isNaN(end)) { + for (let i = Math.min(start, end); i <= Math.max(start, end); i++) ids.add(i); + } + } else { + const n = Number(part); + if (!isNaN(n) && n > 0) ids.add(n); + } + }); + return Array.from(ids).sort((a, b) => a - b); +} + +// === Helpers === +function startRun(btnId) { + isRunning = true; shouldStop = false; + document.getElementById(btnId).disabled = true; + document.getElementById('gen-stop-btn').style.display = 'inline-flex'; +} +function endRun(btnId) { + isRunning = false; shouldStop = false; + document.getElementById(btnId).disabled = false; + document.getElementById('gen-stop-btn').style.display = 'none'; +} +function showProgress(wrapId) { document.getElementById(wrapId).style.display = 'block'; } +function updateProgress(fillId, current, total) { + const pct = Math.round((current / total) * 100); + const el = document.getElementById(fillId); + el.style.width = pct + '%'; + el.textContent = pct + '%'; +} +function log(msg, type = '') { + const el = document.getElementById('gen-log'); + if (!el) return; + const cls = type ? ` class="log-${type}"` : ''; + el.innerHTML += `[${new Date().toLocaleTimeString('ko-KR')}] ${msg}`; + el.scrollTop = el.scrollHeight; +} +function elapsed(start) { return ((Date.now() - start) / 1000).toFixed(1) + 's'; } +function randInt(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min; } +function randStr(len) { return Math.random().toString(36).substring(2, 2 + len); } +function modeLabel(section, mode) { + const labels = { + product: { all: '전체 분산', single: '특정 브랜드', multi: '브랜드별 지정' }, + likes: { all: '전체 분산', single: '특정 상품', range: '범위 지정' }, + order: { single: '단일 상품', multi: '다중 상품', random: '랜덤 상품' }, + }; + return labels[section]?.[mode] || mode; +} diff --git a/apps/commerce-api/src/main/resources/static/admin/js/components/orders.js b/apps/commerce-api/src/main/resources/static/admin/js/components/orders.js new file mode 100644 index 000000000..7202ace9f --- /dev/null +++ b/apps/commerce-api/src/main/resources/static/admin/js/components/orders.js @@ -0,0 +1,73 @@ +import { OrderApi } from '../api.js'; + +let currentPage = 0; + +export async function initOrders() { + const panel = document.getElementById('tab-orders'); + panel.innerHTML = ` +
+

주문 관리

+
+ + +
주문 ID결제 금액할인 금액상태주문일액션
+ +
`; + await loadOrders(0); +} + +async function loadOrders(page) { + currentPage = page; + try { + const data = await OrderApi.list(page, 20); + const tbody = document.getElementById('order-tbody'); + tbody.innerHTML = data.items.length === 0 + ? '주문이 없습니다' + : data.items.map(o => ` + ${o.orderId} + ${o.totalPrice.toLocaleString()}원 + ${o.discountAmount.toLocaleString()}원 + ${o.status} + ${formatDate(o.createdAt)} + + `).join(''); + + renderPagination('order-pagination', data, loadOrders); + } catch (e) { Toast.error(e.message); } +} + +window._orderDetail = async (id) => { + try { + const o = await OrderApi.get(id); + const itemsHtml = (o.items || []).map(i => ` + ${i.orderItemId}${esc(i.productName)}${esc(i.brandName)} + ${i.orderPrice.toLocaleString()}원${i.quantity}`).join(''); + + Modal.open(`주문 #${o.orderId} 상세`, ` +
+ 상태: ${o.status} +   결제 금액: ${o.totalPrice.toLocaleString()}원 +
+ + ${itemsHtml}
항목 ID상품명브랜드가격수량
`); + } catch (e) { Toast.error(e.message); } +}; + +function statusBadge(s) { + if (s === 'COMPLETED' || s === 'CONFIRMED') return 'badge-green'; + if (s === 'CANCELLED') return 'badge-red'; + return 'badge-gray'; +} +function renderPagination(containerId, data, loadFn) { + const c = document.getElementById(containerId); + if (!c || data.totalPages <= 1) { if (c) c.innerHTML = ''; return; } + c.innerHTML = ` + + ${data.page + 1} / ${data.totalPages} + `; + const btns = c.querySelectorAll('button'); + btns[0].addEventListener('click', () => loadFn(data.page - 1)); + btns[1].addEventListener('click', () => loadFn(data.page + 1)); +} +function formatDate(d) { return d ? new Date(d).toLocaleDateString('ko-KR') : '-'; } +function esc(s) { return (s || '').replace(/'/g, "\\'").replace(/"/g, '"'); } diff --git a/apps/commerce-api/src/main/resources/static/admin/js/components/products.js b/apps/commerce-api/src/main/resources/static/admin/js/components/products.js new file mode 100644 index 000000000..6df32e089 --- /dev/null +++ b/apps/commerce-api/src/main/resources/static/admin/js/components/products.js @@ -0,0 +1,138 @@ +import { ProductApi, BrandApi } from '../api.js'; + +let currentPage = 0; +let filterBrandId = null; + +export async function initProducts() { + const panel = document.getElementById('tab-products'); + panel.innerHTML = ` +
+
+

상품 관리

+ +
+
+ + +
+
+ + +
ID브랜드상품명가격재고생성일액션
+ +
`; + + document.getElementById('product-add-btn').addEventListener('click', showCreateModal); + document.getElementById('product-filter-btn').addEventListener('click', () => { + filterBrandId = document.getElementById('product-brand-filter').value || null; + loadProducts(0); + }); + + await loadBrandOptions(); + await loadProducts(0); +} + +async function loadBrandOptions() { + try { + const data = await BrandApi.list(0, 200); + const sel = document.getElementById('product-brand-filter'); + data.items.forEach(b => { + sel.insertAdjacentHTML('beforeend', ``); + }); + } catch (e) { /* ignore */ } +} + +async function loadProducts(page) { + currentPage = page; + try { + const data = await ProductApi.list(page, 20, filterBrandId); + const tbody = document.getElementById('product-tbody'); + tbody.innerHTML = data.items.length === 0 + ? '상품이 없습니다' + : data.items.map(p => ` + ${p.id} + ${esc(p.brandName)} + ${esc(p.name)} + ${p.price.toLocaleString()}원 + ${p.stock.toLocaleString()} + ${formatDate(p.createdAt)} + + + + + `).join(''); + + renderPagination('product-pagination', data, loadProducts); + } catch (e) { Toast.error(e.message); } +} + +async function showCreateModal() { + let brands = []; + try { const d = await BrandApi.list(0, 200); brands = d.items; } catch (e) { /* */ } + + const brandOpts = brands.map(b => ``).join(''); + Modal.open('상품 등록', ` +
+
+
+
+
+
+ `); + + document.getElementById('prod-save-btn').addEventListener('click', async () => { + try { + await ProductApi.create({ + brandId: +document.getElementById('prod-brand').value, + name: document.getElementById('prod-name').value.trim(), + price: +document.getElementById('prod-price').value, + stock: +document.getElementById('prod-stock').value, + }); + Modal.close(); Toast.success('상품이 등록되었습니다'); await loadProducts(currentPage); + } catch (e) { Toast.error(e.message); } + }); +} + +window._prodEdit = async (id) => { + try { + const p = await ProductApi.get(id); + Modal.open('상품 수정', ` +
+
+
+
+
+ `); + document.getElementById('prod-save-btn').addEventListener('click', async () => { + try { + await ProductApi.update(id, { + name: document.getElementById('prod-name').value.trim(), + price: +document.getElementById('prod-price').value, + stock: +document.getElementById('prod-stock').value, + }); + Modal.close(); Toast.success('상품이 수정되었습니다'); await loadProducts(currentPage); + } catch (e) { Toast.error(e.message); } + }); + } catch (e) { Toast.error(e.message); } +}; + +window._prodDel = async (id, name) => { + if (!confirm(`"${name}" 상품을 삭제하시겠습니까?`)) return; + try { await ProductApi.delete(id); Toast.success('상품이 삭제되었습니다'); await loadProducts(currentPage); } + catch (e) { Toast.error(e.message); } +}; + +function renderPagination(containerId, data, loadFn) { + const c = document.getElementById(containerId); + if (!c || data.totalPages <= 1) { if (c) c.innerHTML = ''; return; } + c.innerHTML = ` + + ${data.page + 1} / ${data.totalPages} (총 ${data.totalElements.toLocaleString()}건) + `; + const btns = c.querySelectorAll('button'); + btns[0].addEventListener('click', () => loadFn(data.page - 1)); + btns[1].addEventListener('click', () => loadFn(data.page + 1)); +} + +function formatDate(d) { return d ? new Date(d).toLocaleDateString('ko-KR') : '-'; } +function esc(s) { return (s || '').replace(/'/g, "\\'").replace(/"/g, '"'); } diff --git a/apps/commerce-api/src/main/resources/static/admin/js/components/users.js b/apps/commerce-api/src/main/resources/static/admin/js/components/users.js new file mode 100644 index 000000000..c52204d02 --- /dev/null +++ b/apps/commerce-api/src/main/resources/static/admin/js/components/users.js @@ -0,0 +1,120 @@ +import { UserApi } from '../api.js'; + +let currentPage = 0; + +export async function initUsers() { + const panel = document.getElementById('tab-users'); + panel.innerHTML = ` +
+
+

유저 관리

+ +
+
+ + +
ID아이디이름이메일포인트가입일액션
+ +
`; + + document.getElementById('user-point-all-btn').addEventListener('click', showAddPointAllModal); + await loadUsers(0); +} + +async function loadUsers(page) { + currentPage = page; + try { + const data = await UserApi.list(page, 20); + const tbody = document.getElementById('user-tbody'); + tbody.innerHTML = data.items.length === 0 + ? '유저가 없습니다' + : data.items.map(u => ` + ${u.id} + ${esc(u.loginId)} + ${esc(u.name)} + ${esc(u.email)} + ${u.point.toLocaleString()}P + ${formatDate(u.createdAt)} + + + + `).join(''); + + renderPagination('user-pagination', data, loadUsers); + } catch (e) { Toast.error(e.message); } +} + +function showAddPointAllModal() { + Modal.open('전체 유저 포인트 지급', ` +
+
+ + + + +
+ `); + + document.querySelectorAll('.quick-all').forEach(btn => { + btn.addEventListener('click', () => { + document.getElementById('all-point-amount').value = btn.dataset.amount; + }); + }); + + document.getElementById('all-point-save-btn').addEventListener('click', async () => { + const amount = +document.getElementById('all-point-amount').value; + if (amount <= 0) { Toast.error('금액을 입력해주세요'); return; } + try { + const result = await UserApi.addPointAll(amount); + Modal.close(); + Toast.success(result.message); + await loadUsers(currentPage); + } catch (e) { Toast.error(e.message); } + }); +} + +window._userAddPoint = (id, loginId, currentPoint) => { + Modal.open(`${loginId} 포인트 지급`, ` +
+
현재 보유 포인트
+
${currentPoint.toLocaleString()}P
+
+
+
+ + + +
+ `); + + document.querySelectorAll('.quick-one').forEach(btn => { + btn.addEventListener('click', () => { + document.getElementById('user-point-amount').value = btn.dataset.amount; + }); + }); + + document.getElementById('user-point-save-btn').addEventListener('click', async () => { + const amount = +document.getElementById('user-point-amount').value; + if (amount <= 0) { Toast.error('금액을 입력해주세요'); return; } + try { + const result = await UserApi.addPoint(id, amount); + Modal.close(); + Toast.success(result.message); + await loadUsers(currentPage); + } catch (e) { Toast.error(e.message); } + }); +}; + +function renderPagination(containerId, data, loadFn) { + const c = document.getElementById(containerId); + if (!c || data.totalPages <= 1) { if (c) c.innerHTML = ''; return; } + c.innerHTML = ` + + ${data.page + 1} / ${data.totalPages} (총 ${data.totalElements}명) + `; + const btns = c.querySelectorAll('button'); + btns[0].addEventListener('click', () => loadFn(data.page - 1)); + btns[1].addEventListener('click', () => loadFn(data.page + 1)); +} +function formatDate(d) { return d ? new Date(d).toLocaleDateString('ko-KR') : '-'; } +function esc(s) { return (s || '').replace(/'/g, "\\'").replace(/"/g, '"'); } diff --git a/apps/commerce-api/src/main/resources/static/shop/css/styles.css b/apps/commerce-api/src/main/resources/static/shop/css/styles.css new file mode 100644 index 000000000..1d4327856 --- /dev/null +++ b/apps/commerce-api/src/main/resources/static/shop/css/styles.css @@ -0,0 +1,208 @@ +/* === Reset & Base === */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } +body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f8f9fa; color: #1a1a2e; line-height: 1.6; } +a { color: inherit; text-decoration: none; } +button { cursor: pointer; border: none; font: inherit; } +input, select { font: inherit; } + +/* === Header === */ +.header { background: #fff; border-bottom: 1px solid #e5e7eb; position: sticky; top: 0; z-index: 100; } +.header-inner { max-width: 1200px; margin: 0 auto; padding: 0 20px; height: 60px; display: flex; align-items: center; gap: 32px; } +.logo { font-size: 20px; font-weight: 800; color: #6366f1; } +.nav { display: flex; gap: 8px; flex: 1; } +.nav-link { padding: 8px 16px; border-radius: 8px; font-weight: 500; color: #64748b; transition: all .2s; } +.nav-link:hover { background: #f1f5f9; color: #1a1a2e; } +.nav-link.active { background: #eef2ff; color: #6366f1; } +.header-actions { display: flex; align-items: center; gap: 12px; } +.header-actions .user-name { font-weight: 600; color: #334155; font-size: 14px; } +.header-actions .header-point { font-weight: 700; color: #6366f1; font-size: 14px; background: #eef2ff; padding: 4px 10px; border-radius: 16px; } + +/* === Main === */ +.main { max-width: 1200px; margin: 0 auto; padding: 24px 20px; min-height: calc(100vh - 60px); } + +/* === Buttons === */ +.btn { display: inline-flex; align-items: center; justify-content: center; padding: 10px 20px; border-radius: 8px; font-weight: 600; font-size: 14px; transition: all .2s; } +.btn-primary { background: #6366f1; color: #fff; } +.btn-primary:hover { background: #4f46e5; } +.btn-primary:disabled { background: #c7d2fe; cursor: not-allowed; } +.btn-secondary { background: #f1f5f9; color: #475569; } +.btn-secondary:hover { background: #e2e8f0; } +.btn-danger { background: #fef2f2; color: #dc2626; } +.btn-danger:hover { background: #fee2e2; } +.btn-outline { background: transparent; border: 1px solid #d1d5db; color: #475569; } +.btn-outline:hover { background: #f9fafb; } +.btn-sm { padding: 6px 12px; font-size: 13px; } +.btn-lg { padding: 12px 28px; font-size: 16px; } +.btn-icon { width: 36px; height: 36px; padding: 0; border-radius: 50%; } +.btn-like { background: none; border: 1px solid #e5e7eb; color: #94a3b8; } +.btn-like.liked { border-color: #f43f5e; color: #f43f5e; background: #fff1f2; } + +/* === Cards (Product Grid) === */ +.product-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 20px; } +.product-card { background: #fff; border-radius: 12px; overflow: hidden; border: 1px solid #e5e7eb; transition: all .2s; } +.product-card:hover { border-color: #c7d2fe; box-shadow: 0 4px 12px rgba(99,102,241,.1); transform: translateY(-2px); } +.product-card-img { width: 100%; height: 200px; background: linear-gradient(135deg, #e0e7ff, #c7d2fe); display: flex; align-items: center; justify-content: center; color: #6366f1; font-size: 48px; overflow: hidden; } +.product-card-img img { width: 100%; height: 100%; object-fit: cover; } +.product-card-body { padding: 16px; } +.product-card-brand { font-size: 12px; color: #6366f1; font-weight: 600; margin-bottom: 4px; } +.product-card-name { font-size: 15px; font-weight: 600; margin-bottom: 8px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.product-card-footer { display: flex; justify-content: space-between; align-items: center; } +.product-card-price { font-size: 18px; font-weight: 800; color: #1a1a2e; } +.product-card-likes { font-size: 13px; color: #f43f5e; display: flex; align-items: center; gap: 4px; } + +/* === Product Detail === */ +.product-detail { display: grid; grid-template-columns: 1fr 1fr; gap: 40px; background: #fff; border-radius: 12px; padding: 32px; border: 1px solid #e5e7eb; } +.product-detail-gallery { display: flex; flex-direction: column; gap: 12px; } +.gallery-main { width: 100%; aspect-ratio: 1; background: #f1f5f9; border-radius: 12px; overflow: hidden; } +.gallery-main img { width: 100%; height: 100%; object-fit: cover; } +.gallery-thumbs { display: flex; gap: 8px; } +.gallery-thumb { width: 72px; height: 72px; border-radius: 8px; overflow: hidden; border: 2px solid transparent; cursor: pointer; transition: border-color .2s; } +.gallery-thumb.active { border-color: #6366f1; } +.gallery-thumb:hover { border-color: #c7d2fe; } +.gallery-thumb img { width: 100%; height: 100%; object-fit: cover; } +.product-detail-info h1 { font-size: 24px; margin-bottom: 8px; } +.product-detail-brand { color: #6366f1; font-weight: 600; margin-bottom: 16px; } +.product-detail-price { font-size: 28px; font-weight: 800; margin-bottom: 24px; } +.product-detail-meta { display: flex; gap: 20px; margin-bottom: 24px; color: #64748b; font-size: 14px; } +.product-detail-actions { display: flex; gap: 12px; } + +/* === Product Detail Section (상품 상세 이미지) === */ +.product-detail-section { margin-top: 32px; background: #fff; border-radius: 12px; padding: 32px; border: 1px solid #e5e7eb; } +.product-detail-section h2 { font-size: 20px; margin-bottom: 20px; padding-bottom: 16px; border-bottom: 1px solid #e5e7eb; } +.detail-images { display: flex; flex-direction: column; align-items: center; gap: 4px; } +.detail-images img { width: 100%; max-width: 680px; border-radius: 4px; } + +/* === Shop Layout (Sidebar + Content) === */ +.shop-layout { display: grid; grid-template-columns: 200px 1fr; gap: 32px; } + +/* === Brand Sidebar === */ +.brand-sidebar { height: fit-content; position: sticky; top: 84px; max-height: calc(100vh - 100px); overflow-y: auto; } +.brand-sidebar::-webkit-scrollbar { width: 0; } +.brand-sidebar h3 { font-size: 13px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; margin-bottom: 16px; color: #1a1a2e; } +.brand-search { width: 100%; padding: 6px 0; border: none; border-bottom: 1px solid #e5e7eb; font-size: 13px; margin-bottom: 16px; outline: none; background: transparent; } +.brand-search::placeholder { color: #94a3b8; } +.brand-search:focus { border-bottom-color: #1a1a2e; } +.brand-list { list-style: none; } +.brand-item { display: block; padding: 3px 0; font-size: 13px; color: #64748b; cursor: pointer; transition: color .15s; line-height: 1.6; } +.brand-item:hover { color: #1a1a2e; } +.brand-item.active { color: #1a1a2e; font-weight: 600; } +.brand-clear { display: inline-block; margin-top: 12px; font-size: 12px; color: #94a3b8; cursor: pointer; text-decoration: underline; } +.brand-clear:hover { color: #1a1a2e; } + +/* === Sort & Filter Bar === */ +.filter-bar { display: flex; gap: 8px; margin-bottom: 24px; flex-wrap: wrap; align-items: center; } +.sort-chips { display: flex; gap: 6px; flex-wrap: wrap; } +.sort-chip { padding: 5px 14px; border-radius: 20px; font-size: 13px; font-weight: 500; background: transparent; color: #64748b; border: 1px solid #d1d5db; cursor: pointer; transition: all .2s; } +.sort-chip:hover { border-color: #1a1a2e; color: #1a1a2e; } +.sort-chip.active { background: #1a1a2e; color: #fff; border-color: #1a1a2e; } +.filter-bar .result-count { margin-left: auto; color: #64748b; font-size: 13px; } + +/* === Pagination === */ +.pagination { display: flex; justify-content: center; align-items: center; gap: 8px; margin-top: 32px; } +.pagination .page-info { color: #64748b; font-size: 14px; min-width: 80px; text-align: center; } + +/* === Auth Forms === */ +.auth-container { max-width: 420px; margin: 60px auto; } +.auth-card { background: #fff; border-radius: 12px; padding: 32px; border: 1px solid #e5e7eb; } +.auth-card h2 { text-align: center; margin-bottom: 24px; } +.auth-tabs { display: flex; margin-bottom: 24px; border-bottom: 2px solid #e5e7eb; } +.auth-tab { flex: 1; padding: 12px; text-align: center; font-weight: 600; color: #94a3b8; border-bottom: 2px solid transparent; margin-bottom: -2px; cursor: pointer; transition: all .2s; } +.auth-tab.active { color: #6366f1; border-bottom-color: #6366f1; } +.form-group { margin-bottom: 16px; } +.form-group label { display: block; font-size: 13px; font-weight: 600; color: #475569; margin-bottom: 6px; } +.form-group input { width: 100%; padding: 10px 14px; border: 1px solid #d1d5db; border-radius: 8px; transition: border-color .2s; } +.form-group input:focus { outline: none; border-color: #6366f1; box-shadow: 0 0 0 3px rgba(99,102,241,.1); } +.form-hint { font-size: 12px; color: #94a3b8; margin-top: 4px; } + +/* === MyPage === */ +.mypage-layout { display: grid; grid-template-columns: 220px 1fr; gap: 24px; } +.mypage-sidebar { background: #fff; border-radius: 12px; padding: 16px; border: 1px solid #e5e7eb; height: fit-content; position: sticky; top: 84px; } +.mypage-sidebar .user-info { padding: 16px; text-align: center; border-bottom: 1px solid #e5e7eb; margin-bottom: 12px; } +.mypage-sidebar .user-info .name { font-size: 18px; font-weight: 700; } +.mypage-sidebar .user-info .email { font-size: 13px; color: #94a3b8; } +.mypage-menu-item { display: block; padding: 10px 16px; border-radius: 8px; font-weight: 500; color: #64748b; transition: all .2s; cursor: pointer; } +.mypage-menu-item:hover { background: #f1f5f9; color: #1a1a2e; } +.mypage-menu-item.active { background: #eef2ff; color: #6366f1; } +.mypage-content { background: #fff; border-radius: 12px; padding: 24px; border: 1px solid #e5e7eb; } +.mypage-content h2 { margin-bottom: 20px; font-size: 20px; } + +/* === Tables === */ +.table-wrap { overflow-x: auto; } +table { width: 100%; border-collapse: collapse; } +thead th { background: #f8fafc; padding: 10px 14px; text-align: left; font-size: 13px; color: #64748b; font-weight: 600; border-bottom: 1px solid #e5e7eb; } +tbody td { padding: 12px 14px; border-bottom: 1px solid #f1f5f9; font-size: 14px; } +tbody tr:hover { background: #f8fafc; } + +/* === Badges === */ +.badge { display: inline-block; padding: 3px 10px; border-radius: 12px; font-size: 12px; font-weight: 600; } +.badge-blue { background: #eef2ff; color: #6366f1; } +.badge-green { background: #ecfdf5; color: #059669; } +.badge-red { background: #fef2f2; color: #dc2626; } +.badge-gray { background: #f1f5f9; color: #64748b; } +.badge-orange { background: #fff7ed; color: #ea580c; } + +/* === Modal === */ +.modal-overlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,.4); z-index: 200; align-items: center; justify-content: center; } +.modal-overlay.open { display: flex; } +.modal { background: #fff; border-radius: 16px; width: 90%; max-width: 540px; max-height: 80vh; overflow-y: auto; } +.modal-header { display: flex; justify-content: space-between; align-items: center; padding: 20px 24px; border-bottom: 1px solid #e5e7eb; } +.modal-header h3 { font-size: 18px; } +.modal-close { width: 32px; height: 32px; border-radius: 8px; background: #f1f5f9; display: flex; align-items: center; justify-content: center; font-size: 18px; color: #64748b; } +.modal-body { padding: 24px; } + +/* === Toast === */ +.toast-container { position: fixed; bottom: 24px; right: 24px; z-index: 300; display: flex; flex-direction: column; gap: 8px; } +.toast { padding: 12px 20px; border-radius: 10px; color: #fff; font-size: 14px; font-weight: 500; animation: slideIn .3s ease; min-width: 240px; } +.toast-success { background: #059669; } +.toast-error { background: #dc2626; } +@keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } } + +/* === Order Form === */ +.order-form { margin-top: 24px; padding: 20px; background: #f8fafc; border-radius: 12px; } +.order-form h3 { margin-bottom: 16px; } +.quantity-control { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; } +.quantity-control button { width: 36px; height: 36px; border-radius: 8px; background: #e2e8f0; font-size: 18px; font-weight: 700; } +.quantity-control span { font-size: 18px; font-weight: 700; min-width: 40px; text-align: center; } +.coupon-select { margin-bottom: 16px; } +.coupon-select select { width: 100%; padding: 10px; border: 1px solid #d1d5db; border-radius: 8px; } +.price-summary { display: flex; flex-direction: column; gap: 8px; padding: 16px; background: #fff; border-radius: 8px; margin-bottom: 16px; } +.price-row { display: flex; justify-content: space-between; font-size: 14px; color: #64748b; } +.price-row.total { font-size: 18px; font-weight: 800; color: #1a1a2e; border-top: 1px solid #e5e7eb; padding-top: 8px; } + +/* === Empty State === */ +.empty-state { text-align: center; padding: 60px 20px; color: #94a3b8; } +.empty-state .icon { font-size: 48px; margin-bottom: 12px; } +.empty-state p { font-size: 16px; } + +/* === Coupon Cards === */ +.coupon-grid { display: flex; flex-direction: column; gap: 12px; } +.coupon-card { display: flex; background: #fff; border: 1px solid #e5e7eb; border-radius: 12px; overflow: hidden; transition: all .2s; } +.coupon-card:hover { border-color: #c7d2fe; box-shadow: 0 2px 8px rgba(99,102,241,.08); } +.coupon-card.owned { opacity: .7; } +.coupon-card.sold-out { opacity: .5; } +.coupon-card-left { width: 140px; min-height: 100px; background: linear-gradient(135deg, #6366f1, #818cf8); display: flex; flex-direction: column; align-items: center; justify-content: center; color: #fff; flex-shrink: 0; border-right: 2px dashed #e5e7eb; } +.coupon-card-left .discount-value { font-size: 24px; font-weight: 800; } +.coupon-card-left .coupon-type { font-size: 12px; opacity: .8; margin-top: 4px; } +.coupon-card-right { flex: 1; padding: 16px 20px; display: flex; flex-direction: column; justify-content: center; gap: 6px; } +.coupon-card-right .coupon-name { font-size: 16px; font-weight: 700; } +.coupon-card-right .coupon-meta { font-size: 13px; color: #64748b; display: flex; gap: 12px; } +.coupon-card-right .coupon-action { margin-top: 4px; } + +/* My Coupon Modal */ +.my-coupon-list { display: flex; flex-direction: column; gap: 10px; } +.my-coupon-item { display: flex; gap: 14px; padding: 12px; border: 1px solid #e5e7eb; border-radius: 10px; align-items: center; } +.my-coupon-left { width: 80px; height: 50px; background: linear-gradient(135deg, #6366f1, #818cf8); border-radius: 8px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; } +.coupon-discount-sm { color: #fff; font-size: 14px; font-weight: 800; } +.my-coupon-right { display: flex; flex-direction: column; gap: 3px; } + +/* === Responsive === */ +@media (max-width: 768px) { + .product-detail { grid-template-columns: 1fr; gap: 20px; } + .mypage-layout { grid-template-columns: 1fr; } + .mypage-sidebar { position: static; } + .shop-layout { grid-template-columns: 1fr; } + .brand-sidebar { display: none; } + .header-inner { gap: 16px; } + .filter-bar { flex-direction: column; } + .filter-bar .result-count { margin-left: 0; } +} diff --git a/apps/commerce-api/src/main/resources/static/shop/index.html b/apps/commerce-api/src/main/resources/static/shop/index.html new file mode 100644 index 000000000..98c740900 --- /dev/null +++ b/apps/commerce-api/src/main/resources/static/shop/index.html @@ -0,0 +1,42 @@ + + + + + + Loopers Shop + + + + +
+ +
+ + +
+ + + + + +
+ + + + diff --git a/apps/commerce-api/src/main/resources/static/shop/js/api.js b/apps/commerce-api/src/main/resources/static/shop/js/api.js new file mode 100644 index 000000000..90b34b00c --- /dev/null +++ b/apps/commerce-api/src/main/resources/static/shop/js/api.js @@ -0,0 +1,72 @@ +import { Auth } from './auth.js'; + +async function request(method, url, body = null, requireAuth = false, extraHeaders = {}) { + const headers = { 'Content-Type': 'application/json', ...extraHeaders }; + if (requireAuth) Object.assign(headers, Auth.getHeaders()); + + const opts = { method, headers }; + if (body) opts.body = JSON.stringify(body); + + const res = await fetch(url, opts); + const json = await res.json(); + + if (json.meta.result !== 'SUCCESS') { + throw new Error(json.meta.message || json.meta.errorCode || 'API 요청 실패'); + } + return json.data; +} + +// === Products === +export const ProductApi = { + list: (page = 0, size = 20, brandId = null, sort = null) => { + let url = `/api/v1/products?page=${page}&size=${size}`; + if (brandId) url += `&brandId=${brandId}`; + if (sort) url += `&sort=${sort}`; + return request('GET', url); + }, + get: (id) => request('GET', `/api/v1/products/${id}`), +}; + +// === Brands (admin API for filter list) === +const ADMIN_HEADER = { 'X-Loopers-Ldap': 'loopers.admin' }; +export const BrandApi = { + list: (page = 0, size = 200) => + request('GET', `/api-admin/v1/brands?page=${page}&size=${size}`, null, false, ADMIN_HEADER), +}; + +// === Likes === +export const LikeApi = { + add: (productId) => request('POST', `/api/v1/products/${productId}/likes`, null, true), + remove: (productId) => request('DELETE', `/api/v1/products/${productId}/likes`, null, true), + myList: () => request('GET', '/api/v1/users/me/likes', null, true), +}; + +// === Orders === +export const OrderApi = { + create: (data) => request('POST', '/api/v1/orders', data, true), + list: (startAt = null, endAt = null) => { + const start = startAt || new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString(); + const end = endAt || new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(); + return request('GET', `/api/v1/orders?startAt=${encodeURIComponent(start)}&endAt=${encodeURIComponent(end)}`, null, true); + }, + get: (id) => request('GET', `/api/v1/orders/${id}`, null, true), + cancelItem: (orderId, orderItemId) => + request('PATCH', `/api/v1/orders/${orderId}/items/${orderItemId}/cancel`, null, true), +}; + +// === Coupons === +export const CouponApi = { + available: (page = 0, size = 20) => + request('GET', `/api-admin/v1/coupons?page=${page}&size=${size}`, null, false, ADMIN_HEADER), + issue: (couponId) => request('POST', `/api/v1/coupons/${couponId}/issue`, null, true), + myList: () => request('GET', '/api/v1/users/me/coupons', null, true), +}; + +// === Users === +export const UserApi = { + signup: (data) => request('POST', '/api/v1/users/signup', data), + me: () => request('GET', '/api/v1/users/me', null, true), + changePassword: (data) => request('PATCH', '/api/v1/users/password', data, true), + getPoint: () => request('GET', '/api/v1/users/me/point', null, true), + chargePoint: (amount) => request('POST', '/api/v1/users/me/point', { amount }, true), +}; diff --git a/apps/commerce-api/src/main/resources/static/shop/js/app.js b/apps/commerce-api/src/main/resources/static/shop/js/app.js new file mode 100644 index 000000000..c0655be35 --- /dev/null +++ b/apps/commerce-api/src/main/resources/static/shop/js/app.js @@ -0,0 +1,103 @@ +import { Auth } from './auth.js'; +import { UserApi } from './api.js'; +import { initHome } from './pages/home.js'; +import { initProduct } from './pages/product.js'; +import { initLogin } from './pages/login.js'; +import { initMyPage } from './pages/mypage.js'; +import { initCoupons } from './pages/coupons.js'; + +// === Globals === +window.Modal = { + open(title, html) { + document.getElementById('modal-title').textContent = title; + document.getElementById('modal-body').innerHTML = html; + document.getElementById('modal-overlay').classList.add('open'); + }, + close() { + document.getElementById('modal-overlay').classList.remove('open'); + }, +}; +document.getElementById('modal-close').addEventListener('click', Modal.close); +document.getElementById('modal-overlay').addEventListener('click', (e) => { + if (e.target === e.currentTarget) Modal.close(); +}); + +window.Toast = { + show(msg, type = 'success') { + const c = document.getElementById('toast-container'); + const el = document.createElement('div'); + el.className = `toast toast-${type}`; + el.textContent = msg; + c.appendChild(el); + setTimeout(() => el.remove(), 3000); + }, + success(msg) { this.show(msg, 'success'); }, + error(msg) { this.show(msg, 'error'); }, +}; + +// === Header Auth UI === +async function updateHeaderActions() { + const el = document.getElementById('header-actions'); + if (Auth.isLoggedIn()) { + let pointText = ''; + try { + const data = await UserApi.getPoint(); + pointText = `${data.point.toLocaleString()}P`; + } catch { /* ignore */ } + el.innerHTML = ` + ${pointText} + ${esc(Auth.getName() || Auth.get().loginId)}님 + `; + document.getElementById('logout-btn').addEventListener('click', () => { + Auth.clear(); + updateHeaderActions(); + navigate('home'); + }); + } else { + el.innerHTML = `로그인`; + } +} + +// === Router === +const routes = { + home: initHome, + product: initProduct, + login: initLogin, + coupons: initCoupons, + mypage: initMyPage, +}; + +function navigate(page, params = {}) { + window._routeParams = params; + location.hash = page; +} +window.navigate = navigate; + +async function router() { + const hash = location.hash.slice(1) || 'home'; + const [page, ...rest] = hash.split('/'); + const params = { id: rest[0], ...window._routeParams }; + window._routeParams = {}; + + // Auth guard + if ((page === 'mypage') && !Auth.isLoggedIn()) { + Toast.error('로그인이 필요합니다'); + location.hash = 'login'; + return; + } + + // Nav active + document.querySelectorAll('.nav-link').forEach(el => { + el.classList.toggle('active', el.dataset.page === page); + }); + + updateHeaderActions(); + + const init = routes[page] || routes.home; + await init(params); +} + +window.addEventListener('hashchange', router); +router(); + +function esc(s) { return (s || '').replace(//g, '>'); } diff --git a/apps/commerce-api/src/main/resources/static/shop/js/auth.js b/apps/commerce-api/src/main/resources/static/shop/js/auth.js new file mode 100644 index 000000000..03e80cdd8 --- /dev/null +++ b/apps/commerce-api/src/main/resources/static/shop/js/auth.js @@ -0,0 +1,35 @@ +const STORAGE_KEY = 'loopers_auth'; + +export const Auth = { + get() { + try { + return JSON.parse(localStorage.getItem(STORAGE_KEY)); + } catch { return null; } + }, + + save(loginId, password, name) { + localStorage.setItem(STORAGE_KEY, JSON.stringify({ loginId, password, name })); + }, + + clear() { + localStorage.removeItem(STORAGE_KEY); + }, + + isLoggedIn() { + return !!this.get(); + }, + + getHeaders() { + const auth = this.get(); + if (!auth) return {}; + return { + 'X-Loopers-LoginId': auth.loginId, + 'X-Loopers-LoginPw': auth.password, + }; + }, + + getName() { + const auth = this.get(); + return auth ? auth.name : null; + }, +}; diff --git a/apps/commerce-api/src/main/resources/static/shop/js/pages/coupons.js b/apps/commerce-api/src/main/resources/static/shop/js/pages/coupons.js new file mode 100644 index 000000000..f3f4dbe7a --- /dev/null +++ b/apps/commerce-api/src/main/resources/static/shop/js/pages/coupons.js @@ -0,0 +1,166 @@ +import { CouponApi } from '../api.js'; +import { Auth } from '../auth.js'; + +let currentPage = 0; +let myCouponIds = new Set(); + +export async function initCoupons() { + const app = document.getElementById('app'); + app.innerHTML = ` +
+

쿠폰 다운로드

+ ${Auth.isLoggedIn() + ? '' + : ''} +
+
+ `; + + if (Auth.isLoggedIn()) { + await loadMyCoupons(); + document.getElementById('my-coupons-btn').addEventListener('click', showMyCoupons); + } + + await loadAvailableCoupons(0); +} + +async function loadMyCoupons() { + try { + const data = await CouponApi.myList(); + myCouponIds = new Set((data.items || []).map(c => c.couponId)); + } catch { myCouponIds = new Set(); } +} + +async function loadAvailableCoupons(page) { + currentPage = page; + const el = document.getElementById('coupon-list'); + + try { + const data = await CouponApi.available(page, 20); + const now = new Date(); + const coupons = (data.items || []).filter(c => new Date(c.expiredAt) > now); + + if (coupons.length === 0) { + el.innerHTML = '

다운로드 가능한 쿠폰이 없습니다

'; + return; + } + + el.innerHTML = `
${coupons.map(c => { + const remaining = c.totalQuantity - c.issuedQuantity; + const isOwned = myCouponIds.has(c.id); + const isSoldOut = remaining <= 0; + return ` +
+
+
+ ${c.discountType === 'RATE' + ? `${c.discountValue}%` + : `${c.discountValue.toLocaleString()}원`} +
+
${c.discountType === 'RATE' ? '정률 할인' : '정액 할인'}
+
+
+
${esc(c.name)}
+
+ 잔여 ${remaining.toLocaleString()} / ${c.totalQuantity.toLocaleString()}장 + ~${formatDate(c.expiredAt)} +
+
+ ${isOwned + ? '다운로드 완료' + : isSoldOut + ? '소진됨' + : ``} +
+
+
`; + }).join('')}
`; + + el.querySelectorAll('.coupon-download-btn').forEach(btn => { + btn.addEventListener('click', () => downloadCoupon(+btn.dataset.id, btn.dataset.name, btn)); + }); + + renderPagination(data); + } catch (e) { el.innerHTML = `

${esc(e.message)}

`; } +} + +async function downloadCoupon(couponId, couponName, btn) { + if (!Auth.isLoggedIn()) { + Toast.error('로그인이 필요합니다'); + location.hash = 'login'; + return; + } + + btn.disabled = true; + btn.textContent = '처리 중...'; + + try { + await CouponApi.issue(couponId); + myCouponIds.add(couponId); + Toast.success(`"${couponName}" 쿠폰을 다운로드했습니다!`); + // 버튼을 "다운로드 완료"로 교체 + const action = btn.closest('.coupon-action'); + action.innerHTML = '다운로드 완료'; + btn.closest('.coupon-card').classList.add('owned'); + } catch (e) { + Toast.error(e.message); + btn.disabled = false; + btn.textContent = '다운로드'; + } +} + +async function showMyCoupons() { + try { + const data = await CouponApi.myList(); + const items = data.items || []; + + if (items.length === 0) { + Modal.open('내 쿠폰', '

보유한 쿠폰이 없습니다

'); + return; + } + + const rows = items.map(c => ` +
+
+
+ ${c.discountType === 'RATE' + ? `${c.discountValue}%` + : `${c.discountValue.toLocaleString()}원`} +
+
+
+
${esc(c.couponName)}
+
+ ${c.minOrderAmount ? c.minOrderAmount.toLocaleString() + '원 이상 주문 시' : '조건 없음'} + · ~${formatDate(c.expiredAt)} +
+ ${couponStatusLabel(c.status)} +
+
`).join(''); + + Modal.open(`내 쿠폰 (${items.length}장)`, `
${rows}
`); + } catch (e) { Toast.error(e.message); } +} + +function renderPagination(data) { + const c = document.getElementById('coupon-pagination'); + if (data.totalPages <= 1) { c.innerHTML = ''; return; } + c.innerHTML = ` + + ${data.page + 1} / ${data.totalPages} + `; + document.getElementById('cpg-prev').addEventListener('click', () => loadAvailableCoupons(data.page - 1)); + document.getElementById('cpg-next').addEventListener('click', () => loadAvailableCoupons(data.page + 1)); +} + +function couponStatusBadge(s) { + if (s === 'AVAILABLE' || s === 'ISSUED') return 'badge-green'; + if (s === 'USED') return 'badge-gray'; + return 'badge-red'; +} +function couponStatusLabel(s) { + const map = { AVAILABLE: '사용 가능', ISSUED: '사용 가능', USED: '사용 완료', EXPIRED: '만료됨' }; + return map[s] || s; +} +function formatDate(d) { return d ? new Date(d).toLocaleDateString('ko-KR') : '-'; } +function esc(s) { return (s || '').replace(//g, '>').replace(/"/g, '"'); } diff --git a/apps/commerce-api/src/main/resources/static/shop/js/pages/home.js b/apps/commerce-api/src/main/resources/static/shop/js/pages/home.js new file mode 100644 index 000000000..e1437a3ce --- /dev/null +++ b/apps/commerce-api/src/main/resources/static/shop/js/pages/home.js @@ -0,0 +1,163 @@ +import { ProductApi, BrandApi } from '../api.js'; + +let currentPage = 0; +let currentBrandId = null; +let currentSort = null; +let brands = []; + +const SORT_OPTIONS = [ + { value: '', label: '기본' }, + { value: 'price_asc', label: '가격 낮은순' }, + { value: 'price_desc', label: '가격 높은순' }, + { value: 'likes_desc', label: '좋아요순' }, +]; + +export async function initHome() { + const app = document.getElementById('app'); + app.innerHTML = ` +
+ +
+
+
+ ${SORT_OPTIONS.map(o => ` + + `).join('')} +
+ +
+
+ +
+
`; + + document.getElementById('sort-chips').addEventListener('click', (e) => { + const chip = e.target.closest('.sort-chip'); + if (!chip) return; + document.querySelectorAll('.sort-chip').forEach(c => c.classList.remove('active')); + chip.classList.add('active'); + currentSort = chip.dataset.sort || null; + loadProducts(0); + }); + + document.getElementById('brand-search').addEventListener('input', (e) => { + filterBrandList(e.target.value.trim().toLowerCase()); + }); + + await loadBrands(); + await loadProducts(currentPage); +} + +function getChosung(str) { + const cho = ['ㄱ','ㄲ','ㄴ','ㄷ','ㄸ','ㄹ','ㅁ','ㅂ','ㅃ','ㅅ','ㅆ', + 'ㅇ','ㅈ','ㅉ','ㅊ','ㅋ','ㅌ','ㅍ','ㅎ']; + const code = str.charCodeAt(0) - 0xAC00; + if (code < 0 || code > 11171) return str.charAt(0).toUpperCase(); + return cho[Math.floor(code / 588)]; +} + +function sortBrands(brandList) { + return [...brandList].sort((a, b) => { + const aKor = a.name.charCodeAt(0) >= 0xAC00 && a.name.charCodeAt(0) <= 0xD7A3; + const bKor = b.name.charCodeAt(0) >= 0xAC00 && b.name.charCodeAt(0) <= 0xD7A3; + if (aKor && !bKor) return -1; + if (!aKor && bKor) return 1; + return a.name.localeCompare(b.name, aKor ? 'ko' : 'en'); + }); +} + +async function loadBrands() { + try { + const data = await BrandApi.list(0, 200); + brands = sortBrands(data.items); + renderBrandList(brands); + } catch { /* ignore */ } +} + +function renderBrandList(list) { + const ul = document.getElementById('brand-list'); + let html = `
  • 전체
  • `; + list.forEach(b => { + html += `
  • ${esc(b.name)}
  • `; + }); + ul.innerHTML = html; + + ul.addEventListener('click', (e) => { + const item = e.target.closest('.brand-item'); + if (!item) return; + ul.querySelectorAll('.brand-item').forEach(i => i.classList.remove('active')); + item.classList.add('active'); + currentBrandId = item.dataset.id || null; + loadProducts(0); + }); +} + +function filterBrandList(query) { + const ul = document.getElementById('brand-list'); + const items = ul.querySelectorAll('.brand-item'); + items.forEach(item => { + if (!item.dataset.id) { item.style.display = ''; return; } + const name = item.textContent.toLowerCase(); + item.style.display = name.includes(query) ? '' : 'none'; + }); +} + +async function loadProducts(page) { + currentPage = page; + try { + const data = await ProductApi.list(page, 20, currentBrandId, currentSort); + const grid = document.getElementById('product-grid'); + + document.getElementById('result-count').textContent = + `총 ${data.totalElements.toLocaleString()}개 상품`; + + if (data.items.length === 0) { + grid.innerHTML = '
    📦

    상품이 없습니다

    '; + } else { + grid.innerHTML = data.items.map(p => ` + +
    ${p.thumbnailUrl + ? `${esc(p.name)}` + : getInitial(p.name)}
    +
    +
    ${esc(p.brandName)}
    +
    ${esc(p.name)}
    + +
    +
    `).join(''); + } + + renderPagination(data); + } catch (e) { Toast.error(e.message); } +} + +function renderPagination(data) { + const c = document.getElementById('pagination'); + if (data.totalPages <= 1) { c.innerHTML = ''; return; } + c.innerHTML = ` + + ${data.page + 1} / ${data.totalPages} + `; + document.getElementById('pg-prev').addEventListener('click', () => loadProducts(data.page - 1)); + document.getElementById('pg-next').addEventListener('click', () => loadProducts(data.page + 1)); +} + +function getInitial(name) { + return (name || '?').charAt(0); +} + +function formatCount(n) { + if (n >= 10000) return (n / 10000).toFixed(1) + '만'; + if (n >= 1000) return (n / 1000).toFixed(1) + '천'; + return n; +} + +function esc(s) { return (s || '').replace(//g, '>'); } diff --git a/apps/commerce-api/src/main/resources/static/shop/js/pages/login.js b/apps/commerce-api/src/main/resources/static/shop/js/pages/login.js new file mode 100644 index 000000000..58dba78cc --- /dev/null +++ b/apps/commerce-api/src/main/resources/static/shop/js/pages/login.js @@ -0,0 +1,120 @@ +import { UserApi } from '../api.js'; +import { Auth } from '../auth.js'; + +export async function initLogin() { + if (Auth.isLoggedIn()) { location.hash = 'home'; return; } + + const app = document.getElementById('app'); + app.innerHTML = ` +
    +
    +

    Loopers Shop

    +
    +
    로그인
    +
    회원가입
    +
    +
    +
    +
    `; + + const tabs = app.querySelectorAll('.auth-tab'); + tabs.forEach(tab => tab.addEventListener('click', () => { + tabs.forEach(t => t.classList.remove('active')); + tab.classList.add('active'); + if (tab.dataset.tab === 'login') renderLogin(); + else renderSignup(); + })); + + renderLogin(); +} + +function renderLogin() { + const form = document.getElementById('auth-form'); + form.innerHTML = ` +
    + + +
    +
    + + +
    + `; + + const loginAction = async () => { + const loginId = document.getElementById('login-id').value.trim(); + const password = document.getElementById('login-pw').value; + if (!loginId || !password) { Toast.error('아이디와 비밀번호를 입력해주세요'); return; } + + try { + // 헤더 인증 방식이므로 임시 저장 후 me API 호출로 검증 + Auth.save(loginId, password, loginId); + const me = await UserApi.me(); + Auth.save(loginId, password, me.name || loginId); + Toast.success('로그인되었습니다'); + location.hash = 'home'; + } catch (e) { + Auth.clear(); + Toast.error('로그인에 실패했습니다: ' + e.message); + } + }; + + document.getElementById('login-btn').addEventListener('click', loginAction); + document.getElementById('login-pw').addEventListener('keydown', (e) => { + if (e.key === 'Enter') loginAction(); + }); +} + +function renderSignup() { + const form = document.getElementById('auth-form'); + form.innerHTML = ` +
    + + +
    4~12자, 영문/숫자만 가능
    +
    +
    + + +
    8~16자, 대/소문자+숫자+특수문자 포함
    +
    +
    + + +
    +
    + + +
    yyyyMMdd 형식 (예: 19900101)
    +
    +
    + + +
    + `; + + document.getElementById('signup-btn').addEventListener('click', async () => { + const data = { + loginId: document.getElementById('su-id').value.trim(), + password: document.getElementById('su-pw').value, + name: document.getElementById('su-name').value.trim(), + birthDate: document.getElementById('su-birth').value.trim(), + email: document.getElementById('su-email').value.trim(), + }; + + if (!data.loginId || !data.password || !data.name || !data.birthDate || !data.email) { + Toast.error('모든 항목을 입력해주세요'); + return; + } + + try { + await UserApi.signup(data); + Toast.success('회원가입이 완료되었습니다! 로그인해주세요.'); + // 로그인 탭으로 전환 + document.querySelectorAll('.auth-tab').forEach(t => t.classList.remove('active')); + document.querySelector('[data-tab="login"]').classList.add('active'); + renderLogin(); + document.getElementById('login-id').value = data.loginId; + } catch (e) { Toast.error(e.message); } + }); +} diff --git a/apps/commerce-api/src/main/resources/static/shop/js/pages/mypage.js b/apps/commerce-api/src/main/resources/static/shop/js/pages/mypage.js new file mode 100644 index 000000000..24e75c032 --- /dev/null +++ b/apps/commerce-api/src/main/resources/static/shop/js/pages/mypage.js @@ -0,0 +1,315 @@ +import { OrderApi, LikeApi, CouponApi, UserApi, ProductApi } from '../api.js'; +import { Auth } from '../auth.js'; + +let currentSection = 'orders'; + +export async function initMyPage() { + const app = document.getElementById('app'); + + let userInfo = { name: Auth.getName(), email: '', point: 0 }; + try { + const me = await UserApi.me(); + userInfo = me; + } catch { /* ignore */ } + + app.innerHTML = ` +
    +
    + +
    주문 내역
    +
    좋아요 목록
    +
    내 쿠폰
    +
    포인트 충전
    +
    계정 설정
    +
    +
    +
    `; + + app.querySelectorAll('.mypage-menu-item').forEach(item => { + item.addEventListener('click', () => { + app.querySelectorAll('.mypage-menu-item').forEach(i => i.classList.remove('active')); + item.classList.add('active'); + currentSection = item.dataset.section; + loadSection(currentSection); + }); + }); + + await loadSection(currentSection); +} + +async function loadSection(section) { + const content = document.getElementById('mypage-content'); + switch (section) { + case 'orders': return loadOrders(content); + case 'likes': return loadLikes(content); + case 'coupons': return loadCoupons(content); + case 'point': return loadPoint(content); + case 'profile': return loadProfile(content); + } +} + +// === Orders === +async function loadOrders(el) { + el.innerHTML = '

    주문 내역

    불러오는 중...

    '; + try { + const data = await OrderApi.list(); + const items = data.items || []; + if (items.length === 0) { + el.innerHTML = '

    주문 내역

    주문 내역이 없습니다

    '; + return; + } + el.innerHTML = ` +

    주문 내역

    +
    + + ${items.map(o => ` + + + + + + + + `).join('')} + +
    주문 ID결제 금액할인상태주문일
    #${o.orderId}${o.totalPrice.toLocaleString()}원${o.discountAmount.toLocaleString()}원${statusLabel(o.status)}${formatDate(o.createdAt)}
    `; + + el.querySelectorAll('[data-order-id]').forEach(btn => { + btn.addEventListener('click', () => showOrderDetail(+btn.dataset.orderId)); + }); + } catch (e) { el.innerHTML = `

    주문 내역

    ${esc(e.message)}

    `; } +} + +async function showOrderDetail(orderId) { + try { + const o = await OrderApi.get(orderId); + const itemRows = (o.items || []).map(i => ` + + ${esc(i.productName)} + ${esc(i.brandName)} + ${i.orderPrice.toLocaleString()}원 + ${i.quantity}개 + ${o.status !== 'CANCELLED' ? `` : '-'} + `).join(''); + + Modal.open(`주문 #${o.orderId} 상세`, ` +
    + ${statusLabel(o.status)} + ${o.totalPrice.toLocaleString()}원 +
    +
    + + ${itemRows} +
    상품브랜드가격수량
    `); + + document.querySelectorAll('.cancel-item-btn').forEach(btn => { + btn.addEventListener('click', async () => { + try { + await OrderApi.cancelItem(+btn.dataset.oid, +btn.dataset.iid); + Toast.success('항목이 취소되었습니다'); + Modal.close(); + loadOrders(document.getElementById('mypage-content')); + } catch (e) { Toast.error(e.message); } + }); + }); + } catch (e) { Toast.error(e.message); } +} + +// === Likes === +async function loadLikes(el) { + el.innerHTML = '

    좋아요 목록

    불러오는 중...

    '; + try { + const data = await LikeApi.myList(); + const items = data.items || []; + if (items.length === 0) { + el.innerHTML = '

    좋아요 목록

    좋아요한 상품이 없습니다

    '; + return; + } + + // 상품 정보를 로드 + const productInfos = await Promise.allSettled( + items.map(l => ProductApi.get(l.productId)) + ); + + const rows = items.map((l, idx) => { + const pResult = productInfos[idx]; + const p = pResult.status === 'fulfilled' ? pResult.value : null; + return ` + + ${p ? `${esc(p.name)}` : `상품 #${l.productId}`} + ${p ? esc(p.brandName) : '-'} + ${p ? p.price.toLocaleString() + '원' : '-'} + ${formatDate(l.createdAt)} + + `; + }).join(''); + + el.innerHTML = ` +

    좋아요 목록 (${items.length})

    +
    + + ${rows} +
    상품명브랜드가격좋아요 일시
    `; + + el.querySelectorAll('.unlike-btn').forEach(btn => { + btn.addEventListener('click', async () => { + try { + await LikeApi.remove(+btn.dataset.pid); + Toast.success('좋아요를 취소했습니다'); + loadLikes(el); + } catch (e) { Toast.error(e.message); } + }); + }); + } catch (e) { el.innerHTML = `

    좋아요 목록

    ${esc(e.message)}

    `; } +} + +// === Coupons === +async function loadCoupons(el) { + el.innerHTML = '

    내 쿠폰

    불러오는 중...

    '; + try { + const data = await CouponApi.myList(); + const items = data.items || []; + if (items.length === 0) { + el.innerHTML = '

    내 쿠폰

    보유한 쿠폰이 없습니다

    '; + return; + } + + const rows = items.map(c => ` + + ${esc(c.couponName)} + ${c.discountType === 'RATE' ? c.discountValue + '% 할인' : c.discountValue.toLocaleString() + '원 할인'} + ${c.minOrderAmount ? c.minOrderAmount.toLocaleString() + '원 이상' : '없음'} + ${couponStatusLabel(c.status)} + ${formatDate(c.expiredAt)} + `).join(''); + + el.innerHTML = ` +

    내 쿠폰 (${items.length})

    +
    + + ${rows} +
    쿠폰명할인최소 주문상태만료일
    `; + } catch (e) { el.innerHTML = `

    내 쿠폰

    ${esc(e.message)}

    `; } +} + +// === Point === +async function loadPoint(el) { + el.innerHTML = '

    포인트 충전

    불러오는 중...

    '; + try { + const data = await UserApi.getPoint(); + el.innerHTML = ` +

    포인트 충전

    +
    +
    현재 보유 포인트
    +
    ${data.point.toLocaleString()}P
    +
    +
    + + + + + +
    +
    +
    + + +
    + +
    `; + + el.querySelectorAll('.quick-charge').forEach(btn => { + btn.addEventListener('click', () => chargePoint(+btn.dataset.amount)); + }); + document.getElementById('charge-btn').addEventListener('click', () => { + const amount = +document.getElementById('charge-amount').value; + if (amount > 0) chargePoint(amount); + else Toast.error('금액을 입력해주세요'); + }); + } catch (e) { el.innerHTML = `

    포인트 충전

    ${esc(e.message)}

    `; } +} + +async function chargePoint(amount) { + try { + const data = await UserApi.chargePoint(amount); + document.getElementById('current-point').textContent = data.point.toLocaleString() + 'P'; + // 사이드바 포인트도 업데이트 + const sidebar = document.querySelector('.mypage-sidebar .user-info'); + const pointEl = sidebar.querySelector('div:last-child'); + if (pointEl) pointEl.textContent = data.point.toLocaleString() + 'P'; + Toast.success(`${amount.toLocaleString()}P 충전 완료!`); + } catch (e) { Toast.error(e.message); } +} + +// === Profile === +async function loadProfile(el) { + el.innerHTML = '

    계정 설정

    불러오는 중...

    '; + try { + const me = await UserApi.me(); + el.innerHTML = ` +

    계정 설정

    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +

    비밀번호 변경

    +
    + + +
    +
    + + +
    8~16자, 대/소문자+숫자+특수문자 포함
    +
    + +
    `; + + document.getElementById('pw-change-btn').addEventListener('click', async () => { + const current = document.getElementById('pw-current').value; + const newPw = document.getElementById('pw-new').value; + if (!current || !newPw) { Toast.error('비밀번호를 입력해주세요'); return; } + try { + await UserApi.changePassword({ currentPassword: current, newPassword: newPw }); + Toast.success('비밀번호가 변경되었습니다. 다시 로그인해주세요.'); + Auth.clear(); + location.hash = 'login'; + } catch (e) { Toast.error(e.message); } + }); + } catch (e) { el.innerHTML = `

    계정 설정

    ${esc(e.message)}

    `; } +} + +// === Helpers === +function statusBadge(s) { + if (s === 'COMPLETED' || s === 'CONFIRMED') return 'badge-green'; + if (s === 'CANCELLED') return 'badge-red'; + return 'badge-gray'; +} +function statusLabel(s) { + const map = { CREATED: '주문완료', COMPLETED: '처리완료', CONFIRMED: '확정', CANCELLED: '취소됨' }; + return map[s] || s; +} +function couponStatusLabel(s) { + const map = { AVAILABLE: '사용 가능', ISSUED: '사용 가능', USED: '사용 완료', EXPIRED: '만료됨' }; + return map[s] || s; +} +function formatDate(d) { return d ? new Date(d).toLocaleDateString('ko-KR') : '-'; } +function esc(s) { return (s || '').replace(//g, '>'); } diff --git a/apps/commerce-api/src/main/resources/static/shop/js/pages/product.js b/apps/commerce-api/src/main/resources/static/shop/js/pages/product.js new file mode 100644 index 000000000..c26140c7a --- /dev/null +++ b/apps/commerce-api/src/main/resources/static/shop/js/pages/product.js @@ -0,0 +1,214 @@ +import { ProductApi, LikeApi, OrderApi, CouponApi } from '../api.js'; +import { Auth } from '../auth.js'; + +export async function initProduct(params) { + const app = document.getElementById('app'); + const id = params.id; + if (!id) { location.hash = 'home'; return; } + + app.innerHTML = '

    불러오는 중...

    '; + + try { + const p = await ProductApi.get(id); + let liked = false; + let myLikes = []; + + // 로그인 상태면 좋아요 여부 확인 + if (Auth.isLoggedIn()) { + try { + const data = await LikeApi.myList(); + myLikes = data.items || []; + liked = myLikes.some(l => l.productId === p.id); + } catch { /* ignore */ } + } + + const mainImgs = p.mainImages || []; + const detailImgs = p.detailImages || []; + + app.innerHTML = ` + +
    + +
    +
    ${esc(p.brandName)}
    +

    ${esc(p.name)}

    +
    + 재고: ${p.stock.toLocaleString()}개 + ♥ ${p.likeCount.toLocaleString()} +
    +
    ${p.price.toLocaleString()}원
    +
    + + +
    +
    +
    +
    + ${detailImgs.length ? ` +
    +

    상품 상세

    +
    + ${detailImgs.map(img => ` + 상품 상세 이미지`).join('')} +
    +
    ` : ''}`; + + // Gallery thumbnail click + document.querySelectorAll('.gallery-thumb').forEach(thumb => { + thumb.addEventListener('click', () => { + document.getElementById('gallery-main-img').src = thumb.dataset.url; + document.querySelectorAll('.gallery-thumb').forEach(t => t.classList.remove('active')); + thumb.classList.add('active'); + }); + }); + + // Like toggle + document.getElementById('like-btn').addEventListener('click', async () => { + if (!Auth.isLoggedIn()) { Toast.error('로그인이 필요합니다'); location.hash = 'login'; return; } + try { + if (liked) { + await LikeApi.remove(p.id); + liked = false; + p.likeCount = Math.max(0, p.likeCount - 1); + Toast.success('좋아요를 취소했습니다'); + } else { + await LikeApi.add(p.id); + liked = true; + p.likeCount++; + Toast.success('좋아요를 눌렀습니다'); + } + document.getElementById('like-btn').className = `btn btn-like ${liked ? 'liked' : ''}`; + document.getElementById('like-btn').innerHTML = liked ? '♥ 좋아요 취소' : '♡ 좋아요'; + document.getElementById('like-count').innerHTML = `♥ ${p.likeCount.toLocaleString()}`; + } catch (e) { Toast.error(e.message); } + }); + + // Order form + document.getElementById('order-btn').addEventListener('click', () => { + if (!Auth.isLoggedIn()) { Toast.error('로그인이 필요합니다'); location.hash = 'login'; return; } + showOrderForm(p); + }); + + } catch (e) { + app.innerHTML = `

    상품을 불러올 수 없습니다: ${esc(e.message)}

    `; + } +} + +async function showOrderForm(product) { + const section = document.getElementById('order-section'); + let quantity = 1; + let coupons = []; + + try { + const data = await CouponApi.myList(); + coupons = (data.items || []).filter(c => c.status === 'AVAILABLE' || c.status === 'ISSUED'); + } catch { /* ignore */ } + + const couponOptions = coupons.length + ? coupons.map(c => ``).join('') + : ''; + + function render() { + const total = product.price * quantity; + const sel = section.querySelector('#order-coupon'); + let discount = 0; + let couponId = null; + + if (sel && sel.value) { + const opt = sel.selectedOptions[0]; + couponId = +sel.value; + const type = opt.dataset.type; + const value = +opt.dataset.value; + const min = +opt.dataset.min; + if (total >= min) { + discount = type === 'RATE' ? Math.floor(total * value / 100) : value; + discount = Math.min(discount, total); + } + } + + section.querySelector('#qty-display').textContent = quantity; + section.querySelector('#price-original').textContent = total.toLocaleString() + '원'; + section.querySelector('#price-discount').textContent = '-' + discount.toLocaleString() + '원'; + section.querySelector('#price-total').textContent = (total - discount).toLocaleString() + '원'; + } + + section.innerHTML = ` +
    +

    주문 정보

    +
    + 수량 + + ${quantity} + +
    + ${coupons.length ? ` +
    + + +
    ` : ''} +
    +
    상품 금액${(product.price * quantity).toLocaleString()}원
    +
    할인 금액-0원
    +
    결제 금액${(product.price * quantity).toLocaleString()}원
    +
    + +
    `; + + section.querySelector('#qty-minus').addEventListener('click', () => { + if (quantity > 1) { quantity--; render(); } + }); + section.querySelector('#qty-plus').addEventListener('click', () => { + quantity++; + render(); + }); + if (section.querySelector('#order-coupon')) { + section.querySelector('#order-coupon').addEventListener('change', render); + } + + section.querySelector('#place-order-btn').addEventListener('click', async () => { + const btn = section.querySelector('#place-order-btn'); + btn.disabled = true; + btn.textContent = '주문 처리 중...'; + try { + const body = { + items: [{ productId: product.id, quantity, expectedPrice: product.price }], + }; + const couponSel = section.querySelector('#order-coupon'); + if (couponSel && couponSel.value) { + body.couponId = +couponSel.value; + } + await OrderApi.create(body); + Toast.success('주문이 완료되었습니다!'); + section.innerHTML = ` +
    +
    +

    주문이 완료되었습니다

    + 주문 내역 보기 +
    `; + } catch (e) { + Toast.error(e.message); + btn.disabled = false; + btn.textContent = '주문하기'; + } + }); +} + +function esc(s) { return (s || '').replace(//g, '>'); } 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 31246a65d..bf3b2e2e6 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 @@ -1,13 +1,14 @@ package com.loopers.application.product; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.Mockito.when; import com.loopers.application.product.dto.ProductResult; import com.loopers.domain.brand.BrandModel; import com.loopers.domain.brand.BrandService; -import com.loopers.domain.product.ProductLikeService; +import com.loopers.domain.product.ImageType; +import com.loopers.domain.product.ProductImageModel; +import com.loopers.domain.product.ProductImageService; import com.loopers.domain.product.ProductModel; import com.loopers.domain.product.ProductService; import java.util.List; @@ -34,7 +35,7 @@ class ProductFacadeTest { private BrandService brandService; @Mock - private ProductLikeService productLikeService; + private ProductImageService productImageService; @InjectMocks private ProductFacade productFacade; @@ -48,9 +49,11 @@ class GetProduct { void getProduct_returnsResultWithLikeCount() { // arrange ProductModel product = ProductModel.create(1L, "에어맥스", 150000, 100); + for (int i = 0; i < 5; i++) { + product.addLikeCount(); + } when(productService.getById(1L)).thenReturn(product); when(brandService.getById(1L)).thenReturn(BrandModel.create("나이키")); - when(productLikeService.countLikes(1L)).thenReturn(5L); // act ProductResult result = productFacade.getProduct(1L); @@ -60,6 +63,38 @@ void getProduct_returnsResultWithLikeCount() { } } + @DisplayName("상품 상세(이미지 포함)를 조회할 때, ") + @Nested + class GetProductDetail { + + @DisplayName("메인 이미지와 디테일 이미지를 분리하여 반환한다.") + @Test + void getProductDetail_returnsDetailWithImages() { + // arrange + ProductModel product = ProductModel.create(1L, "에어맥스", 150000, 100); + when(productService.getById(1L)).thenReturn(product); + when(brandService.getById(1L)).thenReturn(BrandModel.create("나이키")); + when(productImageService.getImagesByProductIdAndType(1L, ImageType.MAIN)) + .thenReturn(List.of( + ProductImageModel.create(1L, "https://img.com/main1.jpg", ImageType.MAIN, 0), + ProductImageModel.create(1L, "https://img.com/main2.jpg", ImageType.MAIN, 1))); + when(productImageService.getImagesByProductIdAndType(1L, ImageType.DETAIL)) + .thenReturn(List.of( + ProductImageModel.create(1L, "https://img.com/detail1.jpg", ImageType.DETAIL, 0))); + + // act + ProductResult.DetailWithImages result = productFacade.getProductDetail(1L); + + // assert + assertThat(result.product().name()).isEqualTo("에어맥스"); + assertThat(result.product().brandName()).isEqualTo("나이키"); + assertThat(result.mainImages()).hasSize(2); + assertThat(result.detailImages()).hasSize(1); + assertThat(result.mainImages().get(0).imageUrl()).isEqualTo("https://img.com/main1.jpg"); + assertThat(result.detailImages().get(0).imageType()).isEqualTo(ImageType.DETAIL); + } + } + @DisplayName("상품 목록을 조회할 때, ") @Nested class GetProductsWithActiveBrand { @@ -69,6 +104,9 @@ class GetProductsWithActiveBrand { void getProductsWithActiveBrand_returnsResultsWithLikeCount() { // arrange ProductModel product1 = ProductModel.create(1L, "에어맥스", 150000, 100); + for (int i = 0; i < 3; i++) { + product1.addLikeCount(); + } ProductModel product2 = ProductModel.create(1L, "에어포스", 120000, 50); PageRequest pageable = PageRequest.of(0, 20); @@ -76,8 +114,6 @@ void getProductsWithActiveBrand_returnsResultsWithLikeCount() { .thenReturn(new PageImpl<>(List.of(product1, product2), pageable, 2)); when(brandService.getActiveNameMapByIds(List.of(1L))) .thenReturn(Map.of(1L, "나이키")); - when(productLikeService.countLikesByProductIds(List.of(0L, 0L))) - .thenReturn(Map.of(0L, 3L)); // act Page result = productFacade.getProductsWithActiveBrand(pageable); @@ -92,20 +128,18 @@ void getProductsWithActiveBrand_returnsResultsWithLikeCount() { @Nested class GetProductsWithActiveBrandSortedByLikes { - @DisplayName("좋아요 수 내림차순으로 정렬되고 페이지네이션된다.") + @DisplayName("좋아요 수 내림차순으로 DB 페이지네이션된 결과를 반환한다.") @Test void getProductsSortedByLikes_returnsSortedAndPaginated() { // arrange ProductModel product1 = ProductModel.create(1L, "에어맥스", 150000, 100); ProductModel product2 = ProductModel.create(1L, "에어포스", 120000, 50); - ProductModel product3 = ProductModel.create(1L, "조던1", 200000, 30); + PageRequest pageable = PageRequest.of(0, 2); - when(productService.getAll()) - .thenReturn(List.of(product1, product2, product3)); + when(productService.getAllSortedByLikeCountDesc(pageable)) + .thenReturn(new PageImpl<>(List.of(product1, product2), pageable, 3)); when(brandService.getActiveNameMapByIds(List.of(1L))) .thenReturn(Map.of(1L, "나이키")); - when(productLikeService.countLikesByProductIds(anyList())) - .thenReturn(Map.of(0L, 1L)); // act Page result = @@ -133,8 +167,6 @@ void getProductsWithActiveBrandByBrandId_inactiveBrand_returnsEmpty() { .thenReturn(new PageImpl<>(List.of(product), pageable, 1)); when(brandService.getActiveNameMapByIds(List.of(1L))) .thenReturn(Map.of()); - when(productLikeService.countLikesByProductIds(anyList())) - .thenReturn(Map.of()); // act Page result = @@ -149,14 +181,15 @@ void getProductsWithActiveBrandByBrandId_inactiveBrand_returnsEmpty() { void getProductsWithActiveBrandByBrandId_activeBrand_returnsResults() { // arrange ProductModel product = ProductModel.create(1L, "에어맥스", 150000, 100); + for (int i = 0; i < 10; i++) { + product.addLikeCount(); + } PageRequest pageable = PageRequest.of(0, 20); when(productService.getAllByBrandId(1L, pageable)) .thenReturn(new PageImpl<>(List.of(product), pageable, 1)); when(brandService.getActiveNameMapByIds(List.of(1L))) .thenReturn(Map.of(1L, "나이키")); - when(productLikeService.countLikesByProductIds(anyList())) - .thenReturn(Map.of(0L, 10L)); // act Page result = diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductLikeFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductLikeFacadeTest.java index f50ef2cdd..28a887355 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductLikeFacadeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductLikeFacadeTest.java @@ -34,15 +34,16 @@ class ProductLikeFacadeTest { @Nested class Like { - @DisplayName("상품 존재를 검증하고 like를 호출한다.") + @DisplayName("상품 존재를 검증하고 like를 호출하고 좋아요 수를 증가시킨다.") @Test - void like_validatesProductAndCallsLike() { + void like_validatesProductAndCallsLikeAndIncrementsCount() { // act productLikeFacade.like(1L, 2L); // assert verify(productService).validateExists(2L); verify(productLikeService).like(1L, 2L); + verify(productService).incrementLikeCount(2L); } } @@ -50,14 +51,15 @@ void like_validatesProductAndCallsLike() { @Nested class Unlike { - @DisplayName("unlike를 호출한다.") + @DisplayName("unlike를 호출하고 좋아요 수를 감소시킨다.") @Test - void unlike_callsUnlike() { + void unlike_callsUnlikeAndDecrementsCount() { // act productLikeFacade.unlike(1L, 2L); // assert verify(productLikeService).unlike(1L, 2L); + verify(productService).decrementLikeCount(2L); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/FakeProductImageRepository.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/FakeProductImageRepository.java new file mode 100644 index 000000000..0c017c6d1 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/FakeProductImageRepository.java @@ -0,0 +1,60 @@ +package com.loopers.domain.product; + +import java.lang.reflect.Field; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicLong; + +public class FakeProductImageRepository implements ProductImageRepository { + + private final Map store = new HashMap<>(); + private final AtomicLong idSequence = new AtomicLong(1); + + @Override + public ProductImageModel save(ProductImageModel image) { + if (image.getId() == null || image.getId() == 0L) { + setId(image, idSequence.getAndIncrement()); + } + store.put(image.getId(), image); + return image; + } + + @Override + public List findAllByProductId(Long productId) { + return store.values().stream() + .filter(img -> img.getDeletedAt() == null) + .filter(img -> img.getProductId().equals(productId)) + .sorted(Comparator.comparingInt(ProductImageModel::getSortOrder)) + .toList(); + } + + @Override + public List findAllByProductIdAndImageType(Long productId, ImageType imageType) { + return store.values().stream() + .filter(img -> img.getDeletedAt() == null) + .filter(img -> img.getProductId().equals(productId)) + .filter(img -> img.getImageType() == imageType) + .sorted(Comparator.comparingInt(ProductImageModel::getSortOrder)) + .toList(); + } + + @Override + public void deleteAllByProductId(Long productId) { + store.values().stream() + .filter(img -> img.getProductId().equals(productId)) + .filter(img -> img.getDeletedAt() == null) + .forEach(ProductImageModel::delete); + } + + private void setId(ProductImageModel image, long id) { + try { + Field idField = image.getClass().getSuperclass().getDeclaredField("id"); + idField.setAccessible(true); + idField.set(image, id); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/FakeProductRepository.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/FakeProductRepository.java index 74154feac..2261b2554 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/product/FakeProductRepository.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/FakeProductRepository.java @@ -1,6 +1,7 @@ package com.loopers.domain.product; import java.util.ArrayList; +import java.util.Comparator; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -92,4 +93,52 @@ public List findAllByIdIn(List ids) { .toList(); } + @Override + public Page findAllSortedByLikeCountDesc(Pageable pageable) { + List sorted = store.values().stream() + .filter(product -> product.getDeletedAt() == null) + .sorted(Comparator.comparingInt(ProductModel::getLikeCount).reversed()) + .toList(); + + int start = (int) pageable.getOffset(); + int end = Math.min(start + pageable.getPageSize(), sorted.size()); + + List pageContent = start >= sorted.size() + ? new ArrayList<>() + : sorted.subList(start, end); + + return new PageImpl<>(pageContent, pageable, sorted.size()); + } + + @Override + public Page findAllByBrandIdSortedByLikeCountDesc(Long brandId, Pageable pageable) { + List sorted = store.values().stream() + .filter(product -> product.getDeletedAt() == null) + .filter(product -> product.getBrandId().equals(brandId)) + .sorted(Comparator.comparingInt(ProductModel::getLikeCount).reversed()) + .toList(); + + int start = (int) pageable.getOffset(); + int end = Math.min(start + pageable.getPageSize(), sorted.size()); + + List pageContent = start >= sorted.size() + ? new ArrayList<>() + : sorted.subList(start, end); + + return new PageImpl<>(pageContent, pageable, sorted.size()); + } + + @Override + public void incrementLikeCount(Long id) { + Optional.ofNullable(store.get(id)) + .filter(product -> product.getDeletedAt() == null) + .ifPresent(ProductModel::addLikeCount); + } + + @Override + public void decrementLikeCount(Long id) { + Optional.ofNullable(store.get(id)) + .filter(product -> product.getDeletedAt() == null) + .ifPresent(ProductModel::subtractLikeCount); + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductImageModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductImageModelTest.java new file mode 100644 index 000000000..097e6bf76 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductImageModelTest.java @@ -0,0 +1,94 @@ +package com.loopers.domain.product; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class ProductImageModelTest { + + private static final Long PRODUCT_ID = 1L; + private static final String IMAGE_URL = "https://cdn.example.com/image.jpg"; + + @DisplayName("상품 이미지를 생성할 때, ") + @Nested + class Create { + + @DisplayName("유효한 값이 주어지면, 정상적으로 생성된다.") + @Test + void create_whenValidValues() { + // act + ProductImageModel image = ProductImageModel.create( + PRODUCT_ID, IMAGE_URL, ImageType.MAIN, 0); + + // assert + assertAll( + () -> assertThat(image.getProductId()).isEqualTo(PRODUCT_ID), + () -> assertThat(image.getImageUrl()).isEqualTo(IMAGE_URL), + () -> assertThat(image.getImageType()).isEqualTo(ImageType.MAIN), + () -> assertThat(image.getSortOrder()).isZero()); + } + + @DisplayName("DETAIL 타입으로 생성할 수 있다.") + @Test + void create_withDetailType() { + // act + ProductImageModel image = ProductImageModel.create( + PRODUCT_ID, IMAGE_URL, ImageType.DETAIL, 3); + + // assert + assertAll( + () -> assertThat(image.getImageType()).isEqualTo(ImageType.DETAIL), + () -> assertThat(image.getSortOrder()).isEqualTo(3)); + } + + @DisplayName("imageUrl이 null이면 예외가 발생한다.") + @Test + void create_whenImageUrlIsNull() { + assertThatThrownBy(() -> ProductImageModel.create( + PRODUCT_ID, null, ImageType.MAIN, 0)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("이미지 URL은 필수값입니다."); + } + + @DisplayName("imageUrl이 빈 문자열이면 예외가 발생한다.") + @Test + void create_whenImageUrlIsBlank() { + assertThatThrownBy(() -> ProductImageModel.create( + PRODUCT_ID, " ", ImageType.MAIN, 0)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("이미지 URL은 필수값입니다."); + } + + @DisplayName("productId가 null이면 예외가 발생한다.") + @Test + void create_whenProductIdIsNull() { + assertThatThrownBy(() -> ProductImageModel.create( + null, IMAGE_URL, ImageType.MAIN, 0)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("상품 ID는 필수값입니다."); + } + + @DisplayName("imageType이 null이면 예외가 발생한다.") + @Test + void create_whenImageTypeIsNull() { + assertThatThrownBy(() -> ProductImageModel.create( + PRODUCT_ID, IMAGE_URL, null, 0)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("이미지 타입은 필수값입니다."); + } + + @DisplayName("sortOrder가 음수이면 예외가 발생한다.") + @Test + void create_whenSortOrderIsNegative() { + assertThatThrownBy(() -> ProductImageModel.create( + PRODUCT_ID, IMAGE_URL, ImageType.MAIN, -1)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("정렬 순서는 0 이상이어야 합니다."); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductImageServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductImageServiceTest.java new file mode 100644 index 000000000..fef534a1e --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductImageServiceTest.java @@ -0,0 +1,116 @@ +package com.loopers.domain.product; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class ProductImageServiceTest { + + private ProductImageService productImageService; + private FakeProductImageRepository fakeProductImageRepository; + + @BeforeEach + void setUp() { + fakeProductImageRepository = new FakeProductImageRepository(); + productImageService = new ProductImageService(fakeProductImageRepository); + } + + @DisplayName("이미지를 추가할 때, ") + @Nested + class AddImage { + + @DisplayName("정상적으로 저장된다.") + @Test + void addImage_success() { + // act + ProductImageModel result = productImageService.addImage( + 1L, "https://cdn.example.com/img.jpg", ImageType.MAIN, 0); + + // assert + assertThat(result.getId()).isNotNull(); + assertThat(result.getProductId()).isEqualTo(1L); + assertThat(result.getImageUrl()).isEqualTo("https://cdn.example.com/img.jpg"); + } + } + + @DisplayName("상품별 이미지를 조회할 때, ") + @Nested + class GetImagesByProductId { + + @DisplayName("해당 상품의 이미지만 반환된다.") + @Test + void getImagesByProductId_success() { + // arrange + productImageService.addImage(1L, "https://cdn.example.com/1.jpg", ImageType.MAIN, 0); + productImageService.addImage(1L, "https://cdn.example.com/2.jpg", ImageType.DETAIL, 0); + productImageService.addImage(2L, "https://cdn.example.com/3.jpg", ImageType.MAIN, 0); + + // act + List result = productImageService.getImagesByProductId(1L); + + // assert + assertThat(result).hasSize(2); + } + + @DisplayName("타입별로 필터링할 수 있다.") + @Test + void getImagesByProductIdAndType_success() { + // arrange + productImageService.addImage(1L, "https://cdn.example.com/main.jpg", ImageType.MAIN, 0); + productImageService.addImage(1L, "https://cdn.example.com/detail1.jpg", ImageType.DETAIL, 0); + productImageService.addImage(1L, "https://cdn.example.com/detail2.jpg", ImageType.DETAIL, 1); + + // act + List mainImages = + productImageService.getImagesByProductIdAndType(1L, ImageType.MAIN); + List detailImages = + productImageService.getImagesByProductIdAndType(1L, ImageType.DETAIL); + + // assert + assertThat(mainImages).hasSize(1); + assertThat(detailImages).hasSize(2); + } + + @DisplayName("sortOrder 순으로 정렬된다.") + @Test + void getImagesByProductId_sortedBySortOrder() { + // arrange + productImageService.addImage(1L, "https://cdn.example.com/2.jpg", ImageType.MAIN, 2); + productImageService.addImage(1L, "https://cdn.example.com/0.jpg", ImageType.MAIN, 0); + productImageService.addImage(1L, "https://cdn.example.com/1.jpg", ImageType.MAIN, 1); + + // act + List result = productImageService.getImagesByProductId(1L); + + // assert + assertThat(result.get(0).getImageUrl()).contains("0.jpg"); + assertThat(result.get(1).getImageUrl()).contains("1.jpg"); + assertThat(result.get(2).getImageUrl()).contains("2.jpg"); + } + } + + @DisplayName("상품 이미지를 전체 삭제할 때, ") + @Nested + class DeleteAllByProductId { + + @DisplayName("해당 상품의 이미지만 삭제된다.") + @Test + void deleteAllByProductId_success() { + // arrange + productImageService.addImage(1L, "https://cdn.example.com/1.jpg", ImageType.MAIN, 0); + productImageService.addImage(1L, "https://cdn.example.com/2.jpg", ImageType.DETAIL, 0); + productImageService.addImage(2L, "https://cdn.example.com/3.jpg", ImageType.MAIN, 0); + + // act + productImageService.deleteAllByProductId(1L); + + // assert + assertThat(productImageService.getImagesByProductId(1L)).isEmpty(); + assertThat(productImageService.getImagesByProductId(2L)).hasSize(1); + } + } +} 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 ad47e5bde..ed31ccf17 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 @@ -197,6 +197,84 @@ void increaseStock_whenZeroQuantity() { } } + @DisplayName("좋아요 수를 증감할 때, ") + @Nested + class LikeCount { + + @DisplayName("addLikeCount 호출 시 좋아요 수가 1 증가한다.") + @Test + void addLikeCount_increments() { + // arrange + ProductModel product = ProductModel.create(BRAND_ID, "에어맥스", 150000, 100); + + // act + product.addLikeCount(); + + // assert + assertThat(product.getLikeCount()).isEqualTo(1); + } + + @DisplayName("subtractLikeCount 호출 시 좋아요 수가 1 감소한다.") + @Test + void subtractLikeCount_decrements() { + // arrange + ProductModel product = ProductModel.create(BRAND_ID, "에어맥스", 150000, 100); + product.addLikeCount(); + product.addLikeCount(); + + // act + product.subtractLikeCount(); + + // assert + assertThat(product.getLikeCount()).isEqualTo(1); + } + + @DisplayName("좋아요 수가 0일 때 subtractLikeCount 호출해도 0 이하로 내려가지 않는다.") + @Test + void subtractLikeCount_whenZero_remainsZero() { + // arrange + ProductModel product = ProductModel.create(BRAND_ID, "에어맥스", 150000, 100); + + // act + product.subtractLikeCount(); + + // assert + assertThat(product.getLikeCount()).isZero(); + } + } + + @DisplayName("썸네일 URL을 수정할 때, ") + @Nested + class UpdateThumbnailUrl { + + @DisplayName("정상적으로 수정된다.") + @Test + void updateThumbnailUrl_success() { + // arrange + ProductModel product = ProductModel.create(BRAND_ID, "에어맥스", 150000, 100); + + // act + product.updateThumbnailUrl("https://cdn.example.com/thumb.jpg"); + + // assert + assertThat(product.getThumbnailUrl()).isEqualTo("https://cdn.example.com/thumb.jpg"); + } + + @DisplayName("null로 설정할 수 있다.") + @Test + void updateThumbnailUrl_toNull() { + // arrange + ProductModel product = ProductModel.create(BRAND_ID, "에어맥스", 150000, 100); + product.updateThumbnailUrl("https://cdn.example.com/thumb.jpg"); + + // act + product.updateThumbnailUrl(null); + + // assert + assertThat(product.getThumbnailUrl()).isNull(); + } + } + @DisplayName("품절 여부를 확인할 때, ") @Nested class IsSoldOut { diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/FakeUserRepository.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/FakeUserRepository.java index 0f4a87297..dd49a1e83 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/FakeUserRepository.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/FakeUserRepository.java @@ -1,9 +1,14 @@ package com.loopers.domain.user; +import java.util.ArrayList; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Optional; import java.util.concurrent.atomic.AtomicLong; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; public class FakeUserRepository implements UserRepository { @@ -37,4 +42,27 @@ public Optional findById(Long id) { public Optional findByLoginId(String loginId) { return Optional.ofNullable(storeByLoginId.get(loginId)); } + + @Override + public Page findAll(Pageable pageable) { + List activeUsers = storeById.values().stream() + .filter(user -> user.getDeletedAt() == null) + .toList(); + + int start = (int) pageable.getOffset(); + int end = Math.min(start + pageable.getPageSize(), activeUsers.size()); + + List pageContent = start >= activeUsers.size() + ? new ArrayList<>() + : activeUsers.subList(start, end); + + return new PageImpl<>(pageContent, pageable, activeUsers.size()); + } + + @Override + public List findAll() { + return storeById.values().stream() + .filter(user -> user.getDeletedAt() == null) + .toList(); + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/product/ProductV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/product/ProductV1ApiE2ETest.java index 627133dde..19f5dc80b 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/product/ProductV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/product/ProductV1ApiE2ETest.java @@ -5,9 +5,12 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.product.ImageType; +import com.loopers.domain.product.ProductImageModel; import com.loopers.domain.product.ProductLikeModel; import com.loopers.domain.product.ProductModel; import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.infrastructure.product.ProductImageJpaRepository; import com.loopers.infrastructure.product.ProductLikeJpaRepository; import com.loopers.infrastructure.product.ProductJpaRepository; import com.loopers.interfaces.api.ApiResponse; @@ -21,6 +24,7 @@ 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.cache.CacheManager; import org.springframework.core.ParameterizedTypeReference; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; @@ -35,7 +39,9 @@ class ProductV1ApiE2ETest { private final BrandJpaRepository brandJpaRepository; private final ProductJpaRepository productJpaRepository; private final ProductLikeJpaRepository productLikeJpaRepository; + private final ProductImageJpaRepository productImageJpaRepository; private final DatabaseCleanUp databaseCleanUp; + private final CacheManager cacheManager; private BrandModel savedBrand; @@ -45,13 +51,17 @@ public ProductV1ApiE2ETest( BrandJpaRepository brandJpaRepository, ProductJpaRepository productJpaRepository, ProductLikeJpaRepository productLikeJpaRepository, - DatabaseCleanUp databaseCleanUp + ProductImageJpaRepository productImageJpaRepository, + DatabaseCleanUp databaseCleanUp, + CacheManager cacheManager ) { this.testRestTemplate = testRestTemplate; this.brandJpaRepository = brandJpaRepository; this.productJpaRepository = productJpaRepository; this.productLikeJpaRepository = productLikeJpaRepository; + this.productImageJpaRepository = productImageJpaRepository; this.databaseCleanUp = databaseCleanUp; + this.cacheManager = cacheManager; } @BeforeEach @@ -63,6 +73,7 @@ void setUp() { @AfterEach void tearDown() { databaseCleanUp.truncateAllTables(); + cacheManager.getCacheNames().forEach(name -> cacheManager.getCache(name).clear()); } private ProductModel saveProduct(String name, int price, int stock) { @@ -70,6 +81,13 @@ private ProductModel saveProduct(String name, int price, int stock) { return productJpaRepository.save(product); } + private void saveLike(Long userId, Long productId) { + productLikeJpaRepository.save(ProductLikeModel.create(userId, productId)); + ProductModel product = productJpaRepository.findById(productId).get(); + product.addLikeCount(); + productJpaRepository.save(product); + } + @DisplayName("GET /api/v1/products") @Nested class List { @@ -168,8 +186,8 @@ void returnsList_withLikeCount() { // arrange ProductModel product1 = saveProduct("에어맥스", 150000, 100); saveProduct("에어포스", 120000, 50); - productLikeJpaRepository.save(ProductLikeModel.create(1L, product1.getId())); - productLikeJpaRepository.save(ProductLikeModel.create(2L, product1.getId())); + saveLike(1L, product1.getId()); + saveLike(2L, product1.getId()); // act ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; @@ -191,10 +209,10 @@ void returnsSortedByLikesDesc_whenSortIsLikesDesc() { ProductModel product1 = saveProduct("에어맥스", 150000, 100); ProductModel product2 = saveProduct("에어포스", 120000, 50); ProductModel product3 = saveProduct("조던1", 200000, 30); - productLikeJpaRepository.save(ProductLikeModel.create(1L, product2.getId())); - productLikeJpaRepository.save(ProductLikeModel.create(2L, product2.getId())); - productLikeJpaRepository.save(ProductLikeModel.create(3L, product2.getId())); - productLikeJpaRepository.save(ProductLikeModel.create(1L, product3.getId())); + saveLike(1L, product2.getId()); + saveLike(2L, product2.getId()); + saveLike(3L, product2.getId()); + saveLike(1L, product3.getId()); // act ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; @@ -227,8 +245,8 @@ void returnsProductDetail_whenProductExists() { // arrange ProductModel product = saveProduct("에어맥스", 150000, 100); Long productId = product.getId(); - productLikeJpaRepository.save(ProductLikeModel.create(1L, productId)); - productLikeJpaRepository.save(ProductLikeModel.create(2L, productId)); + saveLike(1L, productId); + saveLike(2L, productId); // act ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; @@ -248,6 +266,36 @@ void returnsProductDetail_whenProductExists() { ); } + @DisplayName("메인 이미지와 디테일 이미지가 분리되어 반환된다.") + @Test + void returnsProductDetail_withMainAndDetailImages() { + // arrange + ProductModel product = saveProduct("에어맥스", 150000, 100); + Long productId = product.getId(); + productImageJpaRepository.save( + ProductImageModel.create(productId, "https://img.com/main1.jpg", ImageType.MAIN, 0)); + productImageJpaRepository.save( + ProductImageModel.create(productId, "https://img.com/main2.jpg", ImageType.MAIN, 1)); + productImageJpaRepository.save( + ProductImageModel.create(productId, "https://img.com/detail1.jpg", ImageType.DETAIL, 0)); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_PRODUCTS + "/" + productId, HttpMethod.GET, null, responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().mainImages()).hasSize(2), + () -> assertThat(response.getBody().data().detailImages()).hasSize(1), + () -> assertThat(response.getBody().data().mainImages().get(0).imageUrl()) + .isEqualTo("https://img.com/main1.jpg"), + () -> assertThat(response.getBody().data().mainImages().get(0).imageType()).isEqualTo("MAIN"), + () -> assertThat(response.getBody().data().detailImages().get(0).imageType()).isEqualTo("DETAIL") + ); + } + @DisplayName("존재하지 않는 상품을 조회하면, 404 NOT_FOUND 응답을 받는다.") @Test void throwsNotFound_whenProductDoesNotExist() { diff --git a/docs/round5/README.md b/docs/round5/README.md new file mode 100644 index 000000000..663231ca6 --- /dev/null +++ b/docs/round5/README.md @@ -0,0 +1,118 @@ +## 테스트 데이터 설계 + +이 프로젝트의 데이터는 패션 이커머스 트래픽 패턴을 재현하도록 설계되었다. 균등 분포와 무의미한 이름(`Brand_001`, `P_0_13`)으로 생성하면 프로덕션과 다른 실행 계획이 나와 인덱스 효과와 쿼리 병목을 제대로 측정할 수 없다. + +### 분포 설계 근거 + +사용된 분포 용어는 다음과 같다. + +- **Power-law** — 소수의 항목이 대부분의 값을 차지하는 분포. 예) 상위 20개 브랜드가 브랜드당 2,000~3,000개 상품을 보유 +- **Zipf** — Power-law의 극단적인 형태. 순위가 낮아질수록 빈도가 급격히 감소한다. 예) 1위 상품이 2위보다 2배, 3위보다 3배 많은 좋아요를 받음 +- **로그정규분포** — 저가 쪽에 더 많이 분포하는 가격 분포. 카테고리별 min/max 범위 내에서 100원 단위로 생성 + +| 테이블 | 건수 | 분포 | 선택 이유 | +|---|---|---|---| +| brands | 100 (활성 90, 삭제 10) | 티어별 편중 | S/A/B 티어로 인기도 구분 → 캐시 히트율 차이 측정 | +| products | 100,000 (활성 ~85K, 삭제 ~15K) | Power-law | 인기 브랜드 2,000~3,000개, 소규모 100~300개 → brandId 필터 인덱스 효과 극대화 | +| users | 10,000 | 균등 | 유저 간 편차보다 상품 간 인기 편차가 핵심 변수 | +| likes | ~416,000 | Zipf | 상위 1% 상품에 ~70% 집중 → `ORDER BY like_count DESC` 정렬 및 캐시 히트율 차이 측정 가능 | +| orders | 100,000 | Power-law | 인기 상품(좋아요 상위)에 집중 → 특정 상품 주문 이력 페이지네이션 병목 재현 | +| order_items | ~200,000 | 주문당 1~3개 | — | + +### 패션 데이터 + +**브랜드 100개** — 한국 패션 이커머스 실제 브랜드명 사용: +- 글로벌 스포츠 (나이키, 아디다스, 뉴발란스 등), 글로벌 SPA (자라, 유니클로 등) +- 국내 스트릿 (커버낫, 디스이즈네버댓 등), 국내 베이직 (무신사스탠다드, 탑텐 등) +- 디자이너, 아웃도어, 캐주얼, 슈즈, 여성 브랜드 등 + +**상품 카테고리 7개:** + +| 카테고리 | 비중 | 가격대 (KRW) | 재고 범위 | 품절률 | +|---------|------|-------------|----------|--------| +| 상의 | 25% | 19,900~89,900 | 100~500 | 5% | +| 하의 | 18% | 29,900~129,900 | 100~500 | 5% | +| 아우터 | 12% | 69,900~399,900 | 30~200 | 8% | +| 원피스/세트 | 8% | 39,900~199,900 | 30~200 | 6% | +| 슈즈 | 15% | 59,900~299,900 | 20~150 | 10% | +| 가방 | 12% | 39,900~249,900 | 30~200 | 6% | +| 악세서리 | 10% | 9,900~149,900 | 200~1,000 | 3% | + +**상품명**: `[수식어] + [아이템 타입] + [컬러]` (예: "오버핏 크루넥 맨투맨 차콜") + +### 소프트 딜리트 + +`WHERE deleted_at IS NULL` 필터링의 실제 효과를 테스트하기 위해 데이터 생성 시 소프트 딜리트를 적용한다. + +| 대상 | 삭제 비율 | 방식 | +|------|----------|------| +| brands | 10% (10/100) | 하위 10개 브랜드 소프트 딜리트 | +| products | ~15% (~15K/100K) | 2-wave 삭제 (아래 참고) | + +**2-wave 삭제:** +- **Wave 1** (좋아요 생성 전): 삭제 브랜드 소속 상품 연쇄 삭제 (~10K) + 오래된 시즌 단종 (3K) → 좋아요/주문 없는 완전 퇴장 상품 +- **Wave 2** (좋아요 생성 후): 최근 단종 (2K) → like_count > 0이지만 삭제된 상품 → 현실적 시나리오 재현 + +### 생성 흐름 + +``` +Phase 1: 브랜드 100개 생성 (패션 이름) +Phase 1.5: 브랜드 10개 소프트 딜리트 (10%) +Phase 2: 상품 100K개 생성 (카테고리별 이름/가격/재고, 브랜드별 편중 분포) +Phase 2.5: 상품 소프트 딜리트 Wave 1 (~13K) +Phase 3: 유저 10K 생성 +Phase 4: 좋아요 ~500K 생성 (Zipf 분포, 활성 상품만 대상) +Phase 4.5: 상품 소프트 딜리트 Wave 2 (2K) +Phase 5: 주문 100K 생성 (Power-law 분포, 활성 상품만 대상) +Phase 6: like_count 동기화 (likes 테이블 COUNT → products.like_count) +``` + +### 설정 (application.yml) + +건수는 아래 값을 변경해 조정할 수 있다. 기본값은 비활성화(`enabled: false`)이다. +```yaml +app: + data-generator: + enabled: false + brand-count: 100 + product-count: 100000 + user-count: 10000 + like-count: 500000 + order-count: 100000 +``` + +### 실행 + +두 가지 방법으로 데이터를 생성할 수 있다. + +**방법 1: 서버 기동 시 자동 생성** +```bash +./gradlew :apps:commerce-api:bootRun --args='--app.data-generator.enabled=true' +``` + +**방법 2: 서버 기동 후 API 호출** +```bash +# 데이터 생성 요청 (비동기, 즉시 반환) +curl -X POST -H "X-Loopers-Ldap: loopers.admin" http://localhost:8080/api-admin/v1/data-generator/bulk-init +``` +```bash +# 생성 현황 확인 +curl -H "X-Loopers-Ldap: loopers.admin" http://localhost:8080/api-admin/v1/data-generator/stats +``` + +### 검증 + +```sql +-- 전체 vs 활성 비율 +SELECT COUNT(*) FROM brands; -- 100 +SELECT COUNT(*) FROM brands WHERE deleted_at IS NULL; -- 90 +SELECT COUNT(*) FROM products; -- 100,000 +SELECT COUNT(*) FROM products WHERE deleted_at IS NULL; -- ~85,000 +SELECT COUNT(*) FROM products WHERE deleted_at IS NOT NULL AND like_count > 0; -- ~2,000 (Wave 2) + +-- 브랜드별 상품 수 분포 확인 +SELECT b.name, COUNT(p.id) as cnt +FROM brands b JOIN products p ON b.id = p.brand_id +WHERE b.deleted_at IS NULL AND p.deleted_at IS NULL +GROUP BY b.id ORDER BY cnt DESC LIMIT 5; +```