Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
31 changes: 31 additions & 0 deletions docs/BACKEND_CODEBASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -688,5 +688,36 @@ private String payload;
| Debezium | 3.1 |
| Resilience4j | Spring Cloud 번들 |
| JWT (jjwt) | 0.13.0 |

---

## 8. Istio VirtualService & 컨트롤러 @RequestMapping 규칙

Kubernetes 환경에서 Istio VirtualService가 서비스별 prefix(`/release`, `/payment`, `/media` 등)를 `/`로 rewrite하여 서비스에 전달한다.

**규칙**: 클래스 레벨 `@RequestMapping`에 서비스 prefix를 포함하면 안 된다.

```java
// ❌ 잘못된 예 — Istio가 /release를 strip하므로 서비스엔 /{id}로 도착 → 404
@RestController
@RequestMapping("/release")
public class ReleaseCommandController { ... }

// ✅ 올바른 예 — prefix 없이 메서드 레벨 경로만 사용
@RestController
public class ReleaseCommandController { ... }
```

**Admin 경로**: `/admin/{service}` prefix도 동일하게 `/admin`까지만 사용한다.

```java
// ❌
@RequestMapping("/admin/release")

// ✅
@RequestMapping("/admin")
```

적용 서비스: `release`, `payment`, `media` (그 외 서비스 추가 시 동일 규칙 적용)
| Lombok | Spring Boot 관리 |
| SonarCloud | GitHub Actions 통합 |
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package kr.magicbox.generalgoods.adapter.in.kafka;

import kr.magicbox.generalgoods.adapter.in.kafka.annotation.Idempotent;
import kr.magicbox.generalgoods.adapter.in.kafka.event.StockReserveCommandEvent;
import kr.magicbox.generalgoods.application.port.in.HandleStockReserveUseCase;
import kr.magicbox.generalgoods.global.exception.BusinessException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.kafka.annotation.RetryableTopic;
import org.springframework.kafka.retrytopic.DltStrategy;
import org.springframework.stereotype.Component;

