diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/common/config/DummyDataProduct.java b/src/main/java/jpa/basic/alldayprojectcommerce/common/config/DummyDataProduct.java index dc6eaa5..b6d75d4 100644 --- a/src/main/java/jpa/basic/alldayprojectcommerce/common/config/DummyDataProduct.java +++ b/src/main/java/jpa/basic/alldayprojectcommerce/common/config/DummyDataProduct.java @@ -26,11 +26,11 @@ public void run(String... args) { return; } - // 기존 더미데이터 3개 + 티켓 - create("볼캡", 36000L, 100, "발렌타인 코듀로이 볼캡", ProductStatus.ON_SALE, Category.MERCHANDISE, "cap.png"); + // 기존 더미데이터 3개 + 이벤트 + create("볼캡", 36000L, 100, "발렌타인 코듀로이 볼캡", ProductStatus.ON_SALE, Category.MERCH, "cap.png"); create("바이닐", 70000L, 50, "한정판 LP", ProductStatus.ON_SALE, Category.ALBUM,"vinyl.png"); - create("슬로건", 15000L, 200, "공식 슬로건", ProductStatus.SOLD_OUT, Category.MERCHANDISE,"slogan.png"); - create("티켓", 0L, 100, "티켓 구매권", ProductStatus.ON_SALE, Category.TICKET,"ticket.png"); + create("슬로건", 15000L, 200, "공식 슬로건", ProductStatus.SOLD_OUT, Category.MERCH,"slogan.png"); + create("이벤트 티켓", 0L, 100, "이벤트 티켓 구매권", ProductStatus.ON_SALE, Category.EVENT,"ticket.png"); // 더미데이터 50,000건 추가 Category[] categories = Category.values(); diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/common/config/JwtHandshakeInterceptor.java b/src/main/java/jpa/basic/alldayprojectcommerce/common/config/JwtHandshakeInterceptor.java new file mode 100644 index 0000000..d28abea --- /dev/null +++ b/src/main/java/jpa/basic/alldayprojectcommerce/common/config/JwtHandshakeInterceptor.java @@ -0,0 +1,49 @@ +package jpa.basic.alldayprojectcommerce.common.config; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jpa.basic.alldayprojectcommerce.common.security.auth.AuthConstants; +import jpa.basic.alldayprojectcommerce.common.security.jwt.JwtTokenProvider; +import lombok.RequiredArgsConstructor; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.http.server.ServletServerHttpRequest; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.server.HandshakeInterceptor; + +import java.util.Map; + +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class JwtHandshakeInterceptor implements HandshakeInterceptor { + + private final JwtTokenProvider jwtTokenProvider; + + @Override + public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, + WebSocketHandler wsHandler, Map attributes) { + if (request instanceof ServletServerHttpRequest servletRequest) { + HttpServletRequest req = servletRequest.getServletRequest(); + Cookie[] cookies = req.getCookies(); + if (cookies != null) { + for (Cookie cookie : cookies) { + if (AuthConstants.ACCESS_TOKEN.equals(cookie.getName())) { + String token = cookie.getValue(); + if (jwtTokenProvider.validateToken(token)) { + attributes.put("jwt_token", token); + } + break; + } + } + } + } + return true; + } + + @Override + public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, + WebSocketHandler wsHandler, Exception exception) { + } +} diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/common/config/StompChannelInterceptor.java b/src/main/java/jpa/basic/alldayprojectcommerce/common/config/StompChannelInterceptor.java index 03af7ba..c4af7f8 100644 --- a/src/main/java/jpa/basic/alldayprojectcommerce/common/config/StompChannelInterceptor.java +++ b/src/main/java/jpa/basic/alldayprojectcommerce/common/config/StompChannelInterceptor.java @@ -75,13 +75,19 @@ public Message preSend(Message message, MessageChannel channel) { return message; } - // Authorization 헤더에서 Bearer 토큰 추출 + // Authorization 헤더 및 Session Attributes에서 Bearer 토큰 추출 private String extractToken(StompHeaderAccessor accessor) { String authHeader = accessor.getFirstNativeHeader("Authorization"); if (authHeader != null && authHeader.startsWith("Bearer ")) { return authHeader.substring(7); } + + java.util.Map sessionAttributes = accessor.getSessionAttributes(); + if (sessionAttributes != null && sessionAttributes.containsKey("jwt_token")) { + return (String) sessionAttributes.get("jwt_token"); + } + return null; } } diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/common/config/WebSocketConfig.java b/src/main/java/jpa/basic/alldayprojectcommerce/common/config/WebSocketConfig.java index 7484468..733d2fa 100644 --- a/src/main/java/jpa/basic/alldayprojectcommerce/common/config/WebSocketConfig.java +++ b/src/main/java/jpa/basic/alldayprojectcommerce/common/config/WebSocketConfig.java @@ -15,6 +15,7 @@ public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { private final StompChannelInterceptor stompChannelInterceptor; + private final JwtHandshakeInterceptor jwtHandshakeInterceptor; @Value("${websocket.allowed-origins}") private String allowedOrigins; @@ -43,6 +44,7 @@ public void configureMessageBroker(MessageBrokerRegistry registry) { public void registerStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint("/ws-chat") .setAllowedOriginPatterns(allowedOrigins.split(",")) // 운영할 때는 도메인 지정 + .addInterceptors(jwtHandshakeInterceptor) .withSockJS(); // WebSocket 미지원 브라우저 fallback 처리 } diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/common/interceptor/AdminInterceptor.java b/src/main/java/jpa/basic/alldayprojectcommerce/common/interceptor/AdminInterceptor.java new file mode 100644 index 0000000..8cfe7c6 --- /dev/null +++ b/src/main/java/jpa/basic/alldayprojectcommerce/common/interceptor/AdminInterceptor.java @@ -0,0 +1,38 @@ +package jpa.basic.alldayprojectcommerce.common.interceptor; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jpa.basic.alldayprojectcommerce.common.exception.CustomException; +import jpa.basic.alldayprojectcommerce.common.exception.ErrorCode; +import jpa.basic.alldayprojectcommerce.common.security.auth.LoginUserInfo; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +@Component +@RequiredArgsConstructor +public class AdminInterceptor implements HandlerInterceptor { + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { + org.springframework.security.core.Authentication authentication = org.springframework.security.core.context.SecurityContextHolder.getContext().getAuthentication(); + + if (authentication == null || "anonymousUser".equals(authentication.getPrincipal())) { + response.sendRedirect("/login"); + return false; + } + + LoginUserInfo userInfo = (LoginUserInfo) authentication.getPrincipal(); + + if (!"ADMIN".equals(userInfo.role())) { + if (request.getRequestURI().startsWith("/api/")) { + response.sendError(HttpServletResponse.SC_FORBIDDEN, "Access Denied"); + return false; + } + response.sendRedirect("/"); + return false; + } + + return true; + } +} diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/common/interceptor/AuthModelInterceptor.java b/src/main/java/jpa/basic/alldayprojectcommerce/common/interceptor/AuthModelInterceptor.java new file mode 100644 index 0000000..106b559 --- /dev/null +++ b/src/main/java/jpa/basic/alldayprojectcommerce/common/interceptor/AuthModelInterceptor.java @@ -0,0 +1,61 @@ +package jpa.basic.alldayprojectcommerce.common.interceptor; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jpa.basic.alldayprojectcommerce.common.security.auth.LoginUserInfo; +import jpa.basic.alldayprojectcommerce.common.security.jwt.JwtTokenProvider; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.servlet.HandlerInterceptor; +import org.springframework.web.servlet.ModelAndView; + +import java.util.Arrays; + +/** + * JWT 쿠키에서 인증 정보를 추출하여 Thymeleaf 모델에 주입하는 인터셉터. + * STATELESS 세션 환경에서 ${user} 모델 어트리뷰트로 로그인 상태를 전달합니다. + * API 스펙에는 영향을 주지 않습니다. + */ +@Component +@RequiredArgsConstructor +public class AuthModelInterceptor implements HandlerInterceptor { + + private final JwtTokenProvider jwtTokenProvider; + + @Override + public void postHandle(HttpServletRequest request, HttpServletResponse response, + Object handler, ModelAndView modelAndView) { + + // API 요청이거나 ModelAndView가 없으면 스킵 + if (modelAndView == null || request.getRequestURI().startsWith("/api/")) { + return; + } + + String token = resolveToken(request); + + if (StringUtils.hasText(token) && jwtTokenProvider.validateToken(token)) { + Long userId = jwtTokenProvider.getMemberId(token); + String role = jwtTokenProvider.getRole(token); + + LoginUserInfo userInfo = LoginUserInfo.builder() + .id(userId) + .role(role) + .build(); + + modelAndView.addObject("user", userInfo); + } + } + + private String resolveToken(HttpServletRequest request) { + Cookie[] cookies = request.getCookies(); + if (cookies == null) return null; + + return Arrays.stream(cookies) + .filter(cookie -> "access_token".equals(cookie.getName())) + .map(Cookie::getValue) + .findFirst() + .orElse(null); + } +} diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/common/security/auth/controller/AuthController.java b/src/main/java/jpa/basic/alldayprojectcommerce/common/security/auth/controller/AuthController.java index f5a2bda..1ba632a 100644 --- a/src/main/java/jpa/basic/alldayprojectcommerce/common/security/auth/controller/AuthController.java +++ b/src/main/java/jpa/basic/alldayprojectcommerce/common/security/auth/controller/AuthController.java @@ -10,9 +10,14 @@ import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import jpa.basic.alldayprojectcommerce.domain.user.service.UserQueryService; +import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @RestController @@ -21,6 +26,13 @@ public class AuthController { private final AuthService authService; + private final UserQueryService userQueryService; + + @GetMapping("/check-duplicate") + public ResponseEntity> checkDuplicate(@RequestParam String email) { + boolean duplicate = userQueryService.getByEmail(email).isPresent(); + return ResponseEntity.ok(ApiResponse.success(HttpStatus.OK, duplicate)); + } @PostMapping("/signup") public ResponseEntity> signup( diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/common/security/config/SecurityConfig.java b/src/main/java/jpa/basic/alldayprojectcommerce/common/security/config/SecurityConfig.java index 3cc0683..cc8aa68 100644 --- a/src/main/java/jpa/basic/alldayprojectcommerce/common/security/config/SecurityConfig.java +++ b/src/main/java/jpa/basic/alldayprojectcommerce/common/security/config/SecurityConfig.java @@ -30,6 +30,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .authorizeHttpRequests(auth -> auth .requestMatchers( "/", "/login", "/signup", + "/cart", "/mypage", "/orders", "/orders/**", "/checkout", "/css/**", "/js/**", "/images/**", "/assets/**", "/img/**", "/error", "/favicon.ico", "/h2-console/**" @@ -50,7 +51,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers( "/actuator/health", "/api/events/**", - "/ws-chat/**" + "/ws/**", "/ws-chat/**" ).permitAll() .requestMatchers( "/api/chat/admin/**" diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/common/security/config/WebMvcConfig.java b/src/main/java/jpa/basic/alldayprojectcommerce/common/security/config/WebMvcConfig.java index 0ed5db8..55c12de 100644 --- a/src/main/java/jpa/basic/alldayprojectcommerce/common/security/config/WebMvcConfig.java +++ b/src/main/java/jpa/basic/alldayprojectcommerce/common/security/config/WebMvcConfig.java @@ -1,10 +1,13 @@ package jpa.basic.alldayprojectcommerce.common.security.config; +import jpa.basic.alldayprojectcommerce.common.interceptor.AuthModelInterceptor; +import jpa.basic.alldayprojectcommerce.common.interceptor.AdminInterceptor; import jpa.basic.alldayprojectcommerce.common.security.LoginUserArgumentResolver; import lombok.NonNull; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Configuration; import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import java.util.List; @@ -14,10 +17,21 @@ public class WebMvcConfig implements WebMvcConfigurer { private final LoginUserArgumentResolver loginUserArgumentResolver; - // 수정 포인트: 다른 ArgumentResolver가 있으면 여기에 함께 추가 + private final AuthModelInterceptor authModelInterceptor; + private final AdminInterceptor adminInterceptor; @Override public void addArgumentResolvers(@NonNull List resolvers) { resolvers.add(loginUserArgumentResolver); } + + @Override + public void addInterceptors(@NonNull InterceptorRegistry registry) { + registry.addInterceptor(authModelInterceptor) + .addPathPatterns("/**") + .excludePathPatterns("/api/**", "/css/**", "/js/**", "/images/**", "/favicon.ico"); + + registry.addInterceptor(adminInterceptor) + .addPathPatterns("/admin/**"); + } } \ No newline at end of file diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/domain/chat/dto/response/ChatRoomResponse.java b/src/main/java/jpa/basic/alldayprojectcommerce/domain/chat/dto/response/ChatRoomResponse.java index 5f5e83e..79664a8 100644 --- a/src/main/java/jpa/basic/alldayprojectcommerce/domain/chat/dto/response/ChatRoomResponse.java +++ b/src/main/java/jpa/basic/alldayprojectcommerce/domain/chat/dto/response/ChatRoomResponse.java @@ -8,6 +8,7 @@ public record ChatRoomResponse( Long id, Long userId, + String userEmail, String title, ChatRoomStatus chatRoomStatus, LocalDateTime lastMessageAt, @@ -18,6 +19,19 @@ public static ChatRoomResponse from(ChatRoom chatRoom) { return new ChatRoomResponse( chatRoom.getId(), chatRoom.getUserId(), + null, + chatRoom.getTitle(), + chatRoom.getChatRoomStatus(), + chatRoom.getLastMessageAt(), + chatRoom.getCreatedAt() + ); + } + + public static ChatRoomResponse from(ChatRoom chatRoom, String userEmail) { + return new ChatRoomResponse( + chatRoom.getId(), + chatRoom.getUserId(), + userEmail, chatRoom.getTitle(), chatRoom.getChatRoomStatus(), chatRoom.getLastMessageAt(), diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/domain/chat/redis/ChatRedisSubscriber.java b/src/main/java/jpa/basic/alldayprojectcommerce/domain/chat/redis/ChatRedisSubscriber.java index 2681070..73b9c3e 100644 --- a/src/main/java/jpa/basic/alldayprojectcommerce/domain/chat/redis/ChatRedisSubscriber.java +++ b/src/main/java/jpa/basic/alldayprojectcommerce/domain/chat/redis/ChatRedisSubscriber.java @@ -40,6 +40,9 @@ public void onMessage(Message message, @Nullable byte[] pattern) { // 구독자 전체에게 브로드캐스트 simpMessagingTemplate.convertAndSend("/sub/chat/" + roomId, response); + // 관리자 상담 목록 페이지에도 업데이트 알림 전송 + simpMessagingTemplate.convertAndSend("/sub/admin/consultations", response); + log.debug("[Redis Sub] roomId: {}, messageId: {}", roomId, response.id()); } catch (Exception e) { diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/domain/chat/repository/ChatRoomRepositoryCustomImpl.java b/src/main/java/jpa/basic/alldayprojectcommerce/domain/chat/repository/ChatRoomRepositoryCustomImpl.java index 35f392b..b4969e5 100644 --- a/src/main/java/jpa/basic/alldayprojectcommerce/domain/chat/repository/ChatRoomRepositoryCustomImpl.java +++ b/src/main/java/jpa/basic/alldayprojectcommerce/domain/chat/repository/ChatRoomRepositoryCustomImpl.java @@ -18,6 +18,7 @@ import java.util.List; import static jpa.basic.alldayprojectcommerce.domain.chat.entity.QChatRoom.chatRoom; +import static jpa.basic.alldayprojectcommerce.domain.user.entity.QUser.user; @Repository @RequiredArgsConstructor @@ -39,12 +40,14 @@ public Page findAllWithFilter(ChatRoomStatus status, Pageable ChatRoomResponse.class, chatRoom.id, chatRoom.userId, + user.email, chatRoom.title, chatRoom.chatRoomStatus, chatRoom.lastMessageAt, chatRoom.createdAt )) .from(chatRoom) + .leftJoin(user).on(user.id.eq(chatRoom.userId)) .where(statusEq(status)) .orderBy(chatRoom.createdAt.desc()) .offset(pageable.getOffset()) @@ -84,7 +87,13 @@ public void bulkCompleteRooms(List roomIds) { .update(chatRoom) .set(chatRoom.chatRoomStatus, ChatRoomStatus.COMPLETED) .setNull(chatRoom.activeFlag) - .where(chatRoom.id.in(roomIds)) + .where( + chatRoom.id.in(roomIds), + chatRoom.chatRoomStatus.in( + ChatRoomStatus.WAITING, + ChatRoomStatus.IN_PROGRESS + ) + ) .execute(); /** diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/domain/chat/scheduler/ChatInactivityScheduler.java b/src/main/java/jpa/basic/alldayprojectcommerce/domain/chat/scheduler/ChatInactivityScheduler.java index 88d5b1c..22f1d4d 100644 --- a/src/main/java/jpa/basic/alldayprojectcommerce/domain/chat/scheduler/ChatInactivityScheduler.java +++ b/src/main/java/jpa/basic/alldayprojectcommerce/domain/chat/scheduler/ChatInactivityScheduler.java @@ -2,7 +2,6 @@ import jpa.basic.alldayprojectcommerce.common.lock.repository.RedisLockRepository; import jpa.basic.alldayprojectcommerce.domain.chat.dto.response.ChatMessageResponse; -import jpa.basic.alldayprojectcommerce.domain.chat.entity.ChatMessage; import jpa.basic.alldayprojectcommerce.domain.chat.entity.ChatRoom; import jpa.basic.alldayprojectcommerce.domain.chat.entity.MessageType; import jpa.basic.alldayprojectcommerce.domain.chat.entity.SenderType; @@ -85,18 +84,9 @@ public void closeInactiveRooms() { .collect(Collectors.toList()); /** - * Bulk Update - * - * 분산락이 스케쥴러 진입 자체를 1대만 허용하므로 - * 비관적 락 없이 Bulk Update 사용 + * 상태 변경 + 메시지 저장을 하나의 트랜잭션으로 처리 */ - chatRoomRepository.bulkCompleteRooms(roomIds); - - List messages = roomIds.stream() - .map(id -> ChatMessage.systemMessage(id, buildCloseMessage())) - .collect(Collectors.toList()); - - chatMessageRepository.saveAll(messages); + chatRoomCommandService.batchAutoCloseRooms(roomIds, buildCloseMessage()); for (Long roomId : roomIds) { try { diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/domain/chat/service/ChatRoomCommandService.java b/src/main/java/jpa/basic/alldayprojectcommerce/domain/chat/service/ChatRoomCommandService.java index 691f49b..3c143ac 100644 --- a/src/main/java/jpa/basic/alldayprojectcommerce/domain/chat/service/ChatRoomCommandService.java +++ b/src/main/java/jpa/basic/alldayprojectcommerce/domain/chat/service/ChatRoomCommandService.java @@ -3,6 +3,8 @@ import jpa.basic.alldayprojectcommerce.domain.chat.dto.request.CreateChatRoomRequest; import jpa.basic.alldayprojectcommerce.domain.chat.dto.response.ChatRoomResponse; +import java.util.List; + public interface ChatRoomCommandService { /** @@ -29,4 +31,10 @@ public interface ChatRoomCommandService { * ADMIN 권한 필수 */ void joinChatRoom(Long adminId, Long roomId, String role); + + /** + * 배치 자동 종료 - 스케쥴러 전용 + * bulkCompleteRooms + saveAll을 하나의 트랜잭션으로 묶어 원자성 보장 + */ + void batchAutoCloseRooms(List roomIds, String closeMessage); } diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/domain/chat/service/ChatRoomCommandServiceImpl.java b/src/main/java/jpa/basic/alldayprojectcommerce/domain/chat/service/ChatRoomCommandServiceImpl.java index 94f8957..e0a7e62 100644 --- a/src/main/java/jpa/basic/alldayprojectcommerce/domain/chat/service/ChatRoomCommandServiceImpl.java +++ b/src/main/java/jpa/basic/alldayprojectcommerce/domain/chat/service/ChatRoomCommandServiceImpl.java @@ -3,10 +3,12 @@ import jpa.basic.alldayprojectcommerce.common.exception.CustomException; import jpa.basic.alldayprojectcommerce.common.exception.ErrorCode; import jpa.basic.alldayprojectcommerce.domain.chat.dto.request.CreateChatRoomRequest; +import jpa.basic.alldayprojectcommerce.domain.chat.dto.response.ChatMessageResponse; import jpa.basic.alldayprojectcommerce.domain.chat.dto.response.ChatRoomResponse; import jpa.basic.alldayprojectcommerce.domain.chat.entity.ChatMessage; import jpa.basic.alldayprojectcommerce.domain.chat.entity.ChatRoom; import jpa.basic.alldayprojectcommerce.domain.chat.entity.ChatRoomStatus; +import jpa.basic.alldayprojectcommerce.domain.chat.redis.ChatRedisPublisher; import jpa.basic.alldayprojectcommerce.domain.chat.repository.ChatMessageRepository; import jpa.basic.alldayprojectcommerce.domain.chat.repository.ChatRoomRepository; import jpa.basic.alldayprojectcommerce.domain.user.entity.UserRole; @@ -14,8 +16,12 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; +import java.util.List; +import java.util.stream.Collectors; + @Slf4j @Service @Transactional @@ -24,6 +30,8 @@ public class ChatRoomCommandServiceImpl implements ChatRoomCommandService { private final ChatRoomRepository chatRoomRepository; private final ChatMessageRepository chatMessageRepository; + private final ChatRedisPublisher chatRedisPublisher; + private final ChatRoomCreator chatRoomCreator; // 매직 넘버 방지 private static final Integer ACTIVE_FLAG = 1; @@ -40,33 +48,25 @@ public ChatRoomResponse createOrGetActiveRoom(Long userId, CreateChatRoomRequest return chatRoomRepository.findByUserIdAndActiveFlag(userId, ACTIVE_FLAG) .map(existing -> { log.info("[채팅방 기존 활성 방 반환 userId: {}, roomId: {}", - userId, existing.getId()); + userId, existing.getId()); return ChatRoomResponse.from(existing); - }).orElseGet(() -> createNewRoom(userId, request.title())); - } - - private ChatRoomResponse createNewRoom(Long userId, String title) { - try { - ChatRoom newRoom = ChatRoom.builder() - .userId(userId) - .title(title) - .build(); - - ChatRoom savedChatRoom = chatRoomRepository.save(newRoom); - - chatMessageRepository.save( - ChatMessage.systemMessage(savedChatRoom.getId(), "상담원을 연결 중입니다...") - ); - - log.info("[채팅방] 신규 생성 userId: {}, roomId: {}", userId, savedChatRoom.getId()); - return ChatRoomResponse.from(savedChatRoom); - } catch (DataIntegrityViolationException e) { - // 동시 요청 경합 - log.warn("[채팅방] 동시 생성 감지 -> 재조회 userId: {}", userId); - return chatRoomRepository.findByUserIdAndActiveFlag(userId, ACTIVE_FLAG) - .map(ChatRoomResponse::from) - .orElseThrow(() -> new CustomException(ErrorCode.CHAT_ROOM_ALREADY_EXISTS)); - } + }) + .orElseGet(() -> { + try { + // REQUIRES_NEW 별도 트랜잭션으로 실행 + return chatRoomCreator.createNewRoom(userId, request.title()); + } catch (DataIntegrityViolationException e) { + /** + * REQUIRES_NEW 트랜잭션이 롤백되고 세션도 폐기됨 + * 여기(부모 트랜잭션)의 세션은 오염되지 않은 상태 + * 깨끗한 세션으로 안전하게 재조회 가능 + */ + log.warn("[채팅방] 동시 생성 감지 -> 재조회 userId: {}", userId); + return chatRoomRepository.findByUserIdAndActiveFlag(userId, ACTIVE_FLAG) + .map(ChatRoomResponse::from) + .orElseThrow(() -> new CustomException(ErrorCode.CHAT_ROOM_ALREADY_EXISTS)); + } + }); } @Override @@ -82,9 +82,10 @@ public void closeChatRoom(Long userId, Long roomId, String role) { */ chatRoom.changeStatus(ChatRoomStatus.COMPLETED); - chatMessageRepository.save( + ChatMessage closeMsg = chatMessageRepository.save( ChatMessage.systemMessage(roomId, "상담이 종료되었습니다.") ); + chatRedisPublisher.publish(roomId, ChatMessageResponse.from(closeMsg)); log.info("[채팅방] 종료 userId: {}, roomId: {}, by: {}", userId, roomId, role); } @@ -100,13 +101,30 @@ public void joinChatRoom(Long adminId, Long roomId, String role) { chatRoom.changeStatus(ChatRoomStatus.IN_PROGRESS); - chatMessageRepository.save( + ChatMessage joinMsg = chatMessageRepository.save( ChatMessage.systemMessage(roomId, "상담원이 연결되었습니다.") ); + chatRedisPublisher.publish(roomId, ChatMessageResponse.from(joinMsg)); log.info("[채팅방] 상담 시작 adminId: {}, roomId: {}", adminId, roomId); } + @Override + @Transactional + public void batchAutoCloseRooms(List roomIds, String closeMessage) { + /** + * 상태 변경 + 시스템 메시지 저장을 하나의 트랜잭션으로 묶음 + * saveAll 실패 시 bulkCompleteRooms도 함께 롤백 + */ + chatRoomRepository.bulkCompleteRooms(roomIds); + + List messages = roomIds.stream() + .map(id -> ChatMessage.systemMessage(id, closeMessage)) + .collect(Collectors.toList()); + + chatMessageRepository.saveAll(messages); + } + /** * 채팅방 접근 권한 검증 * diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/domain/chat/service/ChatRoomCreator.java b/src/main/java/jpa/basic/alldayprojectcommerce/domain/chat/service/ChatRoomCreator.java new file mode 100644 index 0000000..e40b4df --- /dev/null +++ b/src/main/java/jpa/basic/alldayprojectcommerce/domain/chat/service/ChatRoomCreator.java @@ -0,0 +1,52 @@ +package jpa.basic.alldayprojectcommerce.domain.chat.service; + +import jpa.basic.alldayprojectcommerce.common.exception.CustomException; +import jpa.basic.alldayprojectcommerce.common.exception.ErrorCode; +import jpa.basic.alldayprojectcommerce.domain.chat.dto.response.ChatRoomResponse; +import jpa.basic.alldayprojectcommerce.domain.chat.entity.ChatMessage; +import jpa.basic.alldayprojectcommerce.domain.chat.entity.ChatRoom; +import jpa.basic.alldayprojectcommerce.domain.chat.repository.ChatMessageRepository; +import jpa.basic.alldayprojectcommerce.domain.chat.repository.ChatRoomRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ChatRoomCreator { + + private final ChatRoomRepository chatRoomRepository; + private final ChatMessageRepository chatMessageRepository; + + private static final Integer ACTIVE_FLAG = 1; + + /** + * REQUIRES_NEW — 별도 클래스에서 호출해야 프록시가 적용됨 + * + * 같은 클래스 내부 호출은 프록시를 우회하므로 반드시 외부 빈에서 호출해야 함 + * DataIntegrityViolationException 발생 시 이 트랜잭션만 롤백 + * 부모 트랜잭션 세션은 오염되지 않음 + */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public ChatRoomResponse createNewRoom(Long userId, String title) { + // try-catch 제거 — 예외를 그대로 위로 전파 + // REQUIRES_NEW 트랜잭션만 롤백되고 세션 폐기됨 + ChatRoom newRoom = ChatRoom.builder() + .userId(userId) + .title(title) + .build(); + + ChatRoom saved = chatRoomRepository.save(newRoom); + + chatMessageRepository.save( + ChatMessage.systemMessage(saved.getId(), "상담원을 연결 중입니다...") + ); + + log.info("[채팅방] 신규 생성 userId: {}, roomId: {}", userId, saved.getId()); + return ChatRoomResponse.from(saved); + } +} \ No newline at end of file diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/domain/keyword/entity/PopularKeyword.java b/src/main/java/jpa/basic/alldayprojectcommerce/domain/keyword/entity/PopularKeyword.java index 1e6ce59..ade225a 100644 --- a/src/main/java/jpa/basic/alldayprojectcommerce/domain/keyword/entity/PopularKeyword.java +++ b/src/main/java/jpa/basic/alldayprojectcommerce/domain/keyword/entity/PopularKeyword.java @@ -27,7 +27,7 @@ public class PopularKeyword extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @Column(nullable = false) + @Column(nullable = false, length = 100) private String keyword; // 인기검색어 단어 @Column(name = "keyword_rank", nullable = false) diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/dto/response/GetOneOrderResponse.java b/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/dto/response/GetOneOrderResponse.java index f0c4553..cca1229 100644 --- a/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/dto/response/GetOneOrderResponse.java +++ b/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/dto/response/GetOneOrderResponse.java @@ -35,11 +35,13 @@ public static GetOneOrderResponse from(Order order, List items) { item.getProductPrice() * item.getQuantity() )).toList(); + Long deliveryFee = order.getTotalAmount() >= 50000L ? 0L : DELIVERY_FEE; + return new GetOneOrderResponse( order.getOrderUid(), order.getTotalAmount(), - DELIVERY_FEE, - order.getTotalAmount() + DELIVERY_FEE, + deliveryFee, + order.getTotalAmount() + deliveryFee, info ); } diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/dto/response/GetOrderDetailsResponse.java b/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/dto/response/GetOrderDetailsResponse.java index 106f2ff..fdf1bc7 100644 --- a/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/dto/response/GetOrderDetailsResponse.java +++ b/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/dto/response/GetOrderDetailsResponse.java @@ -49,13 +49,15 @@ public static GetOrderDetailsResponse from(Order order, OrderUser orderUser, Lis item.getProductPrice() * item.getQuantity() )).toList(); + Long deliveryFee = order.getTotalAmount() >= 50000L ? 0L : DELIVERY_FEE; + return new GetOrderDetailsResponse( order.getOrderUid(), order.getCreatedAt(), order.getStatus().getDescription(), order.getTotalAmount(), - DELIVERY_FEE, - order.getTotalAmount() + DELIVERY_FEE, + deliveryFee, + order.getTotalAmount() + deliveryFee, ordererInfo, detail ); diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/entity/Order.java b/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/entity/Order.java index b5793fd..97d63be 100644 --- a/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/entity/Order.java +++ b/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/entity/Order.java @@ -10,7 +10,15 @@ @Getter @Entity -@Table(name = "orders") +@Table( + name = "orders", + indexes = { + // 커서 페이징 - WHERE user_id = ? AND id < ? ORDER BY id DESC + @Index(name = "idx_orders_user_id_id", columnList = "user_id, id DESC"), + // 상태별 조회 + @Index(name = "idx_orders_status", columnList = "order_status") + } +) @NoArgsConstructor(access = AccessLevel.PROTECTED) public class Order extends BaseEntity { @@ -27,7 +35,7 @@ public class Order extends BaseEntity { private Long totalAmount; @Enumerated(EnumType.STRING) - @Column(name = "order_status", nullable = false) + @Column(name = "order_status", nullable = false, length = 30) private OrderStatus status; @Builder diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/entity/OrderProduct.java b/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/entity/OrderProduct.java index dbd5706..ca083d5 100644 --- a/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/entity/OrderProduct.java +++ b/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/entity/OrderProduct.java @@ -9,7 +9,15 @@ @Getter @Entity -@Table(name = "order_products") +@Table( + name = "order_products", + indexes = { + // findByOrderId, findByOrderIn + @Index(name = "idx_order_products_order_id", columnList = "order_id"), + // 이벤트 상품 중복 체크용 + @Index(name = "idx_order_products_product_id", columnList = "product_id") + } +) @NoArgsConstructor(access = AccessLevel.PROTECTED) public class OrderProduct extends BaseEntity { diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/entity/OrderUser.java b/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/entity/OrderUser.java index 5d48722..79a381f 100644 --- a/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/entity/OrderUser.java +++ b/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/entity/OrderUser.java @@ -9,7 +9,13 @@ @Getter @Entity -@Table(name = "order_users") +@Table( + name = "order_users", + uniqueConstraints = @UniqueConstraint( + name = "uk_order_users_order_id", + columnNames = {"order_id"} + ) +) @NoArgsConstructor(access = AccessLevel.PROTECTED) public class OrderUser extends BaseEntity { @@ -19,10 +25,10 @@ public class OrderUser extends BaseEntity { @Column(nullable = false) private Long orderId; - @Column(nullable = false) + @Column(nullable = false, length = 20) private String name; - @Column(nullable = false) + @Column(nullable = false, length = 100) private String phone; @Column(nullable = false) diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/repository/OrderProductRepository.java b/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/repository/OrderProductRepository.java index aeedff0..de8df5c 100644 --- a/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/repository/OrderProductRepository.java +++ b/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/repository/OrderProductRepository.java @@ -6,6 +6,7 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import java.util.Collection; import java.util.List; public interface OrderProductRepository extends JpaRepository { @@ -13,6 +14,9 @@ public interface OrderProductRepository extends JpaRepository findByOrderId(Long orderId); + // N+1 문제 - 여러 주문 ID의 상품을 한 번에 조회 + List findByOrderIdIn(Collection orderIds); + @Query(""" select case when count(op) > 0 then true else false end from OrderProduct op diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/OrderCommandServiceImpl.java b/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/OrderCommandServiceImpl.java index 30d95be..bd54735 100644 --- a/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/OrderCommandServiceImpl.java +++ b/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/OrderCommandServiceImpl.java @@ -80,11 +80,13 @@ public CreateOrderResponse createOrder(Long loginId, CreateOrderRequest request) Order savedOrder = orderRepository.save(order); // OrderItem 저장 - 스냅샷 + List orderProducts = new ArrayList<>(); + for (int i = 0; i < request.orderItems().size(); i++) { OrderItemRequest itemRequest = request.orderItems().get(i); Product product = products.get(i); - orderProductRepository.save(OrderProduct.builder() + orderProducts.add(OrderProduct.builder() .orderId(savedOrder.getId()) .productId(product.getId()) .productName(product.getName()) @@ -93,6 +95,8 @@ public CreateOrderResponse createOrder(Long loginId, CreateOrderRequest request) .build()); } + orderProductRepository.saveAll(orderProducts); + log.info("[주문 생성] userId: {}, orderUid: {}, totalAmount: {}", loginId, orderUid, totalAmount); diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/OrderQueryServiceImpl.java b/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/OrderQueryServiceImpl.java index 2d1b4bf..a332344 100644 --- a/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/OrderQueryServiceImpl.java +++ b/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/OrderQueryServiceImpl.java @@ -19,7 +19,10 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.Collections; import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; @Slf4j @Service @@ -42,17 +45,26 @@ public CursorResponse getAllOrder(Long loginId, Long curso // size + 1개 List orders = orderRepository.findByUserIdWithCursor(loginId, cursor, size); - return CursorResponse.of( - orders.stream() - .map(order -> { - List items = orderProductRepository.findByOrderId(order.getId()); - return GetAllOrdersResponse.from(order, items); - }).toList(), - size, - dto -> orderRepository.findByOrderUid(dto.orderUid()) - .map(Order::getId) - .orElse(null) - ); + if (orders.isEmpty()) { + return CursorResponse.of(List.of(), size, dto -> null); + } + + // 주문 ID 리스트 -> IN 쿼리 1번으로 상품 전부 조회 + List orderIds = orders.stream().map(Order::getId).toList(); + + // orderId별 그룹핑 -> 메모리에서 매칭 + Map> productsByOrderId = orderProductRepository + .findByOrderIdIn(orderIds) + .stream() + .collect(Collectors.groupingBy(OrderProduct::getOrderId)); + + List rawContent = orders.stream() + .map(order -> GetAllOrdersResponse.from( + order, + productsByOrderId.getOrDefault(order.getId(), Collections.emptyList()) + )).toList(); + + return CursorResponse.of(rawContent, size, GetAllOrdersResponse::orderId); } /** diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/domain/product/controller/ProductController.java b/src/main/java/jpa/basic/alldayprojectcommerce/domain/product/controller/ProductController.java index 249ac68..2de249d 100644 --- a/src/main/java/jpa/basic/alldayprojectcommerce/domain/product/controller/ProductController.java +++ b/src/main/java/jpa/basic/alldayprojectcommerce/domain/product/controller/ProductController.java @@ -29,8 +29,10 @@ public ResponseEntity> getOne (@PathVariable( @GetMapping public ResponseEntity>> getAll( + @RequestParam(required = false) String category, + @RequestParam(required = false) String keyword, @PageableDefault(size = 10, page = 0, sort = "id", direction = Sort.Direction.DESC) Pageable pageable){ - return ResponseEntity.status(HttpStatus.OK).body(ApiResponse.success(HttpStatus.OK, productQueryService.getAllProduct(pageable))); + return ResponseEntity.status(HttpStatus.OK).body(ApiResponse.success(HttpStatus.OK, productQueryService.getAllProduct(category, keyword, pageable))); } } diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/domain/product/entity/Category.java b/src/main/java/jpa/basic/alldayprojectcommerce/domain/product/entity/Category.java index ed7d8b6..ebfdad0 100644 --- a/src/main/java/jpa/basic/alldayprojectcommerce/domain/product/entity/Category.java +++ b/src/main/java/jpa/basic/alldayprojectcommerce/domain/product/entity/Category.java @@ -2,8 +2,8 @@ public enum Category { ALBUM("앨범"), - MERCHANDISE("굿즈"), - TICKET("티켓"); + MERCH("굿즈"), + EVENT("이벤트"); private final String displayName; diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/domain/product/repository/ProductRepositoryCustom.java b/src/main/java/jpa/basic/alldayprojectcommerce/domain/product/repository/ProductRepositoryCustom.java index f350f53..69d7cb5 100644 --- a/src/main/java/jpa/basic/alldayprojectcommerce/domain/product/repository/ProductRepositoryCustom.java +++ b/src/main/java/jpa/basic/alldayprojectcommerce/domain/product/repository/ProductRepositoryCustom.java @@ -7,7 +7,7 @@ public interface ProductRepositoryCustom { - Page findAllProducts(Pageable pageable); + Page findAllProducts(String category, String keyword, Pageable pageable); } diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/domain/product/repository/ProductRepositoryCustomImpl.java b/src/main/java/jpa/basic/alldayprojectcommerce/domain/product/repository/ProductRepositoryCustomImpl.java index 78a371b..b307502 100644 --- a/src/main/java/jpa/basic/alldayprojectcommerce/domain/product/repository/ProductRepositoryCustomImpl.java +++ b/src/main/java/jpa/basic/alldayprojectcommerce/domain/product/repository/ProductRepositoryCustomImpl.java @@ -1,8 +1,10 @@ package jpa.basic.alldayprojectcommerce.domain.product.repository; +import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.jpa.impl.JPAQuery; import com.querydsl.jpa.impl.JPAQueryFactory; +import jpa.basic.alldayprojectcommerce.domain.product.entity.Category; import jpa.basic.alldayprojectcommerce.domain.product.entity.Product; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; @@ -21,12 +23,35 @@ public class ProductRepositoryCustomImpl implements ProductRepositoryCustom { private final JPAQueryFactory queryFactory; @Override - public Page findAllProducts(Pageable pageable) { + public Page findAllProducts(String category, String keyword, Pageable pageable) { // 1. 콘텐츠 조회 쿼리 - List content = queryFactory + JPAQuery query = queryFactory .selectFrom(product) - .orderBy(product.id.desc()) + .where(categoryEq(category), nameContains(keyword)); + + // 정렬 조건 적용 + if (pageable.getSort().isSorted()) { + pageable.getSort().forEach(order -> { + com.querydsl.core.types.Order direction = order.isAscending() ? com.querydsl.core.types.Order.ASC : com.querydsl.core.types.Order.DESC; + switch (order.getProperty()) { + case "price": + query.orderBy(new com.querydsl.core.types.OrderSpecifier<>(direction, product.price)); + break; + case "name": + query.orderBy(new com.querydsl.core.types.OrderSpecifier<>(direction, product.name)); + break; + case "id": + default: + query.orderBy(new com.querydsl.core.types.OrderSpecifier<>(direction, product.id)); + break; + } + }); + } else { + query.orderBy(product.id.desc()); + } + + List content = query .offset(pageable.getOffset()) .limit(pageable.getPageSize()) .fetch(); @@ -34,8 +59,29 @@ public Page findAllProducts(Pageable pageable) { // 2. 카운트 쿼리 JPAQuery countQuery = queryFactory .select(product.count()) - .from(product); + .from(product) + .where(categoryEq(category), nameContains(keyword)); return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne); } + + private BooleanExpression nameContains(String keyword) { + if (keyword == null || keyword.trim().isEmpty()) { + return null; + } + return product.name.containsIgnoreCase(keyword); + } + + private BooleanExpression categoryEq(String category) { + if (category == null || category.isEmpty() || "ALL".equalsIgnoreCase(category)) { + return null; + } + + try { + Category categoryEnum = Category.valueOf(category.toUpperCase()); + return product.category.eq(categoryEnum); + } catch (IllegalArgumentException e) { + return null; + } + } } \ No newline at end of file diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/domain/product/service/ProductQueryService.java b/src/main/java/jpa/basic/alldayprojectcommerce/domain/product/service/ProductQueryService.java index 301ce2e..d12610e 100644 --- a/src/main/java/jpa/basic/alldayprojectcommerce/domain/product/service/ProductQueryService.java +++ b/src/main/java/jpa/basic/alldayprojectcommerce/domain/product/service/ProductQueryService.java @@ -12,7 +12,7 @@ public interface ProductQueryService { GetOneProductResponse getOneProduct(Long productId); - Page getAllProduct(Pageable pageable); + Page getAllProduct(String category, String keyword, Pageable pageable); // 상품 단건 조회 - 주문 생성에서 사용 Product getByProductId(Long productId); diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/domain/product/service/ProductQueryServiceImpl.java b/src/main/java/jpa/basic/alldayprojectcommerce/domain/product/service/ProductQueryServiceImpl.java index a151732..d7c0afd 100644 --- a/src/main/java/jpa/basic/alldayprojectcommerce/domain/product/service/ProductQueryServiceImpl.java +++ b/src/main/java/jpa/basic/alldayprojectcommerce/domain/product/service/ProductQueryServiceImpl.java @@ -32,8 +32,8 @@ public GetOneProductResponse getOneProduct(Long productId){ // 전체 조회 @Override - public Page getAllProduct(Pageable pageable){ - return productRepository.findAllProducts(pageable) + public Page getAllProduct(String category, String keyword, Pageable pageable){ + return productRepository.findAllProducts(category, keyword, pageable) .map(GetAllProductResponse::from); } diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/domain/user/controller/UserController.java b/src/main/java/jpa/basic/alldayprojectcommerce/domain/user/controller/UserController.java index 833135c..e185830 100644 --- a/src/main/java/jpa/basic/alldayprojectcommerce/domain/user/controller/UserController.java +++ b/src/main/java/jpa/basic/alldayprojectcommerce/domain/user/controller/UserController.java @@ -6,6 +6,7 @@ import jpa.basic.alldayprojectcommerce.common.security.auth.LoginUserInfo; import jpa.basic.alldayprojectcommerce.domain.user.dto.request.UpdatePasswordRequest; import jpa.basic.alldayprojectcommerce.domain.user.dto.request.UpdatemeUserRequest; +import jpa.basic.alldayprojectcommerce.domain.user.dto.response.GetUnmaskedUserResponse; import jpa.basic.alldayprojectcommerce.domain.user.dto.response.GetmeUserResponse; import jpa.basic.alldayprojectcommerce.domain.user.service.UserCommandService; import jpa.basic.alldayprojectcommerce.domain.user.service.UserQueryService; @@ -30,6 +31,14 @@ public ResponseEntity> getMyProfile( return ResponseEntity.ok(ApiResponse.success(HttpStatus.OK, response)); } + // 내 정보 조회 (마스킹 없음) - 주문서 작성 등 + @GetMapping("/me/unmasked") + public ResponseEntity> getMyProfileUnmasked( + @LoginUser LoginUserInfo loginUser) { + GetUnmaskedUserResponse response = userQueryService.getUnmaskedProfile(loginUser.id()); + return ResponseEntity.ok(ApiResponse.success(HttpStatus.OK, response)); + } + // 내 정보 수정 @PatchMapping("/me") public ResponseEntity> updateMyProfile( diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/domain/user/dto/response/GetUnmaskedUserResponse.java b/src/main/java/jpa/basic/alldayprojectcommerce/domain/user/dto/response/GetUnmaskedUserResponse.java new file mode 100644 index 0000000..862f456 --- /dev/null +++ b/src/main/java/jpa/basic/alldayprojectcommerce/domain/user/dto/response/GetUnmaskedUserResponse.java @@ -0,0 +1,23 @@ +package jpa.basic.alldayprojectcommerce.domain.user.dto.response; + +import jpa.basic.alldayprojectcommerce.domain.user.entity.User; +import lombok.Builder; + +@Builder +public record GetUnmaskedUserResponse( + Long id, + String email, + String name, + String phone, + String address +) { + public static GetUnmaskedUserResponse from(User user) { + return new GetUnmaskedUserResponse( + user.getId(), + user.getEmail(), + user.getName(), + user.getPhone(), + user.getAddress() + ); + } +} diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/domain/user/service/AdminInitializer.java b/src/main/java/jpa/basic/alldayprojectcommerce/domain/user/service/AdminInitializer.java new file mode 100644 index 0000000..50f8c54 --- /dev/null +++ b/src/main/java/jpa/basic/alldayprojectcommerce/domain/user/service/AdminInitializer.java @@ -0,0 +1,40 @@ +package jpa.basic.alldayprojectcommerce.domain.user.service; + +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Component +@RequiredArgsConstructor +public class AdminInitializer { + + private final JdbcTemplate jdbcTemplate; + private final PasswordEncoder passwordEncoder; + + @PostConstruct + @Transactional + public void initAdmin() { + String email = "admin@allday.com"; + String encodedPassword = passwordEncoder.encode("admin1234"); + + try { + Integer count = jdbcTemplate.queryForObject( + "SELECT count(*) FROM users WHERE email = ?", Integer.class, email); + + if (count == null || count == 0) { + jdbcTemplate.update( + "INSERT INTO users (email, password, name, phone, address, role, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, NOW(), NOW())", + email, encodedPassword, "관리자", "010-0000-0000", "관리자 기본 주소", "ADMIN" + ); + log.info("관리자 계정이 성공적으로 생성되었습니다: {}", email); + } + } catch (Exception e) { + log.error("관리자 계정 생성 중 오류 발생: {}", e.getMessage()); + } + } +} diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/domain/user/service/UserQueryService.java b/src/main/java/jpa/basic/alldayprojectcommerce/domain/user/service/UserQueryService.java index f8ad539..32cc86c 100644 --- a/src/main/java/jpa/basic/alldayprojectcommerce/domain/user/service/UserQueryService.java +++ b/src/main/java/jpa/basic/alldayprojectcommerce/domain/user/service/UserQueryService.java @@ -1,5 +1,6 @@ package jpa.basic.alldayprojectcommerce.domain.user.service; +import jpa.basic.alldayprojectcommerce.domain.user.dto.response.GetUnmaskedUserResponse; import jpa.basic.alldayprojectcommerce.domain.user.dto.response.GetmeUserResponse; import jpa.basic.alldayprojectcommerce.domain.user.entity.User; @@ -13,5 +14,7 @@ public interface UserQueryService { GetmeUserResponse getProfile(Long userId); + GetUnmaskedUserResponse getUnmaskedProfile(Long userId); + boolean hasRequiredOrdererInfo(Long userId); } diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/domain/user/service/UserQueryServiceImpl.java b/src/main/java/jpa/basic/alldayprojectcommerce/domain/user/service/UserQueryServiceImpl.java index aec0629..0084f64 100644 --- a/src/main/java/jpa/basic/alldayprojectcommerce/domain/user/service/UserQueryServiceImpl.java +++ b/src/main/java/jpa/basic/alldayprojectcommerce/domain/user/service/UserQueryServiceImpl.java @@ -2,6 +2,7 @@ import jpa.basic.alldayprojectcommerce.common.exception.CustomException; import jpa.basic.alldayprojectcommerce.common.exception.ErrorCode; +import jpa.basic.alldayprojectcommerce.domain.user.dto.response.GetUnmaskedUserResponse; import jpa.basic.alldayprojectcommerce.domain.user.dto.response.GetmeUserResponse; import jpa.basic.alldayprojectcommerce.domain.user.entity.User; import jpa.basic.alldayprojectcommerce.domain.user.repository.UserRepository; @@ -42,6 +43,12 @@ public GetmeUserResponse getProfile(Long userId) { return GetmeUserResponse.from(user); } + @Override + public GetUnmaskedUserResponse getUnmaskedProfile(Long userId) { + User user = getById(userId); + return GetUnmaskedUserResponse.from(user); + } + @Override public boolean hasRequiredOrdererInfo(Long userId) { User user = getById(userId); diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/domain/view/ViewController.java b/src/main/java/jpa/basic/alldayprojectcommerce/domain/view/ViewController.java index 334dbb2..014fa52 100644 --- a/src/main/java/jpa/basic/alldayprojectcommerce/domain/view/ViewController.java +++ b/src/main/java/jpa/basic/alldayprojectcommerce/domain/view/ViewController.java @@ -1,16 +1,24 @@ package jpa.basic.alldayprojectcommerce.domain.view; +import jakarta.servlet.http.Cookie; +import jpa.basic.alldayprojectcommerce.common.security.auth.AuthConstants; +import jpa.basic.alldayprojectcommerce.common.security.cookie.CookieUtils; +import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import jakarta.servlet.http.HttpServletResponse; + @Controller +@RequiredArgsConstructor public class ViewController { + private final CookieUtils cookieUtils; + @GetMapping("/") public String index(Model model) { - // products는 추후 서비스 연동 시 주입 return "index"; } @@ -26,25 +34,42 @@ public String signup(Model model) { @GetMapping("/cart") public String cart(Model model) { - // cartItems, totalPrice는 추후 서비스 연동 시 주입 return "cart"; } @GetMapping("/mypage") public String mypage(Model model) { - // user 정보는 추후 서비스 연동 시 주입 return "mypage"; } @GetMapping("/orders") public String orders(Model model) { - // orderGroups는 추후 서비스 연동 시 주입 return "orders"; } @GetMapping("/orders/{id}") - public String orderDetail(@PathVariable Long id, Model model) { - // order 정보는 추후 서비스 연동 시 주입 + public String orderDetail(@PathVariable String id, Model model) { return "order-detail"; } + + @GetMapping("/checkout") + public String checkout(Model model) { + return "checkout"; + } + + @GetMapping("/logout") + public String logout(HttpServletResponse response) { + Cookie accessToken = cookieUtils.deleteCookie(AuthConstants.ACCESS_TOKEN); + Cookie refreshToken = cookieUtils.deleteCookie(AuthConstants.REFRESH_TOKEN); + + response.addCookie(accessToken); + response.addCookie(refreshToken); + + return "redirect:/"; + } + + @GetMapping("/admin/consultations") + public String adminConsultations(Model model) { + return "admin-consultation"; + } } diff --git a/src/main/resources/static/css/admin-consultation.css b/src/main/resources/static/css/admin-consultation.css new file mode 100644 index 0000000..019dc5f --- /dev/null +++ b/src/main/resources/static/css/admin-consultation.css @@ -0,0 +1,415 @@ +/* =========================== + Admin Consultation Page + =========================== */ + +.admin-consultation-page { + padding: 40px 0; + max-width: 1200px; + margin: 0 auto; +} + +/* Page Header */ +.page-header { + margin-bottom: 32px; +} + +.page-title { + font-size: 28px; + font-weight: 700; + color: #1a1a1a; + margin-bottom: 8px; +} + +.page-subtitle { + font-size: 14px; + color: #666; + font-weight: 400; +} + +/* Status Tabs */ +.status-tabs { + display: flex; + gap: 8px; + margin-bottom: 24px; + border-bottom: 1px solid #e8e8e8; + padding-bottom: 0; +} + +.status-tab { + position: relative; + padding: 12px 20px; + font-size: 14px; + font-weight: 500; + color: #666; + background: transparent; + border: none; + cursor: pointer; + transition: all 0.2s ease; + display: flex; + align-items: center; + gap: 8px; +} + +.status-tab:hover { + color: #1a1a1a; +} + +.status-tab.active { + color: #1a1a1a; + font-weight: 600; +} + +.status-tab.active::after { + content: ''; + position: absolute; + bottom: -1px; + left: 0; + right: 0; + height: 2px; + background: #1a1a1a; +} + +.tab-count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 20px; + height: 20px; + padding: 0 6px; + font-size: 11px; + font-weight: 600; + background: #f0f0f0; + color: #666; + border-radius: 10px; + line-height: 1; +} + +.status-tab.active .tab-count { + background: #1a1a1a; + color: #fff; +} + +/* Consultation List */ +.consultation-list { + display: flex; + flex-direction: column; + gap: 12px; + min-height: 400px; +} + +.consultation-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 20px 24px; + background: #ffffff; + border: 1px solid #e8e8e8; + border-radius: 12px; + cursor: pointer; + transition: all 0.2s ease; +} + +.consultation-item:hover { + border-color: #ccc; + box-shadow: 0 2px 8px rgba(0,0,0,0.06); + transform: translateY(-1px); +} + +.consultation-item.unread { + background: #fafafa; + border-color: #1a1a1a; +} + +.consultation-main { + flex: 1; + display: flex; + align-items: center; + gap: 16px; +} + +.consultation-status-badge { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 6px 12px; + font-size: 12px; + font-weight: 600; + border-radius: 6px; + text-transform: uppercase; + letter-spacing: 0.3px; +} + +.consultation-status-badge.waiting { + background: #fff3cd; + color: #856404; +} + +.consultation-status-badge.in-progress { + background: #d1ecf1; + color: #0c5460; +} + +.consultation-status-badge.completed { + background: #d4edda; + color: #155724; +} + +.consultation-info { + flex: 1; +} + +.consultation-title { + font-size: 15px; + font-weight: 600; + color: #1a1a1a; + margin-bottom: 4px; +} + +.consultation-meta { + display: flex; + align-items: center; + gap: 12px; + font-size: 13px; + color: #666; +} + +.consultation-customer { + display: flex; + align-items: center; + gap: 4px; +} + +.consultation-customer svg { + width: 14px; + height: 14px; +} + +.consultation-time { + display: flex; + align-items: center; + gap: 4px; +} + +.consultation-time svg { + width: 14px; + height: 14px; +} + +.consultation-actions { + display: flex; + align-items: center; + gap: 12px; +} + +.consultation-unread-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 24px; + height: 24px; + padding: 0 8px; + background: #e53e3e; + color: #fff; + font-size: 12px; + font-weight: 700; + border-radius: 12px; +} + +.consultation-action-btn { + padding: 8px 16px; + font-size: 13px; + font-weight: 500; + color: #fff; + background: #1a1a1a; + border: none; + border-radius: 6px; + cursor: pointer; + transition: all 0.2s ease; +} + +.consultation-action-btn:hover { + background: #333; +} + +.consultation-action-btn.secondary { + background: #f5f5f5; + color: #333; +} + +.consultation-action-btn.secondary:hover { + background: #e8e8e8; +} + +/* Loading State */ +.consultation-loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 80px 20px; + color: #999; +} + +.loading-spinner { + width: 40px; + height: 40px; + border: 3px solid #f0f0f0; + border-top-color: #1a1a1a; + border-radius: 50%; + animation: spin 0.8s linear infinite; + margin-bottom: 16px; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.consultation-loading p { + font-size: 14px; +} + +/* Empty State */ +.consultation-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 80px 20px; + text-align: center; +} + +.consultation-empty svg { + margin-bottom: 16px; +} + +.empty-title { + font-size: 16px; + font-weight: 600; + color: #666; + margin-bottom: 8px; +} + +.empty-subtitle { + font-size: 14px; + color: #999; +} + +/* Pagination */ +.pagination-container { + display: flex; + justify-content: center; + margin-top: 32px; + padding-top: 32px; + border-top: 1px solid #e8e8e8; +} + +.pagination { + display: flex; + align-items: center; + gap: 8px; +} + +.pagination-btn { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + font-size: 14px; + font-weight: 500; + color: #666; + background: #ffffff; + border: 1px solid #e0e0e0; + border-radius: 6px; + cursor: pointer; + transition: all 0.2s ease; +} + +.pagination-btn:hover:not(:disabled) { + border-color: #1a1a1a; + color: #1a1a1a; +} + +.pagination-btn.active { + background: #1a1a1a; + color: #ffffff; + border-color: #1a1a1a; +} + +.pagination-btn:disabled { + opacity: 0.3; + cursor: not-allowed; +} + +.pagination-ellipsis { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + color: #999; + font-size: 14px; +} + +/* Responsive */ +@media (max-width: 768px) { + .admin-consultation-page { + padding: 24px 0; + } + + .page-title { + font-size: 24px; + } + + .status-tabs { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } + + .status-tab { + white-space: nowrap; + padding: 12px 16px; + font-size: 13px; + } + + .consultation-item { + flex-direction: column; + align-items: flex-start; + gap: 12px; + padding: 16px; + } + + .consultation-main { + width: 100%; + flex-direction: column; + align-items: flex-start; + gap: 12px; + } + + .consultation-actions { + width: 100%; + justify-content: space-between; + } + + .consultation-meta { + flex-wrap: wrap; + } +} + +@media (max-width: 480px) { + .page-title { + font-size: 20px; + } + + .page-subtitle { + font-size: 13px; + } + + .consultation-title { + font-size: 14px; + } + + .consultation-meta { + font-size: 12px; + } +} diff --git a/src/main/resources/static/css/checkout.css b/src/main/resources/static/css/checkout.css index 61d41c9..a22abd6 100644 --- a/src/main/resources/static/css/checkout.css +++ b/src/main/resources/static/css/checkout.css @@ -412,6 +412,50 @@ background: #eede30; } +/* Form Validation Errors */ +.modal-form-input.error { + border-color: #ff4444; + background: #fff5f5; +} + +.field-error-message { + display: block; + font-size: 11px; + color: #ff4444; + margin-top: 4px; +} + +/* Toast Messages */ +.toast-message { + position: fixed; + top: 80px; + right: 20px; + padding: 14px 20px; + border-radius: 8px; + font-size: 14px; + font-weight: 500; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + z-index: 3000; + opacity: 0; + transform: translateX(20px); + transition: all 0.3s ease; +} + +.toast-message.show { + opacity: 1; + transform: translateX(0); +} + +.toast-message.success { + background: #4caf50; + color: #ffffff; +} + +.toast-message.error { + background: #ff4444; + color: #ffffff; +} + /* =========================== Responsive =========================== */ diff --git a/src/main/resources/static/css/common.css b/src/main/resources/static/css/common.css index 3998e92..d7103ef 100644 --- a/src/main/resources/static/css/common.css +++ b/src/main/resources/static/css/common.css @@ -199,6 +199,9 @@ img { background: #fafafa; border-top: 1px solid #f0f0f0; overflow-x: auto; + min-height: 40px; + display: flex; + align-items: center; } .keywords-scroll { @@ -222,6 +225,14 @@ img { color: #1a1a1a; } +.keywords-empty { + font-size: 13px; + color: #999; + text-align: center; + width: 100%; + padding: 4px 0; +} + /* Header Right */ .header-right { display: flex; @@ -307,6 +318,7 @@ img { } .chatbot-toggle { + position: relative; width: 56px; height: 56px; border-radius: 50%; @@ -323,6 +335,65 @@ img { box-shadow: 0 6px 24px rgba(0,0,0,0.3); } +/* Chatbot notification dot */ +.chatbot-notification-dot { + position: absolute; + top: 0; + right: 0; + width: 12px; + height: 12px; + background: #e53e3e; + border-radius: 50%; + border: 2px solid #1a1a1a; + pointer-events: none; + animation: notificationPulse 2s ease-in-out infinite; +} + +@keyframes notificationPulse { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.2); } +} + +/* Chat input disabled state */ +.chatbot-input:disabled { + background: #eee; + color: #999; + cursor: not-allowed; +} + +/* Waiting message pulse animation */ +.chat-waiting-message { + padding: 12px 16px; + margin: 12px auto; + background: #f8f9fa; + color: #666; + border-radius: 12px; + font-size: 13px; + text-align: center; + max-width: 85%; + width: fit-content; + animation: waitingPulse 2.5s ease-in-out infinite; +} + +@keyframes waitingPulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +/* Warning message style */ +.chat-warning-message { + padding: 10px 14px; + margin: 8px auto; + background: #fff3e0; + color: #e65100; + border-radius: 12px; + font-size: 12px; + text-align: center; + max-width: 80%; + width: fit-content; + border: 1px solid #ffe0b2; +} + .chatbot-panel { position: absolute; bottom: 72px; @@ -349,6 +420,9 @@ img { padding: 16px 20px; background: #1a1a1a; color: #ffffff; + display: flex; + align-items: center; + justify-content: space-between; } .chatbot-title { @@ -356,11 +430,31 @@ img { font-weight: 600; } +.chatbot-close-btn { + display: flex; + align-items: center; + justify-content: center; + padding: 4px; + color: #ffffff; + background: transparent; + border: none; + border-radius: 4px; + cursor: pointer; + transition: background-color 0.2s ease; +} + +.chatbot-close-btn:hover { + background: rgba(255, 255, 255, 0.1); +} + .chatbot-body { padding: 16px; min-height: 200px; max-height: 360px; overflow-y: auto; + display: flex; + flex-direction: column; + gap: 12px; } .chatbot-menu { @@ -396,6 +490,61 @@ img { background: #333; } +/* Chat Message Alignment Styles */ +.chat-message-container { + display: flex; + flex-direction: column; + max-width: 75%; + gap: 4px; +} + +.chat-message-container.chat-message-own { + align-self: flex-end; + align-items: flex-end; +} + +.chat-message-container.chat-message-other { + align-self: flex-start; + align-items: flex-start; +} + +.chat-message-bubble { + padding: 10px 14px; + border-radius: 12px; + font-size: 13px; + line-height: 1.5; + word-wrap: break-word; + word-break: break-word; +} + +.chat-message-own .chat-message-bubble { + background: #1a1a1a; + color: #ffffff; + border-bottom-right-radius: 4px; +} + +.chat-message-other .chat-message-bubble { + background: #f0f0f0; + color: #333; + border-bottom-left-radius: 4px; +} + +.chat-message-time { + font-size: 11px; + color: #999; + padding: 0 4px; +} + +.chat-error-message { + padding: 8px 12px; + margin: 8px 0; + background: #fee; + color: #c33; + border-radius: 6px; + font-size: 12px; + text-align: center; +} + .chatbot-footer { display: flex; align-items: center; diff --git a/src/main/resources/static/css/index.css b/src/main/resources/static/css/index.css index 66aee61..b4468a8 100644 --- a/src/main/resources/static/css/index.css +++ b/src/main/resources/static/css/index.css @@ -347,7 +347,7 @@ left: 0; width: 100%; height: 100%; - background: rgba(0, 0, 0, 0.55); + background: rgba(0, 0, 0, 0.6); z-index: 2000; display: flex; align-items: center; @@ -364,11 +364,10 @@ .modal-content { background: #ffffff; - border-radius: 16px; - padding: 32px; + border-radius: 12px; position: relative; max-height: 90vh; - overflow-y: auto; + overflow: hidden; transform: translateY(12px) scale(0.97); transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1); } @@ -380,40 +379,51 @@ .modal-close { position: absolute; top: 16px; - right: 20px; - font-size: 24px; - color: #999; + right: 16px; + width: 32px; + height: 32px; + border-radius: 50%; + background: rgba(0, 0, 0, 0.1); + border: none; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + color: #666; cursor: pointer; - line-height: 1; - transition: color 0.2s ease; z-index: 10; + transition: all 0.2s ease; } .modal-close:hover { - color: #1a1a1a; + background: rgba(0, 0, 0, 0.2); + color: #333; } .product-detail-modal { - width: 100%; - max-width: 720px; + width: 900px; + max-width: 95vw; } .product-detail-body { display: flex; - gap: 28px; + height: 550px; } .product-detail-left { flex-shrink: 0; - width: 240px; + width: 400px; + background: #f8f8f8; + display: flex; + align-items: center; + justify-content: center; + padding: 0; } .product-detail-img { - width: 240px; - height: 240px; - border-radius: 10px; - overflow: hidden; - background: #f0f0f0; + width: 100%; + height: 100%; + background: #e0e0e0; position: relative; } @@ -440,24 +450,23 @@ .product-detail-right { flex: 1; - min-width: 0; + padding: 40px; display: flex; flex-direction: column; + overflow-y: auto; } .detail-label { - font-size: 12px; - font-weight: 600; - color: #999; + font-size: 15px; + font-weight: 700; + color: #1a1a1a; + margin-bottom: 20px; text-transform: uppercase; letter-spacing: 0.5px; - margin-bottom: 12px; } .detail-description { - margin-bottom: 16px; - max-height: 140px; - overflow-y: auto; + margin-bottom: 24px; } .detail-desc-list { @@ -467,83 +476,296 @@ } .detail-desc-list li { - font-size: 12px; - color: #666; - line-height: 1.7; - padding: 1px 0; + font-size: 14px; + color: #555; + line-height: 1.6; + padding: 2px 0; } .detail-product-name { - font-size: 16px; - font-weight: 700; + font-size: 22px; + font-weight: 800; color: #1a1a1a; line-height: 1.4; - margin-bottom: 8px; + margin-bottom: 20px; + padding-bottom: 20px; + border-bottom: 1px solid #eee; +} + +.detail-shipping-info { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 24px; + font-size: 14px; + color: #666; +} + +.shipping-label { + font-weight: 600; + color: #333; } .detail-product-price { - font-size: 18px; + font-size: 24px; font-weight: 800; color: #1a1a1a; - margin-bottom: 20px; + margin-bottom: 32px; display: flex; align-items: baseline; - gap: 2px; + gap: 4px; + margin-top: auto; +} + +.price-original { + font-size: 16px; + color: #999; + text-decoration: line-through; + margin-right: 8px; + font-weight: 500; } .detail-actions { display: flex; - gap: 10px; - margin-top: auto; + gap: 12px; } .detail-btn { flex: 1; - padding: 12px 16px; - font-size: 14px; - font-weight: 600; + padding: 16px 20px; + font-size: 15px; + font-weight: 700; border-radius: 8px; cursor: pointer; transition: all 0.2s ease; text-align: center; + border: none; } .detail-btn-cart { - background: #f0f0f0; + background: #ffffff; color: #333; - border: 1px solid #ddd; + border: 1px solid #ccc; } .detail-btn-cart:hover { - background: #e5e5e5; + background: #f5f5f5; + border-color: #999; } .detail-btn-buy { background: #1a1a1a; color: #ffffff; - border: 1px solid #1a1a1a; } .detail-btn-buy:hover { background: #333; } -@media (max-width: 600px) { +@media (max-width: 768px) { .product-detail-modal { - max-width: calc(100% - 32px); + width: 95%; + max-width: none; + height: 90vh; } .product-detail-body { flex-direction: column; + height: auto; } .product-detail-left { width: 100%; + height: 300px; } - .product-detail-img { - width: 100%; - height: 200px; + .product-detail-right { + padding: 24px; + } + + .detail-actions { + flex-direction: column; + } +} + + +/* =========================== + Pagination Component + =========================== */ +.pagination-container { + display: flex; + justify-content: center; + align-items: center; + margin-top: 48px; + padding: 0 16px; +} + +.pagination-wrapper { + display: flex; + align-items: center; + gap: 8px; +} + +/* Pagination Buttons (Prev/Next) */ +.pagination-btn { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border-radius: 6px; + background: #ffffff; + border: 1px solid #e0e0e0; + color: #555; + cursor: pointer; + transition: all 0.2s ease; +} + +.pagination-btn:hover:not(:disabled) { + background: #f5f5f5; + border-color: #ccc; + color: #1a1a1a; +} + +.pagination-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.pagination-btn svg { + flex-shrink: 0; +} + +/* Pagination Numbers Container */ +.pagination-numbers { + display: flex; + align-items: center; + gap: 4px; + margin: 0 4px; +} + +/* Pagination Number Buttons */ +.pagination-number { + display: flex; + align-items: center; + justify-content: center; + min-width: 36px; + height: 36px; + padding: 0 8px; + border-radius: 6px; + background: #ffffff; + border: 1px solid #e0e0e0; + color: #555; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; +} + +.pagination-number:hover:not(.active) { + background: #f5f5f5; + border-color: #ccc; + color: #1a1a1a; +} + +.pagination-number.active { + background: #1a1a1a; + border-color: #1a1a1a; + color: #ffffff; + font-weight: 600; + cursor: default; +} + +/* Pagination Ellipsis */ +.pagination-ellipsis { + display: flex; + align-items: center; + justify-content: center; + min-width: 36px; + height: 36px; + color: #999; + font-size: 14px; + user-select: none; +} + +/* Responsive */ +@media (max-width: 600px) { + .pagination-container { + margin-top: 32px; + } + + .pagination-btn, + .pagination-number { + min-width: 32px; + height: 32px; + font-size: 13px; + } + + .pagination-btn { + width: 32px; + } + + .pagination-btn svg { + width: 14px; + height: 14px; } + + .pagination-ellipsis { + min-width: 28px; + height: 32px; + font-size: 13px; + } +} + +/* =========================== + Loading and Error States + =========================== */ +.loading-state, +.error-state, +.empty-state { + grid-column: 1 / -1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 60px 20px; + text-align: center; } +.loading-spinner { + width: 40px; + height: 40px; + border: 3px solid #f0f0f0; + border-top-color: #1a1a1a; + border-radius: 50%; + animation: spin 0.8s linear infinite; + margin-bottom: 16px; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.loading-state p, +.error-state p, +.empty-state { + font-size: 15px; + color: #666; + margin: 0; +} + +.retry-btn { + margin-top: 16px; + padding: 10px 24px; + font-size: 14px; + font-weight: 600; + background: #1a1a1a; + color: #ffffff; + border-radius: 6px; + cursor: pointer; + transition: background 0.2s ease; +} + +.retry-btn:hover { + background: #333; +} diff --git a/src/main/resources/static/css/login.css b/src/main/resources/static/css/login.css index de77d5a..363e7ca 100644 --- a/src/main/resources/static/css/login.css +++ b/src/main/resources/static/css/login.css @@ -55,6 +55,19 @@ font-weight: 500; margin-bottom: 20px; text-align: center; + display: none; +} + +.login-error.success { + background: #f3fff3; + border-color: #c8e6c9; + color: #2e7d32; +} + +.login-error.error { + background: #fff3f3; + border-color: #ffcdd2; + color: #d32f2f; } /* Form */ @@ -139,66 +152,6 @@ opacity: 0.7; } -/* Social Divider */ -.social-divider { - width: 100%; - height: 1px; - background: #e8e8e8; - margin: 32px 0; - position: relative; -} - -/* Social Login */ -.social-login { - width: 100%; - display: flex; - flex-direction: column; - gap: 12px; -} - -.social-btn { - display: flex; - align-items: center; - justify-content: center; - gap: 10px; - width: 100%; - padding: 13px 16px; - font-size: 14px; - font-weight: 500; - border-radius: 8px; - cursor: pointer; - transition: all 0.2s ease; -} - -.social-icon { - flex-shrink: 0; -} - -/* Google Button */ -.social-btn-google { - background: #ffffff; - color: #333; - border: 1px solid #ddd; -} - -.social-btn-google:hover { - background: #f9f9f9; - border-color: #ccc; - opacity: 1; -} - -/* Kakao Button */ -.social-btn-kakao { - background: #FEE500; - color: #3C1E1E; - border: 1px solid #FEE500; -} - -.social-btn-kakao:hover { - background: #f5dd00; - opacity: 1; -} - /* =========================== Responsive =========================== */ diff --git a/src/main/resources/static/css/signup.css b/src/main/resources/static/css/signup.css index c89a0b9..5e1bd22 100644 --- a/src/main/resources/static/css/signup.css +++ b/src/main/resources/static/css/signup.css @@ -47,6 +47,19 @@ font-weight: 500; margin-bottom: 20px; text-align: center; + display: none; +} + +.signup-error.success { + background: #f3fff3; + border-color: #c8e6c9; + color: #2e7d32; +} + +.signup-error.error { + background: #fff3f3; + border-color: #ffcdd2; + color: #d32f2f; } /* Form */ diff --git a/src/main/resources/static/js/admin-consultation.js b/src/main/resources/static/js/admin-consultation.js new file mode 100644 index 0000000..2992d85 --- /dev/null +++ b/src/main/resources/static/js/admin-consultation.js @@ -0,0 +1,374 @@ +/** + * Admin Consultation Page + * 관리자 상담 목록 관리 및 실시간 업데이트 + */ + +let stompClient = null; +let currentStatus = 'ALL'; +let currentPage = 0; +const pageSize = 20; + +document.addEventListener('DOMContentLoaded', () => { + initStatusTabs(); + loadConsultations(); + connectWebSocket(); +}); + +/* =========================== + Status Tabs + =========================== */ +function initStatusTabs() { + const tabs = document.querySelectorAll('.status-tab'); + + tabs.forEach(tab => { + tab.addEventListener('click', () => { + // Update active state + tabs.forEach(t => t.classList.remove('active')); + tab.classList.add('active'); + + // Update current status and reload + currentStatus = tab.dataset.status; + currentPage = 0; + loadConsultations(); + }); + }); +} + +/* =========================== + Load Consultations + =========================== */ +async function loadConsultations() { + const listContainer = document.getElementById('consultation-list'); + const emptyState = document.getElementById('consultation-empty'); + const paginationContainer = document.getElementById('pagination-container'); + + // Show loading + listContainer.innerHTML = ` +
+
+

