Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
439b5a3
feat: Member ๊ธฐ๋Šฅ ๊ตฌํ˜„
APapeIsName Feb 22, 2026
88c70a4
docs: ์š”๊ตฌ์‚ฌํ•ญ ๋ฐ ์„ค๊ณ„ ๋ฌธ์„œ ์ž‘์„ฑ
APapeIsName Feb 22, 2026
0c43e18
refactor: Phase 1 ๊ตฌ์กฐ ๋ณ€๊ฒฝ
APapeIsName Feb 22, 2026
d574c3f
refactor: Phase 2 ๋ชจ๋ธ๋ง ๋ฐ ์„ค๊ณ„ ๋ณ€๊ฒฝ
APapeIsName Feb 22, 2026
3d45d19
refactor: Phase 3 ํ…Œ์ŠคํŠธ ์ฝ”๋“œ ์ˆ˜์ •
APapeIsName Feb 22, 2026
155dbef
docs: Like ์„ค๊ณ„ ๋ฐ˜์˜ ๋ฐ ์ฝ”๋“œ/๋ฌธ์„œ ์ •๋ฆฌ
APapeIsName Feb 22, 2026
5af7ea1
docs: Brand/Product ์„ค๊ณ„ ๋ฐ ์•„ํ‚คํ…์ฒ˜ ์„ค๊ณ„์„œ ์ž‘์„ฑ
APapeIsName Feb 22, 2026
f3edafd
refactor: Member ๋„๋ฉ”์ธ ์‹ฌ์ธต ๋ฆฌํŒฉํ† ๋ง ๋ฐ ๊ตฌ์กฐ ๊ฐœ์„ 
APapeIsName Feb 24, 2026
6609ecf
feat: Brand/Product ๋„๋ฉ”์ธ ๊ตฌํ˜„ ๋ฐ Order ์„ค๊ณ„ ์ค€๋น„
APapeIsName Feb 25, 2026
0203734
refactor: ํ…Œ์ŠคํŠธ ์บก์Аํ™” ๋ฆฌํŒฉํ† ๋ง ๋ฐ ๋„๋ฉ”์ธ ํ–‰์œ„ ๋ฉ”์„œ๋“œ ์ถ”๊ฐ€
APapeIsName Feb 25, 2026
c015337
feat: Order ๋„๋ฉ”์ธ ๋ชจ๋ธ ๊ตฌํ˜„
APapeIsName Feb 25, 2026
147f222
feat: Order Application Service ๊ตฌํ˜„
APapeIsName Feb 25, 2026
a130cf3
feat: Order Infrastructure Repository ๊ตฌํ˜„
APapeIsName Feb 25, 2026
cf96f3a
feat: Order Presentation Layer ๊ตฌํ˜„ ๋ฐ E2E ํ…Œ์ŠคํŠธ
APapeIsName Feb 25, 2026
8728688
Merge branch 'APapeIsName' of https://github.com/Loopers-dev-lab/loopโ€ฆ
APapeIsName Feb 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file removed .DS_Store
Binary file not shown.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,5 +39,8 @@ out/
### Kotlin ###
.kotlin

### OS ###
.DS_Store

/.claude
CLAUDE.md
13 changes: 13 additions & 0 deletions application/commerce-service/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
plugins {
`java-library`
}

