-
Notifications
You must be signed in to change notification settings - Fork 0
[feat] #40 - 구글 캘린더 연동/해제 #44
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
6757337
1241507
ffc1855
a9a8aff
138f43d
0602283
9dba14a
dca234c
8d1696d
85f793b
b26dad2
c5a262a
d0ad363
84a388a
7d16e4d
07282c5
db14d2f
7e8a7f1
69ce4a5
eb7da00
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,68 @@ | ||
| package com.Timo.Timo.domain.calendar.client; | ||
|
|
||
| import com.Timo.Timo.domain.calendar.dto.response.GoogleTokenResponse; | ||
| import com.Timo.Timo.domain.calendar.dto.response.GoogleUserInfoResponse; | ||
| import com.Timo.Timo.domain.calendar.exception.CalendarErrorCode; | ||
| import com.Timo.Timo.global.exception.CustomException; | ||
| import org.springframework.beans.factory.annotation.Value; | ||
| import org.springframework.stereotype.Component; | ||
| import org.springframework.util.LinkedMultiValueMap; | ||
| import org.springframework.util.MultiValueMap; | ||
| import org.springframework.web.client.RestClient; | ||
|
|
||
| @Component | ||
| public class GoogleOAuthClient { | ||
|
|
||
| private final RestClient restClient = RestClient.create(); | ||
|
|
||
| @Value("${spring.security.oauth2.client.registration.google.client-id}") | ||
| private String clientId; | ||
|
|
||
| @Value("${spring.security.oauth2.client.registration.google.client-secret}") | ||
| private String clientSecret; | ||
|
|
||
| @Value("${app.oauth2.redirect-uri}") | ||
| private String redirectUri; | ||
|
|
||
| public GoogleTokenResponse exchangeToken(String authorizationCode){ | ||
| MultiValueMap<String, String> body = new LinkedMultiValueMap<>(); | ||
| body.add("code", authorizationCode); | ||
| body.add("client_id", clientId); | ||
| body.add("client_secret", clientSecret); | ||
| body.add("redirect_uri", redirectUri); | ||
| body.add("grant_type", "authorization_code"); | ||
|
|
||
| try { | ||
| return restClient.post() | ||
| .uri("https://oauth2.googleapis.com/token") | ||
| .body(body) | ||
| .retrieve() | ||
| .body(GoogleTokenResponse.class); | ||
| } catch (Exception e) { | ||
| throw new CustomException(CalendarErrorCode.CALENDAR_401_AUTH_FAILED); | ||
| } | ||
| } | ||
|
|
||
| public GoogleUserInfoResponse fetchUserInfo(String accessToken){ | ||
| try { | ||
| return restClient.get() | ||
| .uri("https://www.googleapis.com/oauth2/v2/userinfo") | ||
| .header("Authorization", "Bearer " + accessToken) | ||
| .retrieve() | ||
| .body(GoogleUserInfoResponse.class); | ||
| } catch (Exception e) { | ||
| throw new CustomException(CalendarErrorCode.CALENDAR_401_AUTH_FAILED); | ||
| } | ||
| } | ||
|
|
||
| public void revokeToken(String token) { | ||
| try { | ||
| restClient.post() | ||
| .uri("https://oauth2.googleapis.com/revoke?token=" + token) | ||
| .retrieve() | ||
| .toBodilessEntity(); | ||
| } catch (Exception e) { | ||
| throw new CustomException(CalendarErrorCode.CALENDAR_500_REVOKE_FAILED); | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| package com.Timo.Timo.domain.calendar.controller; | ||
|
|
||
| import com.Timo.Timo.domain.calendar.docs.CalendarControllerDocs; | ||
| import com.Timo.Timo.domain.calendar.dto.request.CalendarConnectRequest; | ||
| import com.Timo.Timo.domain.calendar.dto.response.CalendarConnectResponse; | ||
| import com.Timo.Timo.domain.calendar.dto.response.CalendarDisconnectResponse; | ||
| import com.Timo.Timo.domain.calendar.factory.CalendarResponseFactory; | ||
| import com.Timo.Timo.domain.calendar.service.CalendarService; | ||
| import com.Timo.Timo.global.auth.principal.CustomUserDetails; | ||
| import com.Timo.Timo.global.response.BaseResponse; | ||
| import io.swagger.v3.oas.annotations.tags.Tag; | ||
| import jakarta.validation.Valid; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.http.ResponseEntity; | ||
| import org.springframework.security.core.annotation.AuthenticationPrincipal; | ||
| import org.springframework.web.bind.annotation.DeleteMapping; | ||
| 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.RestController; | ||
|
|
||
| @RestController | ||
| @RequestMapping("/api/v1/users/calendar") | ||
| @RequiredArgsConstructor | ||
| @Tag(name = "Calendar", description = "구글 캘린더 연동 API") | ||
| public class CalendarController implements CalendarControllerDocs { | ||
|
|
||
| private final CalendarService calendarService; | ||
| private final CalendarResponseFactory calendarResponseFactory; | ||
|
|
||
| @Override | ||
| @PostMapping | ||
| public ResponseEntity<BaseResponse<CalendarConnectResponse>> connectCalendar( | ||
| @AuthenticationPrincipal CustomUserDetails userDetails, | ||
| @Valid @RequestBody CalendarConnectRequest request | ||
| ) { | ||
| Long userId = userDetails.getUserId(); | ||
| CalendarConnectResponse response = calendarService.connect(userId, request); | ||
|
|
||
| return calendarResponseFactory.connectResponse(response); | ||
| } | ||
|
|
||
| @Override | ||
| @DeleteMapping | ||
| public ResponseEntity<BaseResponse<CalendarDisconnectResponse>> disconnectCalendar( | ||
| @AuthenticationPrincipal CustomUserDetails userDetails | ||
| ) { | ||
| Long userId = userDetails.getUserId(); | ||
| CalendarDisconnectResponse response = calendarService.disconnect(userId); | ||
|
|
||
| return calendarResponseFactory.disconnectResponse(response); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,65 @@ | ||
| package com.Timo.Timo.domain.calendar.docs; | ||
|
|
||
| import com.Timo.Timo.domain.calendar.dto.request.CalendarConnectRequest; | ||
| import com.Timo.Timo.domain.calendar.dto.response.CalendarConnectResponse; | ||
| import com.Timo.Timo.domain.calendar.dto.response.CalendarDisconnectResponse; | ||
| import com.Timo.Timo.global.auth.principal.CustomUserDetails; | ||
| import com.Timo.Timo.global.exception.dto.ErrorDto; | ||
| import com.Timo.Timo.global.response.BaseResponse; | ||
| import io.swagger.v3.oas.annotations.Operation; | ||
| import io.swagger.v3.oas.annotations.Parameter; | ||
| import io.swagger.v3.oas.annotations.media.Content; | ||
| import io.swagger.v3.oas.annotations.media.Schema; | ||
| import io.swagger.v3.oas.annotations.responses.ApiResponse; | ||
| import io.swagger.v3.oas.annotations.responses.ApiResponses; | ||
| import io.swagger.v3.oas.annotations.security.SecurityRequirement; | ||
| import jakarta.validation.Valid; | ||
| import org.springframework.http.ResponseEntity; | ||
| import org.springframework.web.bind.annotation.RequestBody; | ||
|
|
||
| public interface CalendarControllerDocs { | ||
|
|
||
| @Operation( | ||
| summary = "구글 캘린더 연동", | ||
| description = """ | ||
| 구글 OAuth 동의 완료 후 발급된 authorizationCode로 구글 토큰을 교환하여 캘린더 연동합니다. | ||
| 가입 시 사용한 구글 계정과 다른 계정으로 연동을 시도하면 거부됩니다. | ||
| """, | ||
| security = @SecurityRequirement(name = "bearerAuth") | ||
| ) | ||
| @ApiResponses({ | ||
| @ApiResponse(responseCode = "201", description = "연동 성공", useReturnTypeSchema = true), | ||
| @ApiResponse(responseCode = "400", description = "authorizationCode 누락", | ||
| content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorDto.class))), | ||
| @ApiResponse(responseCode = "401", description = "구글 인증 실패, 토큰 없음/만료, 또는 가입 이메일과 다른 계정으로 연동 시도", | ||
| content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorDto.class))), | ||
| @ApiResponse(responseCode = "409", description = "이미 캘린더가 연동된 상태", | ||
| content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorDto.class))), | ||
| @ApiResponse(responseCode = "500", description = "서버 내부 오류", | ||
| content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorDto.class))) | ||
| }) | ||
| ResponseEntity<BaseResponse<CalendarConnectResponse>> connectCalendar( | ||
| @Parameter(hidden = true) CustomUserDetails userDetails, | ||
| @Valid @RequestBody CalendarConnectRequest request | ||
| ); | ||
|
|
||
| @Operation( | ||
| summary = "구글 캘린더 연동 해제", | ||
| description = """ | ||
| 연동된 구글 캘린더 정보를 삭제하고 구글 토큰을 revoke합니다. | ||
| """, | ||
| security = @SecurityRequirement(name = "bearerAuth") | ||
| ) | ||
| @ApiResponses({ | ||
| @ApiResponse(responseCode = "200", description = "연동 해제 성공", useReturnTypeSchema = true), | ||
| @ApiResponse(responseCode = "401", description = "토큰 없음/만료/유효하지 않음", | ||
| content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorDto.class))), | ||
| @ApiResponse(responseCode = "404", description = "연동된 캘린더가 없는 경우", | ||
| content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorDto.class))), | ||
| @ApiResponse(responseCode = "500", description = "서버 내부 오류", | ||
| content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorDto.class))) | ||
| }) | ||
| ResponseEntity<BaseResponse<CalendarDisconnectResponse>> disconnectCalendar( | ||
| @Parameter(hidden = true) CustomUserDetails userDetails | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| package com.Timo.Timo.domain.calendar.dto.request; | ||
|
|
||
| import jakarta.validation.constraints.NotBlank; | ||
|
|
||
| public record CalendarConnectRequest( | ||
| @NotBlank(message = "authorizationCode는 필수입니다.") | ||
| String authorizationCode | ||
| ) {} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| package com.Timo.Timo.domain.calendar.dto.response; | ||
|
|
||
| import com.fasterxml.jackson.annotation.JsonFormat; | ||
| import io.swagger.v3.oas.annotations.media.Schema; | ||
| import java.time.LocalDateTime; | ||
| import lombok.Builder; | ||
|
|
||
| @Builder | ||
| public record CalendarConnectResponse( | ||
| boolean calendarConnected, | ||
| String calendarEmail, | ||
|
|
||
| @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") | ||
| @Schema(example = "2026-07-06 17:51:50", type = "string") | ||
| LocalDateTime connectedAt | ||
| ) {} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| package com.Timo.Timo.domain.calendar.dto.response; | ||
|
|
||
| import lombok.Builder; | ||
|
|
||
| @Builder | ||
| public record CalendarDisconnectResponse( | ||
| boolean calendarConnected | ||
| ) {} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| package com.Timo.Timo.domain.calendar.dto.response; | ||
|
|
||
| public record GoogleTokenResponse ( | ||
| String accessToken, | ||
| String refreshToken, | ||
| Integer expiresIn | ||
| ){} | ||
|
Comment on lines
+3
to
+7
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# 전역 Jackson naming strategy 설정 확인
rg -i "naming-strategy|SNAKE_CASE" -g '*.yml' -g '*.yaml' -g '*.properties' -n || echo "No naming strategy found"
# `@JsonNaming` 사용 패턴 확인
rg -l "`@JsonNaming`" --type java -g '!**/test/**' || echo "No `@JsonNaming` usage found"
# RestClient 빈 구성 확인
rg -n "RestClient" --type java -g '!**/test/**' -g '!**/build/**'Repository: Team-Timo/Timo-Server Length of output: 184 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '\n== GoogleTokenResponse ==\n'
sed -n '1,80p' src/main/java/com/Timo/Timo/domain/calendar/dto/response/GoogleTokenResponse.java
printf '\n== RestClient / ObjectMapper configuration ==\n'
rg -n "RestClient|ObjectMapper|Jackson2ObjectMapperBuilder|JsonNaming|JsonProperty|SnakeCaseStrategy|PropertyNamingStrategies" src/main/java -g '!**/test/**' -g '!**/build/**'
printf '\n== Google token usage ==\n'
rg -n "GoogleTokenResponse|access_token|refresh_token|expires_in|token" src/main/java -g '!**/test/**' -g '!**/build/**'Repository: Team-Timo/Timo-Server Length of output: 7536 src/main/java/com/Timo/Timo/domain/calendar/dto/response/GoogleTokenResponse.java: snake_case 매핑을 추가하세요. 🤖 Prompt for AI Agents |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| package com.Timo.Timo.domain.calendar.dto.response; | ||
|
|
||
| public record GoogleUserInfoResponse( | ||
| String email | ||
| ) {} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| package com.Timo.Timo.domain.calendar.entity; | ||
|
|
||
| import com.Timo.Timo.domain.user.entity.User; | ||
| import jakarta.persistence.Column; | ||
| import jakarta.persistence.Entity; | ||
| import jakarta.persistence.GeneratedValue; | ||
| import jakarta.persistence.GenerationType; | ||
| import jakarta.persistence.Id; | ||
| import jakarta.persistence.JoinColumn; | ||
| import jakarta.persistence.OneToOne; | ||
| import jakarta.persistence.Table; | ||
| import java.time.LocalDateTime; | ||
| import lombok.AccessLevel; | ||
| import lombok.Builder; | ||
| import lombok.Getter; | ||
| import lombok.NoArgsConstructor; | ||
|
|
||
| @Entity | ||
| @Table(name = "calendar_connections") | ||
| @Getter | ||
| @NoArgsConstructor(access = AccessLevel.PROTECTED) | ||
| public class CalendarConnection { | ||
|
|
||
| @Id | ||
| @GeneratedValue(strategy = GenerationType.IDENTITY) | ||
| @Column(name = "id") | ||
| private Long id; | ||
|
|
||
| @OneToOne | ||
| @JoinColumn(name = "user_id", nullable = false, unique = true) | ||
| private User user; | ||
|
|
||
| @Column(name = "calendar_email", nullable = false) | ||
| private String calendarEmail; | ||
|
|
||
| @Column(name = "access_token", nullable = false, length = 2048) | ||
| private String accessToken; | ||
|
|
||
| @Column(name = "refresh_token", length = 2048) | ||
| private String refreshToken; | ||
|
|
||
| @Column(name = "token_expires_at") | ||
| private LocalDateTime tokenExpiresAt; | ||
|
|
||
| @Column(name = "connected_at", nullable = false) | ||
| private LocalDateTime connectedAt; | ||
|
|
||
| @Builder | ||
| private CalendarConnection( | ||
| User user, | ||
| String calendarEmail, | ||
| String accessToken, | ||
| String refreshToken, | ||
| LocalDateTime tokenExpiresAt | ||
| ) { | ||
| this.user = user; | ||
| this.calendarEmail = calendarEmail; | ||
| this.accessToken = accessToken; | ||
| this.refreshToken = refreshToken; | ||
| this.tokenExpiresAt = tokenExpiresAt; | ||
| this.connectedAt = LocalDateTime.now(); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,21 @@ | ||||||||||
| package com.Timo.Timo.domain.calendar.exception; | ||||||||||
|
|
||||||||||
| import com.Timo.Timo.global.exception.code.BaseErrorCode; | ||||||||||
| import lombok.Getter; | ||||||||||
| import lombok.RequiredArgsConstructor; | ||||||||||
| import org.springframework.http.HttpStatus; | ||||||||||
|
|
||||||||||
| @Getter | ||||||||||
| @RequiredArgsConstructor | ||||||||||
| public enum CalendarErrorCode implements BaseErrorCode { | ||||||||||
|
|
||||||||||
| CALENDAR_401_AUTH_FAILED(HttpStatus.UNAUTHORIZED, "CALENDAR_401", "구글 캘린더 인증에 실패했습니다."), | ||||||||||
| CALENDAR_401_EMAIL_MISMATCH(HttpStatus.UNAUTHORIZED, "CALENDAR_401", "가입 시 사용한 구글 계정으로만 연동할 수 있습니다."), | ||||||||||
|
Comment on lines
+12
to
+13
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win 중복된 에러 코드 "CALENDAR_401"로 클라이언트가 에러를 구분할 수 없습니다.
🔧 제안: 에러 코드 분리 CALENDAR_401_AUTH_FAILED(HttpStatus.UNAUTHORIZED, "CALENDAR_401", "구글 캘린더 인증에 실패했습니다."),
- CALENDAR_401_EMAIL_MISMATCH(HttpStatus.UNAUTHORIZED, "CALENDAR_401", "가입 시 사용한 구글 계정으로만 연동할 수 있습니다."),
+ CALENDAR_401_EMAIL_MISMATCH(HttpStatus.UNAUTHORIZED, "CALENDAR_401_EMAIL", "가입 시 사용한 구글 계정으로만 연동할 수 있습니다."),📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||
| CALENDAR_404_NOT_CONNECTED(HttpStatus.NOT_FOUND, "CALENDAR_404", "연동된 캘린더가 없습니다."), | ||||||||||
| CALENDAR_409_ALREADY_CONNECTED(HttpStatus.CONFLICT, "CALENDAR_409", "이미 캘린더가 연동되어 있습니다."), | ||||||||||
| CALENDAR_500_REVOKE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "CALENDAR_500", "구글 토큰 해제에 실패했습니다."); | ||||||||||
|
|
||||||||||
| private final HttpStatus httpStatus; | ||||||||||
| private final String code; | ||||||||||
| private final String message; | ||||||||||
| } | ||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| package com.Timo.Timo.domain.calendar.exception; | ||
|
|
||
| import com.Timo.Timo.global.exception.code.BaseSuccessCode; | ||
| import lombok.Getter; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.http.HttpStatus; | ||
|
|
||
| @Getter | ||
| @RequiredArgsConstructor | ||
| public enum CalendarSuccessCode implements BaseSuccessCode { | ||
|
|
||
| CALENDAR_CONNECTED(HttpStatus.CREATED, "구글 캘린더가 연동되었습니다."), | ||
| CALENDAR_DISCONNECTED(HttpStatus.OK, "구글 캘린더 연동이 해제되었습니다."); | ||
|
|
||
| private final HttpStatus httpStatus; | ||
| private final String message; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| package com.Timo.Timo.domain.calendar.factory; | ||
|
|
||
| import com.Timo.Timo.domain.calendar.dto.response.CalendarConnectResponse; | ||
| import com.Timo.Timo.domain.calendar.dto.response.CalendarDisconnectResponse; | ||
| import com.Timo.Timo.domain.calendar.exception.CalendarSuccessCode; | ||
| import com.Timo.Timo.global.response.BaseResponse; | ||
| import org.springframework.http.ResponseEntity; | ||
| import org.springframework.stereotype.Component; | ||
|
|
||
| @Component | ||
| public class CalendarResponseFactory { | ||
|
|
||
| public ResponseEntity<BaseResponse<CalendarConnectResponse>> connectResponse( | ||
| CalendarConnectResponse response | ||
| ) { | ||
| return ResponseEntity.status(CalendarSuccessCode.CALENDAR_CONNECTED.getHttpStatus()) | ||
| .body(BaseResponse.onSuccess(CalendarSuccessCode.CALENDAR_CONNECTED, response)); | ||
| } | ||
|
|
||
| public ResponseEntity<BaseResponse<CalendarDisconnectResponse>> disconnectResponse( | ||
| CalendarDisconnectResponse response | ||
| ) { | ||
| return ResponseEntity.ok( | ||
| BaseResponse.onSuccess(CalendarSuccessCode.CALENDAR_DISCONNECTED, response) | ||
| ); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| package com.Timo.Timo.domain.calendar.repository; | ||
|
|
||
| import com.Timo.Timo.domain.calendar.entity.CalendarConnection; | ||
| import java.util.Optional; | ||
| import org.springframework.data.jpa.repository.JpaRepository; | ||
|
|
||
| public interface CalendarConnectionRepository extends JpaRepository<CalendarConnection, Long> { | ||
|
|
||
| Optional<CalendarConnection> findByUserId(Long userId); | ||
| boolean existsByUserId(Long userId); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
RestClient에 타임아웃이 설정되지 않아 스레드 블로킹 위험이 있습니다.
RestClient.create()는 기본 connect/read 타임아웃이 없어, Google API 응답 지연 시 요청 스레드가 무한 대기합니다. 다수 사용자가 동시에 연동/해제 시도 시 Tomcat 스레드 풀 고갈로 전체 서비스 장애가 발생할 수 있습니다.⏱️ 제안: 타임아웃 설정 추가
📝 Committable suggestion
🤖 Prompt for AI Agents