diff --git a/src/main/java/stackpot/stackpot/apiPayload/code/status/ErrorStatus.java b/src/main/java/stackpot/stackpot/apiPayload/code/status/ErrorStatus.java index 9f1d706..09034b4 100755 --- a/src/main/java/stackpot/stackpot/apiPayload/code/status/ErrorStatus.java +++ b/src/main/java/stackpot/stackpot/apiPayload/code/status/ErrorStatus.java @@ -22,6 +22,11 @@ public enum ErrorStatus implements BaseErrorCode { USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER4004", "유저를 찾을 수 없습니다."), USER_WITHDRAWAL_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "USER5001", "회원 탈퇴에 실패했습니다."), USER_ALREADY_WITHDRAWN(HttpStatus.BAD_REQUEST, "USER4002", "이미 탈퇴한 사용자입니다."), + LOGIN_TICKET_NOT_FOUND(HttpStatus.NOT_FOUND, "AUTH4001", "로그인 티켓을 찾을 수 없습니다."), + LOGIN_TICKET_EXPIRED(HttpStatus.BAD_REQUEST, "AUTH4002", "로그인 티켓이 만료되었습니다."), + LOGIN_TICKET_PARSE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "AUTH5001", "로그인 티켓 처리 중 오류가 발생했습니다."), + LOGIN_TICKET_SERIALIZE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "AUTH5001", "로그인 티켓 생성 중 오류가 발생했습니다."), + LOGIN_TICKET_DESERIALIZE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "AUTH5002", "로그인 티켓 처리 중 오류가 발생했습니다."), // 인증 관련 에러 AUTHENTICATION_FAILED(HttpStatus.UNAUTHORIZED, "AUTH4010", "인증에 실패했습니다."), diff --git a/src/main/java/stackpot/stackpot/user/controller/UserController.java b/src/main/java/stackpot/stackpot/user/controller/UserController.java index e7dda53..a7b343b 100644 --- a/src/main/java/stackpot/stackpot/user/controller/UserController.java +++ b/src/main/java/stackpot/stackpot/user/controller/UserController.java @@ -1,5 +1,25 @@ package stackpot.stackpot.user.controller; +import java.io.IOException; +import java.net.URLDecoder; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.List; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Content; @@ -9,32 +29,39 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.security.core.Authentication; -import org.springframework.web.bind.annotation.*; import stackpot.stackpot.apiPayload.ApiResponse; import stackpot.stackpot.apiPayload.code.status.ErrorStatus; import stackpot.stackpot.common.swagger.ApiErrorCodeExamples; import stackpot.stackpot.feed.service.FeedQueryService; -import stackpot.stackpot.pot.dto.*; +import stackpot.stackpot.pot.dto.AppealContentDto; +import stackpot.stackpot.pot.dto.CompletedPotRequestDto; +import stackpot.stackpot.pot.dto.OngoingPotResponseDto; +import stackpot.stackpot.pot.dto.PotResponseDto; +import stackpot.stackpot.pot.dto.PotSummaryDto; import stackpot.stackpot.pot.service.pot.MyPotService; import stackpot.stackpot.pot.service.pot.PotCommandService; +import stackpot.stackpot.user.dto.request.ExchangeDto; import stackpot.stackpot.user.dto.request.MyDescriptionRequestDto; import stackpot.stackpot.user.dto.request.TokenRequestDto; import stackpot.stackpot.user.dto.request.UserRequestDto; import stackpot.stackpot.user.dto.request.UserUpdateRequestDto; -import stackpot.stackpot.user.dto.response.*; +import stackpot.stackpot.user.dto.response.GoogleUserInfoResponseDto; +import stackpot.stackpot.user.dto.response.KakaoUserInfoResponseDto; +import stackpot.stackpot.user.dto.response.MyDescriptionResponseDto; +import stackpot.stackpot.user.dto.response.NaverUserInfoResponseDto; +import stackpot.stackpot.user.dto.response.NicknameResponseDto; +import stackpot.stackpot.user.dto.response.TokenServiceResponse; +import stackpot.stackpot.user.dto.response.UserMyPageResponseDto; +import stackpot.stackpot.user.dto.response.UserResponseDto; +import stackpot.stackpot.user.dto.response.UserSignUpResponseDto; import stackpot.stackpot.user.entity.enums.Provider; +import stackpot.stackpot.user.service.LoginTicketService; import stackpot.stackpot.user.service.UserCommandService; import stackpot.stackpot.user.service.UserQueryService; import stackpot.stackpot.user.service.oauth.GoogleService; import stackpot.stackpot.user.service.oauth.KakaoService; import stackpot.stackpot.user.service.oauth.NaverService; -import java.io.IOException; -import java.util.List; - @Tag(name = "User or MyPage Management", description = "유저 및 마이페이지 관리 API") @Slf4j @RestController @@ -42,422 +69,450 @@ @RequestMapping("/users") public class UserController { - private final UserCommandService userCommandService; - private final KakaoService kakaoService; - private final NaverService naverService; - private final GoogleService googleService; - private final MyPotService myPotService; - private final PotCommandService potCommandService; - private final UserQueryService userQueryService; - private final FeedQueryService feedQueryService; - - @GetMapping("/login/token") - @Operation( - summary = "토큰 테스트 API", - description = "현재 로그인 된 사용자의 토큰을 테스트 하는 api입니다.", - responses = { - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "200", - description = "유요한 토큰", - content = @Content(mediaType = "application/json") - ), - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "401", - description = "유요하지 않은 토큰", - content = @Content(mediaType = "application/json") - ) - } - ) - public ResponseEntity testEndpoint(Authentication authentication) { - if (authentication == null) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("User is not authenticated"); - } - return ResponseEntity.ok("Authenticated user: " + authentication.getName()); - } - - @GetMapping("/oauth/kakao") - @Operation( - summary = "카카오 로그인 및 토큰발급 API", - description = "\"code\" 와 함께 요청시 기존/신규 유저 구분 및 accessToken을 발급합니다. 이떄 발급된 AccessToken은 회원가입 관련 엔드포인트만 접급 가능합니다.\nisNewUser : false( DB 조회 확인 기존 유저 ), ture ( DB에 없음 신규 유저 )", - responses = { - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "200", - description = "성공적으로 토큰 발급", - content = @Content(mediaType = "application/json", schema = @Schema(implementation = UserResponseDto.loginDto.class)) - ), - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "400", - description = "Invalid Parameter", - content = @Content(mediaType = "application/json") - ), - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "500", - description = "Internal Server Error", - content = @Content(mediaType = "application/json") - ) - } - ) - public ResponseEntity> kakaoCallback(@RequestParam("code") String code, HttpServletResponse response) throws IOException { - String accessToken = kakaoService.getAccessTokenFromKakao(code); - KakaoUserInfoResponseDto userInfo = kakaoService.getUserInfo(accessToken); - - String providerId = String.valueOf(userInfo.getId()); - String email = userInfo.getKakaoAccount().getEmail(); - - UserResponseDto.loginDto userResponse = userCommandService.isnewUser(Provider.KAKAO, providerId, email); - return ResponseEntity.ok(ApiResponse.onSuccess(userResponse)); - } - - @GetMapping("/oauth/naver") - @Operation( - summary = "네이버 로그인 및 토큰발급 API", - description = "\"code\" 와 함께 요청시 기존/신규 유저 구분 및 accessToken을 발급합니다. 이떄 발급된 AccessToken은 회원가입 관련 엔드포인트만 접급 가능합니다.\nisNewUser : false( DB 조회 확인 기존 유저 ), ture ( DB에 없음 신규 유저 )", - responses = { - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "200", - description = "성공적으로 토큰 발급", - content = @Content(mediaType = "application/json", schema = @Schema(implementation = UserResponseDto.loginDto.class)) - ), - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "400", - description = "Invalid Parameter", - content = @Content(mediaType = "application/json") - ), - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "500", - description = "Internal Server Error", - content = @Content(mediaType = "application/json") - ) - } - ) - public ResponseEntity> naverCallback(@RequestParam("code") String code, HttpServletResponse response) throws IOException { - String accessToken = naverService.getAccessTokenFromNaver(code); - NaverUserInfoResponseDto userInfo = naverService.getUserInfo(accessToken); - - String providerId = userInfo.getResponse().id(); - String email = userInfo.getResponse().email(); - - UserResponseDto.loginDto userResponse = userCommandService.isnewUser(Provider.NAVER, providerId, email); - return ResponseEntity.ok(ApiResponse.onSuccess(userResponse)); - } - - @GetMapping("/oauth/google") - @Operation( - summary = "구글 로그인 및 토큰발급 API", - description = "\"code\" 와 함께 요청시 기존/신규 유저 구분 및 accessToken을 발급합니다. 이떄 발급된 AccessToken은 회원가입 관련 엔드포인트만 접급 가능합니다.\nisNewUser : false( DB 조회 확인 기존 유저 ), ture ( DB에 없음 신규 유저 )", - responses = { - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "200", - description = "성공적으로 토큰 발급", - content = @Content(mediaType = "application/json", schema = @Schema(implementation = UserResponseDto.loginDto.class)) - ), - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "400", - description = "Invalid Parameter", - content = @Content(mediaType = "application/json") - ), - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "500", - description = "Internal Server Error", - content = @Content(mediaType = "application/json") - ) - } - ) - public ResponseEntity> googleCallback(@RequestParam("code") String code, HttpServletResponse response) throws IOException { - String accessToken = googleService.getAccessTokenFromGoogle(code); - GoogleUserInfoResponseDto userInfo = googleService.getUserInfo(accessToken); - - String providerId = userInfo.getId(); - String email = userInfo.getEmail(); - - UserResponseDto.loginDto userResponse = userCommandService.isnewUser(Provider.GOOGLE, providerId, email); - return ResponseEntity.ok(ApiResponse.onSuccess(userResponse)); - } - - @PatchMapping("/profile") - @Operation( - summary = "회원가입 API", - description = "신규 User 회원가입 시 필요한 정보를 저장합니다.\n" + - "- interests: 다중 선택 가능하며 string입니다. [사이드 프로젝트, 1인 개발, 공모전, 창업, 네트워킹 행사]\n" - ) - public ResponseEntity> signup(@Valid @RequestBody UserRequestDto.JoinDto request) { - UserSignUpResponseDto user = userCommandService.joinUser(request); - return ResponseEntity.ok(ApiResponse.onSuccess(user)); - } - - @GetMapping("/nickname") - @Operation( - summary = "닉네임 생성 API", - description = "새싹 관련 닉네임이 생성됩니다. 기존 유저와 중복되지 않는 닉네임이 생성됩니다." - ) - public ResponseEntity> nickname() { - NicknameResponseDto nickName = userCommandService.createNickname(); - return ResponseEntity.ok(ApiResponse.onSuccess(nickName)); - } - - @PostMapping("/nickname/save") - @Operation( - summary = "닉네임 저장 및 회원가입 완료 API", - description = "사용자의 닉네임을 저장하고 회원가입을 완료합니다. accessToken과 refreshToken도 함께 반환합니다." - ) - @ApiErrorCodeExamples({ - ErrorStatus.USER_NOT_FOUND - }) - public ResponseEntity> saveNickname(@RequestParam("nickname") String nickname) { - TokenServiceResponse tokenServiceResponse = userCommandService.saveNickname(nickname); - return ResponseEntity.ok(ApiResponse.onSuccess(tokenServiceResponse)); - } - - @PostMapping("/logout") - @Operation( - summary = "회원 로그아웃 API", - description = "AccessToken 토큰과 함께 요청 시 로그아웃" - ) - @ApiErrorCodeExamples({ - ErrorStatus.INVALID_AUTH_TOKEN, - ErrorStatus.USER_NOT_FOUND, - ErrorStatus.REDIS_KEY_NOT_FOUND, - ErrorStatus.REDIS_BLACKLIST_SAVE_FAILED - }) - public ResponseEntity> logout(@RequestHeader("Authorization") String accessToken, @RequestBody TokenRequestDto refreshToken) { - String response = userCommandService.logout(accessToken, refreshToken.getRefreshToken()); - return ResponseEntity.ok(ApiResponse.onSuccess(response)); - } - - @DeleteMapping("/delete") - @Operation( - summary = "회원 탈퇴 API", - description = "AccessToken 토큰과 함께 요청 시 회원 탈퇴 " + - "-pot 생성자인 경우 softDelet\n" + - "-pot 생성자가 아닌 경우 hardDelet" - ) - @ApiErrorCodeExamples({ - ErrorStatus.USER_NOT_FOUND, - ErrorStatus.REDIS_BLACKLIST_SAVE_FAILED, - ErrorStatus.USER_WITHDRAWAL_FAILED - }) - public ResponseEntity> deleteUser(@RequestHeader("Authorization") String accessToken) { - String response = userCommandService.deleteUser(accessToken); - return ResponseEntity.ok(ApiResponse.onSuccess(response)); - } - - @GetMapping("/{userId}") - @Operation( - summary = "사용자별 정보 조회 API", - description = "userId를 통해 '마이페이지'의 피드, 끓인 팟을 제외한 사용자 정보만을 제공하는 API입니다. 사용자의 Pot, FEED 조회와 조합해서 마이페이지를 제작하실 수 있습니다." - ) - @ApiErrorCodeExamples({ - ErrorStatus.USER_ALREADY_WITHDRAWN, - ErrorStatus.USER_NOT_FOUND - }) - public ResponseEntity> usersPages( - @PathVariable(name = "userId") Long userId) { - UserResponseDto.UserInfoDto userDetails = userCommandService.getUsers(userId); - return ResponseEntity.ok(ApiResponse.onSuccess(userDetails)); - } - - @GetMapping("") - @Operation( - summary = "나의 정보 조회 API", - description = "토큰을 통해 '설정 페이지'와 '마이페이지'의 피드, 끓인 팟을 제외한 사용자 자신의 정보만을 제공하는 API입니다. 사용자의 Pot, FEED 조회와 조합해서 마이페이지를 제작하실 수 있습니다." - ) - @ApiErrorCodeExamples({ - ErrorStatus.USER_NOT_FOUND, - ErrorStatus.USER_ALREADY_WITHDRAWN, - }) - public ResponseEntity> usersMyPages() { - UserResponseDto.UserInfoDto userDetails = userCommandService.getMyUsers(); - return ResponseEntity.ok(ApiResponse.onSuccess(userDetails)); - } - - @PatchMapping("/profile/update") - @Operation( - summary = "나의 프로필 수정 API", - description = "사용자의 역할, 관심사, 한 줄 소개, 카카오 아이디를 수정합니다.\n" + - "- interests: 다중 선택 가능하며 string입니다. [사이드 프로젝트, 1인 개발, 공모전, 창업, 네트워킹 행사]\n" - ) - @ApiErrorCodeExamples({ - ErrorStatus.USER_NOT_FOUND - }) - public ResponseEntity> updateUserProfile( - @RequestBody UserUpdateRequestDto requestDto) { - UserResponseDto.Userdto updatedUser = userCommandService.updateUserProfile(requestDto); - return ResponseEntity.ok(ApiResponse.onSuccess(updatedUser)); - } - - @GetMapping("/pots") - @Operation(summary = "마이페이지 팟 조회 API", description = "나의 모든 팟(모집중 / 진행중 / 완료)을 조회합니다. status = all / recruiting / ongoing / completed") - public ResponseEntity>> getMyAllInvolvedPots(@RequestParam(name = "potStatus", required = false) String dataType) { - List pots = myPotService.getMyAllInvolvedPots(dataType); - return ResponseEntity.ok(ApiResponse.onSuccess(pots)); - } - - @GetMapping("/pots/{user_id}") - @Operation(summary = "사용자별 마이페이지 팟 조회 API", description = "나의 모든 팟(모집중 / 진행중 / 완료)을 조회합니다. status = all / recruiting / ongoing / completed") - public ResponseEntity>> getUserAllInvolvedPots(@PathVariable("user_id") Long user_id, @RequestParam(name = "potStatus", required = false) String dataType) { - List pots = myPotService.getUserAllInvolvedPots(user_id, dataType); - return ResponseEntity.ok(ApiResponse.onSuccess(pots)); - } - - @GetMapping("/potAppealContent/{pot_id}") - @Operation( - summary = "마이페이지 '여기서 저는요' 모달 조회 API", - description = "'끓인 팟 상세보기 모달'에 쓰이는 Role, Badge, Appeal Content를 반환합니다." - ) - @ApiErrorCodeExamples({ - ErrorStatus.USER_NOT_FOUND, - }) - public ResponseEntity> getAppealContent( - @PathVariable(name = "pot_id") Long potId) { - AppealContentDto response = myPotService.getAppealContent(potId); - return ResponseEntity.ok(ApiResponse.onSuccess(response)); - } - - @GetMapping("/potAppealContent/{pot_id}/{user_id}") - @Operation( - summary = "다른 사람 마이페이지 '여기서 저는요' 모달 조회 API", - description = "'끓인 팟 상세보기 모달'에 쓰이는 Role, Badge, Appeal Content를 반환합니다." - ) - @ApiErrorCodeExamples({ - ErrorStatus.USER_NOT_FOUND, - }) - public ResponseEntity> getAppealContent( - @PathVariable(name = "pot_id") Long potId, - @PathVariable(name = "user_id") Long userId) { - AppealContentDto response = myPotService.getUserAppealContent(potId, userId); - return ResponseEntity.ok(ApiResponse.onSuccess(response)); - } - - @GetMapping("/potSummary/{pot_id}") - @Operation( - summary = "끓인 팟 AI 요약 모달 조회 API", - description = "끓인 팟을 상세보기할 때 쓰이는 PotSummary, potLan을 반환합니다." - ) - @ApiErrorCodeExamples({ - ErrorStatus.POT_NOT_FOUND - }) - public ResponseEntity> getPotSummary( - @PathVariable(name = "pot_id") Long potId) { - PotSummaryDto response = myPotService.getPotSummary(potId); - return ResponseEntity.ok(ApiResponse.onSuccess(response)); - } - - @PatchMapping("/{pot_id}") - @Operation( - summary = "끓인 팟 수정하기 API", - description = "끓은 팟의 정보를 수정하는 api 입니다." - ) - @ApiErrorCodeExamples({ - ErrorStatus.POT_NOT_FOUND, - ErrorStatus.POT_FORBIDDEN, - }) - public ResponseEntity> updatePot( - @PathVariable(name = "pot_id") Long potId, - @RequestBody @Valid CompletedPotRequestDto requestDto) { - // 팟 수정 로직 호출 - PotResponseDto responseDto = potCommandService.updateCompletedPot(potId, requestDto); - return ResponseEntity.ok(ApiResponse.onSuccess(responseDto)); // 수정된 팟 정보 반환 - } - - @GetMapping("/description") - @Operation( - summary = "나의 소개 조회 API", - description = "로그인한 사용자의 소개를 조회합니다." - ) - @ApiErrorCodeExamples({ - ErrorStatus.USER_NOT_FOUND - }) - public ResponseEntity> getMyDescription() { - MyDescriptionResponseDto responseDto = userQueryService.getMyDescription(); - return ResponseEntity.ok(ApiResponse.onSuccess(responseDto)); - } - - @GetMapping("/description/{userId}") - @Operation( - summary = "특정 사용자의 소개 조회 API", - description = "특정 사용자의 소개를 조회합니다." - ) - @ApiErrorCodeExamples({ - ErrorStatus.USER_NOT_FOUND - }) - public ResponseEntity> getUserDescription(@PathVariable Long userId) { - - MyDescriptionResponseDto responseDto = userQueryService.getUserDescription(userId); - return ResponseEntity.ok(ApiResponse.onSuccess(responseDto)); - } - - @PatchMapping("/description") - @Operation( - summary = "나의 소개 수정 또는 추가 API", - description = "로그인한 사용자의 소개를 수정하거나 추가합니다." - ) - @ApiErrorCodeExamples({ - ErrorStatus.USER_NOT_FOUND, - - }) - public ResponseEntity> upsertMyDescription( - @RequestBody @Valid MyDescriptionRequestDto dto) { - MyDescriptionResponseDto responseDto = userCommandService.upsertDescription(dto); - return ResponseEntity.ok(ApiResponse.onSuccess(responseDto)); - } - - @DeleteMapping("/description") - @Operation( - summary = "나의 소개 삭제 API", - description = "로그인한 사용자의 소개를 삭제합니다." - ) - @ApiErrorCodeExamples({ - ErrorStatus.USER_NOT_FOUND - }) - public ResponseEntity> deleteMyDescription() { - userCommandService.deleteDescription(); - return ResponseEntity.noContent().build(); - } - - - @GetMapping("/{userId}/feeds") - @Operation( - summary = "사용자별 피드 조회 API", - description = "userId에 해당하는 사용자의 시리즈 코멘트와 피드를 반환합니다. 피드는 커서 기반 페이지네이션을 지원합니다. \n" + - "시리즈가 '전체보기' 일 때는 seriesId = 0" - ) - @ApiErrorCodeExamples({ - ErrorStatus.USER_NOT_FOUND, - ErrorStatus.USER_ALREADY_WITHDRAWN - }) - public ResponseEntity> getFeedsByUserId( - @Parameter(description = "사용자 ID", example = "1") - @PathVariable("userId") Long userId, - - @Parameter(description = "커서", example = "100", required = false) - @RequestParam(value = "cursor", required = false) Long cursor, - - @Parameter(description = "페이지 크기", example = "10") - @RequestParam(value = "size", defaultValue = "10") int size, - - @RequestParam(value = "seriesId", required = false, defaultValue = "0") Long seriesId - ) { - UserMyPageResponseDto mypage = feedQueryService.getFeedsByUserId(userId, cursor, size, seriesId); - return ResponseEntity.ok(ApiResponse.onSuccess(mypage)); - } - - @Operation( - summary = "나의 피드 조회 API", - description = "로그인한 사용자의 시리즈 코멘트와 피드를 반환합니다. 피드는 커서 기반 페이지네이션을 지원합니다. \n" + - "시리즈가 '전체보기' 일 때는 seriesId = 0" - ) - @GetMapping("/feeds") - @ApiErrorCodeExamples({ - ErrorStatus.USER_NOT_FOUND, - ErrorStatus.USER_ALREADY_WITHDRAWN - }) - public ResponseEntity> getFeeds( - @RequestParam(name = "cursor", required = false) Long cursor, - @RequestParam(name = "size", defaultValue = "10") int size, - @RequestParam(value = "seriesId", required = false, defaultValue = "0") Long seriesId - ) { - UserMyPageResponseDto mypage = feedQueryService.getFeeds(cursor, size, seriesId); - return ResponseEntity.ok(ApiResponse.onSuccess(mypage)); - } + private final UserCommandService userCommandService; + private final KakaoService kakaoService; + private final NaverService naverService; + private final GoogleService googleService; + private final LoginTicketService loginTicketService; + private final MyPotService myPotService; + private final PotCommandService potCommandService; + private final UserQueryService userQueryService; + private final FeedQueryService feedQueryService; + + @GetMapping("/login/token") + @Operation( + summary = "토큰 테스트 API", + description = "현재 로그인 된 사용자의 토큰을 테스트 하는 api입니다.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "유요한 토큰", + content = @Content(mediaType = "application/json") + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "401", + description = "유요하지 않은 토큰", + content = @Content(mediaType = "application/json") + ) + } + ) + public ResponseEntity testEndpoint(Authentication authentication) { + if (authentication == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("User is not authenticated"); + } + return ResponseEntity.ok("Authenticated user: " + authentication.getName()); + } + + @GetMapping("/oauth/kakao") + @Operation( + summary = "카카오 로그인 및 토큰발급 API", + description = "\"code\" 와 함께 요청시 기존/신규 유저 구분 및 accessToken을 발급합니다. 이떄 발급된 AccessToken은 회원가입 관련 엔드포인트만 접급 가능합니다.\nisNewUser : false( DB 조회 확인 기존 유저 ), ture ( DB에 없음 신규 유저 )", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "성공적으로 토큰 발급", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = UserResponseDto.loginDto.class)) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + description = "Invalid Parameter", + content = @Content(mediaType = "application/json") + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "500", + description = "Internal Server Error", + content = @Content(mediaType = "application/json") + ) + } + ) + public ResponseEntity> kakaoCallback(@RequestParam("code") String code, + HttpServletResponse response) throws IOException { + String accessToken = kakaoService.getAccessTokenFromKakao(code); + KakaoUserInfoResponseDto userInfo = kakaoService.getUserInfo(accessToken); + + String providerId = String.valueOf(userInfo.getId()); + String email = userInfo.getKakaoAccount().getEmail(); + + UserResponseDto.loginDto userResponse = userCommandService.isnewUser(Provider.KAKAO, providerId, email); + return ResponseEntity.ok(ApiResponse.onSuccess(userResponse)); + } + + @GetMapping("/oauth/naver") + @Operation( + summary = "네이버 로그인 및 토큰발급 API", + description = "\"code\" 와 함께 요청시 기존/신규 유저 구분 및 accessToken을 발급합니다. 이떄 발급된 AccessToken은 회원가입 관련 엔드포인트만 접급 가능합니다.\nisNewUser : false( DB 조회 확인 기존 유저 ), ture ( DB에 없음 신규 유저 )", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "성공적으로 토큰 발급", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = UserResponseDto.loginDto.class)) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + description = "Invalid Parameter", + content = @Content(mediaType = "application/json") + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "500", + description = "Internal Server Error", + content = @Content(mediaType = "application/json") + ) + } + ) + public ResponseEntity> naverCallback(@RequestParam("code") String code, + HttpServletResponse response) throws IOException { + String accessToken = naverService.getAccessTokenFromNaver(code); + NaverUserInfoResponseDto userInfo = naverService.getUserInfo(accessToken); + + String providerId = userInfo.getResponse().id(); + String email = userInfo.getResponse().email(); + + UserResponseDto.loginDto userResponse = userCommandService.isnewUser(Provider.NAVER, providerId, email); + return ResponseEntity.ok(ApiResponse.onSuccess(userResponse)); + } + + @GetMapping("/oauth/google") + @Operation( + summary = "구글 로그인 및 토큰발급 API", + description = "\"code\" 와 함께 요청시 기존/신규 유저 구분 및 accessToken을 발급합니다. 이떄 발급된 AccessToken은 회원가입 관련 엔드포인트만 접급 가능합니다.\nisNewUser : false( DB 조회 확인 기존 유저 ), ture ( DB에 없음 신규 유저 )", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "성공적으로 토큰 발급", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = UserResponseDto.loginDto.class)) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + description = "Invalid Parameter", + content = @Content(mediaType = "application/json") + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "500", + description = "Internal Server Error", + content = @Content(mediaType = "application/json") + ) + } + ) + public ResponseEntity> googleCallback(@RequestParam("code") String code, + @RequestParam(value = "state", required = false) String state, HttpServletResponse response) throws + IOException { + String accessToken = googleService.getAccessTokenFromGoogle(code); + GoogleUserInfoResponseDto userInfo = googleService.getUserInfo(accessToken); + + String providerId = userInfo.getId(); + String email = userInfo.getEmail(); + + UserResponseDto.loginDto userResponse = userCommandService.isnewUser(Provider.GOOGLE, providerId, email); + + // 로컬 프론트용 state 파라미터 처리 (리다이렉트 URL) + if (state != null && !state.isBlank()) { + String returnUrl = URLDecoder.decode(state, StandardCharsets.UTF_8); + + if (!returnUrl.startsWith("http://localhost:5173")) { + throw new IllegalArgumentException("Invalid returnUrl"); + } + + String ticket = loginTicketService.issue(userResponse); + response.sendRedirect(returnUrl + "?ticket=" + URLEncoder.encode(ticket, StandardCharsets.UTF_8)); + return ResponseEntity.ok(ApiResponse.onSuccess(userResponse)); // (리다이렉트라 실사용은 안됨) + } + + return ResponseEntity.ok(ApiResponse.onSuccess(userResponse)); + } + + @PostMapping("/oauth/google/exchange") + public ResponseEntity> exchange(@RequestBody ExchangeDto dto) { + UserResponseDto.loginDto loginDto = loginTicketService.consume(dto.ticket()); + return ResponseEntity.ok(ApiResponse.onSuccess(loginDto)); + } + + @PatchMapping("/profile") + @Operation( + summary = "회원가입 API", + description = "신규 User 회원가입 시 필요한 정보를 저장합니다.\n" + + "- interests: 다중 선택 가능하며 string입니다. [사이드 프로젝트, 1인 개발, 공모전, 창업, 네트워킹 행사]\n" + ) + public ResponseEntity> signup( + @Valid @RequestBody UserRequestDto.JoinDto request) { + UserSignUpResponseDto user = userCommandService.joinUser(request); + return ResponseEntity.ok(ApiResponse.onSuccess(user)); + } + + @GetMapping("/nickname") + @Operation( + summary = "닉네임 생성 API", + description = "새싹 관련 닉네임이 생성됩니다. 기존 유저와 중복되지 않는 닉네임이 생성됩니다." + ) + public ResponseEntity> nickname() { + NicknameResponseDto nickName = userCommandService.createNickname(); + return ResponseEntity.ok(ApiResponse.onSuccess(nickName)); + } + + @PostMapping("/nickname/save") + @Operation( + summary = "닉네임 저장 및 회원가입 완료 API", + description = "사용자의 닉네임을 저장하고 회원가입을 완료합니다. accessToken과 refreshToken도 함께 반환합니다." + ) + @ApiErrorCodeExamples({ + ErrorStatus.USER_NOT_FOUND + }) + public ResponseEntity> saveNickname(@RequestParam("nickname") String nickname) { + TokenServiceResponse tokenServiceResponse = userCommandService.saveNickname(nickname); + return ResponseEntity.ok(ApiResponse.onSuccess(tokenServiceResponse)); + } + + @PostMapping("/logout") + @Operation( + summary = "회원 로그아웃 API", + description = "AccessToken 토큰과 함께 요청 시 로그아웃" + ) + @ApiErrorCodeExamples({ + ErrorStatus.INVALID_AUTH_TOKEN, + ErrorStatus.USER_NOT_FOUND, + ErrorStatus.REDIS_KEY_NOT_FOUND, + ErrorStatus.REDIS_BLACKLIST_SAVE_FAILED + }) + public ResponseEntity> logout(@RequestHeader("Authorization") String accessToken, + @RequestBody TokenRequestDto refreshToken) { + String response = userCommandService.logout(accessToken, refreshToken.getRefreshToken()); + return ResponseEntity.ok(ApiResponse.onSuccess(response)); + } + + @DeleteMapping("/delete") + @Operation( + summary = "회원 탈퇴 API", + description = "AccessToken 토큰과 함께 요청 시 회원 탈퇴 " + + "-pot 생성자인 경우 softDelet\n" + + "-pot 생성자가 아닌 경우 hardDelet" + ) + @ApiErrorCodeExamples({ + ErrorStatus.USER_NOT_FOUND, + ErrorStatus.REDIS_BLACKLIST_SAVE_FAILED, + ErrorStatus.USER_WITHDRAWAL_FAILED + }) + public ResponseEntity> deleteUser(@RequestHeader("Authorization") String accessToken) { + String response = userCommandService.deleteUser(accessToken); + return ResponseEntity.ok(ApiResponse.onSuccess(response)); + } + + @GetMapping("/{userId}") + @Operation( + summary = "사용자별 정보 조회 API", + description = "userId를 통해 '마이페이지'의 피드, 끓인 팟을 제외한 사용자 정보만을 제공하는 API입니다. 사용자의 Pot, FEED 조회와 조합해서 마이페이지를 제작하실 수 있습니다." + ) + @ApiErrorCodeExamples({ + ErrorStatus.USER_ALREADY_WITHDRAWN, + ErrorStatus.USER_NOT_FOUND + }) + public ResponseEntity> usersPages( + @PathVariable(name = "userId") Long userId) { + UserResponseDto.UserInfoDto userDetails = userCommandService.getUsers(userId); + return ResponseEntity.ok(ApiResponse.onSuccess(userDetails)); + } + + @GetMapping("") + @Operation( + summary = "나의 정보 조회 API", + description = "토큰을 통해 '설정 페이지'와 '마이페이지'의 피드, 끓인 팟을 제외한 사용자 자신의 정보만을 제공하는 API입니다. 사용자의 Pot, FEED 조회와 조합해서 마이페이지를 제작하실 수 있습니다." + ) + @ApiErrorCodeExamples({ + ErrorStatus.USER_NOT_FOUND, + ErrorStatus.USER_ALREADY_WITHDRAWN, + }) + public ResponseEntity> usersMyPages() { + UserResponseDto.UserInfoDto userDetails = userCommandService.getMyUsers(); + return ResponseEntity.ok(ApiResponse.onSuccess(userDetails)); + } + + @PatchMapping("/profile/update") + @Operation( + summary = "나의 프로필 수정 API", + description = "사용자의 역할, 관심사, 한 줄 소개, 카카오 아이디를 수정합니다.\n" + + "- interests: 다중 선택 가능하며 string입니다. [사이드 프로젝트, 1인 개발, 공모전, 창업, 네트워킹 행사]\n" + ) + @ApiErrorCodeExamples({ + ErrorStatus.USER_NOT_FOUND + }) + public ResponseEntity> updateUserProfile( + @RequestBody UserUpdateRequestDto requestDto) { + UserResponseDto.Userdto updatedUser = userCommandService.updateUserProfile(requestDto); + return ResponseEntity.ok(ApiResponse.onSuccess(updatedUser)); + } + + @GetMapping("/pots") + @Operation(summary = "마이페이지 팟 조회 API", description = "나의 모든 팟(모집중 / 진행중 / 완료)을 조회합니다. status = all / recruiting / ongoing / completed") + public ResponseEntity>> getMyAllInvolvedPots( + @RequestParam(name = "potStatus", required = false) String dataType) { + List pots = myPotService.getMyAllInvolvedPots(dataType); + return ResponseEntity.ok(ApiResponse.onSuccess(pots)); + } + + @GetMapping("/pots/{user_id}") + @Operation(summary = "사용자별 마이페이지 팟 조회 API", description = "나의 모든 팟(모집중 / 진행중 / 완료)을 조회합니다. status = all / recruiting / ongoing / completed") + public ResponseEntity>> getUserAllInvolvedPots( + @PathVariable("user_id") Long user_id, @RequestParam(name = "potStatus", required = false) String dataType) { + List pots = myPotService.getUserAllInvolvedPots(user_id, dataType); + return ResponseEntity.ok(ApiResponse.onSuccess(pots)); + } + + @GetMapping("/potAppealContent/{pot_id}") + @Operation( + summary = "마이페이지 '여기서 저는요' 모달 조회 API", + description = "'끓인 팟 상세보기 모달'에 쓰이는 Role, Badge, Appeal Content를 반환합니다." + ) + @ApiErrorCodeExamples({ + ErrorStatus.USER_NOT_FOUND, + }) + public ResponseEntity> getAppealContent( + @PathVariable(name = "pot_id") Long potId) { + AppealContentDto response = myPotService.getAppealContent(potId); + return ResponseEntity.ok(ApiResponse.onSuccess(response)); + } + + @GetMapping("/potAppealContent/{pot_id}/{user_id}") + @Operation( + summary = "다른 사람 마이페이지 '여기서 저는요' 모달 조회 API", + description = "'끓인 팟 상세보기 모달'에 쓰이는 Role, Badge, Appeal Content를 반환합니다." + ) + @ApiErrorCodeExamples({ + ErrorStatus.USER_NOT_FOUND, + }) + public ResponseEntity> getAppealContent( + @PathVariable(name = "pot_id") Long potId, + @PathVariable(name = "user_id") Long userId) { + AppealContentDto response = myPotService.getUserAppealContent(potId, userId); + return ResponseEntity.ok(ApiResponse.onSuccess(response)); + } + + @GetMapping("/potSummary/{pot_id}") + @Operation( + summary = "끓인 팟 AI 요약 모달 조회 API", + description = "끓인 팟을 상세보기할 때 쓰이는 PotSummary, potLan을 반환합니다." + ) + @ApiErrorCodeExamples({ + ErrorStatus.POT_NOT_FOUND + }) + public ResponseEntity> getPotSummary( + @PathVariable(name = "pot_id") Long potId) { + PotSummaryDto response = myPotService.getPotSummary(potId); + return ResponseEntity.ok(ApiResponse.onSuccess(response)); + } + + @PatchMapping("/{pot_id}") + @Operation( + summary = "끓인 팟 수정하기 API", + description = "끓은 팟의 정보를 수정하는 api 입니다." + ) + @ApiErrorCodeExamples({ + ErrorStatus.POT_NOT_FOUND, + ErrorStatus.POT_FORBIDDEN, + }) + public ResponseEntity> updatePot( + @PathVariable(name = "pot_id") Long potId, + @RequestBody @Valid CompletedPotRequestDto requestDto) { + // 팟 수정 로직 호출 + PotResponseDto responseDto = potCommandService.updateCompletedPot(potId, requestDto); + return ResponseEntity.ok(ApiResponse.onSuccess(responseDto)); // 수정된 팟 정보 반환 + } + + @GetMapping("/description") + @Operation( + summary = "나의 소개 조회 API", + description = "로그인한 사용자의 소개를 조회합니다." + ) + @ApiErrorCodeExamples({ + ErrorStatus.USER_NOT_FOUND + }) + public ResponseEntity> getMyDescription() { + MyDescriptionResponseDto responseDto = userQueryService.getMyDescription(); + return ResponseEntity.ok(ApiResponse.onSuccess(responseDto)); + } + + @GetMapping("/description/{userId}") + @Operation( + summary = "특정 사용자의 소개 조회 API", + description = "특정 사용자의 소개를 조회합니다." + ) + @ApiErrorCodeExamples({ + ErrorStatus.USER_NOT_FOUND + }) + public ResponseEntity> getUserDescription(@PathVariable Long userId) { + + MyDescriptionResponseDto responseDto = userQueryService.getUserDescription(userId); + return ResponseEntity.ok(ApiResponse.onSuccess(responseDto)); + } + + @PatchMapping("/description") + @Operation( + summary = "나의 소개 수정 또는 추가 API", + description = "로그인한 사용자의 소개를 수정하거나 추가합니다." + ) + @ApiErrorCodeExamples({ + ErrorStatus.USER_NOT_FOUND, + + }) + public ResponseEntity> upsertMyDescription( + @RequestBody @Valid MyDescriptionRequestDto dto) { + MyDescriptionResponseDto responseDto = userCommandService.upsertDescription(dto); + return ResponseEntity.ok(ApiResponse.onSuccess(responseDto)); + } + + @DeleteMapping("/description") + @Operation( + summary = "나의 소개 삭제 API", + description = "로그인한 사용자의 소개를 삭제합니다." + ) + @ApiErrorCodeExamples({ + ErrorStatus.USER_NOT_FOUND + }) + public ResponseEntity> deleteMyDescription() { + userCommandService.deleteDescription(); + return ResponseEntity.noContent().build(); + } + + @GetMapping("/{userId}/feeds") + @Operation( + summary = "사용자별 피드 조회 API", + description = "userId에 해당하는 사용자의 시리즈 코멘트와 피드를 반환합니다. 피드는 커서 기반 페이지네이션을 지원합니다. \n" + + "시리즈가 '전체보기' 일 때는 seriesId = 0" + ) + @ApiErrorCodeExamples({ + ErrorStatus.USER_NOT_FOUND, + ErrorStatus.USER_ALREADY_WITHDRAWN + }) + public ResponseEntity> getFeedsByUserId( + @Parameter(description = "사용자 ID", example = "1") + @PathVariable("userId") Long userId, + + @Parameter(description = "커서", example = "100", required = false) + @RequestParam(value = "cursor", required = false) Long cursor, + + @Parameter(description = "페이지 크기", example = "10") + @RequestParam(value = "size", defaultValue = "10") int size, + + @RequestParam(value = "seriesId", required = false, defaultValue = "0") Long seriesId + ) { + UserMyPageResponseDto mypage = feedQueryService.getFeedsByUserId(userId, cursor, size, seriesId); + return ResponseEntity.ok(ApiResponse.onSuccess(mypage)); + } + + @Operation( + summary = "나의 피드 조회 API", + description = "로그인한 사용자의 시리즈 코멘트와 피드를 반환합니다. 피드는 커서 기반 페이지네이션을 지원합니다. \n" + + "시리즈가 '전체보기' 일 때는 seriesId = 0" + ) + @GetMapping("/feeds") + @ApiErrorCodeExamples({ + ErrorStatus.USER_NOT_FOUND, + ErrorStatus.USER_ALREADY_WITHDRAWN + }) + public ResponseEntity> getFeeds( + @RequestParam(name = "cursor", required = false) Long cursor, + @RequestParam(name = "size", defaultValue = "10") int size, + @RequestParam(value = "seriesId", required = false, defaultValue = "0") Long seriesId + ) { + UserMyPageResponseDto mypage = feedQueryService.getFeeds(cursor, size, seriesId); + return ResponseEntity.ok(ApiResponse.onSuccess(mypage)); + } } diff --git a/src/main/java/stackpot/stackpot/user/dto/request/ExchangeDto.java b/src/main/java/stackpot/stackpot/user/dto/request/ExchangeDto.java new file mode 100644 index 0000000..33a4125 --- /dev/null +++ b/src/main/java/stackpot/stackpot/user/dto/request/ExchangeDto.java @@ -0,0 +1,4 @@ +package stackpot.stackpot.user.dto.request; + +public record ExchangeDto(String ticket) { +} diff --git a/src/main/java/stackpot/stackpot/user/service/LoginTicketService.java b/src/main/java/stackpot/stackpot/user/service/LoginTicketService.java new file mode 100644 index 0000000..d5237f1 --- /dev/null +++ b/src/main/java/stackpot/stackpot/user/service/LoginTicketService.java @@ -0,0 +1,65 @@ +package stackpot.stackpot.user.service; + +import java.time.Duration; +import java.util.UUID; + +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; +import stackpot.stackpot.apiPayload.code.status.ErrorStatus; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.RequiredArgsConstructor; +import stackpot.stackpot.apiPayload.exception.handler.UserHandler; +import stackpot.stackpot.user.dto.response.UserResponseDto; + +@Service +@RequiredArgsConstructor +public class LoginTicketService { + + private static final String KEY_PREFIX = "login-ticket:"; + private static final Duration TTL = Duration.ofMinutes(2); + + private final StringRedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + + public String issue(UserResponseDto.loginDto dto) { + String ticket = UUID.randomUUID().toString(); + String key = KEY_PREFIX + ticket; + + String payload = toJson(dto); + + redisTemplate.opsForValue().set(key, payload, TTL); + return ticket; + } + + public UserResponseDto.loginDto consume(String ticket) { + String key = KEY_PREFIX + ticket; + + String payload = redisTemplate.opsForValue().get(key); + if (payload == null) { + throw new UserHandler(ErrorStatus.LOGIN_TICKET_EXPIRED); + } + + // 1회용 처리 + redisTemplate.delete(key); + + return fromJson(payload); + } + + private String toJson(UserResponseDto.loginDto dto) { + try { + return objectMapper.writeValueAsString(dto); + } catch (JsonProcessingException e) { + throw new UserHandler(ErrorStatus.LOGIN_TICKET_SERIALIZE_FAILED); + } + } + + private UserResponseDto.loginDto fromJson(String payload) { + try { + return objectMapper.readValue(payload, UserResponseDto.loginDto.class); + } catch (JsonProcessingException e) { + throw new UserHandler(ErrorStatus.LOGIN_TICKET_DESERIALIZE_FAILED); + } + } +}