diff --git a/src/main/java/com/Timo/Timo/domain/calendar/client/GoogleOAuthClient.java b/src/main/java/com/Timo/Timo/domain/calendar/client/GoogleOAuthClient.java new file mode 100644 index 00000000..08402061 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/calendar/client/GoogleOAuthClient.java @@ -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 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); + } + } +} diff --git a/src/main/java/com/Timo/Timo/domain/calendar/controller/CalendarController.java b/src/main/java/com/Timo/Timo/domain/calendar/controller/CalendarController.java new file mode 100644 index 00000000..3172d3c1 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/calendar/controller/CalendarController.java @@ -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> 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> disconnectCalendar( + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + Long userId = userDetails.getUserId(); + CalendarDisconnectResponse response = calendarService.disconnect(userId); + + return calendarResponseFactory.disconnectResponse(response); + } +} diff --git a/src/main/java/com/Timo/Timo/domain/calendar/docs/CalendarControllerDocs.java b/src/main/java/com/Timo/Timo/domain/calendar/docs/CalendarControllerDocs.java new file mode 100644 index 00000000..d87d50b9 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/calendar/docs/CalendarControllerDocs.java @@ -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> 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> disconnectCalendar( + @Parameter(hidden = true) CustomUserDetails userDetails + ); +} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/calendar/dto/request/CalendarConnectRequest.java b/src/main/java/com/Timo/Timo/domain/calendar/dto/request/CalendarConnectRequest.java new file mode 100644 index 00000000..4f2eea05 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/calendar/dto/request/CalendarConnectRequest.java @@ -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 +) {} diff --git a/src/main/java/com/Timo/Timo/domain/calendar/dto/response/CalendarConnectResponse.java b/src/main/java/com/Timo/Timo/domain/calendar/dto/response/CalendarConnectResponse.java new file mode 100644 index 00000000..06e0dc14 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/calendar/dto/response/CalendarConnectResponse.java @@ -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 +) {} diff --git a/src/main/java/com/Timo/Timo/domain/calendar/dto/response/CalendarDisconnectResponse.java b/src/main/java/com/Timo/Timo/domain/calendar/dto/response/CalendarDisconnectResponse.java new file mode 100644 index 00000000..f35a53ca --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/calendar/dto/response/CalendarDisconnectResponse.java @@ -0,0 +1,8 @@ +package com.Timo.Timo.domain.calendar.dto.response; + +import lombok.Builder; + +@Builder +public record CalendarDisconnectResponse( + boolean calendarConnected +) {} diff --git a/src/main/java/com/Timo/Timo/domain/calendar/dto/response/GoogleTokenResponse.java b/src/main/java/com/Timo/Timo/domain/calendar/dto/response/GoogleTokenResponse.java new file mode 100644 index 00000000..986fe260 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/calendar/dto/response/GoogleTokenResponse.java @@ -0,0 +1,7 @@ +package com.Timo.Timo.domain.calendar.dto.response; + +public record GoogleTokenResponse ( + String accessToken, + String refreshToken, + Integer expiresIn +){} diff --git a/src/main/java/com/Timo/Timo/domain/calendar/dto/response/GoogleUserInfoResponse.java b/src/main/java/com/Timo/Timo/domain/calendar/dto/response/GoogleUserInfoResponse.java new file mode 100644 index 00000000..00b684dc --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/calendar/dto/response/GoogleUserInfoResponse.java @@ -0,0 +1,5 @@ +package com.Timo.Timo.domain.calendar.dto.response; + +public record GoogleUserInfoResponse( + String email +) {} diff --git a/src/main/java/com/Timo/Timo/domain/calendar/entity/CalendarConnection.java b/src/main/java/com/Timo/Timo/domain/calendar/entity/CalendarConnection.java new file mode 100644 index 00000000..d148506d --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/calendar/entity/CalendarConnection.java @@ -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(); + } +} diff --git a/src/main/java/com/Timo/Timo/domain/calendar/exception/CalendarErrorCode.java b/src/main/java/com/Timo/Timo/domain/calendar/exception/CalendarErrorCode.java new file mode 100644 index 00000000..d837b1be --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/calendar/exception/CalendarErrorCode.java @@ -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", "가입 시 사용한 구글 계정으로만 연동할 수 있습니다."), + 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; +} diff --git a/src/main/java/com/Timo/Timo/domain/calendar/exception/CalendarSuccessCode.java b/src/main/java/com/Timo/Timo/domain/calendar/exception/CalendarSuccessCode.java new file mode 100644 index 00000000..ca23f0dc --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/calendar/exception/CalendarSuccessCode.java @@ -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; +} diff --git a/src/main/java/com/Timo/Timo/domain/calendar/factory/CalendarResponseFactory.java b/src/main/java/com/Timo/Timo/domain/calendar/factory/CalendarResponseFactory.java new file mode 100644 index 00000000..a0343b99 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/calendar/factory/CalendarResponseFactory.java @@ -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> connectResponse( + CalendarConnectResponse response + ) { + return ResponseEntity.status(CalendarSuccessCode.CALENDAR_CONNECTED.getHttpStatus()) + .body(BaseResponse.onSuccess(CalendarSuccessCode.CALENDAR_CONNECTED, response)); + } + + public ResponseEntity> disconnectResponse( + CalendarDisconnectResponse response + ) { + return ResponseEntity.ok( + BaseResponse.onSuccess(CalendarSuccessCode.CALENDAR_DISCONNECTED, response) + ); + } +} diff --git a/src/main/java/com/Timo/Timo/domain/calendar/repository/CalendarConnectionRepository.java b/src/main/java/com/Timo/Timo/domain/calendar/repository/CalendarConnectionRepository.java new file mode 100644 index 00000000..5ef2ec9f --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/calendar/repository/CalendarConnectionRepository.java @@ -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 { + + Optional findByUserId(Long userId); + boolean existsByUserId(Long userId); +} diff --git a/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarService.java b/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarService.java new file mode 100644 index 00000000..31337769 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarService.java @@ -0,0 +1,78 @@ +package com.Timo.Timo.domain.calendar.service; + +import com.Timo.Timo.domain.calendar.client.GoogleOAuthClient; +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.dto.response.GoogleTokenResponse; +import com.Timo.Timo.domain.calendar.dto.response.GoogleUserInfoResponse; +import com.Timo.Timo.domain.calendar.entity.CalendarConnection; +import com.Timo.Timo.domain.calendar.exception.CalendarErrorCode; +import com.Timo.Timo.domain.calendar.repository.CalendarConnectionRepository; +import com.Timo.Timo.domain.user.entity.User; +import com.Timo.Timo.domain.user.exception.UserErrorCode; +import com.Timo.Timo.domain.user.repository.UserRepository; +import com.Timo.Timo.global.exception.CustomException; +import java.time.LocalDateTime; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class CalendarService { + + private final CalendarConnectionRepository calendarConnectionRepository; + private final UserRepository userRepository; + private final GoogleOAuthClient googleOAuthClient; + + @Transactional + public CalendarConnectResponse connect(Long userId, CalendarConnectRequest request) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); + + if (calendarConnectionRepository.existsByUserId(userId)) { + throw new CustomException(CalendarErrorCode.CALENDAR_409_ALREADY_CONNECTED); + } + + GoogleTokenResponse tokenResponse = googleOAuthClient.exchangeToken(request.authorizationCode()); + GoogleUserInfoResponse userInfo = googleOAuthClient.fetchUserInfo(tokenResponse.accessToken()); + + validateSameAccount(user, userInfo); + + CalendarConnection calendarConnection = CalendarConnection.builder() + .user(user) + .calendarEmail(userInfo.email()) + .accessToken(tokenResponse.accessToken()) + .refreshToken(tokenResponse.refreshToken()) + .tokenExpiresAt(LocalDateTime.now().plusSeconds(tokenResponse.expiresIn())) + .build(); + + calendarConnectionRepository.save(calendarConnection); + + return CalendarConnectResponse.builder() + .calendarConnected(true) + .calendarEmail(calendarConnection.getCalendarEmail()) + .connectedAt(calendarConnection.getConnectedAt()) + .build(); + } + + private void validateSameAccount(User user, GoogleUserInfoResponse userInfo) { + if (!user.getEmail().equalsIgnoreCase(userInfo.email())) { + throw new CustomException(CalendarErrorCode.CALENDAR_401_EMAIL_MISMATCH); + } + } + + @Transactional + public CalendarDisconnectResponse disconnect(Long userId) { + CalendarConnection calendarConnection = calendarConnectionRepository.findByUserId(userId) + .orElseThrow(() -> new CustomException(CalendarErrorCode.CALENDAR_404_NOT_CONNECTED)); + + googleOAuthClient.revokeToken(calendarConnection.getAccessToken()); + calendarConnectionRepository.delete(calendarConnection); + + return CalendarDisconnectResponse.builder() + .calendarConnected(false) + .build(); + } +} diff --git a/src/main/java/com/Timo/Timo/global/auth/docs/AuthControllerDocs.java b/src/main/java/com/Timo/Timo/global/auth/docs/AuthControllerDocs.java index dcb4fad6..a0ad0584 100644 --- a/src/main/java/com/Timo/Timo/global/auth/docs/AuthControllerDocs.java +++ b/src/main/java/com/Timo/Timo/global/auth/docs/AuthControllerDocs.java @@ -24,11 +24,11 @@ public interface AuthControllerDocs { @Operation( summary = "AccessToken 발급", description = """ - 1회성 인증 코드(code)를 AccessToken으로 교환 - - isNewUser: 이번 요청으로 신규 회원가입이 이루어졌는지 여부 (true면 아직 온보딩을 진행한 적이 없는 사용자) - - user.onboardingCompleted: 온보딩 완료 여부 (false면 온보딩 화면으로, true면 홈 화면으로 이동) - - 기존 가입자가 온보딩을 중단한 경우에도 isNewUser는 false, onboardingCompleted는 false로 반환될 수 가능 - - RefreshToken과 sessionId는 Set-Cookie 헤더로 전달되며, 응답 바디에는 포함되지 않음 + 1회성 인증 코드(code)를 AccessToken으로 교환합니다. + - isNewUser: 이번 요청으로 신규 회원가입이 이루어졌는지 여부 (true면 아직 온보딩을 진행한 적이 없는 사용자) + - user.onboardingCompleted: 온보딩 완료 여부 (false면 온보딩 화면으로, true면 홈 화면으로 이동) + - 기존 가입자가 온보딩을 중단한 경우에도 isNewUser는 false, onboardingCompleted는 false로 반환될 수 가능 + - RefreshToken과 sessionId는 Set-Cookie 헤더로 전달되며, 응답 바디에는 포함되지 않음 """ ) @SecurityRequirements @@ -93,8 +93,8 @@ ResponseEntity> token( @Operation( summary = "AccessToken 재발급", description = """ - 쿠키로 전달된 RefreshToken과 sessionId를 검증하여 AccessToken 재발급 - 재발급 성공 시 RefreshToken과 sessionId 쿠키 갱신 + 쿠키로 전달된 RefreshToken과 sessionId를 검증하여 AccessToken 재발급합니다. + 재발급 성공 시 RefreshToken과 sessionId 쿠키를 갱신합니다. """ )