상담 목록을 불러오는 중...

+
+ `; + emptyState.style.display = 'none'; + paginationContainer.style.display = 'none'; + + try { + // Build API URL + let url = `/api/chat/admin/rooms?page=${currentPage}&size=${pageSize}&sort=createdAt,desc`; + if (currentStatus !== 'ALL') { + url += `&chatRoomStatus=${currentStatus}`; + } + + const response = await fetch(url); + const result = await response.json(); + + if (!result.success) { + throw new Error(result.message || '상담 목록을 불러오는데 실패했습니다'); + } + + const pageData = result.data; + const consultations = pageData.content || []; + + // Update counts + updateStatusCounts(); + + // Render consultations + if (consultations.length === 0) { + listContainer.innerHTML = ''; + emptyState.style.display = 'flex'; + } else { + renderConsultations(consultations); + emptyState.style.display = 'none'; + + // Render pagination if needed + if (pageData.totalPages > 1) { + renderPagination(pageData); + paginationContainer.style.display = 'flex'; + } + } + } catch (error) { + console.error('상담 목록 로드 실패:', error); + listContainer.innerHTML = ` +
+

상담 목록을 불러오는데 실패했습니다

+

${error.message}

+
+ `; + } +} + +/* =========================== + Render Consultations + =========================== */ +function renderConsultations(consultations) { + const listContainer = document.getElementById('consultation-list'); + + listContainer.innerHTML = consultations.map(consultation => { + const statusClass = consultation.chatRoomStatus.toLowerCase().replace('_', '-'); + const statusText = getStatusText(consultation.chatRoomStatus); + const timeAgo = getTimeAgo(consultation.lastMessageAt || consultation.createdAt); + + return ` +
+
+ + ${statusText} + +
+
${escapeHtml(consultation.title)}
+
+ + + + + + 고객 ID: ${consultation.userId}${consultation.userEmail ? ' (' + escapeHtml(consultation.userEmail) + ')' : ''} + + + + + + + ${timeAgo} + +
+
+
+
+ ${consultation.chatRoomStatus === 'WAITING' ? + `` : + `` + } +
+
+ `; + }).join(''); + + // Add click handlers to items + document.querySelectorAll('.consultation-item').forEach(item => { + item.addEventListener('click', (e) => { + // Don't trigger if clicking on button + if (e.target.closest('button')) return; + + const roomId = item.dataset.roomId; + openConsultation(roomId); + }); + }); +} + +/* =========================== + Update Status Counts + =========================== */ +async function updateStatusCounts() { + try { + // Fetch counts for each status + const statuses = ['ALL', 'WAITING', 'IN_PROGRESS', 'COMPLETED']; + + for (const status of statuses) { + let url = `/api/chat/admin/rooms?page=0&size=1`; + if (status !== 'ALL') { + url += `&chatRoomStatus=${status}`; + } + + const response = await fetch(url); + const result = await response.json(); + + if (result.success) { + const count = result.data.totalElements || 0; + const countEl = document.getElementById(`count-${status.toLowerCase().replace('_', '-')}`); + if (countEl) { + countEl.textContent = count; + } + } + } + } catch (error) { + console.error('상태별 카운트 업데이트 실패:', error); + } +} + +/* =========================== + Pagination + =========================== */ +function renderPagination(pageData) { + const container = document.getElementById('pagination-container'); + const currentPage = pageData.number; + const totalPages = pageData.totalPages; + + let paginationHTML = ''; + container.innerHTML = paginationHTML; +} + +function goToPage(page) { + currentPage = page; + loadConsultations(); + window.scrollTo({ top: 0, behavior: 'smooth' }); +} + +/* =========================== + WebSocket Connection + =========================== */ +function connectWebSocket() { + const socket = new SockJS('/ws-chat'); + stompClient = Stomp.over(socket); + + // Disable debug logging + stompClient.debug = null; + + stompClient.connect({}, (frame) => { + console.log('WebSocket 연결 성공:', frame); + + // Subscribe to admin consultation updates + stompClient.subscribe('/sub/admin/consultations', (message) => { + console.log('새로운 상담 업데이트:', message.body); + + // 뱃지 업데이트 (chatbotPanel 활성 방 수 재조회 위임) + if (window.chatbotPanel) { + window.chatbotPanel.initAdminBadge(); + } + + // Reload consultations when update received + loadConsultations(); + }); + }, (error) => { + console.error('WebSocket 연결 실패:', error); + + // Retry connection after 5 seconds + setTimeout(() => { + console.log('WebSocket 재연결 시도...'); + connectWebSocket(); + }, 5000); + }); +} + +/* =========================== + Consultation Actions + =========================== */ +async function joinConsultation(roomId) { + try { + const response = await fetch(`/api/chat/admin/rooms/${roomId}/join`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + } + }); + + const result = await response.json(); + + if (!result.success) { + throw new Error(result.message || '상담 참여에 실패했습니다'); + } + + // Open consultation after joining + openConsultation(roomId); + } catch (error) { + console.error('상담 참여 실패:', error); + alert(error.message); + } +} + +function openConsultation(roomId) { + if (window.chatbotPanel) { + window.chatbotPanel.openAdminConsultation(roomId); + } else { + alert('챗봇 패널이 초기화되지 않았습니다.'); + } +} + +/* =========================== + Utility Functions + =========================== */ +function getStatusText(status) { + const statusMap = { + 'WAITING': '대기중', + 'IN_PROGRESS': '상담중', + 'COMPLETED': '완료' + }; + return statusMap[status] || status; +} + +function getTimeAgo(dateString) { + if (!dateString) return ''; + // Append 'Z' to treat as UTC if it's missing + const isoString = dateString.endsWith('Z') ? dateString : dateString + 'Z'; + const date = new Date(isoString); + const now = new Date(); + const diffMs = now - date; + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + + if (diffMins < 1 || diffMs < 0) return '방금 전'; + if (diffMins < 60) return `${diffMins}분 전`; + if (diffHours < 24) return `${diffHours}시간 전`; + if (diffDays < 7) return `${diffDays}일 전`; + + return date.toLocaleDateString('ko-KR', { + year: 'numeric', + month: 'long', + day: 'numeric' + }); +} + +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} diff --git a/src/main/resources/static/js/cart.js b/src/main/resources/static/js/cart.js index e3e9981..a5fdb93 100644 --- a/src/main/resources/static/js/cart.js +++ b/src/main/resources/static/js/cart.js @@ -2,58 +2,168 @@ * ADP Commerce - Cart Page JavaScript */ +let cartItemsData = []; + document.addEventListener('DOMContentLoaded', () => { - initQuantityButtons(); - initRemoveButtons(); + loadCartItems(); initClearCart(); initOrderButton(); }); -function initQuantityButtons() { +function loadCartItems() { + fetch('/api/cart') + .then(res => res.json()) + .then(data => { + if (data.success && data.data && data.data.content) { + cartItemsData = data.data.content; + renderCart(); + } else { + cartItemsData = []; + renderCart(); + } + }) + .catch(err => { + console.error('장바구니 조회 실패:', err); + cartItemsData = []; + renderCart(); + }); +} + +function renderCart() { + const itemsContainer = document.getElementById('cart-items'); + if (!itemsContainer) return; + + if (cartItemsData.length === 0) { + itemsContainer.innerHTML = '
장바구니가 비어있습니다.
'; + updateTotal(); + return; + } + + const html = cartItemsData.map(item => { + const hasImage = item.imageUrl && item.imageUrl !== ''; + const imageHTML = hasImage + ? `${item.productName}` + : `상품 이미지`; + + return ` +
+
+
+ ${imageHTML} +
+
+

