diff --git a/build.gradle b/build.gradle index a285cc94b..2da7214dd 100644 --- a/build.gradle +++ b/build.gradle @@ -26,6 +26,7 @@ dependencies { implementation 'io.jsonwebtoken:jjwt-api:0.11.2' implementation 'io.jsonwebtoken:jjwt-impl:0.11.2' implementation 'io.jsonwebtoken:jjwt-jackson:0.11.2' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0' runtimeOnly 'com.h2database:h2' testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' diff --git a/src/main/java/gift/config/DatabaseInitializer.java b/src/main/java/gift/config/DatabaseInitializer.java new file mode 100644 index 000000000..756fe45e5 --- /dev/null +++ b/src/main/java/gift/config/DatabaseInitializer.java @@ -0,0 +1,39 @@ +package gift.config; + +import gift.model.Category; +import gift.model.Option; +import gift.model.Product; +import gift.repository.CategoryRepository; +import gift.repository.OptionRepository; +import gift.repository.ProductRepository; +import org.springframework.boot.CommandLineRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class DatabaseInitializer { + + @Bean + public CommandLineRunner initDatabase(CategoryRepository categoryRepository, ProductRepository productRepository, OptionRepository optionRepository) { + return args -> { + Category category = new Category("Electronics"); + categoryRepository.save(category); + + Product product1 = new Product("Smartphone", 500, "image_url_1", category); + Product product2 = new Product("Laptop", 1000, "image_url_2", category); + + productRepository.save(product1); + productRepository.save(product2); + + Option option1 = new Option("64GB", 100, product1); + Option option2 = new Option("128GB", 200, product1); + Option option3 = new Option("256GB", 150, product2); + Option option4 = new Option("512GB", 250, product2); + + optionRepository.save(option1); + optionRepository.save(option2); + optionRepository.save(option3); + optionRepository.save(option4); + }; + } +} diff --git a/src/main/java/gift/config/RestTemplateConfig.java b/src/main/java/gift/config/RestTemplateConfig.java new file mode 100644 index 000000000..902d29f2e --- /dev/null +++ b/src/main/java/gift/config/RestTemplateConfig.java @@ -0,0 +1,23 @@ +package gift.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; +import org.springframework.http.client.ClientHttpRequestFactory; +import org.springframework.http.client.SimpleClientHttpRequestFactory; + +@Configuration +public class RestTemplateConfig { + + @Bean + public RestTemplate restTemplate() { + return new RestTemplate(clientHttpRequestFactory()); + } + + private ClientHttpRequestFactory clientHttpRequestFactory() { + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(5000); // 연결 타임아웃 5초 + factory.setReadTimeout(5000); // 읽기 타임아웃 5초 + return factory; + } +} diff --git a/src/main/java/gift/controller/KakaoAuthController.java b/src/main/java/gift/controller/KakaoAuthController.java index a4e0ed104..4d0b6d9d8 100644 --- a/src/main/java/gift/controller/KakaoAuthController.java +++ b/src/main/java/gift/controller/KakaoAuthController.java @@ -1,14 +1,17 @@ package gift.controller; import gift.dto.KakaoTokenResponseDTO; +import gift.dto.KakaoUserDTO; +import gift.model.Member; +import gift.service.KakaoAuthService; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.*; -import org.springframework.util.LinkedMultiValueMap; -import org.springframework.util.MultiValueMap; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.client.RestTemplate; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; @RestController public class KakaoAuthController { @@ -19,28 +22,30 @@ public class KakaoAuthController { @Value("${kakao.redirect-uri}") private String redirectUri; - private final RestTemplate restTemplate = new RestTemplate(); - - @GetMapping("/oauth/kakao/callback") - public ResponseEntity kakaoCallback(@RequestParam String code) { - String tokenUrl = "https://kauth.kakao.com/oauth/token"; - - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + @Autowired + private KakaoAuthService kakaoAuthService; - MultiValueMap body = new LinkedMultiValueMap<>(); - body.add("grant_type", "authorization_code"); - body.add("client_id", clientId); - body.add("redirect_uri", redirectUri); - body.add("code", code); + public KakaoAuthController(KakaoAuthService kakaoAuthService) { + this.kakaoAuthService = kakaoAuthService; + } - HttpEntity> request = new HttpEntity<>(body, headers); - ResponseEntity response = restTemplate.exchange(tokenUrl, HttpMethod.POST, request, KakaoTokenResponseDTO.class); + @GetMapping("/login/kakao") + public void redirectKakaoLogin(HttpServletResponse response) throws IOException { + String url = kakaoAuthService.getKakaoLoginUrl(); + response.sendRedirect(url); + } - if (response.getStatusCode() == HttpStatus.OK) { - return ResponseEntity.ok(response.getBody().toString()); - } else { - return ResponseEntity.status(response.getStatusCode()).body("카카오 로그인 실패"); + @GetMapping("/oauth/kakao/callback") + public ResponseEntity kakaoCallback(@RequestParam(name = "code") String code) throws IOException { + try { + KakaoTokenResponseDTO tokenResponse = kakaoAuthService.getKakaoToken(code); + KakaoUserDTO kakaoUserDTO = kakaoAuthService.getKakaoUser( + tokenResponse.getAccessToken()); + Member member = kakaoAuthService.registerOrGetMember(kakaoUserDTO, tokenResponse.getAccessToken()); + return ResponseEntity.ok(tokenResponse.getAccessToken()); + } catch (Exception e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body("카카오 로그인 실패: " + e.getMessage()); } } } diff --git a/src/main/java/gift/controller/OrderController.java b/src/main/java/gift/controller/OrderController.java new file mode 100644 index 000000000..d28d394b1 --- /dev/null +++ b/src/main/java/gift/controller/OrderController.java @@ -0,0 +1,35 @@ +package gift.controller; + +import gift.dto.OrderRequestDTO; +import gift.dto.OrderResponseDTO; +import gift.model.Order; +import gift.service.KakaoAuthService; +import gift.service.OrderService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/orders") +public class OrderController { + + @Autowired + private OrderService orderService; + + @Autowired + private KakaoAuthService kakaoAuthService; + + @PostMapping + public ResponseEntity placeOrder(@RequestHeader("Authorization") String token, @RequestBody OrderRequestDTO orderRequestDTO) { + try { + String accessToken = token.substring(7); // "Bearer " 제거 + String email = kakaoAuthService.getEmailFromAccessToken(accessToken); + Order order = orderService.placeOrder(email, orderRequestDTO, accessToken); + OrderResponseDTO orderResponseDTO = new OrderResponseDTO(order.getId(), order.getOption().getId(), order.getQuantity(), order.getOrderDateTime(), order.getMessage()); + return new ResponseEntity<>(orderResponseDTO, HttpStatus.CREATED); + } catch (Exception e) { + return new ResponseEntity<>(null, HttpStatus.INTERNAL_SERVER_ERROR); + } + } +} diff --git a/src/main/java/gift/dto/KakaoTokenResponseDTO.java b/src/main/java/gift/dto/KakaoTokenResponseDTO.java index 9e61fec2e..e3b14b764 100644 --- a/src/main/java/gift/dto/KakaoTokenResponseDTO.java +++ b/src/main/java/gift/dto/KakaoTokenResponseDTO.java @@ -18,6 +18,10 @@ public class KakaoTokenResponseDTO { @JsonProperty("scope") private String scope; + public String getAccessToken() { + return accessToken; + } + @Override public String toString() { return "KakaoTokenResponseDTO{" + diff --git a/src/main/java/gift/dto/KakaoUserDTO.java b/src/main/java/gift/dto/KakaoUserDTO.java new file mode 100644 index 000000000..49b8c3258 --- /dev/null +++ b/src/main/java/gift/dto/KakaoUserDTO.java @@ -0,0 +1,25 @@ +package gift.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class KakaoUserDTO { + @JsonProperty("id") + private Long id; + + @JsonProperty("kakao_account") + private KakaoAccount kakaoAccount; + + public Long getId() { + return id; + } + + public String getEmail() { + return kakaoAccount.email; + } + + public static class KakaoAccount { + @JsonProperty("email") + private String email; + + } +} diff --git a/src/main/java/gift/dto/OrderRequestDTO.java b/src/main/java/gift/dto/OrderRequestDTO.java new file mode 100644 index 000000000..865f19de2 --- /dev/null +++ b/src/main/java/gift/dto/OrderRequestDTO.java @@ -0,0 +1,27 @@ +package gift.dto; + +public class OrderRequestDTO { + private Long optionId; + private int quantity; + private String message; + + public OrderRequestDTO() {} + + public OrderRequestDTO(Long optionId, int quantity, String message) { + this.optionId = optionId; + this.quantity = quantity; + this.message = message; + } + + public Long getOptionId() { + return optionId; + } + + public int getQuantity() { + return quantity; + } + + public String getMessage() { + return message; + } +} diff --git a/src/main/java/gift/dto/OrderResponseDTO.java b/src/main/java/gift/dto/OrderResponseDTO.java new file mode 100644 index 000000000..1cc7b9bcf --- /dev/null +++ b/src/main/java/gift/dto/OrderResponseDTO.java @@ -0,0 +1,39 @@ +package gift.dto; + +import java.time.LocalDateTime; + +public class OrderResponseDTO { + private Long id; + private Long optionId; + private int quantity; + private LocalDateTime orderDateTime; + private String message; + + public OrderResponseDTO(Long id, Long optionId, int quantity, LocalDateTime orderDateTime, String message) { + this.id = id; + this.optionId = optionId; + this.quantity = quantity; + this.orderDateTime = orderDateTime; + this.message = message; + } + + public Long getId() { + return id; + } + + public Long getOptionId() { + return optionId; + } + + public int getQuantity() { + return quantity; + } + + public LocalDateTime getOrderDateTime() { + return orderDateTime; + } + + public String getMessage() { + return message; + } +} diff --git a/src/main/java/gift/model/Member.java b/src/main/java/gift/model/Member.java index 12987b806..24ac13cad 100644 --- a/src/main/java/gift/model/Member.java +++ b/src/main/java/gift/model/Member.java @@ -25,6 +25,12 @@ public class Member { @NotBlank(message = "비밀번호는 공백이 될 수 없습니다.") private String password; + private String token; + + public String getToken() { + return token; + } + @OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true) private Set wishLists = new HashSet<>(); @@ -38,6 +44,12 @@ public Member(String email, String password) { public Long getId() { return id; } + + public void setToken(String token) { + this.token = token; + } + + public String getEmail() { return email; } diff --git a/src/main/java/gift/model/Order.java b/src/main/java/gift/model/Order.java new file mode 100644 index 000000000..adcf36d5e --- /dev/null +++ b/src/main/java/gift/model/Order.java @@ -0,0 +1,62 @@ +package gift.model; + +import jakarta.persistence.*; +import java.time.LocalDateTime; + +@Entity +@Table(name = "`order`") +public class Order { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") + private Member member; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "option_id") + private Option option; + + private int quantity; + + private LocalDateTime orderDateTime; + + private String message; + + public Order() { + } + + public Order(Member member, Option option, int quantity, String message) { + this.member = member; + this.option = option; + this.quantity = quantity; + this.orderDateTime = LocalDateTime.now(); + this.message = message; + } + + public Long getId() { + return id; + } + + public Member getMember() { + return member; + } + + public Option getOption() { + return option; + } + + public int getQuantity() { + return quantity; + } + + public LocalDateTime getOrderDateTime() { + return orderDateTime; + } + + public String getMessage() { + return message; + } +} diff --git a/src/main/java/gift/repository/OrderRepository.java b/src/main/java/gift/repository/OrderRepository.java new file mode 100644 index 000000000..ea79d47eb --- /dev/null +++ b/src/main/java/gift/repository/OrderRepository.java @@ -0,0 +1,7 @@ +package gift.repository; + +import gift.model.Order; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface OrderRepository extends JpaRepository { +} diff --git a/src/main/java/gift/service/KakaoAuthService.java b/src/main/java/gift/service/KakaoAuthService.java new file mode 100644 index 000000000..54382c21b --- /dev/null +++ b/src/main/java/gift/service/KakaoAuthService.java @@ -0,0 +1,93 @@ +package gift.service; + +import gift.dto.KakaoTokenResponseDTO; +import gift.dto.KakaoUserDTO; +import gift.model.Member; +import gift.repository.MemberRepository; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.*; +import org.springframework.stereotype.Service; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; +import org.springframework.beans.factory.annotation.Autowired; + +@Service +public class KakaoAuthService { + + @Value("${kakao.client-id}") + private String clientId; + + @Value("${kakao.redirect-uri}") + private String redirectUri; + + private final RestTemplate restTemplate; + private final MemberRepository memberRepository; + + @Autowired + public KakaoAuthService(RestTemplate restTemplate, MemberRepository memberRepository) { + this.restTemplate = restTemplate; + this.memberRepository = memberRepository; + } + + public KakaoTokenResponseDTO getKakaoToken(String code) { + String tokenUrl = "https://kauth.kakao.com/oauth/token"; + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("grant_type", "authorization_code"); + body.add("client_id", clientId); + body.add("redirect_uri", redirectUri); + body.add("code", code); + + HttpEntity> request = new HttpEntity<>(body, headers); + + ResponseEntity response = restTemplate.exchange(tokenUrl, HttpMethod.POST, request, KakaoTokenResponseDTO.class); + + if (response.getStatusCode() == HttpStatus.OK) { + return response.getBody(); + } else { + throw new RuntimeException("카카오 토큰을 찾을 수 없습니다."); + } + } + + // 카카오 사용자 정보 조회 + public KakaoUserDTO getKakaoUser(String accessToken) { + String userUrl = "https://kapi.kakao.com/v2/user/me"; + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(accessToken); + HttpEntity request = new HttpEntity<>(headers); + ResponseEntity response = restTemplate.exchange(userUrl, HttpMethod.GET, request, KakaoUserDTO.class); + + if (response.getStatusCode() == HttpStatus.OK) { + return response.getBody(); + } else { + throw new RuntimeException("카카오 사용자 정보를 가져올 수 없습니다."); + } + } + + // 사용자 등록 or 조회 + public Member registerOrGetMember(KakaoUserDTO kakaoUserDTO, String accessToken) { + return memberRepository.findByEmail(kakaoUserDTO.getEmail()) + .orElseGet(() -> { + // 카카오 로그인을 통해 가입한 회원의 경우 임의로 비밀번호 생성 + Member newMember = new Member(kakaoUserDTO.getEmail(), "kakao"); + newMember.setToken(accessToken); + memberRepository.save(newMember); + return newMember; + }); + } + + public String getKakaoLoginUrl() { + String url = "https://kauth.kakao.com/oauth/authorize"; + String queryString = String.format("?response_type=code&client_id=%s&redirect_uri=%s", clientId, redirectUri); + return url + queryString; + } + + public String getEmailFromAccessToken(String accessToken) { + KakaoUserDTO kakaoUserDTO = getKakaoUser(accessToken); + return kakaoUserDTO.getEmail(); + } +} diff --git a/src/main/java/gift/service/KakaoMessageService.java b/src/main/java/gift/service/KakaoMessageService.java new file mode 100644 index 000000000..c2faf0c42 --- /dev/null +++ b/src/main/java/gift/service/KakaoMessageService.java @@ -0,0 +1,59 @@ +package gift.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.HashMap; +import java.util.Map; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.*; +import org.springframework.stereotype.Service; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; + + +@Service +public class KakaoMessageService { + + @Value("${kakao.client-id}") + private String clientId; + + @Value("${kakao.redirect-uri}") + private String redirectUri; + + private final RestTemplate restTemplate; + + public KakaoMessageService(RestTemplate restTemplate) { + this.restTemplate = restTemplate; + } + + public void sendOrderMessage(String accessToken, String message) throws Exception { + String url = "https://kapi.kakao.com/v2/api/talk/memo/default/send"; + + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(accessToken); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + + Map templateMap = new HashMap<>(); + templateMap.put("object_type", "text"); + templateMap.put("text", message); + Map linkMap = new HashMap<>(); + linkMap.put("web_url", "http://localhost:8080/"); + linkMap.put("mobile_web_url", "http://localhost:8080/"); + templateMap.put("link", linkMap); + templateMap.put("button_title", "바로 확인"); + + ObjectMapper objectMapper = new ObjectMapper(); + String templateObject = objectMapper.writeValueAsString(templateMap); + + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("template_object", templateObject); + + HttpEntity> request = new HttpEntity<>(body, headers); + ResponseEntity response = restTemplate.postForEntity(url, request, String.class); + + if (response.getStatusCode() != HttpStatus.OK) { + throw new RuntimeException("카카오 메세지 전송 실패: " + response.getBody()); + } + } + +} diff --git a/src/main/java/gift/service/OptionService.java b/src/main/java/gift/service/OptionService.java index e5a8aeff5..0ef5df720 100644 --- a/src/main/java/gift/service/OptionService.java +++ b/src/main/java/gift/service/OptionService.java @@ -58,4 +58,10 @@ public void subtractOptionQuantity(Long optionId, int quantityToSubtract) { option.subtractQuantity(quantityToSubtract); optionRepository.save(option); } + + public Option findOptionById(Long optionId) { + return optionRepository.findById(optionId) + .orElseThrow(() -> new IllegalArgumentException("옵션 정보를 찾을 수 없습니다.")); + } + } diff --git a/src/main/java/gift/service/OrderService.java b/src/main/java/gift/service/OrderService.java new file mode 100644 index 000000000..cb06e1869 --- /dev/null +++ b/src/main/java/gift/service/OrderService.java @@ -0,0 +1,46 @@ +package gift.service; + +import gift.dto.OrderRequestDTO; +import gift.model.Member; +import gift.model.Option; +import gift.model.Order; +import gift.repository.OrderRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +@Service +public class OrderService { + + @Autowired + private OrderRepository orderRepository; + + @Autowired + private MemberService memberService; + + @Autowired + private OptionService optionService; + + @Autowired + private KakaoMessageService kakaoMessageService; + + public Order placeOrder(String email, OrderRequestDTO orderRequestDTO, String accessToken) + throws Exception { + Member member = memberService.findMemberEntityByEmail(email); + Option option = optionService.findOptionById(orderRequestDTO.getOptionId()); + optionService.subtractOptionQuantity(option.getId(), orderRequestDTO.getQuantity()); + + Order order = new Order(member, option, orderRequestDTO.getQuantity(), orderRequestDTO.getMessage()); + Order savedOrder = orderRepository.save(order); + + kakaoMessageService.sendOrderMessage(accessToken, createOrderMessage(savedOrder)); + return savedOrder; + } + + private String createOrderMessage(Order order) { + return "주문이 완료되었습니다.\n" + + "상품명: " + order.getOption().getProduct().getName() + "\n" + + "옵션: " + order.getOption().getName() + "\n" + + "수량: " + order.getQuantity() + "\n" + + "메시지: " + order.getMessage(); + } +}