dependencies {
api(project(":domain"))

// @Service, @Transactional
implementation("org.springframework:spring-tx")
implementation("org.springframework:spring-context")

testImplementation(testFixtures(project(":domain")))
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.loopers.application.example;

import com.loopers.domain.example.ExampleModel;
import com.loopers.domain.example.ExampleService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

@RequiredArgsConstructor
@Component
public class ExampleFacade {
private final ExampleService exampleService;

public ExampleInfo getExample(Long id) {
ExampleModel example = exampleService.getExample(id);
return ExampleInfo.from(example);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.loopers.application.example;

import com.loopers.domain.example.ExampleModel;

public record ExampleInfo(Long id, String name, String description) {
public static ExampleInfo from(ExampleModel model) {
return new ExampleInfo(
model.getId(),
model.getName(),
model.getDescription()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package com.loopers.application.service;

import com.loopers.application.service.dto.BrandCreateCommand;
import com.loopers.application.service.dto.BrandInfo;
import com.loopers.application.service.dto.BrandUpdateCommand;
import com.loopers.domain.catalog.BrandDeleteService;
import com.loopers.domain.catalog.brand.Brand;
import com.loopers.domain.catalog.brand.BrandExceptionMessage;
import com.loopers.domain.catalog.brand.BrandRepository;
import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Service
@RequiredArgsConstructor
public class BrandService {

private final BrandRepository brandRepository;
private final BrandDeleteService brandDeleteService;

@Transactional
public void create(BrandCreateCommand command) {
if (brandRepository.existsByName(command.name())) {
throw new CoreException(ErrorType.CONFLICT,
BrandExceptionMessage.Brand.DUPLICATE_NAME.message());
}

Brand brand = Brand.register(command.name());
brandRepository.save(brand);
}

@Transactional(readOnly = true)
public BrandInfo getById(Long id) {
Brand brand = brandRepository.findById(id)
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND,
BrandExceptionMessage.Brand.NOT_FOUND.message()));
return BrandInfo.from(brand);
}

@Transactional(readOnly = true)
public List<BrandInfo> getAll() {
return brandRepository.findAll().stream()
.map(BrandInfo::from)
.toList();
}

@Transactional(readOnly = true)
public List<BrandInfo> getActiveBrands() {
return brandRepository.findAllByDeletedAtIsNull().stream()
.map(BrandInfo::from)
.toList();
}

@Transactional
public void update(Long id, BrandUpdateCommand command) {
Brand brand = brandRepository.findById(id)
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND,
BrandExceptionMessage.Brand.NOT_FOUND.message()));

if (brandRepository.existsByName(command.name())) {
throw new CoreException(ErrorType.CONFLICT,
BrandExceptionMessage.Brand.DUPLICATE_NAME.message());
}

brand.updateName(command.name());
}

@Transactional
public void delete(Long id) {
brandDeleteService.delete(id);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package com.loopers.application.service;

import com.loopers.application.service.dto.MemberRegisterCommand;
import com.loopers.application.service.dto.MemberInfo;
import com.loopers.domain.member.Member;
import com.loopers.domain.member.MemberExceptionMessage;
import com.loopers.domain.member.MemberRepository;
import com.loopers.domain.member.PasswordEncryptor;
import com.loopers.domain.member.vo.*;
import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class MemberService {

private final MemberRepository memberRepository;
private final PasswordEncryptor passwordEncryptor;

@Transactional
public void register(MemberRegisterCommand request) {
boolean isLoginIdAlreadyExists = memberRepository.existsByLoginId(request.loginId());

if (isLoginIdAlreadyExists) {
throw new CoreException(ErrorType.CONFLICT, MemberExceptionMessage.LoginId.DUPLICATE_ID_EXISTS.message());
}

Member member = Member.register(
LoginId.of(request.loginId()),
Password.of(request.password(), request.birthdate(), passwordEncryptor),
MemberName.of(request.name()),
request.birthdate(),
Email.of(request.email())
);
memberRepository.save(member);
}

@Transactional(readOnly = true)
public MemberInfo getMyInfo(String userId, String password) {
Member member = memberRepository.findByLoginId(userId)
.orElseThrow(() -> new CoreException(ErrorType.UNAUTHORIZED, MemberExceptionMessage.ExistsMember.CANNOT_LOGIN.message()));

if (!member.matchesPassword(password, passwordEncryptor)) {
throw new CoreException(ErrorType.UNAUTHORIZED, MemberExceptionMessage.ExistsMember.CANNOT_LOGIN.message());
}

return new MemberInfo(
member.getId(),
member.getLoginId(),
member.getName(),
member.getBirthDate(),
member.getEmail()
);
}

@Transactional
public void updatePassword(String userId, String currentPassword, String newPassword) {
Member member = memberRepository.findByLoginId(userId)
.orElseThrow(() -> new CoreException(ErrorType.UNAUTHORIZED, MemberExceptionMessage.ExistsMember.CANNOT_LOGIN.message()));

if (!member.matchesPassword(currentPassword, passwordEncryptor)) {
throw new CoreException(ErrorType.UNAUTHORIZED, MemberExceptionMessage.Password.PASSWORD_INCORRECT.message());
}

member.updatePassword(newPassword, passwordEncryptor);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
package com.loopers.application.service;

import com.loopers.application.service.dto.OrderCreateCommand;
import com.loopers.application.service.dto.OrderInfo;
import com.loopers.application.service.dto.OrderLineInfo;
import com.loopers.application.service.dto.OrderLineRequest;
import com.loopers.domain.catalog.brand.Brand;
import com.loopers.domain.catalog.brand.BrandRepository;
import com.loopers.domain.catalog.product.Product;
import com.loopers.domain.catalog.product.ProductExceptionMessage;
import com.loopers.domain.catalog.product.ProductRepository;
import com.loopers.domain.catalog.product.vo.Quantity;
import com.loopers.domain.order.*;
import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;

@Service
@RequiredArgsConstructor
public class OrderService {

private final OrderRepository orderRepository;
private final OrderLineRepository orderLineRepository;
private final OrderLineSnapshotRepository orderLineSnapshotRepository;
private final ProductRepository productRepository;
private final BrandRepository brandRepository;

@Transactional
public OrderInfo create(OrderCreateCommand command) {
List<OrderLineRequest> requests = command.orderLines();
List<Long> productIds = requests.stream()
.map(OrderLineRequest::productId)
.distinct()
.sorted()
.toList();

Map<Long, Product> productMap = findActiveProducts(productIds);

List<Long> brandIds = productMap.values().stream()
.map(Product::getBrandId).distinct().toList();
Map<Long, Brand> brandMap = brandRepository.findAllByIdIn(brandIds).stream()
.collect(Collectors.toMap(Brand::getId, Function.identity()));

boolean allEnough = requests.stream()
.allMatch(req -> productMap.get(req.productId())
.hasEnoughStock(Quantity.of(req.quantity())));
OrderStatus status = allEnough ? OrderStatus.ACCEPTED : OrderStatus.REJECTED;

if (allEnough) {
requests.forEach(req ->
productMap.get(req.productId()).decreaseStock(Quantity.of(req.quantity())));
}

List<OrderLine> orderLines = requests.stream()
.map(req -> {
Product product = productMap.get(req.productId());
Brand brand = brandMap.get(product.getBrandId());
return OrderLine.of(
req.productId(), Quantity.of(req.quantity()),
product.getName().getValue(), product.getDescription(),
product.getPrice().getValue(),
brand != null ? brand.getName().getValue() : null
);
})
.toList();

Order order = Order.place(command.memberId(), orderLines, status);
Order savedOrder = orderRepository.save(order);
List<OrderLine> savedLines = orderLineRepository.saveAll(
savedOrder.assignOrderLines(orderLines));

List<OrderLineSnapshot> snapshots = savedLines.stream()
.map(line -> line.assignSnapshot().getSnapshot())
.toList();
orderLineSnapshotRepository.saveAll(snapshots);

return toOrderInfo(savedOrder, savedLines, snapshots);
}

@Transactional(readOnly = true)
public OrderInfo getById(Long orderId, Long memberId) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND,
OrderExceptionMessage.Order.NOT_FOUND.message()));

if (!order.isOwnedBy(memberId)) {
throw new CoreException(ErrorType.FORBIDDEN,
OrderExceptionMessage.Order.NOT_OWNER.message());
}

return toOrderInfo(order);
}

@Transactional(readOnly = true)
public List<OrderInfo> getByMemberId(Long memberId) {
return orderRepository.findByMemberId(memberId).stream()
.map(this::toOrderInfo)
.toList();
}

@Transactional(readOnly = true)
public List<OrderInfo> getAll() {
return orderRepository.findAll().stream()
.map(this::toOrderInfo)
.toList();
}

@Transactional(readOnly = true)
public OrderInfo getByIdForAdmin(Long orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND,
OrderExceptionMessage.Order.NOT_FOUND.message()));

return toOrderInfo(order);
}

private Map<Long, Product> findActiveProducts(List<Long> productIds) {
List<Product> products = productRepository.findAllByIdIn(productIds);

if (products.size() != productIds.size()) {
throw new CoreException(ErrorType.NOT_FOUND,
ProductExceptionMessage.Product.NOT_FOUND.message());
}

products.forEach(product -> {
if (product.isDeleted()) {
throw new CoreException(ErrorType.BAD_REQUEST,
ProductExceptionMessage.Product.ALREADY_DELETED.message());
}
});

return products.stream()
.collect(Collectors.toMap(Product::getId, Function.identity()));
}

private OrderInfo toOrderInfo(Order order) {
List<OrderLine> lines = orderLineRepository.findByOrderId(order.getId());
List<Long> lineIds = lines.stream().map(OrderLine::getId).toList();
List<OrderLineSnapshot> snapshots = orderLineSnapshotRepository.findByOrderLineIdIn(lineIds);
return toOrderInfo(order, lines, snapshots);
}

private OrderInfo toOrderInfo(Order order, List<OrderLine> lines, List<OrderLineSnapshot> snapshots) {
Map<Long, OrderLineSnapshot> snapshotMap = snapshots.stream()
.collect(Collectors.toMap(OrderLineSnapshot::getOrderLineId, Function.identity()));

List<OrderLineInfo> lineInfos = lines.stream()
.map(line -> {
OrderLineSnapshot snapshot = snapshotMap.get(line.getId());
return new OrderLineInfo(
line.getId(),
line.getProductId(),
line.getQuantity().getValue(),
snapshot != null ? snapshot.getProductName() : null,
snapshot != null ? snapshot.getProductDescription() : null,
snapshot != null ? snapshot.getPrice() : 0,
snapshot != null ? snapshot.getBrandName() : null
);
})
.toList();

return new OrderInfo(
order.getId(),
order.getMemberId(),
order.getStatus(),
order.getCreatedAt(),
lineInfos
);
}
}
Loading