@Slf4j
@Component
@RequiredArgsConstructor
public class OrchestratorCommandKafkaListener {

private final HandleStockReserveUseCase handleStockReserveUseCase;

@Idempotent
@RetryableTopic(dltStrategy = DltStrategy.FAIL_ON_ERROR, dltTopicSuffix = "-dlt", exclude = {BusinessException.class})
@KafkaListener(topics = "outbox.event.stock-reserve.general-good", groupId = "general-goods-service")
public void handleStockReserve(ConsumerRecord<String, StockReserveCommandEvent> consumerRecord) {
log.info("[Inbox] stock-reserve.general-good 커맨드 수신. key={}", consumerRecord.key());
handleStockReserveUseCase.handleStockReserve(consumerRecord.value());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package kr.magicbox.generalgoods.adapter.in.kafka.event;

import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Builder;

import java.time.Instant;
import java.util.List;

@Builder
public record StockReserveCommandEvent(
@JsonProperty("order_id") Long orderId,
@JsonProperty("customer_id") Long customerId,
@JsonProperty("total_amount") Long totalAmount,
@JsonProperty("items") List<ItemPayload> items,
@JsonProperty("occurred_at") Instant occurredAt
) implements InboxEvent {

@Builder
public record ItemPayload(
@JsonProperty("order_line_id") Long orderLineId,
@JsonProperty("product_id") Long productId,
@JsonProperty("quantity") int quantity,
@JsonProperty("unit_price") long unitPrice
) {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -74,4 +74,9 @@ public List<GeneralGoods> findAllByCursor(Long cursorId, int size) {
.map(generalGoodsMapper::toDomain)
.toList();
}

@Override
public boolean decreaseStock(Long productId, long quantity) {
return generalGoodsJpaRepository.decreaseStock(productId, quantity) > 0;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,8 @@ public interface GeneralGoodsJpaRepository extends JpaRepository<GeneralGoodsEnt
@Modifying
@Query("UPDATE GeneralGoodsEntity g SET g.isDeleted = true WHERE g.creatorId = :creatorId AND g.isDeleted = false")
void softDeleteByCreatorId(@Param("creatorId") Long creatorId);

@Modifying
@Query("UPDATE GeneralGoodsEntity g SET g.stock = g.stock - :quantity WHERE g.id = :id AND g.stock >= :quantity AND g.isDeleted = false")
int decreaseStock(@Param("id") Long id, @Param("quantity") long quantity);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package kr.magicbox.generalgoods.application.port.in;

import kr.magicbox.generalgoods.adapter.in.kafka.event.StockReserveCommandEvent;

public interface HandleStockReserveUseCase {
void handleStockReserve(StockReserveCommandEvent event);
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,6 @@ public interface GeneralGoodsRepositoryPort {
GeneralGoods findById(GeneralGoodsId id);

List<GeneralGoods> findAllByCursor(Long cursorId, int size);

boolean decreaseStock(Long productId, long quantity);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package kr.magicbox.generalgoods.application.service;

import kr.magicbox.generalgoods.adapter.in.kafka.event.StockReserveCommandEvent;
import kr.magicbox.generalgoods.application.port.in.HandleStockReserveUseCase;
import kr.magicbox.generalgoods.application.port.out.GeneralGoodsOutboxPort;
import kr.magicbox.generalgoods.application.port.out.GeneralGoodsRepositoryPort;
import kr.magicbox.generalgoods.domain.event.StockReserveFailedEvent;
import kr.magicbox.generalgoods.domain.event.StockReserveSucceededEvent;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.Instant;

@Slf4j
@Service
@RequiredArgsConstructor
public class HandleStockReserveService implements HandleStockReserveUseCase {

private final GeneralGoodsRepositoryPort generalGoodsRepositoryPort;
private final GeneralGoodsOutboxPort generalGoodsOutboxPort;

@Transactional
@Override
public void handleStockReserve(StockReserveCommandEvent event) {
if (event.items() == null || event.items().isEmpty()) {
log.warn("[StockReserve] items 없음. orderId={}", event.orderId());
generalGoodsOutboxPort.save(StockReserveFailedEvent.builder()
.orderId(event.orderId())
.customerId(event.customerId())
.reason("items 없음")
.occurredAt(Instant.now())
.build());
return;
}

for (StockReserveCommandEvent.ItemPayload item : event.items()) {
boolean decreased = generalGoodsRepositoryPort.decreaseStock(item.productId(), item.quantity());
if (!decreased) {
log.warn("[StockReserve] 재고 부족. orderId={}, productId={}", event.orderId(), item.productId());
generalGoodsOutboxPort.save(StockReserveFailedEvent.builder()
.orderId(event.orderId())
.customerId(event.customerId())
.reason("재고 부족. productId=" + item.productId())
.occurredAt(Instant.now())
.build());
return;
}
}

log.info("[StockReserve] 재고 예약 성공. orderId={}", event.orderId());
generalGoodsOutboxPort.save(StockReserveSucceededEvent.builder()
.orderId(event.orderId())
.customerId(event.customerId())
.totalAmount(event.totalAmount())
.occurredAt(Instant.now())
.build());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ public enum GeneralGoodsDomainEventType {

GENERAL_GOODS_CREATED("general-goods-created"),
GENERAL_GOODS_UPDATED("general-goods-updated"),
GENERAL_GOODS_DELETED("general-goods-deleted");
GENERAL_GOODS_DELETED("general-goods-deleted"),
STOCK_RESERVE_SUCCEEDED("stock-reserve-general-goods-succeeded"),
STOCK_RESERVE_FAILED("stock-reserve-general-goods-failed");

private final String value;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package kr.magicbox.generalgoods.domain.event;

import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Builder;

import java.time.Instant;

@Builder
public record StockReserveFailedEvent(
@JsonProperty("order_id") Long orderId,
@JsonProperty("customer_id") Long customerId,
@JsonProperty("reason") String reason,
@JsonProperty("occurred_at") Instant occurredAt
) implements GeneralGoodsDomainEvent {

@Override
public String key() {
return orderId.toString();
}

@Override
public GeneralGoodsDomainEventType eventType() {
return GeneralGoodsDomainEventType.STOCK_RESERVE_FAILED;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package kr.magicbox.generalgoods.domain.event;

import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Builder;

import java.time.Instant;

@Builder
public record StockReserveSucceededEvent(
@JsonProperty("order_id") Long orderId,
@JsonProperty("customer_id") Long customerId,
@JsonProperty("total_amount") Long totalAmount,
@JsonProperty("occurred_at") Instant occurredAt
) implements GeneralGoodsDomainEvent {

@Override
public String key() {
return orderId.toString();
}

@Override
public GeneralGoodsDomainEventType eventType() {
return GeneralGoodsDomainEventType.STOCK_RESERVE_SUCCEEDED;
}
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
package kr.magicbox.notification.adapter.in.web.controller;

import kr.magicbox.notification.adapter.in.web.dto.request.ReadNotificationsRequest;
import kr.magicbox.notification.adapter.in.web.dto.request.RegisterFcmTokenRequest;
import kr.magicbox.notification.application.dto.command.ReadNotificationCommand;
import kr.magicbox.notification.application.dto.command.RegisterFcmTokenCommand;
import kr.magicbox.notification.application.port.in.ReadNotificationUseCase;
import kr.magicbox.notification.application.port.in.RegisterFcmTokenUseCase;
import kr.magicbox.notification.domain.vo.UserId;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
Expand All @@ -18,6 +22,7 @@
public class NotificationCommandController {

private final RegisterFcmTokenUseCase registerFcmTokenUseCase;
private final ReadNotificationUseCase readNotificationUseCase;

@PostMapping("/fcm-token")
public ResponseEntity<Void> registerFcmToken(
Expand All @@ -26,4 +31,12 @@ public ResponseEntity<Void> registerFcmToken(
registerFcmTokenUseCase.register(RegisterFcmTokenCommand.of(userId.value(), request.token()));
return ResponseEntity.ok().build();
}

@PatchMapping("/read")
public ResponseEntity<Void> readNotifications(
@AuthenticationPrincipal UserId userId,
@RequestBody ReadNotificationsRequest request) {
readNotificationUseCase.readAll(ReadNotificationCommand.of(request.notificationIds(), userId.value()));
return ResponseEntity.noContent().build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package kr.magicbox.notification.adapter.in.web.dto.request;

import java.util.List;

public record ReadNotificationsRequest(List<Long> notificationIds) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@
import kr.magicbox.notification.adapter.out.persistence.repository.NotificationJpaRepository;
import kr.magicbox.notification.application.port.out.NotificationRepositoryPort;
import kr.magicbox.notification.domain.aggregate.Notification;
import kr.magicbox.notification.domain.enums.NotificationStatus;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;

import java.util.List;
import java.util.Optional;

@Repository
@RequiredArgsConstructor
Expand All @@ -20,6 +22,24 @@ public void save(Notification notification) {
notificationJpaRepository.save(NotificationEntity.from(notification));
}

@Override
public void update(Notification notification) {
notificationJpaRepository.findByIdAndUserId(
notification.getId().value(), notification.getUserId().value())
.ifPresent(NotificationEntity::markRead);
}

@Override
public void updateAllByIdsAndUserId(List<Long> notificationIds, Long userId) {
notificationJpaRepository.updateStatusByIdsAndUserId(notificationIds, userId, NotificationStatus.READ);
}

@Override
public Optional<Notification> findByIdAndUserId(Long notificationId, Long userId) {
return notificationJpaRepository.findByIdAndUserId(notificationId, userId)
.map(NotificationEntity::toDomain);
}

@Override
public List<Notification> findAllByUserId(Long userId) {
return notificationJpaRepository.findAllByUserIdOrderByCreatedAtDesc(userId)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,20 @@
package kr.magicbox.notification.adapter.out.persistence.repository;

import kr.magicbox.notification.adapter.out.persistence.entity.NotificationEntity;
import kr.magicbox.notification.domain.enums.NotificationStatus;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.util.List;
import java.util.Optional;

public interface NotificationJpaRepository extends JpaRepository<NotificationEntity, Long> {
List<NotificationEntity> findAllByUserIdOrderByCreatedAtDesc(Long userId);
Optional<NotificationEntity> findByIdAndUserId(Long id, Long userId);

@Modifying
@Query("UPDATE NotificationEntity n SET n.status = :status WHERE n.id IN :ids AND n.userId = :userId")
void updateStatusByIdsAndUserId(@Param("ids") List<Long> ids, @Param("userId") Long userId, @Param("status") NotificationStatus status);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package kr.magicbox.notification.application.dto.command;

import java.util.List;

public record ReadNotificationCommand(
List<Long> notificationIds,
Long userId
) {
public static ReadNotificationCommand of(List<Long> notificationIds, Long userId) {
return new ReadNotificationCommand(notificationIds, userId);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package kr.magicbox.notification.application.port.in;

import kr.magicbox.notification.application.dto.command.ReadNotificationCommand;

public interface ReadNotificationUseCase {
void readAll(ReadNotificationCommand command);
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,12 @@
import kr.magicbox.notification.domain.aggregate.Notification;

import java.util.List;
import java.util.Optional;

public interface NotificationRepositoryPort {
void save(Notification notification);
void update(Notification notification);
void updateAllByIdsAndUserId(List<Long> notificationIds, Long userId);
Optional<Notification> findByIdAndUserId(Long notificationId, Long userId);
List<Notification> findAllByUserId(Long userId);
}
Loading
Loading