Skip to content
Open
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ out/
/nbdist/
/.nb-gradle/

src/main/resources/application.properties

### VS Code ###
.vscode/

Expand Down
4 changes: 4 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ dependencies {
// spring security & OAuth2
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
testImplementation 'org.springframework.security:spring-security-test'

// jwt
implementation 'com.auth0:java-jwt:4.2.1'
Expand All @@ -58,6 +59,9 @@ dependencies {
implementation 'javax.xml.bind:jaxb-api:2.3.1'
implementation 'org.glassfish.jaxb:jaxb-runtime:2.3.1'

//Kafka
implementation 'org.springframework.kafka:spring-kafka:3.0.7'

// //Querydsl 추가
// implementation 'com.querydsl:querydsl-core:5.0.0'
// implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
Expand Down
25 changes: 25 additions & 0 deletions compose.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
version: '3'
services:
zookeeper:
image: confluentinc/cp-zookeeper:latest
container_name: zookeeper
ports:
- "2181:2181"
environment:
ZOOKEEPER_CLIENT_PORT: 2181
ZOOKEEPER_TICK_TIME: 2000

kafka:
image: confluentinc/cp-kafka:latest
container_name: kafka
depends_on:
- zookeeper
ports:
- "9092:9092"
environment:
KAFKA_BROKER_ID: 1
KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
# 클러스터 내부와 외부에서 접근할 listener 설정 (내부: kafka:29092, 외부: localhost:9092)
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:29092,PLAINTEXT_HOST://localhost:9092
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ public class CeoInfo extends BaseTimeEntity {
private LocalDateTime lastLoginDate;

// 사장님 가게 연관관계 매핑
@Builder.Default
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "store_idx")
private Store store = null;
Expand Down
13 changes: 13 additions & 0 deletions src/main/java/com/readyvery/readyverydemo/domain/Order.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

import java.time.LocalDateTime;

import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
Expand Down Expand Up @@ -111,6 +114,16 @@ public class Order extends BaseTimeEntity {
@OneToOne(mappedBy = "order", fetch = FetchType.LAZY)
private Receipt receipt;

@Builder.Default
@CreatedDate
@Column(name = "created_at")
private LocalDateTime createdAt = LocalDateTime.now();

@Builder.Default
@LastModifiedDate
@Column(name = "last_modified_at")
private LocalDateTime lastModifiedAt = LocalDateTime.now();

public void completeOrder(Progress progress) {
this.progress = progress;
}
Expand Down
3 changes: 3 additions & 0 deletions src/main/java/com/readyvery/readyverydemo/domain/Store.java
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import jakarta.persistence.Table;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;
Expand All @@ -25,6 +26,7 @@
@Table(name = "STORE")
@AllArgsConstructor
@Slf4j
@Builder
public class Store extends BaseTimeEntity {

@Id
Expand Down Expand Up @@ -101,4 +103,5 @@ public class Store extends BaseTimeEntity {
public void updateStatus(boolean status) {
this.status = status;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,8 @@
import org.springframework.context.annotation.Configuration;

import com.auth0.jwt.JWT;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.readyvery.readyverydemo.config.JwtConfig;
import com.readyvery.readyverydemo.domain.Role;
import com.readyvery.readyverydemo.security.jwt.service.create.JwtTokenGenerator;

import jakarta.servlet.http.HttpServletResponse;
import lombok.Getter;
Expand All @@ -23,9 +21,7 @@
@RequiredArgsConstructor
public class JwtTokenizer {

private final JwtTokenGenerator tokenGenerator;
private final TokenSendManager tokenSendManager;
private final ObjectMapper objectMapper;
private final JwtConfig jwtConfig;

public void addAccessTokenCookie(HttpServletResponse response, String accessToken) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
import org.springframework.stereotype.Component;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.readyvery.readyverydemo.config.JwtConfig;
import com.readyvery.readyverydemo.domain.Role;
import com.readyvery.readyverydemo.security.jwt.dto.CeoLoginSuccessRes;

Expand All @@ -18,7 +17,7 @@
@Component
@Slf4j
public class TokenSendManager {
private final JwtConfig jwtConfig;

private final ObjectMapper objectMapper;

public void addTokenCookie(HttpServletResponse response, String name, String value, String path, int maxAge,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package com.readyvery.readyverydemo.src.order;

import java.util.regex.Matcher;
import java.util.regex.Pattern;

import org.springframework.stereotype.Service;

import jakarta.servlet.http.HttpServletRequest;

@Service
public class BrowserCapabilityService {

private static final int MIN_CHROMIUM_VERSION_FOR_SSE = 90;
private static final int MIN_FIREFOX_VERSION_FOR_SSE = 6;
private static final int MIN_SAFARI_VERSION_FOR_SSE = 5;

private static final Pattern CHROMIUM_CH_PATTERN =
Pattern.compile("(Chromium|Google Chrome)\"?;v=\"?(\\d+)");
private static final Pattern FIREFOX_CH_PATTERN =
Pattern.compile("(Firefox)\"?;v=\"?(\\d+)");
private static final Pattern SAFARI_CH_PATTERN =
Pattern.compile("(Safari)\"?;v=\"?(\\d+)");

private static final Pattern CHROME_UA_PATTERN =
Pattern.compile("Chrome/(\\d+)");

private static final Pattern FIREFOX_UA_PATTERN =
Pattern.compile("Firefox/(\\d+)");

private static final Pattern SAFARI_VERSION_UA_PATTERN =
Pattern.compile("Version/(\\d+).*Safari/");

/**
* Client Hints와 User-Agent 정보를 종합해서 SSE 지원 여부를 판별.
*/
public boolean isSseCapableBrowser(HttpServletRequest request) {
// 1) 우선 Sec-CH-UA (Client Hints)를 확인
String secChUa = request.getHeader("Sec-CH-UA");
if (secChUa != null) {
// Chromium/Chrome
Integer chromiumVersion = parseVersionFromCh(secChUa, CHROMIUM_CH_PATTERN);
if (chromiumVersion != null && chromiumVersion >= MIN_CHROMIUM_VERSION_FOR_SSE) {
return true;
}

// Firefox
Integer firefoxVersion = parseVersionFromCh(secChUa, FIREFOX_CH_PATTERN);
if (firefoxVersion != null && firefoxVersion >= MIN_FIREFOX_VERSION_FOR_SSE) {
return true;
}

// Safari
Integer safariVersion = parseVersionFromCh(secChUa, SAFARI_CH_PATTERN);
if (safariVersion != null && safariVersion >= MIN_SAFARI_VERSION_FOR_SSE) {
return true;
}
}

// 2) Client Hints가 없거나 파싱 실패 → User-Agent로 폴백
String userAgent = request.getHeader("User-Agent");
if (userAgent == null) {
return false;
}

// 구형 IE (Trident, MSIE) → SSE 미지원
if (userAgent.contains("Trident") || userAgent.contains("MSIE")) {
return false;
}

// Firefox 판별
Matcher fxMatcher = FIREFOX_UA_PATTERN.matcher(userAgent);
if (fxMatcher.find()) {
int fxVersion = Integer.parseInt(fxMatcher.group(1));
return fxVersion >= MIN_FIREFOX_VERSION_FOR_SSE;
}

// Safari 판별
// Safari UA 문자열엔 "Safari"가 포함되지만
// Chrome 계열에도 "Safari" 문자열이 들어갈 수 있으므로
// "Chrome/" 또는 "Chromium"이 있는지 먼저 확인이 필요
if (userAgent.contains("Safari") && !userAgent.contains("Chrome")) {
Matcher safariMatcher = SAFARI_VERSION_UA_PATTERN.matcher(userAgent);
if (safariMatcher.find()) {
int safariVersion = Integer.parseInt(safariMatcher.group(1));
return safariVersion >= MIN_SAFARI_VERSION_FOR_SSE;
}
// Safari 문자열은 있는데 Version/이 없으면 구버전이거나 별도 케이스
// 일단 미지원으로 처리
return false;
}

// 2-4) Chromium/Chrome 판별
Matcher chromeMatcher = CHROME_UA_PATTERN.matcher(userAgent);
if (chromeMatcher.find()) {
int chromeVersion = Integer.parseInt(chromeMatcher.group(1));
return chromeVersion >= MIN_CHROMIUM_VERSION_FOR_SSE;
}

// 3) 그 외 오페라, Edge(Chromium), 기타 브라우저 등은 별도 처리 가능
return false;
}

/**
* Sec-CH-UA 문자열에서 특정 브랜드(Chromium, Firefox, Safari 등) 버전을 찾는 메서드
*/
private Integer parseVersionFromCh(String secChUa, Pattern pattern) {
Matcher matcher = pattern.matcher(secChUa);
if (matcher.find()) {
try {
return Integer.parseInt(matcher.group(2));
} catch (NumberFormatException ignored) {
}
}
return null;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package com.readyvery.readyverydemo.src.order;

import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

import com.readyvery.readyverydemo.domain.CeoInfo;
import com.readyvery.readyverydemo.security.jwt.dto.CustomUserDetails;
import com.readyvery.readyverydemo.src.ceo.CeoServiceFacade;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import lombok.RequiredArgsConstructor;

@RestController
@RequiredArgsConstructor
@RequestMapping("/v2")
public class OrderControllerV2 {

private final StoreSseEmitterManager storeSseEmitterManager;
private final CeoServiceFacade ceoServiceFacade;

@Operation(summary = "사장님 전용 주문 SSE",
description = "Kafka로 전달되는 주문 상태 변경을 실시간 수신 (사장님 권한 + 가게 소유권 검증)",
tags = "Order")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "OK")
})
@PreAuthorize("hasRole('CEO')")
@GetMapping("/order/stream")
public SseEmitter streamOrderUpdates(@AuthenticationPrincipal CustomUserDetails userDetails) {

CeoInfo ceoInfo = ceoServiceFacade.getCeoInfo(userDetails.getId());
// 검증 완료 후, storeSseEmitterManager에 등록
return storeSseEmitterManager.createEmitterForStore(ceoInfo.getStore().getId());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.readyvery.readyverydemo.src.order;

import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.stereotype.Service;

import com.readyvery.readyverydemo.domain.Progress;
import com.readyvery.readyverydemo.src.order.dto.OrderRegisterRes;
import com.readyvery.readyverydemo.src.store.dto.OrderUpdateMessage;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@Service
@RequiredArgsConstructor
public class OrderKafkaConsumer {

private final StoreSseEmitterManager storeSseEmitterManager;
private final OrderService orderServiceImpl;

@KafkaListener(topics = "order_updates", groupId = "store_owner_group")
public void processOrderUpdate(OrderUpdateMessage message) {
log.info("[OrderKafkaConsumer] Received message for storeId: {}, orderId: {}, status: {}",
message.getStoreId(), message.getOrderId(), message.getStatus());

OrderRegisterRes orderList = orderServiceImpl.getOrdersV2(message.getStoreId(), Progress.INTEGRATION);
storeSseEmitterManager.sendEventToStore(message.getStoreId(), orderList);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ public interface OrderService {

OrderRegisterRes getOrders(Long id, Progress progress);

OrderRegisterRes getOrdersV2(Long id, Progress progress);

OrderStatusRes completeOrder(Long id, OrderStatusUpdateReq request);

OrderStatusRes cancelOrder(Long id, OrderStatusUpdateReq request);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,29 @@ public OrderRegisterRes getOrders(Long id, Progress progress) {
return orderMapper.orderToOrderRegisterRes(ceoInfo.getStore().getId(), orders, Collections.emptyList());
}

@Override
public OrderRegisterRes getOrdersV2(Long id, Progress progress) {

if (progress == null) {
throw new BusinessLogicException(ExceptionCode.NOT_PROGRESS_ORDER);
}
LocalDateTime startOfDay = LocalDate.now().atStartOfDay();
LocalDateTime endOfDay = startOfDay.plusDays(1).minusNanos(1);

if (progress == Progress.INTEGRATION) {
List<Order> waitOrders = orderRepository.findAllByProgressAndStoreIdAndCreatedAtBetween(
Progress.ORDER, id, startOfDay, endOfDay);
List<Order> makeOrders = orderRepository.findAllByProgressAndStoreIdAndCreatedAtBetween(
Progress.MAKE, id, startOfDay, endOfDay);

return orderMapper.orderToOrderRegisterRes(id, waitOrders, makeOrders);
}

List<Order> orders = orderRepository.findAllByProgressAndStoreIdAndCreatedAtBetween(
progress, id, startOfDay, endOfDay);
return orderMapper.orderToOrderRegisterRes(id, orders, Collections.emptyList());
}

@Override
@Transactional
public OrderStatusRes completeOrder(Long id, OrderStatusUpdateReq request) {
Expand Down Expand Up @@ -325,7 +348,7 @@ private TosspaymentDto requestTossPaymentCancel(String paymentKey, String reject
return restTemplate.postForObject(TossPaymentConfig.PAYMENT_URL + paymentKey + "/cancel",
new HttpEntity<>(params, headers),
TosspaymentDto.class);
} catch (HttpClientErrorException e) {
} catch (HttpClientErrorException e) {
/*
* 취소 실패 시, 이미 취소된 거래라면 결제 정보 조회
* 취소된 정보 재적용
Expand Down
Loading
Loading