${item.productName}

+
+ + + +
+
+
+ 금액: ${formatPrice(item.subtotal)} + +
+
+
+ `; + }).join(''); + + itemsContainer.innerHTML = html; + updateTotal(); + attachCartEvents(); +} + +function attachCartEvents() { document.querySelectorAll('.qty-minus').forEach(btn => { btn.addEventListener('click', () => { - const valueEl = btn.parentElement.querySelector('.qty-value'); - let val = parseInt(valueEl.textContent); - if (val > 1) { - valueEl.textContent = val - 1; - // TODO: API 호출 + const id = btn.getAttribute('data-id'); + const item = cartItemsData.find(i => i.cartProductId == id); + if (item && item.quantity > 1) { + updateQuantity(id, item.quantity - 1); } }); }); document.querySelectorAll('.qty-plus').forEach(btn => { btn.addEventListener('click', () => { - const valueEl = btn.parentElement.querySelector('.qty-value'); - let val = parseInt(valueEl.textContent); - valueEl.textContent = val + 1; - // TODO: API 호출 + const id = btn.getAttribute('data-id'); + const item = cartItemsData.find(i => i.cartProductId == id); + if (item) { + updateQuantity(id, item.quantity + 1); + } + }); + }); + + document.querySelectorAll('.qty-value-input').forEach(input => { + input.addEventListener('change', (e) => { + const id = e.target.getAttribute('data-id'); + const val = parseInt(e.target.value, 10); + if (val > 0) { + updateQuantity(id, val); + } else { + e.target.value = 1; + updateQuantity(id, 1); + } }); }); -} -function initRemoveButtons() { document.querySelectorAll('.cart-item-remove').forEach(btn => { btn.addEventListener('click', () => { - const item = btn.closest('.cart-item'); - if (item) { - item.style.opacity = '0'; - item.style.transform = 'translateX(20px)'; - item.style.transition = 'all 0.3s ease'; - setTimeout(() => item.remove(), 300); - // TODO: API 호출 - } + const id = btn.getAttribute('data-id'); + deleteCartItem(id); }); }); } +function updateQuantity(cartProductId, quantity) { + fetch(`/api/cart/${cartProductId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ quantity }) + }).then(res => { + if (res.ok) { + loadCartItems(); + } else { + alert('수량 변경에 실패했습니다.'); + } + }).catch(err => { + console.error('수량 변경 에러:', err); + }); +} + +function deleteCartItem(cartProductId) { + if (!confirm('상품을 장바구니에서 삭제하시겠습니까?')) return; + + fetch(`/api/cart/${cartProductId}`, { + method: 'DELETE' + }).then(res => { + if (res.ok) { + loadCartItems(); + // Update badge + if (typeof initCartBadge === 'function') initCartBadge(); + } else { + alert('삭제에 실패했습니다.'); + } + }).catch(err => { + console.error('삭제 에러:', err); + }); +} + function initClearCart() { const clearBtn = document.getElementById('cart-clear-btn'); if (clearBtn) { clearBtn.addEventListener('click', () => { - if (confirm('장바구니를 비우시겠습니까?')) { - const items = document.getElementById('cart-items'); - if (items) items.innerHTML = ''; - // TODO: API 호출 + if (cartItemsData.length === 0) return; + if (confirm('장바구니를 모두 비우시겠습니까?')) { + fetch('/api/cart', { method: 'DELETE' }) + .then(res => { + if (res.ok) { + loadCartItems(); + if (typeof initCartBadge === 'function') initCartBadge(); + } else { + alert('장바구니 비우기에 실패했습니다.'); + } + }); } }); } @@ -63,8 +173,54 @@ function initOrderButton() { const orderBtn = document.getElementById('cart-order-btn'); if (orderBtn) { orderBtn.addEventListener('click', () => { - // TODO: 주문 페이지로 이동 - console.log('주문하기'); + if (cartItemsData.length === 0) { + alert('장바구니가 비어있습니다.'); + return; + } + + const orderItems = cartItemsData.map(item => ({ + productId: item.productId, + quantity: item.quantity + })); + + // 로딩 상태 처리 + orderBtn.disabled = true; + orderBtn.textContent = '주문 처리 중...'; + + fetch('/api/orders', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ orderItems }) + }) + .then(res => res.json()) + .then(data => { + if (data.success) { + window.location.href = `/checkout?orderUid=${data.data.orderUid}`; + } else { + alert('주문 생성에 실패했습니다: ' + (data.message || '알 수 없는 오류')); + orderBtn.disabled = false; + orderBtn.textContent = '주문하기'; + } + }) + .catch(err => { + console.error('주문 실패:', err); + alert('주문 생성 중 오류가 발생했습니다.'); + orderBtn.disabled = false; + orderBtn.textContent = '주문하기'; + }); }); } } + +function updateTotal() { + const totalEl = document.getElementById('cart-total-value'); + if (totalEl) { + const sum = cartItemsData.reduce((acc, item) => acc + (item.subtotal || 0), 0); + totalEl.innerHTML = `${formatPrice(sum)} 원`; + } +} + +function formatPrice(price) { + if (!price) return '0'; + return price.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','); +} diff --git a/src/main/resources/static/js/chatbot.js b/src/main/resources/static/js/chatbot.js new file mode 100644 index 0000000..aa99273 --- /dev/null +++ b/src/main/resources/static/js/chatbot.js @@ -0,0 +1,777 @@ +/** + * ADP Commerce - Chatbot Module + * 실시간 챗봇 메시지 정렬 및 WebSocket 통신 처리 + */ + +class ChatbotPanel { + constructor(panelId) { + this.panel = document.getElementById(panelId); + this.body = document.getElementById('chatbot-body'); + this.input = document.getElementById('chatbot-input'); + this.sendBtn = document.getElementById('chatbot-send-btn'); + this.toggleBtn = document.getElementById('chatbot-toggle'); + this.closeBtn = document.getElementById('chatbot-close-btn'); + this.endBtn = document.getElementById('chatbot-end-btn'); + + this.stompClient = null; + this.currentRoomId = null; + this.currentUserId = null; + this.currentUserRole = null; // 'USER' or 'ADMIN' + this.isInConsultation = false; + this.isAgentJoined = false; // 상담원 입장 여부 + this.currentSubscription = null; // STOMP 구독 객체 (중복 방지) + this._warningTimer = null; // 비활성 경고 타이머 + + this.init(); + } + + init() { + // Set user info from session (passed from server) + if (window.chatbotUserInfo && window.chatbotUserInfo.id) { + this.currentUserId = window.chatbotUserInfo.id; + this.currentUserRole = window.chatbotUserInfo.role; + } + + // Initialize event listeners + this.initEventListeners(); + + // Connect to WebSocket only if logged in + if (this.currentUserId) { + this.connectWebSocket(); + + // ADMIN일 경우 페이지 로드 시 뱃지 초기화 + if (this.currentUserRole === 'ADMIN') { + this.initAdminBadge(); + } + + // 고객(USER)일 경우 활성 방 확인 → WebSocket 구독 (알림 수신용) + if (this.currentUserRole === 'USER') { + this.initUserActiveRoom(); + } + } + } + + /** + * 고객 페이지 로드 시 활성 채팅방 확인 + * 활성 방이 있으면 WebSocket 구독하여 패널 닫혀있어도 메시지 수신 → 알림 점 표시 + */ + async initUserActiveRoom() { + try { + const response = await fetch('/api/chat/rooms/my'); + if (response.ok) { + const result = await response.json(); + if (result.success && result.data) { + const room = result.data; + this.currentRoomId = room.id; + // UI는 메뉴 화면 유지 (isInConsultation 설정 안 함) + // WebSocket만 구독하여 알림 점 표시용 + if (this.stompClient && this.stompClient.connected) { + this.subscribeToRoom(room.id); + } + } + } + } catch (e) { + console.error('활성 방 확인 실패:', e); + } + } + + async initAdminBadge() { + try { + // WAITING + IN_PROGRESS 채팅방 수 합산 + let totalActive = 0; + for (const status of ['WAITING', 'IN_PROGRESS']) { + const response = await fetch(`/api/chat/admin/rooms?page=0&size=1&chatRoomStatus=${status}`); + if (response.ok) { + const result = await response.json(); + if (result.success && result.data) { + totalActive += (result.data.totalElements || 0); + } + } + } + this.updateAdminBadge(totalActive, true); + } catch (e) { + console.error('관리자 뱃지 초기화 실패:', e); + } + } + + updateAdminBadge(count, isAbsolute = false) { + const badge = document.getElementById('admin-chat-badge'); + if (badge) { + let newCount = isAbsolute ? count : parseInt(badge.textContent || '0') + count; + newCount = Math.max(0, newCount); + + badge.textContent = newCount; + if (newCount > 0) { + badge.style.display = 'flex'; + badge.style.justifyContent = 'center'; + badge.style.alignItems = 'center'; + badge.style.color = 'white'; + badge.style.fontSize = '10px'; + badge.style.fontWeight = 'bold'; + badge.style.width = '16px'; + badge.style.height = '16px'; + badge.style.borderRadius = '50%'; + } else { + badge.style.display = 'none'; + } + } + } + + initEventListeners() { + // Toggle panel + if (this.toggleBtn) { + this.toggleBtn.addEventListener('click', () => { + this.panel.classList.toggle('active'); + if (this.panel.classList.contains('active')) { + this.hideNotificationDot(); + } + }); + } + + // Close panel on outside click + document.addEventListener('click', (e) => { + const container = document.getElementById('chatbot-container'); + if (container && !container.contains(e.target)) { + this.panel.classList.remove('active'); + } + }); + + // Send message on button click + if (this.sendBtn) { + this.sendBtn.addEventListener('click', () => { + if (!this.isInConsultation) return; + this.sendMessage(); + }); + } + + // Send message on Enter key + if (this.input) { + this.input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + if (!this.isInConsultation) return; + this.sendMessage(); + } + }); + } + + // Close button in header - just closes the panel + if (this.closeBtn) { + this.closeBtn.addEventListener('click', () => { + this.panel.classList.remove('active'); + }); + } + + // End consultation button + if (this.endBtn) { + this.endBtn.addEventListener('click', () => { + if (this.isInConsultation) { + if (confirm('상담을 종료하시겠습니까?')) { + this.endConsultation(); + } + } else { + alert('진행 중인 상담이 없습니다.'); + } + }); + } + + // Menu button handlers + const orderBtn = document.getElementById('chatbot-order-btn'); + const cartBtn = document.getElementById('chatbot-cart-btn'); + const mypageBtn = document.getElementById('chatbot-mypage-btn'); + const agentBtn = document.getElementById('chatbot-agent-btn'); + + // Admin menu handlers + const adminConsultationBtn = document.getElementById('chatbot-admin-consultation-btn'); + const adminOrderBtn = document.getElementById('chatbot-admin-order-btn'); + const adminProductBtn = document.getElementById('chatbot-admin-product-btn'); + + if (orderBtn) orderBtn.addEventListener('click', () => { this.handleMenuClick('/orders'); }); + if (cartBtn) cartBtn.addEventListener('click', () => { this.handleMenuClick('/cart'); }); + if (mypageBtn) mypageBtn.addEventListener('click', () => { this.handleMenuClick('/mypage'); }); + if (agentBtn) agentBtn.addEventListener('click', () => { + this.handleAgentConnect(); + }); + + if (adminConsultationBtn) adminConsultationBtn.addEventListener('click', () => { this.handleMenuClick('/admin/consultations'); }); + if (adminOrderBtn) adminOrderBtn.addEventListener('click', () => { alert('전체 주문 관리 페이지는 준비 중입니다.'); }); + if (adminProductBtn) adminProductBtn.addEventListener('click', () => { alert('상품 관리 페이지는 준비 중입니다.'); }); + } + + hideMenu() { + const menu = document.getElementById('chatbot-menu'); + if (menu) { + menu.style.display = 'none'; + } + } + + showMenu() { + const menu = document.getElementById('chatbot-menu'); + if (menu) { + menu.style.display = 'flex'; + } + } + + connectWebSocket() { + try { + const socket = new SockJS('/ws-chat'); + this.stompClient = Stomp.over(socket); + + // Disable debug logging + this.stompClient.debug = null; + + this.stompClient.connect({}, (frame) => { + console.log('WebSocket 연결 성공'); + + // Subscribe to current room if exists + if (this.currentRoomId) { + this.subscribeToRoom(this.currentRoomId); + } + + // ADMIN일 경우 전역 상담 알림 구독 + if (this.currentUserRole === 'ADMIN') { + this.stompClient.subscribe('/sub/admin/consultations', (message) => { + // 활성 채팅방 수 기반으로 뱃지 재조회 + this.initAdminBadge(); + }); + } + }, (error) => { + console.error('WebSocket 연결 실패:', error); + + // Retry connection after 5 seconds + setTimeout(() => { + if (this.currentUserId) { + this.connectWebSocket(); + } + }, 5000); + }); + } catch (e) { + console.error('WebSocket 초기화 실패:', e); + } + } + + subscribeToRoom(roomId) { + if (!this.stompClient || !this.stompClient.connected) { + console.error('WebSocket not connected'); + return; + } + + // 기존 구독 해제 (중복 방지) + if (this.currentSubscription) { + try { this.currentSubscription.unsubscribe(); } catch(e) {} + this.currentSubscription = null; + } + + this.currentRoomId = roomId; + + // Subscribe to room messages + this.currentSubscription = this.stompClient.subscribe('/sub/chat/' + roomId, (message) => { + const chatMessage = JSON.parse(message.body); + this.receiveMessage(chatMessage); + }); + + console.log('Subscribed to chat room:', roomId); + } + + sendMessage() { + const message = this.input.value.trim(); + if (!message) return; + + if (!this.isAgentJoined) { + this.showError('상담원이 아직 연결되지 않았습니다.'); + return; + } + + // Check if we have a room ID and WebSocket connection + if (!this.currentRoomId) { + console.error('No active chat room'); + this.showError('채팅방이 연결되지 않았습니다.'); + return; + } + + if (!this.stompClient || !this.stompClient.connected) { + console.error('WebSocket not connected'); + this.showError('연결이 끊어졌습니다. 페이지를 새로고침해주세요.'); + return; + } + + // Send message via WebSocket + this.stompClient.send( + '/pub/chat/' + this.currentRoomId, + { 'content-type': 'application/json' }, + JSON.stringify({ content: message }) + ); + + this.input.value = ''; + } + + receiveMessage(message) { + const isOwnMessage = this.isOwnMessage(message); + + // 상담 화면이 활성화된 경우에만 메시지 렌더링 (메뉴 화면에서는 렌더 안 함) + if (this.isInConsultation) { + this.renderMessage(message, isOwnMessage, false); + this.scrollToBottom(); + } + + // 패널 닫힘 + 상대방 메시지 → 알림 점 (고객 전용) + if (this.currentUserRole !== 'ADMIN' && !isOwnMessage && + !this.panel.classList.contains('active') && + message.senderType !== 'SYSTEM') { + this.showNotificationDot(); + } + + // 비활성 타이머 리셋 + if (this.isInConsultation && this.isAgentJoined) { + this.resetInactivityTimer(); + } + } + + isOwnMessage(message) { + // SYSTEM 메시지는 항상 상대방(왼쪽) 취급 + if (message.senderType === 'SYSTEM') return false; + + // senderId 기반 비교 (가장 정확) + if (message.senderId && this.currentUserId) { + return String(message.senderId) === String(this.currentUserId); + } + + // fallback: role 기반 비교 (UserRole: USER/ADMIN ↔ SenderType: CUSTOMER/ADMIN) + const mappedRole = this.currentUserRole === 'USER' ? 'CUSTOMER' : this.currentUserRole; + return message.senderType === mappedRole; + } + + renderMessage(message, isOwnMessage, isHistorical = false) { + if (!this.body) return; + + // SYSTEM 메시지는 가운데 시스템 알림으로 표시 + if (message.senderType === 'SYSTEM' || message.messageType === 'SYSTEM') { + const systemEl = document.createElement('div'); + systemEl.className = 'chat-system-message'; + systemEl.textContent = message.content; + systemEl.style.cssText = ` + padding: 8px 14px; + margin: 8px auto; + background: #f0f0f0; + color: #888; + border-radius: 12px; + font-size: 12px; + text-align: center; + max-width: 80%; + width: fit-content; + `; + this.body.appendChild(systemEl); + + // 실시간 메시지일 때만 side-effect 실행 (히스토리 로딩 시는 무시) + if (!isHistorical) { + if (message.content.includes('상담원이 연결되었습니다')) { + this.isAgentJoined = true; + this.setInputEnabled(true); + const waitingEl = this.body.querySelector('.chat-waiting-message'); + if (waitingEl) waitingEl.remove(); + this.resetInactivityTimer(); + } + + if (message.content.includes('상담이 종료되었습니다') || message.content.includes('자동 종료되었습니다')) { + this.handleRemoteClose(); + } + } + return; + } + + // Create message container + const messageContainer = document.createElement('div'); + messageContainer.className = 'chat-message-container'; + messageContainer.className += isOwnMessage ? ' chat-message-own' : ' chat-message-other'; + + // Create message bubble + const messageBubble = document.createElement('div'); + messageBubble.className = 'chat-message-bubble'; + messageBubble.textContent = message.content; + + // Create timestamp + const timestamp = document.createElement('div'); + timestamp.className = 'chat-message-time'; + timestamp.textContent = this.formatTime(message.createdAt); + + // Append elements + messageContainer.appendChild(messageBubble); + messageContainer.appendChild(timestamp); + + this.body.appendChild(messageContainer); + } + + scrollToBottom() { + if (!this.body) return; + + this.body.scrollTo({ + top: this.body.scrollHeight, + behavior: 'smooth' + }); + } + + formatTime(dateTimeString) { + if (!dateTimeString) return ''; + // 서버는 UTC(LocalDateTime)로 전송하므로 'Z'를 붙여 브라우저가 로컬 시간으로 변환하도록 함 + const isoString = dateTimeString.endsWith('Z') ? dateTimeString : dateTimeString + 'Z'; + const date = new Date(isoString); + const hours = date.getHours().toString().padStart(2, '0'); + const minutes = date.getMinutes().toString().padStart(2, '0'); + return `${hours}:${minutes}`; + } + + showError(message) { + if (!this.body) return; + + const errorEl = document.createElement('div'); + errorEl.className = 'chat-error-message'; + errorEl.textContent = message; + + this.body.appendChild(errorEl); + this.scrollToBottom(); + + // Remove error after 5 seconds + setTimeout(() => errorEl.remove(), 5000); + } + + handleMenuClick(url) { + // 로그인 체크 + if (!this.currentUserId) { + this.showLoginRequired(); + return; + } + + window.location.href = url; + } + + openAdminConsultation(roomId) { + // 이미 같은 방을 보고 있으면 패널만 열기 + if (this.currentRoomId === roomId && this.isInConsultation) { + this.panel.classList.add('active'); + return; + } + + this.hideMenu(); + this.clearMessages(); + this.currentRoomId = roomId; + this.isInConsultation = true; + this.isAgentJoined = true; + this.setInputEnabled(true); + + // Subscribe if already connected + if (this.stompClient && this.stompClient.connected) { + this.subscribeToRoom(roomId); + } else { + this.connectWebSocket(); + } + + const titleEl = document.querySelector('.chatbot-title'); + if (titleEl) titleEl.textContent = '상담 채팅'; + if (this.endBtn) this.endBtn.style.display = 'inline-block'; + + this.loadRoomMessages(roomId); + this.panel.classList.add('active'); + } + + async handleAgentConnect() { + // 로그인 체크 + if (!this.currentUserId) { + this.showLoginRequired(); + return; + } + + this.hideMenu(); + this.clearMessages(); + + // 상담원 연결 중 메시지 표시 + const connectingMsg = document.createElement('div'); + connectingMsg.className = 'chat-system-message'; + connectingMsg.textContent = '상담원을 연결하고 있습니다...'; + connectingMsg.style.cssText = ` + padding: 10px 14px; + margin: 8px auto; + background: #f0f0f0; + color: #666; + border-radius: 12px; + font-size: 13px; + text-align: center; + max-width: 80%; + width: fit-content; + `; + this.body.appendChild(connectingMsg); + this.scrollToBottom(); + + try { + // 채팅방 생성/조회 API 호출 + const response = await fetch('/api/chat/rooms', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title: '상담 요청' }) + }); + + if (!response.ok) { + throw new Error('상담방 생성에 실패했습니다.'); + } + + const apiResponse = await response.json(); + + if (apiResponse.success && apiResponse.data) { + const room = apiResponse.data; + this.currentRoomId = room.id; + this.isInConsultation = true; + + // 상태에 따라 입력 제어 + if (room.chatRoomStatus === 'IN_PROGRESS') { + this.isAgentJoined = true; + this.setInputEnabled(true); + } else { + this.isAgentJoined = false; + this.setInputEnabled(false); + } + + // WebSocket 구독 + if (this.stompClient && this.stompClient.connected) { + this.subscribeToRoom(room.id); + } + + // 헤더 타이틀 변경 + const titleEl = document.querySelector('.chatbot-title'); + if (titleEl) titleEl.textContent = '상담 채팅'; + + // 상담 종료 버튼 노출 + if (this.endBtn) this.endBtn.style.display = 'inline-block'; + + // 연결 중 메시지 제거 후 이전 대화 내역 로드 + connectingMsg.remove(); + await this.loadRoomMessages(room.id); + + // WAITING 상태이면 대기 메시지 표시 + if (room.chatRoomStatus === 'WAITING') { + this.showWaitingMessage(); + } + + // IN_PROGRESS 상태이면 비활성 타이머 시작 + if (room.chatRoomStatus === 'IN_PROGRESS') { + this.resetInactivityTimer(); + } + } else { + throw new Error('상담방 생성 응답이 올바르지 않습니다.'); + } + } catch (error) { + console.error('상담원 연결 실패:', error); + connectingMsg.textContent = '상담원 연결에 실패했습니다. 다시 시도해주세요.'; + connectingMsg.style.background = '#ffebee'; + connectingMsg.style.color = '#c62828'; + + // 메뉴 다시 표시 + setTimeout(() => { + connectingMsg.remove(); + this.showMenu(); + }, 3000); + } + } + + async endConsultation() { + if (this.currentRoomId) { + try { + await fetch(`/api/chat/rooms/${this.currentRoomId}/close`, { + method: 'POST' + }); + } catch (e) { + console.error('상담 종료 실패:', e); + } + } + + this.isInConsultation = false; + this.isAgentJoined = false; + this.currentRoomId = null; + this.clearInactivityTimers(); + this.setInputEnabled(true); + + // UI 초기화 + const titleEl = document.querySelector('.chatbot-title'); + if (titleEl) titleEl.textContent = '챗봇 상담'; + if (this.endBtn) this.endBtn.style.display = 'none'; + + // 메시지 영역 초기화 + 메뉴 표시 + this.clearMessages(); + this.showMenu(); + + // 종료 메시지 + const endMsg = document.createElement('div'); + endMsg.className = 'chat-system-message'; + endMsg.textContent = '상담이 종료되었습니다.'; + endMsg.style.cssText = ` + padding: 10px 14px; + margin: 8px 0; + background: #f5f5f5; + color: #999; + border-radius: 8px; + font-size: 13px; + text-align: center; + `; + this.body.appendChild(endMsg); + setTimeout(() => endMsg.remove(), 3000); + } + + /** + * 상대방이 상담을 종료했을 때 호출 + * 3초 후 자동으로 챗봇 메인 화면으로 복귀 + */ + handleRemoteClose() { + // 이미 종료 처리 중이면 무시 + if (!this.isInConsultation) return; + + this.isInConsultation = false; + this.isAgentJoined = false; + this.currentRoomId = null; + this.clearInactivityTimers(); + this.setInputEnabled(true); + + setTimeout(() => { + const titleEl = document.querySelector('.chatbot-title'); + if (titleEl) titleEl.textContent = '챗봇 상담'; + if (this.endBtn) this.endBtn.style.display = 'none'; + + this.clearMessages(); + this.showMenu(); + }, 3000); + } + + async loadRoomMessages(roomId) { + try { + const response = await fetch(`/api/chat/rooms/${roomId}/messages?size=30`); + if (response.ok) { + const apiResponse = await response.json(); + if (apiResponse.success && apiResponse.data && apiResponse.data.content) { + this.loadMessageHistory(apiResponse.data.content); + } + } + } catch (e) { + console.error('메시지 로드 실패:', e); + } + } + + showLoginRequired() { + // 로그인 필요 메시지 표시 + const loginMessage = document.createElement('div'); + loginMessage.className = 'chat-login-message'; + loginMessage.innerHTML = ` +

로그인이 필요한 서비스입니다.

+ + `; + loginMessage.style.cssText = ` + padding: 20px; + margin: 8px 0; + background: #f8f9fa; + border: 1px solid #e9ecef; + border-radius: 8px; + text-align: center; + `; + + const loginBtn = loginMessage.querySelector('.login-redirect-btn'); + if (loginBtn) { + loginBtn.addEventListener('click', () => { + window.location.href = '/login'; + }); + } + + this.body.appendChild(loginMessage); + this.scrollToBottom(); + + // 5초 후 메시지 제거 + setTimeout(() => loginMessage.remove(), 5000); + } + + clearMessages() { + if (!this.body) return; + // 메뉴는 유지하고 메시지만 제거 + const menu = document.getElementById('chatbot-menu'); + this.body.innerHTML = ''; + if (menu) { + this.body.appendChild(menu); + } + } + + loadMessageHistory(messages) { + // API는 최신순(DESC)으로 반환하므로, 채팅 표시를 위해 시간순(ASC)으로 뒤집음 + const sorted = [...messages].reverse(); + sorted.forEach(message => { + const isOwnMessage = this.isOwnMessage(message); + this.renderMessage(message, isOwnMessage, true); + }); + + this.scrollToBottom(); + } + + // === 대기 메시지 === + showWaitingMessage() { + if (!this.body) return; + const waitingEl = document.createElement('div'); + waitingEl.className = 'chat-waiting-message'; + waitingEl.textContent = '상담원 연결 대기 중입니다...'; + this.body.appendChild(waitingEl); + this.scrollToBottom(); + } + + // === 입력 활성화/비활성화 === + setInputEnabled(enabled) { + if (this.input) { + this.input.disabled = !enabled; + this.input.placeholder = enabled ? '메시지를 입력하세요' : '상담원 연결을 기다리는 중...'; + } + } + + // === 알림 점 === + showNotificationDot() { + const dot = document.getElementById('chatbot-notification-dot'); + if (dot) dot.style.display = 'block'; + } + + hideNotificationDot() { + const dot = document.getElementById('chatbot-notification-dot'); + if (dot) dot.style.display = 'none'; + } + + // === 비활성 타이머 (10분 자동 종료, 7분에 경고) === + resetInactivityTimer() { + this.clearInactivityTimers(); + + // 7분(420초) 후 경고 메시지 + this._warningTimer = setTimeout(() => { + if (this.isInConsultation && this.body) { + const warningEl = document.createElement('div'); + warningEl.className = 'chat-warning-message'; + warningEl.id = 'inactivity-warning'; + warningEl.textContent = '⚠️ 3분 후 응답이 없으면 상담이 자동 종료됩니다.'; + this.body.appendChild(warningEl); + this.scrollToBottom(); + } + }, 7 * 60 * 1000); + } + + clearInactivityTimers() { + if (this._warningTimer) { + clearTimeout(this._warningTimer); + this._warningTimer = null; + } + const warningEl = document.getElementById('inactivity-warning'); + if (warningEl) warningEl.remove(); + } +} + +// Initialize chatbot when DOM is ready +document.addEventListener('DOMContentLoaded', () => { + if (document.getElementById('chatbot-panel')) { + window.chatbotPanel = new ChatbotPanel('chatbot-panel'); + } +}); \ No newline at end of file diff --git a/src/main/resources/static/js/checkout.js b/src/main/resources/static/js/checkout.js new file mode 100644 index 0000000..dd8eb65 --- /dev/null +++ b/src/main/resources/static/js/checkout.js @@ -0,0 +1,345 @@ +/** + * ADP Commerce - Checkout Page JavaScript + */ + +class AddressModal { + constructor(modalId) { + this.modal = document.getElementById(modalId); + this.form = document.getElementById('shipping-form'); + this.addressInput = document.getElementById('shipping-address'); + this.addressDetailInput = document.getElementById('shipping-address-detail'); + this.submitBtn = document.getElementById('shipping-submit-btn'); + + this.initEventListeners(); + } + + initEventListeners() { + this.form.addEventListener('submit', (e) => { + e.preventDefault(); + this.submitAddress(); + }); + + this.modal.addEventListener('click', (e) => { + if (e.target === this.modal) this.close(); + }); + } + + open() { + this.modal.classList.add('active'); + document.body.style.overflow = 'hidden'; + this.addressInput.focus(); + } + + close() { + this.modal.classList.remove('active'); + document.body.style.overflow = ''; + this.form.reset(); + } + + async submitAddress() { + const address = this.addressInput.value.trim(); + const addressDetail = this.addressDetailInput.value.trim(); + if (!address) { + alert('주소를 입력해주세요.'); + return; + } + + const fullAddress = address + ' ' + addressDetail; + this.submitBtn.disabled = true; + this.submitBtn.textContent = '등록 중...'; + + try { + // 주소 정보를 User Profile에 저장 + const response = await fetch('/api/users/me', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ address: fullAddress }) + }); + + if (!response.ok) throw new Error('주소 등록에 실패했습니다.'); + + this.close(); + alert('배송 주소가 등록되었습니다.'); + if (window.checkoutPage) window.checkoutPage.loadUserProfile(); + + } catch (error) { + alert(error.message); + } finally { + this.submitBtn.disabled = false; + this.submitBtn.textContent = '등록'; + } + } +} + +class OrdererModal { + constructor(modalId) { + this.modal = document.getElementById(modalId); + this.form = document.getElementById('orderer-form'); + this.nameInput = document.getElementById('orderer-input-name'); + this.phoneInput = document.getElementById('orderer-input-phone'); + this.submitBtn = document.getElementById('orderer-submit-btn'); + + this.initEventListeners(); + } + + initEventListeners() { + this.form.addEventListener('submit', (e) => { + e.preventDefault(); + this.submitOrderer(); + }); + + this.modal.addEventListener('click', (e) => { + if (e.target === this.modal) this.close(); + }); + } + + open() { + this.modal.classList.add('active'); + document.body.style.overflow = 'hidden'; + this.nameInput.focus(); + } + + close() { + this.modal.classList.remove('active'); + document.body.style.overflow = ''; + this.form.reset(); + } + + async submitOrderer() { + const name = this.nameInput.value.trim(); + const phone = this.phoneInput.value.trim(); + let formattedPhone = phone.replace(/[-\s]/g, ''); + if (formattedPhone.length === 11) { + formattedPhone = formattedPhone.replace(/(\d{3})(\d{4})(\d{4})/, '$1-$2-$3'); + } else if (formattedPhone.length === 10) { + formattedPhone = formattedPhone.replace(/(\d{3})(\d{3})(\d{4})/, '$1-$2-$3'); + } + + this.submitBtn.disabled = true; + this.submitBtn.textContent = '등록 중...'; + + try { + const response = await fetch('/api/users/me', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: name, phone: formattedPhone }) + }); + + if (!response.ok) throw new Error('주문자 등록에 실패했습니다.'); + + this.close(); + alert('주문자 정보가 등록되었습니다.'); + if (window.checkoutPage) window.checkoutPage.loadUserProfile(); + + } catch (error) { + alert(error.message); + } finally { + this.submitBtn.disabled = false; + this.submitBtn.textContent = '등록'; + } + } +} + +class CheckoutPage { + constructor() { + this.paymentBtn = document.getElementById('payment-submit-btn'); + this.paymentAmount = document.getElementById('payment-submit-amount'); + this.orderUid = new URLSearchParams(window.location.search).get('orderUid'); + this.orderData = null; + + this.initEventListeners(); + this.loadOrderData(); + this.loadUserProfile(); + } + + initEventListeners() { + if (this.paymentBtn) { + this.paymentBtn.addEventListener('click', () => this.submitPayment()); + } + } + + async loadOrderData() { + if (!this.orderUid) { + alert('잘못된 접근입니다. 주문 번호가 없습니다.'); + return; + } + + try { + const response = await fetch(`/api/orders/${this.orderUid}`); + const result = await response.json(); + + if (result.success && result.data) { + this.orderData = result.data; + this.renderOrderData(this.orderData); + } else { + throw new Error('주문 정보를 불러오는데 실패했습니다.'); + } + } catch (error) { + console.error(error); + alert('주문 정보를 불러오는데 실패했습니다.'); + } + } + + renderOrderData(data) { + document.getElementById('checkout-order-id').textContent = data.orderUid; + + const productsContainer = document.getElementById('checkout-products'); + if (productsContainer && data.items) { + productsContainer.innerHTML = '

주문 상품

'; + data.items.forEach(item => { + productsContainer.innerHTML += ` +
+
+
+
+
+ ${item.productName} + 수량: ${item.quantity} + ${item.itemAmount.toLocaleString()}원 +
+
+ `; + }); + } + + document.getElementById('payment-product-price').innerHTML = `${data.totalAmount.toLocaleString()}원`; + document.getElementById('payment-delivery-fee').innerHTML = `${data.deliveryFee.toLocaleString()}원`; + document.getElementById('payment-total-price').innerHTML = `${data.finalAmount.toLocaleString()}원`; + + if (this.paymentAmount) { + this.paymentAmount.textContent = data.finalAmount.toLocaleString(); + } + + this.validateCheckout(); + } + + async loadUserProfile() { + try { + const response = await fetch('/api/users/me/unmasked'); + const result = await response.json(); + + if (result.success && result.data) { + this.renderUserProfile(result.data); + } + } catch (error) { + console.error('Failed to load user profile', error); + } + } + + renderUserProfile(user) { + const ordererEmpty = document.getElementById('orderer-empty'); + const ordererFilled = document.getElementById('orderer-filled'); + const shippingEmpty = document.getElementById('shipping-empty'); + const shippingFilled = document.getElementById('shipping-filled'); + + window.checkoutData = window.checkoutData || {}; + + if (user.name && user.phone) { + ordererEmpty.style.display = 'none'; + ordererFilled.style.display = 'block'; + document.getElementById('orderer-filled-name').textContent = user.name; + document.getElementById('orderer-filled-email').textContent = user.maskedEmail || user.email; + document.getElementById('orderer-filled-phone').textContent = user.phone; + window.checkoutData.orderer = true; + } else { + ordererEmpty.style.display = 'block'; + ordererFilled.style.display = 'none'; + window.checkoutData.orderer = false; + } + + if (user.address) { + shippingEmpty.style.display = 'none'; + shippingFilled.style.display = 'block'; + document.getElementById('shipping-filled-address').textContent = user.address; + window.checkoutData.shipping = true; + } else { + shippingEmpty.style.display = 'block'; + shippingFilled.style.display = 'none'; + window.checkoutData.shipping = false; + } + + this.validateCheckout(); + } + + validateCheckout() { + const isValid = window.checkoutData?.orderer && window.checkoutData?.shipping && this.orderData; + if (this.paymentBtn) { + if (isValid) { + this.paymentBtn.disabled = false; + this.paymentBtn.style.background = '#f5e642'; + this.paymentBtn.style.color = '#333'; + this.paymentBtn.style.cursor = 'pointer'; + } else { + this.paymentBtn.disabled = true; + this.paymentBtn.style.background = '#e8e8e8'; + this.paymentBtn.style.color = '#999'; + this.paymentBtn.style.cursor = 'not-allowed'; + } + } + } + + async submitPayment() { + if (!window.checkoutData?.orderer || !window.checkoutData?.shipping) { + alert('주문자 정보와 배송 주소를 모두 등록해주세요.'); + return; + } + + if (!this.orderData) return; + + this.paymentBtn.disabled = true; + this.paymentBtn.textContent = '결제 처리 중...'; + + try { + const response = await fetch(`/api/orders/${this.orderUid}/payments`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + amount: this.orderData.totalAmount, + deliveryFee: this.orderData.deliveryFee + }) + }); + + if (!response.ok) throw new Error('결제 처리에 실패했습니다.'); + const result = await response.json(); + + if (result.success && result.data && result.data.paymentUid) { + const confirmRes = await fetch(`/api/orders/${this.orderUid}/payments/${result.data.paymentUid}/confirm`, { + method: 'POST' + }); + if (!confirmRes.ok) throw new Error('결제 승인에 실패했습니다.'); + + // 장바구니 비우기 호출 + try { + await fetch('/api/cart', { method: 'DELETE' }); + } catch (e) { + console.error('장바구니 비우기 실패:', e); + } + } + + alert('결제가 완료되었습니다!'); + window.location.href = `/orders`; + + } catch (error) { + alert(error.message); + this.paymentBtn.disabled = false; + this.paymentBtn.textContent = `${this.orderData.finalAmount.toLocaleString()}원 결제하기`; + } + } +} + +document.addEventListener('DOMContentLoaded', () => { + const addressModal = new AddressModal('shipping-modal'); + const ordererModal = new OrdererModal('orderer-modal'); + + window.checkoutPage = new CheckoutPage(); + + const btnRegisterShipping = document.getElementById('btn-register-shipping'); + const btnChangeShipping = document.getElementById('btn-change-shipping'); + const btnRegisterOrderer = document.getElementById('btn-register-orderer'); + const btnChangeOrderer = document.getElementById('btn-change-orderer'); + + if (btnRegisterShipping) btnRegisterShipping.addEventListener('click', () => addressModal.open()); + if (btnChangeShipping) btnChangeShipping.addEventListener('click', () => addressModal.open()); + if (btnRegisterOrderer) btnRegisterOrderer.addEventListener('click', () => ordererModal.open()); + if (btnChangeOrderer) btnChangeOrderer.addEventListener('click', () => ordererModal.open()); +}); diff --git a/src/main/resources/static/js/common.js b/src/main/resources/static/js/common.js index d6dd29c..68eff52 100644 --- a/src/main/resources/static/js/common.js +++ b/src/main/resources/static/js/common.js @@ -5,9 +5,43 @@ document.addEventListener('DOMContentLoaded', () => { initSearch(); - initChatbot(); + initPopularKeywords(); + initCartBadge(); + initLogout(); }); +function initLogout() { + const logoutBtn = document.getElementById('logout-link'); + if (logoutBtn) { + logoutBtn.addEventListener('click', (e) => { + e.preventDefault(); + fetch('/api/auth/logout', { method: 'POST' }) + .then(() => { + window.location.href = '/'; + }) + .catch(() => { + window.location.href = '/'; + }); + }); + } +} + +function initCartBadge() { + // 로그인한 유저만 카트 뱃지 업데이트 + if (window.isLoggedIn) { + fetch('/api/cart') + .then(res => res.json()) + .then(data => { + if (data.success && data.data && data.data.content) { + const count = data.data.content.length; + const badge = document.getElementById('cart-badge'); + if (badge) badge.textContent = count; + } + }) + .catch(() => {}); + } +} + /* =========================== Search =========================== */ @@ -35,11 +69,10 @@ function initSearch() { if (keywordList) { keywordList.addEventListener('click', (e) => { const li = e.target.closest('li'); - if (li) { + if (li && !li.classList.contains('empty-keyword')) { const text = li.textContent.replace(/^\d+\.\s*/, '').trim(); searchInput.value = text; searchDropdown.classList.remove('active'); - // 검색 실행 (추후 연동) performSearch(text); } }); @@ -69,107 +102,88 @@ function initSearch() { } function performSearch(query) { - console.log('검색:', query); - // TODO: 서버 검색 API 연동 + // 검색어 기록 API 호출 (비동기, 결과 무시) + fetch('/api/keywords/search', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query: query }) + }).catch(() => {}); + + // 검색 결과 페이지로 이동 (URL 파라미터로 검색어 전달) + window.location.href = `/?search=${encodeURIComponent(query)}`; } /* =========================== - Chatbot + Popular Keywords =========================== */ -function initChatbot() { - const toggleBtn = document.getElementById('chatbot-toggle'); - const panel = document.getElementById('chatbot-panel'); - const chatInput = document.getElementById('chatbot-input'); - const sendBtn = document.getElementById('chatbot-send-btn'); - const endBtn = document.getElementById('chatbot-end-btn'); - - if (!toggleBtn || !panel) return; - - // 토글 - toggleBtn.addEventListener('click', () => { - panel.classList.toggle('active'); - }); +class PopularKeywords { + constructor(containerId) { + this.container = document.getElementById(containerId); + if (!this.container) return; + + this.loadKeywords(); + } - // 외부 클릭으로 닫기 - document.addEventListener('click', (e) => { - const container = document.getElementById('chatbot-container'); - if (container && !container.contains(e.target)) { - panel.classList.remove('active'); + async loadKeywords() { + try { + const response = await fetch('/api/keywords/v2/top5'); + const data = await response.json(); + + if (data.success && data.data) { + this.renderKeywords(data.data); + } else { + this.renderEmptyState(); + } + } catch (error) { + console.error('인기 검색어 로드 실패:', error); + this.renderEmptyState(); } - }); - - // 상담 종료 - if (endBtn) { - endBtn.addEventListener('click', () => { - panel.classList.remove('active'); - }); } - // 메시지 전송 - if (sendBtn && chatInput) { - sendBtn.addEventListener('click', () => { - sendChatMessage(chatInput); - }); + renderKeywords(keywords) { + if (!keywords || keywords.length === 0) { + this.renderEmptyState(); + return; + } - chatInput.addEventListener('keydown', (e) => { - if (e.key === 'Enter') { - e.preventDefault(); - sendChatMessage(chatInput); - } + // Clear container + this.container.innerHTML = ''; + + // Render into dropdown list + keywords.forEach(keyword => { + const li = document.createElement('li'); + li.innerHTML = `${keyword.rank}. ${keyword.keyword}`; + + li.addEventListener('click', () => { + const searchInput = document.getElementById('search-input'); + const searchDropdown = document.getElementById('search-dropdown'); + if (searchInput) { + searchInput.value = keyword.keyword; + if (searchDropdown) searchDropdown.classList.remove('active'); + performSearch(keyword.keyword); + } + }); + this.container.appendChild(li); }); } - // 메뉴 버튼 클릭 - const orderBtn = document.getElementById('chatbot-order-btn'); - const cartBtn = document.getElementById('chatbot-cart-btn'); - const mypageBtn = document.getElementById('chatbot-mypage-btn'); - const agentBtn = document.getElementById('chatbot-agent-btn'); - - if (orderBtn) orderBtn.addEventListener('click', () => { window.location.href = '/orders'; }); - if (cartBtn) cartBtn.addEventListener('click', () => { window.location.href = '/cart'; }); - if (mypageBtn) mypageBtn.addEventListener('click', () => { window.location.href = '/mypage'; }); - if (agentBtn) agentBtn.addEventListener('click', () => { addChatbotMessage('상담원을 연결 중입니다...'); }); -} - -function sendChatMessage(input) { - const message = input.value.trim(); - if (!message) return; - - addChatbotMessage(message, true); - input.value = ''; + renderEmptyState() { + this.container.innerHTML = '
  • 현재 인기 검색어가 없습니다
  • '; + } - // TODO: 챗봇 API 연동 - setTimeout(() => { - addChatbotMessage('죄송합니다, 현재 자동 응답 기능을 준비 중입니다.'); - }, 800); + handleKeywordClick(keyword) { + const searchInput = document.getElementById('search-input'); + if (searchInput) { + searchInput.value = keyword; + performSearch(keyword); + } + } } -function addChatbotMessage(text, isUser = false) { - const body = document.getElementById('chatbot-body'); - if (!body) return; - - const msgEl = document.createElement('div'); - msgEl.className = 'chatbot-message' + (isUser ? ' chatbot-message-user' : ' chatbot-message-bot'); - msgEl.textContent = text; - - // 스타일 인라인 (간단한 메시지) - msgEl.style.padding = '8px 12px'; - msgEl.style.marginTop = '8px'; - msgEl.style.borderRadius = '8px'; - msgEl.style.fontSize = '13px'; - msgEl.style.lineHeight = '1.5'; - msgEl.style.maxWidth = '80%'; - - if (isUser) { - msgEl.style.background = '#1a1a1a'; - msgEl.style.color = '#fff'; - msgEl.style.marginLeft = 'auto'; - msgEl.style.textAlign = 'right'; - } else { - msgEl.style.background = '#f0f0f0'; - msgEl.style.color = '#333'; +function initPopularKeywords() { + // Initialize popular keywords component for all users inside the dropdown + const keywordList = document.getElementById('search-keyword-list'); + if (keywordList) { + new PopularKeywords('search-keyword-list'); } - - body.appendChild(msgEl); - body.scrollTop = body.scrollHeight; } diff --git a/src/main/resources/static/js/index.js b/src/main/resources/static/js/index.js index 76868e9..ff14b02 100644 --- a/src/main/resources/static/js/index.js +++ b/src/main/resources/static/js/index.js @@ -1,12 +1,36 @@ /** * ADP Commerce - Index Page JavaScript - * 카테고리 탭, 정렬, 필터 인터랙션 + * 카테고리 탭, 정렬, 필터 인터랙션, 페이지네이션 */ +// 전역 상태 +let pagination = null; +let productDetailModal = null; +let currentFilters = { + category: 'all', + sort: 'newest', + onSale: false, + search: '' +}; + document.addEventListener('DOMContentLoaded', () => { + // URL에서 검색어 파라미터 읽기 + const urlParams = new URLSearchParams(window.location.search); + const searchQuery = urlParams.get('search'); + if (searchQuery) { + currentFilters.search = searchQuery; + const searchInput = document.getElementById('search-input'); + if (searchInput) { + searchInput.value = searchQuery; + } + } + initCategoryTabs(); initSortDropdown(); initOnSaleFilter(); + initPagination(); + initProductDetailModal(); + loadProducts(1); // 초기 상품 로드 }); /* =========================== @@ -30,8 +54,25 @@ function initCategoryTabs() { } function filterByCategory(category) { - console.log('카테고리 필터:', category); - // TODO: 서버 API 연동 또는 클라이언트 필터링 + currentFilters.category = category; + currentFilters.search = ''; // 카테고리 변경 시 검색어 초기화 + + // URL에서 search 파라미터 제거 + const url = new URL(window.location); + url.searchParams.delete('search'); + window.history.replaceState({}, '', url); + + // 페이지네이션을 1페이지로 리셋 + resetPagination(); + + // 1페이지 상품 로드 + loadProducts(1); +} + +function resetPagination() { + if (pagination) { + pagination.currentPage = 1; + } } /* =========================== @@ -53,7 +94,8 @@ function initSortDropdown() { // 옵션 선택 options.forEach(option => { - option.addEventListener('click', () => { + option.addEventListener('click', (e) => { + e.stopPropagation(); options.forEach(o => o.classList.remove('active')); option.classList.add('active'); @@ -75,8 +117,8 @@ function initSortDropdown() { } function sortProducts(sortType) { - console.log('정렬:', sortType); - // TODO: 서버 API 연동 또는 클라이언트 정렬 + currentFilters.sort = sortType; + loadProducts(1); // 페이지 1로 리셋하여 상품 로드 } /* =========================== @@ -94,6 +136,202 @@ function initOnSaleFilter() { } function filterOnSale(onSaleOnly) { - console.log('판매 중 필터:', onSaleOnly); - // TODO: 서버 API 연동 또는 클라이언트 필터링 + currentFilters.onSale = onSaleOnly; + loadProducts(1); +} + +/* =========================== + Product Detail Modal + =========================== */ +function initProductDetailModal() { + productDetailModal = new ProductDetailModal('product-detail-modal'); +} + +/* =========================== + Pagination + =========================== */ +function initPagination() { + pagination = new Pagination('pagination-container', { + maxVisiblePages: 5 + }); + + // 페이지 변경 시 상품 로드 + pagination.onPageChange((page) => { + loadProducts(page); + scrollToTop(); + }); +} + +function scrollToTop() { + window.scrollTo({ + top: 0, + behavior: 'smooth' + }); +} + +/* =========================== + Product Loading + =========================== */ +function loadProducts(page) { + const productGrid = document.getElementById('product-grid'); + if (!productGrid) return; + + // 로딩 상태 표시 + showLoadingState(productGrid); + + // API 호출 파라미터 구성 (Spring Pageable 형식: page는 0부터 시작) + const params = new URLSearchParams({ + page: page - 1, // Spring Pageable은 0-based index + size: 10 + }); + + // 카테고리 필터 추가 (ALL이 아닌 경우에만) + if (currentFilters.category && currentFilters.category !== 'all') { + params.append('category', currentFilters.category.toUpperCase()); + } + + if (currentFilters.search) { + params.append('keyword', currentFilters.search); + } + + // 정렬 파라미터 추가 + let sortParam = 'id,desc'; // 기본 정렬 + switch (currentFilters.sort) { + case 'newest': + sortParam = 'id,desc'; + break; + case 'price-asc': + sortParam = 'price,asc'; + break; + case 'price-desc': + sortParam = 'price,desc'; + break; + case 'popular': + sortParam = 'id,desc'; // 인기순은 임시로 최신순으로 처리 + break; + } + params.append('sort', sortParam); + + // 판매중 필터 추가 + if (currentFilters.onSale) { + params.append('onSale', 'true'); + } + + // API 호출 + fetch(`/api/products?${params.toString()}`) + .then(response => { + if (!response.ok) { + throw new Error('Failed to fetch products'); + } + return response.json(); + }) + .then(apiResponse => { + // ApiResponse> 구조 + if (apiResponse.success && apiResponse.data) { + const pageData = apiResponse.data; + let products = pageData.content; + + updateProductGrid(products); + updatePagination(pageData.number + 1, pageData.totalPages); + } else { + throw new Error('Invalid API response'); + } + }) + .catch(error => { + console.error('상품 로드 실패:', error); + showErrorState(productGrid); + }); +} + +function updateProductGrid(products) { + const productGrid = document.getElementById('product-grid'); + if (!productGrid) return; + + if (!products || products.length === 0) { + productGrid.innerHTML = '
    상품이 없습니다.
    '; + return; + } + + // 상품 카드 HTML 생성 + const productsHTML = products.map(product => { + const hasImage = product.imageUrl && product.imageUrl !== ''; + const imageHTML = hasImage + ? `${escapeHtml(product.name)}` + : `
    `; + + return ` + + `}).join(''); + + productGrid.innerHTML = productsHTML; + + // 상품 카드 클릭 이벤트 추가 + attachProductCardListeners(); +} + +/** + * 상품 카드 클릭 이벤트 리스너 추가 + */ +function attachProductCardListeners() { + const productLinks = document.querySelectorAll('.product-link'); + + productLinks.forEach(link => { + link.addEventListener('click', (e) => { + e.preventDefault(); + const productId = link.dataset.productId; + if (productId && productDetailModal) { + productDetailModal.open(parseInt(productId)); + } + }); + }); +} + +function updatePagination(currentPage, totalPages) { + if (pagination) { + pagination.render(currentPage, totalPages); + } +} + +function showLoadingState(container) { + container.innerHTML = ` +
    +
    +

    상품을 불러오는 중...

    +
    + `; +} + +function showErrorState(container) { + container.innerHTML = ` +
    +

    상품을 불러오는데 실패했습니다.

    + +
    + `; +} + +/* =========================== + Utility Functions + =========================== */ +function formatPrice(price) { + return price.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','); +} + +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; } diff --git a/src/main/resources/static/js/login.js b/src/main/resources/static/js/login.js new file mode 100644 index 0000000..57c39d3 --- /dev/null +++ b/src/main/resources/static/js/login.js @@ -0,0 +1,83 @@ +/** + * ADP Commerce - Login Page JavaScript + */ + +document.addEventListener('DOMContentLoaded', () => { + const form = document.getElementById('login-form'); + const emailInput = document.getElementById('email'); + const passwordInput = document.getElementById('password'); + const submitBtn = document.getElementById('login-submit-btn'); + const errorDiv = document.getElementById('login-error'); + + if (!form) return; + + // 폼 제출 처리 + form.addEventListener('submit', async (e) => { + e.preventDefault(); + + const email = emailInput?.value?.trim(); + const password = passwordInput?.value?.trim(); + + // 클라이언트 사이드 검증 + if (!email || !password) { + showError('이메일과 비밀번호를 모두 입력해주세요.'); + return; + } + + // 로딩 상태 표시 + setLoading(true); + + try { + const response = await fetch('/api/auth/login', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + email: email, + password: password + }) + }); + + const data = await response.json(); + + if (response.ok && data.success) { + // 로그인 성공 + showSuccess('로그인 성공! 메인 페이지로 이동합니다.'); + window.location.href = '/'; + } else { + // 로그인 실패 (비밀번호 불일치 등) + const errorMessage = data.message || '로그인에 실패했습니다. 이메일과 비밀번호를 확인해주세요.'; + showError(errorMessage); + setLoading(false); + } + } catch (error) { + console.error('Login error:', error); + showError('서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.'); + setLoading(false); + } + }); + + function showError(message) { + if (errorDiv) { + errorDiv.textContent = message; + errorDiv.style.display = 'block'; + errorDiv.className = 'login-error error'; + } + } + + function showSuccess(message) { + if (errorDiv) { + errorDiv.textContent = message; + errorDiv.style.display = 'block'; + errorDiv.className = 'login-error success'; + } + } + + function setLoading(loading) { + if (submitBtn) { + submitBtn.disabled = loading; + submitBtn.textContent = loading ? '로그인 중...' : '로그인'; + } + } +}); \ No newline at end of file diff --git a/src/main/resources/static/js/mypage.js b/src/main/resources/static/js/mypage.js index 0fef53a..4730cc3 100644 --- a/src/main/resources/static/js/mypage.js +++ b/src/main/resources/static/js/mypage.js @@ -3,11 +3,41 @@ */ document.addEventListener('DOMContentLoaded', () => { + loadMyProfile(); initEditButtons(); + initSaveForm(); }); +function loadMyProfile() { + fetch('/api/users/me') + .then(res => res.json()) + .then(data => { + if (data.success && data.data) { + const user = data.data; + document.getElementById('info-email-value').textContent = user.maskedEmail || user.email || '이메일 정보 없음'; + + const nameEl = document.getElementById('info-name-value'); + const btnName = document.getElementById('btn-edit-name'); + if (nameEl) nameEl.textContent = user.name || '이름 설정 필요'; + if (btnName) btnName.textContent = user.name ? '변경' : '설정'; + + const phoneEl = document.getElementById('info-phone-value'); + const btnPhone = document.getElementById('btn-edit-phone'); + if (phoneEl) phoneEl.textContent = user.phone || '전화번호 설정'; + if (btnPhone) btnPhone.textContent = user.phone ? '변경' : '설정'; + + const addressEl = document.getElementById('info-address-value'); + const btnAddress = document.getElementById('btn-edit-address'); + if (addressEl) addressEl.textContent = user.address || '주소 설정'; + if (btnAddress) btnAddress.textContent = user.address ? '변경' : '설정'; + } + }) + .catch(err => { + console.error('Failed to load profile:', err); + }); +} + function initEditButtons() { - // 이름 변경 const btnEditName = document.getElementById('btn-edit-name'); if (btnEditName) { btnEditName.addEventListener('click', () => { @@ -16,16 +46,44 @@ function initEditButtons() { }); } - // 비밀번호 설정 const btnEditPassword = document.getElementById('btn-edit-password'); if (btnEditPassword) { btnEditPassword.addEventListener('click', () => { - // TODO: 비밀번호 변경 모달/페이지 - alert('비밀번호 변경 기능은 준비 중입니다.'); + const currentPassword = prompt('현재 비밀번호를 입력해주세요.'); + if (!currentPassword) return; + + const newPassword = prompt('새로운 비밀번호를 입력해주세요.'); + if (newPassword) { + if (newPassword.length < 8) { + alert('비밀번호는 8자 이상이어야 합니다.'); + return; + } + const confirmPassword = prompt('비밀번호를 다시 한 번 입력해주세요.'); + if (newPassword !== confirmPassword) { + alert('비밀번호가 일치하지 않습니다.'); + return; + } + + fetch('/api/users/password', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ currentPassword: currentPassword, newPassword: newPassword }) + }) + .then(res => res.json()) + .then(data => { + if (data.success) { + alert('비밀번호가 성공적으로 변경되었습니다.'); + } else { + alert(data.message || '비밀번호 변경에 실패했습니다.'); + } + }) + .catch(err => { + alert('서버 오류가 발생했습니다.'); + }); + } }); } - // 전화번호 설정 const btnEditPhone = document.getElementById('btn-edit-phone'); if (btnEditPhone) { btnEditPhone.addEventListener('click', () => { @@ -34,7 +92,6 @@ function initEditButtons() { }); } - // 주소 설정 const btnEditAddress = document.getElementById('btn-edit-address'); if (btnEditAddress) { btnEditAddress.addEventListener('click', () => { @@ -47,29 +104,73 @@ function initEditButtons() { function toggleInlineEdit(valueEl, btn, fieldName) { if (!valueEl) return; - // 이미 수정 모드면 저장 const existingInput = valueEl.parentElement.querySelector('.inline-edit-input'); + const existingError = valueEl.parentElement.querySelector('.inline-error-msg'); + if (existingInput) { - valueEl.textContent = existingInput.value || valueEl.dataset.original; + let newVal = existingInput.value.trim(); + + // Validation logic per field + if (newVal) { + if (fieldName === 'name') { + const nameRegex = /^[가-힣]{2,10}$/; + if (!nameRegex.test(newVal)) { + if (existingError) { + existingError.textContent = '이름은 한글 2~10자로 입력해주세요.'; + existingError.style.display = 'block'; + } + return; + } + } + if (fieldName === 'phone') { + let formattedPhone = newVal.replace(/[-\s]/g, ''); + if (!/^01[0-9]{8,9}$/.test(formattedPhone)) { + if (existingError) { + existingError.textContent = '올바른 전화번호를 입력해주세요. (예: 01012345678)'; + existingError.style.display = 'block'; + } + return; + } + if (formattedPhone.length === 11) { + newVal = formattedPhone.replace(/(\d{3})(\d{4})(\d{4})/, '$1-$2-$3'); + } else if (formattedPhone.length === 10) { + newVal = formattedPhone.replace(/(\d{3})(\d{3})(\d{4})/, '$1-$2-$3'); + } + } + if (fieldName === 'address' && newVal.length < 5) { + if (existingError) { + existingError.textContent = '상세한 주소를 입력해주세요.'; + existingError.style.display = 'block'; + } + return; + } + } + + valueEl.textContent = newVal || valueEl.dataset.original; existingInput.remove(); + if (existingError) existingError.remove(); valueEl.style.display = ''; - btn.textContent = fieldName === 'name' ? '변경' : '설정'; + btn.textContent = newVal ? '변경' : '설정'; return; } - // 수정 모드 진입 valueEl.dataset.original = valueEl.textContent; const input = document.createElement('input'); input.type = 'text'; input.className = 'inline-edit-input'; - input.value = valueEl.textContent === '전화번호 설정' || valueEl.textContent === '주소 설정' ? '' : valueEl.textContent; - input.placeholder = fieldName === 'phone' ? '010-1234-5678' : fieldName === 'address' ? '배송지 주소를 입력하세요' : '이름을 입력하세요'; + + input.value = ''; // 항상 빈 칸으로 표시 + input.placeholder = fieldName === 'phone' ? '01012345678' : fieldName === 'address' ? '배송지 주소를 입력하세요' : '이름을 입력하세요 (한글 2~10자)'; - // 스타일 input.style.cssText = 'font-size:14px;padding:6px 10px;border:1px solid #1a1a1a;border-radius:4px;outline:none;width:100%;max-width:300px;'; + const errorMsg = document.createElement('div'); + errorMsg.className = 'inline-error-msg'; + errorMsg.style.cssText = 'color: red; font-size: 12px; margin-top: 4px; display: none;'; + valueEl.style.display = 'none'; valueEl.parentElement.insertBefore(input, valueEl); + valueEl.parentElement.insertBefore(errorMsg, valueEl); input.focus(); btn.textContent = '확인'; @@ -80,3 +181,100 @@ function toggleInlineEdit(valueEl, btn, fieldName) { } }); } + +function initSaveForm() { + const form = document.getElementById('mypage-form'); + if (form) { + form.addEventListener('submit', (e) => { + e.preventDefault(); + + const btnSave = document.getElementById('mypage-save-btn'); + btnSave.disabled = true; + btnSave.textContent = '저장 중...'; + + let nameVal = document.getElementById('info-name-value').textContent.trim(); + let phoneVal = document.getElementById('info-phone-value').textContent.trim(); + let addressVal = document.getElementById('info-address-value').textContent.trim(); + + if (nameVal.includes('설정 필요') || nameVal.includes('설정')) nameVal = ''; + if (phoneVal.includes('설정')) phoneVal = ''; + if (addressVal.includes('설정')) addressVal = ''; + + let hasError = false; + + // Remove old messages + document.querySelectorAll('.save-error-msg').forEach(el => el.remove()); + + if (!nameVal) { + document.getElementById('info-name-value').parentElement.style.border = '1px solid red'; + const msg = document.createElement('div'); + msg.className = 'save-error-msg'; + msg.style.cssText = 'color: red; font-size: 12px; margin-top: 4px;'; + msg.textContent = '이름을 입력해주세요.'; + document.getElementById('info-name-value').parentElement.appendChild(msg); + hasError = true; + } else { + document.getElementById('info-name-value').parentElement.style.border = ''; + } + + if (!phoneVal) { + document.getElementById('info-phone-value').parentElement.style.border = '1px solid red'; + const msg = document.createElement('div'); + msg.className = 'save-error-msg'; + msg.style.cssText = 'color: red; font-size: 12px; margin-top: 4px;'; + msg.textContent = '전화번호를 입력해주세요.'; + document.getElementById('info-phone-value').parentElement.appendChild(msg); + hasError = true; + } else { + document.getElementById('info-phone-value').parentElement.style.border = ''; + } + + if (!addressVal) { + document.getElementById('info-address-value').parentElement.style.border = '1px solid red'; + const msg = document.createElement('div'); + msg.className = 'save-error-msg'; + msg.style.cssText = 'color: red; font-size: 12px; margin-top: 4px;'; + msg.textContent = '주소를 입력해주세요.'; + document.getElementById('info-address-value').parentElement.appendChild(msg); + hasError = true; + } else { + document.getElementById('info-address-value').parentElement.style.border = ''; + } + + if (hasError) { + btnSave.disabled = false; + btnSave.textContent = '저장'; + return; + } + + fetch('/api/users/me', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: nameVal, + phone: phoneVal, + address: addressVal + }) + }) + .then(res => res.json()) + .then(data => { + if (data.success) { + alert('정보가 성공적으로 저장되었습니다.'); + loadMyProfile(); + } else { + // 백엔드 validation 에러 등의 메시지를 출력합니다. + const errorMessage = data.message || '저장에 실패했습니다. 입력값을 확인해주세요.'; + alert(errorMessage); + } + }) + .catch(err => { + console.error('저장 에러:', err); + alert('저장 중 오류가 발생했습니다.'); + }) + .finally(() => { + btnSave.disabled = false; + btnSave.textContent = '저장'; + }); + }); + } +} diff --git a/src/main/resources/static/js/order-detail.js b/src/main/resources/static/js/order-detail.js new file mode 100644 index 0000000..a88d4b7 --- /dev/null +++ b/src/main/resources/static/js/order-detail.js @@ -0,0 +1,89 @@ +document.addEventListener('DOMContentLoaded', () => { + const pathParts = window.location.pathname.split('/'); + const orderUid = pathParts[pathParts.length - 1]; + + if (!orderUid || !orderUid.startsWith('ORD')) { + alert('잘못된 주문 번호입니다.'); + window.location.href = '/orders'; + return; + } + + loadOrderDetail(orderUid); + + document.getElementById('btn-cancel-order')?.addEventListener('click', () => { + alert('취소 기능은 구현 중입니다.'); + }); +}); + +async function loadOrderDetail(orderUid) { + try { + const response = await fetch(`/api/orders/${orderUid}/details`); + if (!response.ok) { + throw new Error('주문 상세 내역을 불러오지 못했습니다.'); + } + const result = await response.json(); + const order = result.data; + + // 헤더 (배열 또는 문자열 처리) + let dateObj; + if (Array.isArray(order.orderDate)) { + // [year, month, day, hour, minute, second] + dateObj = new Date(order.orderDate[0], order.orderDate[1] - 1, order.orderDate[2], order.orderDate[3] || 0, order.orderDate[4] || 0); + } else { + dateObj = new Date(order.orderDate); + } + + const dateStr = `${dateObj.getFullYear()}.${String(dateObj.getMonth() + 1).padStart(2, '0')}.${String(dateObj.getDate()).padStart(2, '0')} 결제`; + + document.getElementById('detail-order-date').textContent = dateStr; + document.getElementById('detail-order-number').textContent = order.orderUid; + + // 아이템 렌더링 + const itemsContainer = document.getElementById('delivery-items'); + itemsContainer.innerHTML = ''; + + let mappedStatus = order.status; + if (order.status === '결제 완료') mappedStatus = '배송 완료'; + + document.getElementById('detail-order-status').textContent = mappedStatus; + + order.items.forEach(item => { + + const html = ` +
    +
    +
    +
    + +
    +
    +

    ${item.productName}

    +

    수량 : ${item.quantity}

    +

    금액 : ${item.itemAmount.toLocaleString()}

    +
    +
    +
    +
    + `; + itemsContainer.insertAdjacentHTML('beforeend', html); + }); + + // 주문자 정보 + if (order.ordererInfo) { + document.getElementById('recipient-name').textContent = order.ordererInfo.name || '알 수 없음'; + document.getElementById('recipient-phone').textContent = order.ordererInfo.phone || '알 수 없음'; + document.getElementById('recipient-address').textContent = order.ordererInfo.address || '알 수 없음'; + } + + // 결제 요약 + document.getElementById('payment-product-total').innerHTML = `${order.totalAmount.toLocaleString()} 원`; + document.getElementById('payment-shipping-fee').innerHTML = `${order.deliveryFee.toLocaleString()} 원`; + document.getElementById('payment-grand-total').innerHTML = `${order.finalAmount.toLocaleString()} 원`; + + document.getElementById('order-total-value').textContent = `${order.finalAmount.toLocaleString()}원`; + + } catch (e) { + alert(e.message); + console.error(e); + } +} diff --git a/src/main/resources/static/js/orders.js b/src/main/resources/static/js/orders.js index e911c81..a41425f 100644 --- a/src/main/resources/static/js/orders.js +++ b/src/main/resources/static/js/orders.js @@ -3,34 +3,262 @@ */ document.addEventListener('DOMContentLoaded', () => { - initShippingInfoButtons(); - initOrderActionButtons(); + loadOrders(); }); +let cursorId = null; +let isLoading = false; + +function loadOrders() { + if (isLoading) return; + isLoading = true; + + const container = document.getElementById('order-list-container'); + const loading = document.getElementById('order-loading'); + + if (loading) loading.style.display = 'block'; + + const url = cursorId ? `/api/orders?cursorId=${cursorId}&size=10` : `/api/orders?size=10`; + + fetch(url) + .then(res => res.json()) + .then(data => { + if (data.success && data.data) { + const orders = data.data.content; + cursorId = data.data.nextCursorId; // 다음 페이지 커서 + + if (!orders || orders.length === 0) { + if (!cursorId && container.innerHTML.trim() === '') { + container.innerHTML = '
    주문 내역이 없습니다.
    '; + } + } else { + renderOrders(orders, container); + initShippingInfoButtons(); + initOrderActionButtons(); + } + } else { + if (container.innerHTML.trim() === '') { + container.innerHTML = '
    주문 내역을 불러오지 못했습니다.
    '; + } + } + }) + .catch(err => { + console.error(err); + if (container.innerHTML.trim() === '') { + container.innerHTML = '
    서버 오류가 발생했습니다.
    '; + } + }) + .finally(() => { + isLoading = false; + if (loading) loading.style.display = 'none'; + }); +} + +function renderOrders(orders, container) { + let html = ''; + + orders.forEach(order => { + // Map status based on requirements + let mappedStatus = order.status; + if (order.status === '결제 완료') { + mappedStatus = '배송 완료'; + } + + // Setup data-status for filtering + let filterCategory = 'COMPLETED'; // 기본값 (배송 중/완료 등) + if (order.status === '결제 대기') { + filterCategory = 'PENDING_PAYMENT'; + } else if (order.status === '구매 확정') { + filterCategory = 'CONFIRMED'; + } else if (order.status === '환불 처리 중' || order.status === '환불 완료' || order.status === '주문 취소됨') { + filterCategory = 'REFUNDED'; + } + + const dateObj = new Date(order.orderDate); + const dateStr = `${dateObj.getFullYear()}.${String(dateObj.getMonth() + 1).padStart(2, '0')}.${String(dateObj.getDate()).padStart(2, '0')} 결제`; + + let actionBtnsHtml = ''; + if (filterCategory === 'PENDING_PAYMENT') { + actionBtnsHtml = ` + + + `; + } else if (mappedStatus === '배송 완료') { + actionBtnsHtml = ` + + + + `; + } + + let itemsHtml = ''; + if (order.items && order.items.length > 0) { + // 최대 2개 항목만 렌더링 + const displayItems = order.items.slice(0, 2); + + displayItems.forEach(item => { + itemsHtml += ` +
    +
    + +
    +
    +
    ${item.productName}
    +
    + ${item.itemAmount.toLocaleString()}원 · ${item.quantity}개 +
    +
    +
    + `; + }); + + // 3개 이상일 경우 축약 텍스트 추가 + if (order.items.length > 2) { + const moreCount = order.items.length - 2; + itemsHtml += ` +
    + 외 ${moreCount}건의 상품이 더 있습니다. 주문 상세보기 +
    + `; + } + } + + html += ` +
    +
    +
    + ${dateStr} + 주문번호: ${order.orderUid} + ${mappedStatus} +
    +
    +
    + ${actionBtnsHtml} +
    + ${filterCategory !== 'PENDING_PAYMENT' ? `주문 상세보기 >` : ''} +
    +
    + ${itemsHtml} +
    + `; + }); + + container.innerHTML += html; + applyFilter(); +} + + // Removed original logic + function initShippingInfoButtons() { document.querySelectorAll('.btn-shipping-info').forEach(btn => { + // 이미 이벤트 리스너가 달린 요소 처리 (중복 방지) + if (btn.dataset.init) return; + btn.dataset.init = 'true'; + btn.addEventListener('click', () => { - // TODO: 배송지 정보 모달/팝업 - alert('배송지 정보를 확인합니다.'); + alert('해당 주문건은 배송이 완료되었습니다.'); }); }); } function initOrderActionButtons() { document.querySelectorAll('.btn-order-action').forEach(btn => { + if (btn.dataset.init) return; + btn.dataset.init = 'true'; + btn.addEventListener('click', () => { const action = btn.textContent.trim(); - if (action === '환불 요청') { + const container = btn.closest('.order-actions'); + + if (action === '결제하기') { + const uid = btn.getAttribute('data-uid'); + window.location.href = `/checkout?orderUid=${uid}`; + } else if (action === '주문 취소') { + if (confirm('주문을 취소하시겠습니까?')) { + btn.closest('.order-item').style.opacity = '0.5'; + if (container) container.style.display = 'none'; + const statusEl = btn.closest('.order-item-header').querySelector('.order-item-status-text'); + if (statusEl) { + statusEl.textContent = '주문 취소됨'; + statusEl.style.color = '#999'; + statusEl.style.background = '#f5f5f5'; + } + btn.closest('.order-item').setAttribute('data-filter-category', 'REFUNDED'); + alert('주문이 취소되었습니다.'); + applyFilter(); + } + } else if (action === '환불 요청') { if (confirm('환불을 요청하시겠습니까?')) { - // TODO: 환불 API 호출 - btn.textContent = '환불 처리 중'; - btn.disabled = true; - btn.style.opacity = '0.5'; + if (container) container.style.display = 'none'; + const statusEl = btn.closest('.order-item-header').querySelector('.order-item-status-text'); + if (statusEl) { + statusEl.textContent = '환불 처리 중'; + statusEl.style.color = '#e53e3e'; + statusEl.style.background = '#fee'; + } + btn.closest('.order-item').setAttribute('data-filter-category', 'REFUNDED'); + applyFilter(); + } + } else if (action === '구매확정') { + if (confirm('구매확정하시겠습니까? 구매확정 시 환불이 불가능합니다.')) { + if (container) container.style.display = 'none'; + const statusEl = btn.closest('.order-item-header').querySelector('.order-item-status-text'); + if (statusEl) { + statusEl.textContent = '구매 확정'; + statusEl.style.color = '#1a1a1a'; + statusEl.style.background = '#f5f5f5'; + } + btn.closest('.order-item').setAttribute('data-filter-category', 'CONFIRMED'); + applyFilter(); } - } else if (action === '리뷰 요청') { - // TODO: 리뷰 작성 페이지로 이동 - console.log('리뷰 작성'); } }); }); } + +let currentFilter = 'all'; + +document.addEventListener('DOMContentLoaded', () => { + document.querySelectorAll('.filter-tab').forEach(tab => { + tab.addEventListener('click', (e) => { + document.querySelectorAll('.filter-tab').forEach(t => { + t.classList.remove('active'); + t.style.background = 'white'; + t.style.color = '#666'; + t.style.borderColor = '#ccc'; + }); + const target = e.target; + target.classList.add('active'); + target.style.background = '#1a1a1a'; + target.style.color = 'white'; + target.style.borderColor = '#1a1a1a'; + + currentFilter = target.getAttribute('data-filter'); + applyFilter(); + }); + }); +}); + +function applyFilter() { + document.querySelectorAll('.order-item').forEach(item => { + if (currentFilter === 'all') { + item.style.display = 'block'; + } else { + const cat = item.getAttribute('data-filter-category'); + if (cat === currentFilter) { + item.style.display = 'block'; + } else { + item.style.display = 'none'; + } + } + }); +} + +// 스크롤 시 추가 로드 (무한 스크롤) +window.addEventListener('scroll', () => { + if ((window.innerHeight + window.scrollY) >= document.body.offsetHeight - 500) { + if (cursorId && !isLoading) { + loadOrders(); + } + } +}); diff --git a/src/main/resources/static/js/pagination.js b/src/main/resources/static/js/pagination.js new file mode 100644 index 0000000..4ffa7fe --- /dev/null +++ b/src/main/resources/static/js/pagination.js @@ -0,0 +1,245 @@ +/** + * ADP Commerce - Pagination Component + * 페이지 네비게이션 컴포넌트 (10개 상품/페이지, 2행 5열) + */ + +class Pagination { + /** + * @param {string} containerId - 페이지네이션 컨테이너 ID + * @param {Object} options - 설정 옵션 + * @param {number} options.maxVisiblePages - 표시할 최대 페이지 번호 개수 (기본값: 5) + */ + constructor(containerId, options = {}) { + this.container = document.getElementById(containerId); + if (!this.container) { + console.error(`Pagination container not found: ${containerId}`); + return; + } + + this.currentPage = 1; + this.totalPages = 1; + this.maxVisiblePages = options.maxVisiblePages || 5; + this.onPageChangeCallback = null; + } + + /** + * 페이지네이션 렌더링 + * @param {number} currentPage - 현재 페이지 번호 + * @param {number} totalPages - 전체 페이지 수 + */ + render(currentPage, totalPages) { + this.currentPage = currentPage; + this.totalPages = totalPages; + + // 페이지가 1개 이하면 페이지네이션 숨김 + if (totalPages <= 1) { + this.container.innerHTML = ''; + this.container.style.display = 'none'; + return; + } + + this.container.style.display = 'flex'; + + const paginationHTML = this._buildPaginationHTML(); + this.container.innerHTML = paginationHTML; + + this._attachEventListeners(); + } + + /** + * 페이지네이션 HTML 생성 + * @private + */ + _buildPaginationHTML() { + const pages = this._calculateVisiblePages(); + let html = '
    '; + + // 이전 버튼 + html += ` + + `; + + // 페이지 번호 + html += '
    '; + + // 첫 페이지 + if (pages.showFirstEllipsis) { + html += ``; + html += '...'; + } + + // 중간 페이지들 + pages.visible.forEach(page => { + const isActive = page === this.currentPage; + html += ` + + `; + }); + + // 마지막 페이지 + if (pages.showLastEllipsis) { + html += '...'; + html += ``; + } + + html += '
    '; + + // 다음 버튼 + html += ` + + `; + + html += '
    '; + return html; + } + + /** + * 표시할 페이지 번호 계산 + * @private + */ + _calculateVisiblePages() { + const { currentPage, totalPages, maxVisiblePages } = this; + const visible = []; + let showFirstEllipsis = false; + let showLastEllipsis = false; + + if (totalPages <= maxVisiblePages) { + // 전체 페이지가 maxVisiblePages 이하면 모두 표시 + for (let i = 1; i <= totalPages; i++) { + visible.push(i); + } + } else { + // 현재 페이지를 중심으로 표시할 범위 계산 + const halfVisible = Math.floor(maxVisiblePages / 2); + let startPage = Math.max(1, currentPage - halfVisible); + let endPage = Math.min(totalPages, currentPage + halfVisible); + + // 시작이나 끝에 가까우면 범위 조정 + if (currentPage <= halfVisible + 1) { + endPage = Math.min(totalPages, maxVisiblePages); + } else if (currentPage >= totalPages - halfVisible) { + startPage = Math.max(1, totalPages - maxVisiblePages + 1); + } + + // 첫 페이지 생략 표시 여부 + if (startPage > 1) { + showFirstEllipsis = true; + startPage = Math.max(2, startPage); + } + + // 마지막 페이지 생략 표시 여부 + if (endPage < totalPages) { + showLastEllipsis = true; + endPage = Math.min(totalPages - 1, endPage); + } + + for (let i = startPage; i <= endPage; i++) { + visible.push(i); + } + } + + return { visible, showFirstEllipsis, showLastEllipsis }; + } + + /** + * 이벤트 리스너 연결 + * @private + */ + _attachEventListeners() { + // 페이지 번호 클릭 + const numberButtons = this.container.querySelectorAll('.pagination-number'); + numberButtons.forEach(btn => { + btn.addEventListener('click', () => { + const page = parseInt(btn.dataset.page, 10); + this.goToPage(page); + }); + }); + + // 이전/다음 버튼 클릭 + const prevBtn = this.container.querySelector('[data-action="prev"]'); + const nextBtn = this.container.querySelector('[data-action="next"]'); + + if (prevBtn) { + prevBtn.addEventListener('click', () => this.previousPage()); + } + + if (nextBtn) { + nextBtn.addEventListener('click', () => this.nextPage()); + } + } + + /** + * 특정 페이지로 이동 + * @param {number} pageNumber - 이동할 페이지 번호 + */ + goToPage(pageNumber) { + if (pageNumber < 1 || pageNumber > this.totalPages || pageNumber === this.currentPage) { + return; + } + + this.currentPage = pageNumber; + this.render(this.currentPage, this.totalPages); + + if (this.onPageChangeCallback) { + this.onPageChangeCallback(pageNumber); + } + } + + /** + * 다음 페이지로 이동 + */ + nextPage() { + if (this.currentPage < this.totalPages) { + this.goToPage(this.currentPage + 1); + } + } + + /** + * 이전 페이지로 이동 + */ + previousPage() { + if (this.currentPage > 1) { + this.goToPage(this.currentPage - 1); + } + } + + /** + * 페이지 변경 콜백 등록 + * @param {Function} callback - 페이지 변경 시 호출될 콜백 함수 + */ + onPageChange(callback) { + this.onPageChangeCallback = callback; + } + + /** + * 현재 페이지 번호 반환 + * @returns {number} + */ + getCurrentPage() { + return this.currentPage; + } + + /** + * 전체 페이지 수 반환 + * @returns {number} + */ + getTotalPages() { + return this.totalPages; + } +} diff --git a/src/main/resources/static/js/product-detail.js b/src/main/resources/static/js/product-detail.js new file mode 100644 index 0000000..4759866 --- /dev/null +++ b/src/main/resources/static/js/product-detail.js @@ -0,0 +1,338 @@ +/** + * ADP Commerce - Product Detail Modal JavaScript + * 상품 상세 모달: API 호출, 장바구니 추가, 구매하기 + */ + +class ProductDetailModal { + constructor(modalId) { + this.modal = document.getElementById(modalId); + this.closeBtn = document.getElementById('product-detail-close'); + this.productData = null; + + this.initEventListeners(); + } + + initEventListeners() { + // 모달 닫기 (X 버튼) + if (this.closeBtn) { + this.closeBtn.addEventListener('click', () => this.close()); + } + + // 모달 오버레이 클릭으로 닫기 + if (this.modal) { + this.modal.addEventListener('click', (e) => { + if (e.target === this.modal) { + this.close(); + } + }); + } + + // ESC 키로 닫기 + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && this.modal && this.modal.classList.contains('active')) { + this.close(); + } + }); + + // 장바구니 버튼 + const cartBtn = document.getElementById('modal-btn-cart'); + if (cartBtn) { + cartBtn.addEventListener('click', () => this.handleAddToCart()); + } + + // 구매하기 버튼 + const buyBtn = document.getElementById('modal-btn-buy'); + if (buyBtn) { + buyBtn.addEventListener('click', () => this.handleBuyNow()); + } + } + + async open(productId) { + if (!this.modal) return; + + try { + // API에서 상품 정보 가져오기 + const response = await fetch(`/api/products/${productId}`); + + if (!response.ok) { + throw new Error('Failed to fetch product'); + } + + const apiResponse = await response.json(); + + if (apiResponse.success && apiResponse.data) { + this.productData = apiResponse.data; + this.renderProduct(apiResponse.data); + this.modal.classList.add('active'); + document.body.style.overflow = 'hidden'; + } else { + throw new Error('Invalid API response'); + } + } catch (error) { + console.error('상품 상세 로드 실패:', error); + alert('상품 정보를 불러오는데 실패했습니다.'); + } + } + + close() { + if (this.modal) { + this.modal.classList.remove('active'); + document.body.style.overflow = ''; + } + } + + renderProduct(product) { + // 이미지 + const img = document.getElementById('modal-product-img'); + const placeholder = document.getElementById('modal-img-placeholder'); + if (img) { + if (product.imageUrl) { + img.src = product.imageUrl; + img.alt = product.name; + img.style.display = 'block'; + if (placeholder) placeholder.style.display = 'none'; + } else { + img.src = ''; + img.style.display = 'none'; + if (placeholder) placeholder.style.display = 'block'; + } + } + + // 상품명 + const nameEl = document.getElementById('modal-product-name'); + if (nameEl) nameEl.textContent = product.name; + + // 설명 + const descEl = document.getElementById('modal-product-description'); + if (descEl && product.description) { + const descList = descEl.querySelector('.detail-desc-list'); + if (descList) { + descList.innerHTML = product.description.split('\n').map(line => { + return `
  • • ${this.escapeHtml(line.trim())}
  • `; + }).join(''); + } + } + + // 가격 + const priceEl = document.getElementById('modal-product-price'); + if (priceEl) { + const priceValue = priceEl.querySelector('.price-value'); + if (priceValue) { + priceValue.textContent = this.formatPrice(product.price); + } + + // 원래 가격 (할인이 있는 경우) + const originalPriceEl = document.getElementById('modal-price-original'); + if (originalPriceEl && product.originalPrice && product.originalPrice > product.price) { + originalPriceEl.textContent = `₩${this.formatPrice(product.originalPrice)}`; + originalPriceEl.style.display = 'inline'; + } else if (originalPriceEl) { + originalPriceEl.style.display = 'none'; + } + } + + // 배송비 정보 + const shippingEl = document.getElementById('modal-shipping-info'); + if (shippingEl) { + const shippingValue = shippingEl.querySelector('.shipping-value'); + if (shippingValue) { + shippingValue.textContent = '₩3,000 (₩50,000 이상 구매 시 무료)'; + } + } + } + + isUserLoggedIn() { + // window.isLoggedIn is set by Thymeleaf in base.html + return window.isLoggedIn === true; + } + + handleAddToCart() { + if (!this.isUserLoggedIn()) { + this.showLoginRequired(); + return; + } + + if (!this.productData) return; + + // 장바구니 추가 API 호출 + fetch('/api/cart', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + productId: this.productData.id, + quantity: 1 + }) + }) + .then(response => { + if (!response.ok) { + throw new Error('장바구니 추가 실패'); + } + return response.json(); + }) + .then(data => { + if (data.success) { + this.showToast('장바구니에 추가되었습니다.'); + if (typeof initCartBadge === 'function') initCartBadge(); + } else { + this.showToast('장바구니 추가에 실패했습니다.', 'error'); + } + }) + .catch(error => { + console.error('장바구니 추가 실패:', error); + this.showToast('장바구니 추가에 실패했습니다.', 'error'); + }); + } + + handleBuyNow() { + if (!this.isUserLoggedIn()) { + this.showLoginRequired(); + return; + } + + if (!this.productData) return; + + // 주문 생성 API 호출 + fetch('/api/orders', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + orderItems: [{ + productId: this.productData.id, + quantity: 1 + }] + }) + }) + .then(response => { + if (!response.ok) { + throw new Error('주문 생성 실패'); + } + return response.json(); + }) + .then(data => { + if (data.success && data.data) { + // 주문서 페이지로 이동 + window.location.href = `/checkout?orderUid=${data.data.orderUid}`; + } else { + this.showToast('주문 생성에 실패했습니다.', 'error'); + } + }) + .catch(error => { + console.error('주문 생성 실패:', error); + this.showToast('주문 생성에 실패했습니다.', 'error'); + }); + } + + showLoginRequired() { + // 모달 내에 로그인 안내 표시 + const overlay = document.createElement('div'); + overlay.className = 'login-required-overlay'; + overlay.style.cssText = ` + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0,0,0,0.7); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + z-index: 10; + border-radius: 12px; + `; + + overlay.innerHTML = ` +
    +

    로그인이 필요합니다

    +

    이 기능을 사용하려면 로그인해주세요.

    +
    + + +
    +
    + `; + + const modalContent = document.getElementById('product-detail-modal-content'); + if (modalContent) { + modalContent.style.position = 'relative'; + modalContent.appendChild(overlay); + } + + // 버튼 이벤트 + const cancelBtn = overlay.querySelector('#login-required-cancel'); + const goBtn = overlay.querySelector('#login-required-go'); + + if (cancelBtn) { + cancelBtn.addEventListener('click', () => overlay.remove()); + } + if (goBtn) { + goBtn.addEventListener('click', () => { + window.location.href = '/login'; + }); + } + } + + showToast(message, type = 'success') { + const toast = document.createElement('div'); + toast.className = 'toast-message ' + type; + toast.textContent = message; + toast.style.cssText = ` + position: fixed; + bottom: 100px; + left: 50%; + transform: translateX(-50%); + padding: 12px 24px; + background: ${type === 'error' ? '#e53e3e' : '#1a1a1a'}; + color: white; + border-radius: 8px; + font-size: 14px; + font-weight: 500; + z-index: 9999; + box-shadow: 0 4px 16px rgba(0,0,0,0.2); + opacity: 0; + transition: opacity 0.3s ease; + `; + + document.body.appendChild(toast); + + setTimeout(() => { toast.style.opacity = '1'; }, 10); + setTimeout(() => { + toast.style.opacity = '0'; + setTimeout(() => toast.remove(), 300); + }, 3000); + } + + formatPrice(price) { + return price.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','); + } + + escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } +} diff --git a/src/main/resources/static/js/signup.js b/src/main/resources/static/js/signup.js index 8b4e040..94560a8 100644 --- a/src/main/resources/static/js/signup.js +++ b/src/main/resources/static/js/signup.js @@ -6,7 +6,7 @@ document.addEventListener('DOMContentLoaded', () => { const form = document.getElementById('signup-form'); const emailInput = document.getElementById('email'); const passwordInput = document.getElementById('password'); - const passwordConfirm = document.getElementById('password-confirm'); + const passwordConfirmInput = document.getElementById('password-confirm'); const submitBtn = document.getElementById('signup-submit-btn'); const checkDupBtn = document.getElementById('btn-check-duplicate'); const emailMessage = document.getElementById('email-message'); @@ -20,14 +20,25 @@ document.addEventListener('DOMContentLoaded', () => { const email = emailInput.value.trim(); if (!email) { showMessage(emailMessage, '이메일을 입력해주세요.', 'error'); + emailChecked = false; + updateSubmitBtn(); + return; + } + + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(email)) { + showMessage(emailMessage, '올바른 이메일 형식을 입력해주세요.', 'error'); + emailChecked = false; + updateSubmitBtn(); return; } try { const res = await fetch(`/api/auth/check-duplicate?email=${encodeURIComponent(email)}`); + if (!res.ok) throw new Error('API error'); const data = await res.json(); - if (data.duplicate) { + if (data.data === true) { showMessage(emailMessage, '이미 사용 중인 이메일입니다.', 'error'); emailChecked = false; } else { @@ -35,8 +46,8 @@ document.addEventListener('DOMContentLoaded', () => { emailChecked = true; } } catch (e) { - showMessage(emailMessage, '사용 가능한 이메일입니다.', 'success'); - emailChecked = true; + showMessage(emailMessage, '중복 확인에 실패했습니다. 다시 시도해주세요.', 'error'); + emailChecked = false; } updateSubmitBtn(); @@ -51,10 +62,10 @@ document.addEventListener('DOMContentLoaded', () => { } // 비밀번호 확인 매칭 - if (passwordConfirm && passwordInput) { + if (passwordConfirmInput && passwordInput) { const checkMatch = () => { const pw = passwordInput.value; - const pwc = passwordConfirm.value; + const pwc = passwordConfirmInput.value; if (!pwc) { passwordMessage.textContent = ''; @@ -71,7 +82,7 @@ document.addEventListener('DOMContentLoaded', () => { updateSubmitBtn(); }; - passwordConfirm.addEventListener('input', checkMatch); + passwordConfirmInput.addEventListener('input', checkMatch); passwordInput.addEventListener('input', checkMatch); } @@ -79,7 +90,7 @@ document.addEventListener('DOMContentLoaded', () => { function updateSubmitBtn() { if (!submitBtn) return; const pw = passwordInput?.value || ''; - const pwc = passwordConfirm?.value || ''; + const pwc = passwordConfirmInput?.value || ''; const valid = emailChecked && pw.length > 0 && pw === pwc; if (valid) { @@ -89,9 +100,94 @@ document.addEventListener('DOMContentLoaded', () => { } } + // 폼 제출 처리 + if (form) { + form.addEventListener('submit', async (e) => { + e.preventDefault(); + + const email = emailInput?.value?.trim(); + const password = passwordInput?.value?.trim(); + const pwConfirmValue = passwordConfirmInput?.value?.trim(); + + // 클라이언트 사이드 검증 + if (!email || !password || !pwConfirmValue) { + showGlobalError('모든 필드를 입력해주세요.'); + return; + } + + if (!emailChecked) { + showGlobalError('이메일 중복 확인을 해주세요.'); + return; + } + + if (password !== pwConfirmValue) { + showGlobalError('비밀번호가 일치하지 않습니다.'); + return; + } + + // 로딩 상태 표시 + setLoading(true); + + try { + const response = await fetch('/api/auth/signup', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + email: email, + password: password + }) + }); + + const data = await response.json(); + + if (response.ok && data.success) { + // 회원가입 성공 + showGlobalSuccess('회원가입이 완료되었습니다! 로그인 페이지로 이동합니다.'); + window.location.href = '/login'; + } else { + // 회원가입 실패 + const errorMessage = data.message || '회원가입에 실패했습니다. 다시 시도해주세요.'; + showGlobalError(errorMessage); + setLoading(false); + } + } catch (error) { + console.error('Signup error:', error); + showGlobalError('서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.'); + setLoading(false); + } + }); + } + function showMessage(el, text, type) { if (!el) return; el.textContent = text; el.className = 'form-message ' + type; } -}); + + function showGlobalError(message) { + const errorDiv = document.getElementById('signup-error'); + if (errorDiv) { + errorDiv.textContent = message; + errorDiv.style.display = 'block'; + errorDiv.className = 'signup-error error'; + } + } + + function showGlobalSuccess(message) { + const errorDiv = document.getElementById('signup-error'); + if (errorDiv) { + errorDiv.textContent = message; + errorDiv.style.display = 'block'; + errorDiv.className = 'signup-error success'; + } + } + + function setLoading(loading) { + if (submitBtn) { + submitBtn.disabled = loading; + submitBtn.textContent = loading ? '회원가입 중...' : '회원가입'; + } + } +}); \ No newline at end of file diff --git a/src/main/resources/templates/admin-consultation.html b/src/main/resources/templates/admin-consultation.html new file mode 100644 index 0000000..20a60d7 --- /dev/null +++ b/src/main/resources/templates/admin-consultation.html @@ -0,0 +1,66 @@ + + + + 관리자 상담 목록 + + + + + +
    + + + +
    + + + + +
    + + +
    + +
    +
    +

    상담 목록을 불러오는 중...

    +
    +
    + + + + + + +
    + + + + + + diff --git a/src/main/resources/templates/cart.html b/src/main/resources/templates/cart.html index 37a1fea..5328b80 100644 --- a/src/main/resources/templates/cart.html +++ b/src/main/resources/templates/cart.html @@ -40,58 +40,13 @@

    상품명

    - - -
    -
    -
    - 상품 이미지 -
    -
    -

    올데이프로젝트 응원봉

    -
    - - 1 - -
    -
    -
    - 금액: 50,000 - -
    -
    -
    -
    -
    -
    - 상품 이미지 -
    -
    -

    명서 볼캡

    -
    - - 2 - -
    -
    -
    - 금액: 40,000 - -
    -
    -
    -
    총 금액: - - 0 - 90,000 - 원 - + 0 원
    diff --git a/src/main/resources/templates/checkout.html b/src/main/resources/templates/checkout.html index 60c2fd3..3d1021e 100644 --- a/src/main/resources/templates/checkout.html +++ b/src/main/resources/templates/checkout.html @@ -140,18 +140,6 @@ - - @@ -164,15 +152,12 @@
    -
    diff --git a/src/main/resources/templates/index.html b/src/main/resources/templates/index.html index 0e7f93a..76af96c 100644 --- a/src/main/resources/templates/index.html +++ b/src/main/resources/templates/index.html @@ -11,7 +11,7 @@
    -
    +

    ALLDAY PROJECT

    @@ -121,13 +121,16 @@

    ALLDAY PROJECT 2026 'The AP Collection' AIRLINE TICKET
    -

    ALLDAY PROJECT 2026 'The AP Collection' VINYL RECORD & EXCLUSIVE BOOKLET SET

    +

    ALLDAY PROJECT 2026 'The AP Collection' VINYL RECORD & EXCLUSIVE BOOKLET SET

    70,000

    + + +

    @@ -145,20 +148,26 @@

    ALLDAY PROJECT 2026 'The AP Collection' VINYL RECORD &

    상품 상세

    +
    - - + +
    @@ -167,8 +176,9 @@
    + + - diff --git a/src/main/resources/templates/layout/base.html b/src/main/resources/templates/layout/base.html index 2e64d1d..b354c1c 100644 --- a/src/main/resources/templates/layout/base.html +++ b/src/main/resources/templates/layout/base.html @@ -10,6 +10,9 @@ + + + @@ -42,13 +45,29 @@
    - - -
    -
    - 1. 한정판매 - 2. look at me - 3. one more time - 4. 리을 신상 - 5. 신상 앨범 -
    -
    + @@ -84,19 +94,38 @@ +
    챗봇 상담 +
    + + +
    -
    - - - - - +
    + + + + + + + + + + + + +
    +
    + + diff --git a/src/main/resources/templates/login.html b/src/main/resources/templates/login.html index c0b43ed..a91899a 100644 --- a/src/main/resources/templates/login.html +++ b/src/main/resources/templates/login.html @@ -21,10 +21,10 @@

    로그인

    - + - + diff --git a/src/main/resources/templates/mypage.html b/src/main/resources/templates/mypage.html index 9194dab..63a97fc 100644 --- a/src/main/resources/templates/mypage.html +++ b/src/main/resources/templates/mypage.html @@ -13,7 +13,7 @@

    내 정보

    -
    +

    개인정보

    @@ -21,14 +21,14 @@

    개인정보

    이메일
    - gia*******@gmail.com + 로딩중...
    이름
    - 박경화 + 로딩중...
    @@ -44,7 +44,7 @@

    개인정보

    전화번호
    - 전화번호 설정 + 로딩중...
    @@ -57,7 +57,7 @@

    배송지

    주소
    - 주소 설정 + 로딩중...
    diff --git a/src/main/resources/templates/order-detail.html b/src/main/resources/templates/order-detail.html index bf33120..5a224cb 100644 --- a/src/main/resources/templates/order-detail.html +++ b/src/main/resources/templates/order-detail.html @@ -15,74 +15,25 @@
    -

    주문 날짜 : 2025-03-10

    -

    주문 번호 : ODN-20250319-a5b2c3d4

    +

    주문 날짜 :

    +

    주문 번호 :

    +
    + +
    - -
    -
    -
    배송 완료
    -
    -
    - -
    -
    -
    -

    주문 상품 : 상품명

    -

    수량 : 1

    -

    금액 : 0

    -
    -
    -
    -
    - - - -
    -
    -
    배송 완료
    -
    -
    -
    -
    -
    -

    주문 상품 : 명서 볼캡

    -

    수량 : 2

    -

    금액 : 40,000

    -
    -
    -
    -
    -
    -
    -
    배송 완료
    -
    -
    -
    -
    -
    -

    주문 상품 : 올데이프로젝트 응원봉

    -

    수량 : 1

    -

    금액 : 50,000

    -
    -
    -
    -
    -
    +
    - 총 주문 금액: + 총 결제 금액: - 0 - 90,000 - 원 + 0원
    @@ -92,19 +43,15 @@

    주문자 정보

    받는 사람 - 박○희 +
    연락처 - 01012345678 +
    배송지 - 서울 용산구 이태원로 95길 3층 -
    -
    - 배송 요청 사항 - 문 앞에 두고 초인종 눌러주고 문자주고 다 해주세요 추가로 팩이도 해주세요 +
    @@ -115,37 +62,35 @@

    결제 정보

    결제 수단 - 카카오뱅크 카드/일시불 + 카카오페이
    총 상품 가격 - 0 - 90,000 - 원 + 0 원
    배송비 - 0 - 3,000 - 원 + 0 원
    총 결제 금액 - 0 - 93,000 - 원 + 0 원
    + + + + diff --git a/src/main/resources/templates/orders.html b/src/main/resources/templates/orders.html index 5594cf8..f0fc59c 100644 --- a/src/main/resources/templates/orders.html +++ b/src/main/resources/templates/orders.html @@ -13,122 +13,22 @@

    주문내역

    +
    + + + + +
    +
    - - -
    -
    -
    - CJ 대한통운 -
    - - - 주문 상세보기 › - -
    - -
    -
    -
    - -
    -
    -
    -

    주문 날짜 : 2025-03-19

    -

    주문 상품 : 상품명

    -

    수량 : 1

    -

    주문 금액 : 0

    -
    -
    - 배송 완료 - -
    -
    -
    +
    + +
    + + - - - - -
    -
    -
    - CJ 대한통운 -
    - - - 주문 상세보기 › - -
    - -
    -
    -
    -
    -
    -
    -

    주문 날짜 : 2025-03-19

    -

    주문 상품 : 명서 볼캡

    -

    수량 : 2

    -

    주문 금액 : 40,000

    -
    -
    - 배송 완료 - -
    -
    -
    - -
    -
    -
    -
    -
    -
    -

    주문 날짜 : 2025-03-19

    -

    주문 상품 : 올데이프로젝트 응원봉

    -

    수량 : 1

    -

    주문 금액 : 50,000

    -
    -
    - 배송 완료 - -
    -
    -
    -
    - - -
    -
    -
    - CJ 대한통운 -
    - - - 주문 상세보기 › - -
    - -
    -
    -
    -
    -
    -
    -

    주문 날짜 : 2025-03-17

    -

    주문 상품 : 명서 볼캡

    -

    수량 : 4

    -

    총 주문 금액 : 80,000

    -
    -
    - 구매 확정 -
    -
    -
    -
    -
    diff --git a/src/main/resources/templates/signup.html b/src/main/resources/templates/signup.html index a65a8d2..629cd2b 100644 --- a/src/main/resources/templates/signup.html +++ b/src/main/resources/templates/signup.html @@ -24,7 +24,7 @@

    회원가입

    - +
    diff --git a/src/test/java/jpa/basic/alldayprojectcommerce/domain/chat/service/ChatRoomConcurrencyTest.java b/src/test/java/jpa/basic/alldayprojectcommerce/domain/chat/service/ChatRoomConcurrencyTest.java new file mode 100644 index 0000000..2594620 --- /dev/null +++ b/src/test/java/jpa/basic/alldayprojectcommerce/domain/chat/service/ChatRoomConcurrencyTest.java @@ -0,0 +1,218 @@ +package jpa.basic.alldayprojectcommerce.domain.chat.service; + +import jpa.basic.alldayprojectcommerce.domain.chat.dto.request.CreateChatRoomRequest; +import jpa.basic.alldayprojectcommerce.domain.chat.entity.ChatRoomStatus; +import jpa.basic.alldayprojectcommerce.domain.chat.repository.ChatRoomRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@ActiveProfiles("test") +class ChatRoomConcurrencyTest { + + @Autowired ChatRoomCommandService chatRoomCommandService; + @Autowired ChatRoomRepository chatRoomRepository; + + @AfterEach + void tearDown() { + chatRoomRepository.deleteAll(); + } + + @Test + @DisplayName("[동시성] 같은 유저가 동시에 10번 채팅방 생성 요청해도 1개만 생성된다") + void concurrentRoomCreation_10threads() throws Exception { + concurrentRoomCreationTest(10, 1001L); + } + + @Test + @DisplayName("[동시성] 같은 유저가 동시에 30번 채팅방 생성 요청해도 1개만 생성된다") + void concurrentRoomCreation_30threads() throws Exception { + concurrentRoomCreationTest(30, 1002L); + } + + @Test + @DisplayName("[동시성] 같은 유저가 동시에 50번 채팅방 생성 요청해도 1개만 생성된다") + void concurrentRoomCreation_50threads() throws Exception { + concurrentRoomCreationTest(50, 1003L); + } + + private void concurrentRoomCreationTest(int threadCount, Long userId) throws Exception { + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + CyclicBarrier barrier = new CyclicBarrier(threadCount); + + AtomicInteger success = new AtomicInteger(); + AtomicInteger fail = new AtomicInteger(); + + long startTime = System.currentTimeMillis(); + + for (int i = 0; i < threadCount; i++) { + executor.submit(() -> { + try { + barrier.await(); // 모든 스레드 동시 출발 + chatRoomCommandService.createOrGetActiveRoom( + userId, + new CreateChatRoomRequest("동시성 테스트") + ); + success.incrementAndGet(); + } catch (Exception e) { + fail.incrementAndGet(); + System.out.println("[실패] " + e.getMessage()); + } finally { + latch.countDown(); + } + }); + } + + latch.await(); + executor.shutdown(); + + long elapsed = System.currentTimeMillis() - startTime; + + // 모든 요청이 성공해야 함 (기존 방 반환 포함) + System.out.printf("[결과] 스레드: %d | 성공: %d | 실패: %d | 소요시간: %dms%n", + threadCount, success.get(), fail.get(), elapsed); + + assertThat(success.get()).isEqualTo(threadCount); + assertThat(fail.get()).isZero(); + + // DB에 활성 방은 딱 1개만 존재해야 함 + long activeRoomCount = chatRoomRepository.findAll().stream() + .filter(r -> r.getUserId().equals(userId) && r.getActiveFlag() != null) + .count(); + + System.out.printf("[검증] userId=%d 활성 채팅방 수: %d (기대값: 1)%n", + userId, activeRoomCount); + + assertThat(activeRoomCount).isEqualTo(1); + } + + // === 채팅방 상태 변경 동시성 === + @Test + @DisplayName("[동시성] 고객 종료 + 관리자 참여 동시 요청 — 비관적 락으로 순서 직렬화, 최종 상태 정합성 보장") + void concurrentCloseAndJoin() throws Exception { + var room = chatRoomCommandService.createOrGetActiveRoom( + 2001L, new CreateChatRoomRequest("상태변경 동시성 테스트") + ); + Long roomId = room.id(); + + int threadCount = 2; + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + CyclicBarrier barrier = new CyclicBarrier(threadCount); + + AtomicInteger success = new AtomicInteger(); + AtomicInteger fail = new AtomicInteger(); + + executor.submit(() -> { + try { + barrier.await(); + chatRoomCommandService.closeChatRoom(2001L, roomId, "USER"); + success.incrementAndGet(); + System.out.println("[Thread1] 고객 종료 성공"); + } catch (Exception e) { + fail.incrementAndGet(); + System.out.println("[Thread1] 고객 종료 실패: " + e.getMessage()); + } finally { + latch.countDown(); + } + }); + + executor.submit(() -> { + try { + barrier.await(); + chatRoomCommandService.joinChatRoom(9999L, roomId, "ADMIN"); + success.incrementAndGet(); + System.out.println("[Thread2] 관리자 참여 성공"); + } catch (Exception e) { + fail.incrementAndGet(); + System.out.println("[Thread2] 관리자 참여 실패: " + e.getMessage()); + } finally { + latch.countDown(); + } + }); + + latch.await(); + executor.shutdown(); + + System.out.printf("[결과] 성공: %d | 실패: %d%n", success.get(), fail.get()); + + /** + * 비관적 락의 역할 검증 + * + * 락이 없으면: 두 스레드가 동시에 같은 상태를 읽고 덮어써서 데이터 정합성 깨짐 + * 락이 있으면: 하나씩 순서대로 처리되어 상태 전이가 항상 유효함 + * + * 가능한 시나리오: + * A) 고객 종료 먼저: WAITING→COMPLETED, 이후 관리자 참여 COMPLETED→IN_PROGRESS → 실패 + * 결과: 성공 1 + 실패 1 + * B) 관리자 참여 먼저: WAITING→IN_PROGRESS, 이후 고객 종료 IN_PROGRESS→COMPLETED → 성공 + * 결과: 성공 2 + 실패 0 + * + * 둘 중 어느 시나리오든 최종 상태는 항상 유효한 상태 전이를 거침 + * 중요한 건 "둘 다 동시에 COMPLETED가 되거나 오염된 상태가 되지 않는 것" + */ + assertThat(success.get() + fail.get()).isEqualTo(threadCount); + + // 최종 상태는 반드시 COMPLETED (둘 중 하나가 종료했거나, 순서대로 둘 다 처리됨) + var finalRoom = chatRoomRepository.findById(roomId).orElseThrow(); + System.out.printf("[최종 상태] roomId=%d, status=%s%n", roomId, finalRoom.getChatRoomStatus()); + + assertThat(finalRoom.getChatRoomStatus()) + .isIn(ChatRoomStatus.COMPLETED, ChatRoomStatus.IN_PROGRESS); + } + + @Test + @DisplayName("[동시성] 동일 채팅방 종료를 10번 동시 요청해도 1번만 성공한다") + void concurrentClose_10threads() throws Exception { + var room = chatRoomCommandService.createOrGetActiveRoom( + 3001L, new CreateChatRoomRequest("중복 종료 테스트") + ); + Long roomId = room.id(); + + int threadCount = 10; + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + CyclicBarrier barrier = new CyclicBarrier(threadCount); + + AtomicInteger success = new AtomicInteger(); + AtomicInteger fail = new AtomicInteger(); + + for (int i = 0; i < threadCount; i++) { + executor.submit(() -> { + try { + barrier.await(); + chatRoomCommandService.closeChatRoom(3001L, roomId, "USER"); + success.incrementAndGet(); + } catch (Exception e) { + fail.incrementAndGet(); + } finally { + latch.countDown(); + } + }); + } + + latch.await(); + executor.shutdown(); + + System.out.printf("[결과] 스레드: %d | 성공: %d | 실패: %d%n", + threadCount, success.get(), fail.get()); + System.out.printf("[기대] 성공: 1 | 실패: 9 (이미 종료된 방에 재시도)%n"); + + // 최초 1번만 성공, 나머지는 CHAT_INVALID_STATUS_TRANSITION 예외 + assertThat(success.get()).isEqualTo(1); + assertThat(fail.get()).isEqualTo(threadCount - 1); + } +} \ No newline at end of file