diff --git a/build.gradle b/build.gradle index dbccc220..abd9d71d 100644 --- a/build.gradle +++ b/build.gradle @@ -43,6 +43,11 @@ dependencies { // Swagger implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0' + //json + implementation group: 'org.json', name: 'json', version: '20210307' + //gson + implementation group: 'com.google.code.gson', name: 'gson', version: '2.8.0' + //Querydsl 추가 implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta" @@ -57,6 +62,10 @@ dependencies { //oauth2 추가 implementation group: 'org.springframework.boot', name: 'spring-boot-starter-oauth2-client', version: '3.2.5' implementation group: 'org.springframework.boot', name: 'spring-boot-starter-mustache', version: '3.2.5' + + //Async retry + implementation 'org.springframework.retry:spring-retry' + implementation 'org.springframework:spring-aspects' } def generated = 'src/main/generated' diff --git a/src/main/java/kcs/funding/fundingboost/api/common/Const.java b/src/main/java/kcs/funding/fundingboost/api/common/Const.java new file mode 100644 index 00000000..26611393 --- /dev/null +++ b/src/main/java/kcs/funding/fundingboost/api/common/Const.java @@ -0,0 +1,9 @@ +package kcs.funding.fundingboost.api.common; + +public class Const { + public static final String POST = "POST"; + + public static final String GET = "GET"; + + public static final String EMPTY = ""; +} diff --git a/src/main/java/kcs/funding/fundingboost/api/config/SchedulerConfig.java b/src/main/java/kcs/funding/fundingboost/api/config/SchedulerConfig.java new file mode 100644 index 00000000..69f87baf --- /dev/null +++ b/src/main/java/kcs/funding/fundingboost/api/config/SchedulerConfig.java @@ -0,0 +1,26 @@ +package kcs.funding.fundingboost.api.config; + +import java.util.concurrent.Executor; +import org.springframework.context.annotation.Configuration; +import org.springframework.retry.annotation.EnableRetry; +import org.springframework.scheduling.annotation.AsyncConfigurer; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +@Configuration +@EnableAsync +@EnableRetry +@EnableScheduling +public class SchedulerConfig implements AsyncConfigurer { + @Override + public Executor getAsyncExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(10); //스레드 풀의 기본 크기 + executor.setMaxPoolSize(20); // 스레드 풀 최대 크기 (동시에 실행 가능한 최대 스레드 수) + executor.setQueueCapacity(500); // 작업 큐의 용량 (최대 스레드 수 초과 시 대기 시킬 큐의 크기) + executor.setThreadNamePrefix("Async-"); // 스레드 풀에서 생성된 스레드의 이름 접두사 + executor.initialize(); // 스레드 풀을 초기화 & 사용 준비 완료 + return executor; + } +} diff --git a/src/main/java/kcs/funding/fundingboost/api/controller/KakaoContoller.java b/src/main/java/kcs/funding/fundingboost/api/controller/KakaoContoller.java new file mode 100644 index 00000000..39809192 --- /dev/null +++ b/src/main/java/kcs/funding/fundingboost/api/controller/KakaoContoller.java @@ -0,0 +1,57 @@ +package kcs.funding.fundingboost.api.controller; + +import kcs.funding.fundingboost.api.service.CustomMessageService; +import kcs.funding.fundingboost.api.service.KakaoService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.view.RedirectView; + +@RestController +@RequiredArgsConstructor +public class KakaoContoller { + + private final KakaoService kakaoService; + + private final CustomMessageService customMessageService; + + @RequestMapping("/login") + public RedirectView goKakaoOAuth() { + return kakaoService.goKakaoOAuth(); + } + + @RequestMapping("/login-callback") + public RedirectView loginCallback(@RequestParam("code") String code) { + return kakaoService.loginCallback(code); + } + + @GetMapping("/profile") + public String getProfile() { + return kakaoService.getProfile(); + } + + @GetMapping("/friends") + public String getFriends() { + return kakaoService.getFriends(); + } + + @RequestMapping("/logout") + public String logout() { + return kakaoService.logout(); + } + + @GetMapping("/send/me") + public ResponseEntity sendMyMessage() { + customMessageService.sendReminderMessage(); + return ResponseEntity.accepted().body("나에게 메시지 전송을 요청했습니다. 처리 중입니다."); + } + + @GetMapping("/send/friends") + public ResponseEntity sendMessageToFriends() { + customMessageService.sendMessageToFriends(); + return ResponseEntity.accepted().body("친구들에게 메시지 전송을 요청했습니다. 처리 중입니다."); + } +} diff --git a/src/main/java/kcs/funding/fundingboost/api/dto/DefaultMessageDto.java b/src/main/java/kcs/funding/fundingboost/api/dto/DefaultMessageDto.java new file mode 100644 index 00000000..96f491cd --- /dev/null +++ b/src/main/java/kcs/funding/fundingboost/api/dto/DefaultMessageDto.java @@ -0,0 +1,8 @@ +package kcs.funding.fundingboost.api.dto; + + +public record DefaultMessageDto(String objType, String text, String webUrl, String btnTitle) { + public static DefaultMessageDto createDefaultMessageDto(String objType, String text, String webUrl, String btn) { + return new DefaultMessageDto(objType, text, webUrl, btn); + } +} diff --git a/src/main/java/kcs/funding/fundingboost/api/service/CustomMessageService.java b/src/main/java/kcs/funding/fundingboost/api/service/CustomMessageService.java new file mode 100644 index 00000000..25de710b --- /dev/null +++ b/src/main/java/kcs/funding/fundingboost/api/service/CustomMessageService.java @@ -0,0 +1,73 @@ +package kcs.funding.fundingboost.api.service; + +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import kcs.funding.fundingboost.api.dto.DefaultMessageDto; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.retry.annotation.Backoff; +import org.springframework.retry.annotation.Recover; +import org.springframework.retry.annotation.Retryable; +import org.springframework.scheduling.annotation.Async; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class CustomMessageService { + private final MessageService messageService; + + @Async + @Retryable(value = {Exception.class}, maxAttempts = 3, backoff = @Backoff(delay = 5000)) + //메서드가 실패할 경우 최대 3번까지 재시도, 각 재시도 사이에 5초의 지연을 둠 + //예외(Exception.class)가 발생할 경우 최대 3회까지 자동으로 재시도 +// @Scheduled(cron = "0 0 0 * * ?") // 매일 00:00 작업 실행 + @Scheduled(cron = "*/5 * * * * *") // 5초마다 작업 실행 + public void sendReminderMessage() { + DefaultMessageDto myMsg = DefaultMessageDto.createDefaultMessageDto("text", "바로 확인하기", + "https://www.naver.com", "펀딩 남은 기간이 2일 남았습니다!!"); + String accessToken = HttpCallService.accessToken; + CompletableFuture result = messageService.sendMessageToMe(accessToken, myMsg); + + result.thenAccept(success -> { + if (success) { + log.info("메시지 전송 성공"); + } else { + log.warn("메시지 전송 실패"); + } + }); + } + + @Async + @Retryable(value = {Exception.class}, maxAttempts = 3, backoff = @Backoff(delay = 5000)) + //메서드가 실패할 경우 최대 3번까지 재시도, 각 재시도 사이에 5초의 지연을 둠 + //예외(Exception.class)가 발생할 경우 최대 3회까지 자동으로 재시도 +// @Scheduled(cron = "0 0 0 * * ?") // 매일 00:00 작업 실행 + @Scheduled(cron = "*/5 * * * * *") // 5초마다 작업 실행 + public void sendMessageToFriends() { + DefaultMessageDto myMsg = DefaultMessageDto.createDefaultMessageDto("text", "버튼 버튼", + "https://www.naver.com", "내가 지금 생각하고 있는 것은??"); + String accessToken = HttpCallService.accessToken; + log.info("----------------------친구한테 메시지 보내기 성공!!!----------------------"); + List friendUuids = Arrays.asList( + "aFtpXm1ZaVtuQnRMeUp9Tn5PY1JiV2JRaF8z"); // TODO: 실제로는 적절한 UUID 목록을 제공해야 합니다. + CompletableFuture result = messageService.sendMessageToFriends(accessToken, myMsg, + friendUuids); + + result.thenAccept(success -> { + if (success) { + log.info("메시지 전송 성공"); + } else { + log.warn("메시지 전송 실패"); + } + }); + } + + @Recover + public boolean recover(Exception e) { + log.error("sendMessageToFriends 메서드가 최대 재시도 횟수를 초과하여 실패했습니다.", e); + return false; + } +} diff --git a/src/main/java/kcs/funding/fundingboost/api/service/HttpCallService.java b/src/main/java/kcs/funding/fundingboost/api/service/HttpCallService.java new file mode 100644 index 00000000..6639a511 --- /dev/null +++ b/src/main/java/kcs/funding/fundingboost/api/service/HttpCallService.java @@ -0,0 +1,101 @@ +package kcs.funding.fundingboost.api.service; + +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStreamWriter; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.Scanner; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.web.client.RestTemplate; + +public class HttpCallService { + public static String accessToken; + + public String CallwithToken(String method, String reqURL, String access_Token) { + String header = "Bearer " + access_Token; + accessToken = access_Token; + return Call(method, reqURL, header, null); + } + + public String Call(String method, String reqURL, String header, String param) { + String result = ""; + try { + String response = ""; + URL url = new URL(reqURL); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod(method); + conn.setRequestProperty("Authorization", header); + if (param != null) { + System.out.println("param : " + param); + conn.setDoOutput(true); + BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(conn.getOutputStream())); + bw.write(param); + bw.flush(); + + } + int responseCode = conn.getResponseCode(); + System.out.println("responseCode : " + responseCode); + + System.out.println("reqURL : " + reqURL); + System.out.println("method : " + method); + System.out.println("Authorization : " + header); + InputStream stream = conn.getErrorStream(); + if (stream != null) { + try (Scanner scanner = new Scanner(stream)) { + scanner.useDelimiter("\\Z"); + response = scanner.next(); + } + System.out.println("error response : " + response); + } + BufferedReader br = new BufferedReader(new InputStreamReader(conn.getInputStream())); + String line = ""; + while ((line = br.readLine()) != null) { + result += line; + } + System.out.println("response body : " + result); + + br.close(); + } catch (IOException e) { + return e.getMessage(); + } + return result; + } + + /** + * Http 요청 클라이언트 객체 생성 method + * + * @ param Map header HttpHeader 정보 + * @ param Object params HttpBody 정보 + * @ return HttpEntity 생성된 HttpClient객체 정보 반환 + * @ exception 예외사항 + */ + public HttpEntity httpClientEntity(HttpHeaders header, Object params) { + HttpHeaders requestHeaders = header; + + if (params == null || "".equals(params)) { + return new HttpEntity<>(requestHeaders); + } else { + return new HttpEntity<>(params, requestHeaders); + } + } + + /** + * Http 요청 method + * + * @ param String url 요청 URL 정보 + * @ param HttpMethod method 요청 Method 정보 + * @ param HttpEntity entity 요청 EntityClient 객체 정보 + * @ return HttpEntity 생성된 HttpClient객체 정보 반환 + */ + public ResponseEntity httpRequest(String url, HttpMethod method, HttpEntity entity) { + RestTemplate restTemplate = new RestTemplate(); + return restTemplate.exchange(url, method, entity, String.class); + } +} diff --git a/src/main/java/kcs/funding/fundingboost/api/service/KakaoService.java b/src/main/java/kcs/funding/fundingboost/api/service/KakaoService.java new file mode 100644 index 00000000..8daba00a --- /dev/null +++ b/src/main/java/kcs/funding/fundingboost/api/service/KakaoService.java @@ -0,0 +1,73 @@ +package kcs.funding.fundingboost.api.service; + +import com.google.gson.JsonParser; +import jakarta.servlet.http.HttpSession; +import kcs.funding.fundingboost.api.common.Const; +import kcs.funding.fundingboost.api.transformer.Trans; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.servlet.view.RedirectView; + +@RequiredArgsConstructor +@Service +public class KakaoService { + + private final HttpSession httpSession; + + private final HttpCallService httpCallService; + + public static String token; + + @Value("${rest-api-key}") + private String REST_API_KEY; + + @Value("${redirect-uri}") + private String REDIRECT_URI; + + @Value("${authorize-uri}") + private String AUTHORIZE_URI; + + @Value("${token-uri}") + public String TOKEN_URI; + + @Value("${client-secret}") + private String CLIENT_SECRET; + + @Value("${kakao-api-host}") + private String KAKAO_API_HOST; + + + public RedirectView goKakaoOAuth() { + String uri = AUTHORIZE_URI + "?redirect_uri=" + REDIRECT_URI + "&response_type=code&client_id=" + REST_API_KEY; + if (!"".isEmpty()) { + uri += "&scope=" + ""; + } + return new RedirectView(uri); + } + + public RedirectView loginCallback(String code) { + String param = "grant_type=authorization_code&client_id=" + REST_API_KEY + "&redirect_uri=" + REDIRECT_URI + + "&client_secret=" + CLIENT_SECRET + "&code=" + code; + String rtn = httpCallService.Call(Const.POST, TOKEN_URI, Const.EMPTY, param); + token = Trans.token(rtn, new JsonParser()); + httpSession.setAttribute("token", token); + + return new RedirectView("/index.html"); + } + + public String getProfile() { + String uri = KAKAO_API_HOST + "/v2/user/me"; + return httpCallService.CallwithToken(Const.GET, uri, httpSession.getAttribute("token").toString()); + } + + public String getFriends() { + String uri = KAKAO_API_HOST + "/v1/api/talk/friends"; + return httpCallService.CallwithToken(Const.GET, uri, httpSession.getAttribute("token").toString()); + } + + public String logout() { + String uri = KAKAO_API_HOST + "/v1/user/logout"; + return httpCallService.CallwithToken(Const.POST, uri, httpSession.getAttribute("token").toString()); + } +} diff --git a/src/main/java/kcs/funding/fundingboost/api/service/MessageService.java b/src/main/java/kcs/funding/fundingboost/api/service/MessageService.java new file mode 100644 index 00000000..6dac16b0 --- /dev/null +++ b/src/main/java/kcs/funding/fundingboost/api/service/MessageService.java @@ -0,0 +1,96 @@ +package kcs.funding.fundingboost.api.service; + +import java.util.List; +import java.util.concurrent.CompletableFuture; +import kcs.funding.fundingboost.api.dto.DefaultMessageDto; +import lombok.extern.slf4j.Slf4j; +import org.json.JSONArray; +import org.json.JSONObject; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; + +@Service +@Slf4j +public class MessageService extends HttpCallService { + private static final String MSG_SEND_TO_ME_URL = "https://kapi.kakao.com/v2/api/talk/memo/default/send"; + private static final String MSG_SEND_TO_FRIENDS_URL = "https://kapi.kakao.com/v1/api/talk/friends/message/default/send"; + private static final String SEND_SUCCESS_MSG = "메시지 전송에 성공했습니다."; + private static final String SEND_FAIL_MSG = "메시지 전송에 실패했습니다."; + + private static final String SUCCESS_CODE = "0"; //kakao api에서 return해주는 success code 값 + + public CompletableFuture sendMessageToMe(String accessToken, DefaultMessageDto msgDto) { + MultiValueMap parameters = createMessageParameters(msgDto, null); + return sendMessage(MSG_SEND_TO_ME_URL, accessToken, parameters); + } + + public CompletableFuture sendMessageToFriends(String accessToken, DefaultMessageDto msgDto, + List uuids) { + JSONArray receiverUuids = new JSONArray(uuids); + MultiValueMap parameters = createMessageParameters(msgDto, receiverUuids.toString()); + return sendMessage(MSG_SEND_TO_FRIENDS_URL, accessToken, parameters); + } + + private MultiValueMap createMessageParameters(DefaultMessageDto msgDto, String receiverUuids) { + JSONObject templateObj = createTemplateObject(msgDto); + MultiValueMap parameters = new LinkedMultiValueMap<>(); + parameters.add("template_object", templateObj.toString()); + if (receiverUuids != null) { + parameters.add("receiver_uuids", receiverUuids); + } + return parameters; + } + + private CompletableFuture sendMessage(String url, String accessToken, + MultiValueMap parameters) { + return CompletableFuture.supplyAsync(() -> { + HttpHeaders headers = createHeaders(accessToken); + HttpEntity> requestEntity = new HttpEntity<>(parameters, headers); + RestTemplate restTemplate = new RestTemplate(); + ResponseEntity response; + try { + response = restTemplate.postForEntity(url, requestEntity, String.class); + return processResponse(response.getBody()); + } catch (Exception e) { + // 로그 기록, 에러 처리 + return false; + } + }); + } + + private HttpHeaders createHeaders(String accessToken) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + headers.set("Authorization", "Bearer " + accessToken); + return headers; + } + + private JSONObject createTemplateObject(DefaultMessageDto msgDto) { + JSONObject linkObj = new JSONObject(); + linkObj.put("web_url", msgDto.webUrl()); + JSONObject templateObj = new JSONObject(); + templateObj.put("object_type", msgDto.objType()); + templateObj.put("text", msgDto.text()); + templateObj.put("link", linkObj); + templateObj.put("button_title", msgDto.btnTitle()); + return templateObj; + } + + private boolean processResponse(String responseBody) { + JSONObject jsonData = new JSONObject(responseBody); + String resultCode = jsonData.optString("result_code", ""); + if (SUCCESS_CODE.equals(resultCode) || !jsonData.optString("successful_receiver_uuids", "").isEmpty()) { + log.info(SEND_SUCCESS_MSG); + return true; + } else { + log.debug(SEND_FAIL_MSG); + return false; + } + } +} \ No newline at end of file diff --git a/src/main/java/kcs/funding/fundingboost/api/transformer/Trans.java b/src/main/java/kcs/funding/fundingboost/api/transformer/Trans.java new file mode 100644 index 00000000..9c1da33c --- /dev/null +++ b/src/main/java/kcs/funding/fundingboost/api/transformer/Trans.java @@ -0,0 +1,12 @@ +package kcs.funding.fundingboost.api.transformer; + +import com.google.gson.JsonElement; +import com.google.gson.JsonParser; + +public class Trans { + + public static String token(String rtn, JsonParser parser) { + JsonElement element = parser.parse(rtn); + return element.getAsJsonObject().get("access_token").getAsString(); + } +} diff --git a/src/main/java/kcs/funding/fundingboost/domain/entity/GiftHubItem.java b/src/main/java/kcs/funding/fundingboost/domain/entity/GiftHubItem.java index e7cfb2a1..51218985 100644 --- a/src/main/java/kcs/funding/fundingboost/domain/entity/GiftHubItem.java +++ b/src/main/java/kcs/funding/fundingboost/domain/entity/GiftHubItem.java @@ -44,12 +44,14 @@ public class GiftHubItem extends BaseTimeEntity { @OnDelete(action = OnDeleteAction.CASCADE) private Member member; + private GiftHubItem(int quantity, Item item, Member member) { + this.quantity = quantity; + this.item = item; + this.member = member; + } + public static GiftHubItem createGiftHubItem(int quantity, Item item, Member member) { - GiftHubItem giftHubItem = new GiftHubItem(); - giftHubItem.quantity = quantity; - giftHubItem.item = item; - giftHubItem.member = member; - return giftHubItem; + return new GiftHubItem(quantity, item, member); } public void updateQuantity(int quantity) { diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html new file mode 100644 index 00000000..f62a76a2 --- /dev/null +++ b/src/main/resources/static/index.html @@ -0,0 +1,57 @@ + + + + + Kakao REST-API python Flask example + + + + + +

1. 카카오 로그인 및 프로필 조회 예제

+
+- [KOE101, KOE004] 내 애플리케이션>제품 설정>카카오 로그인 > 활성화 설정 : ON
+- [KOE006] 내 애플리케이션>제품 설정>카카오 로그인 > Redirect URI : http://localhost/redirect
+
+
+ + + +
+ +
+ +
+ + + + + +
+ +
+
+ +