diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index 913376b..f7ca5d8 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -8,6 +8,18 @@ on: jobs: build: runs-on: ubuntu-latest + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + AWS_DEFAULT_REGION: ${{ secrets.AWS_DEFAULT_REGION }} + S3_BUCKET: ${{ secrets.S3_BUCKET }} + JWT_SECRET: ${{ secrets.JWT_SECRET }} + # GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} + # GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }} + # GOOGLE_REDIRECT_URI: ${{ secrets.GOOGLE_REDIRECT_URI }} + # KAKAO_CLIENT_ID: ${{ secrets.KAKAO_CLIENT_ID }} + # KAKAO_CLIENT_SECRET: ${{ secrets.KAKAO_CLIENT_SECRET }} + # KAKAO_REDIRECT_URI: ${{ secrets.KAKAO_REDIRECT_URI }} steps: - name: ✔️ 리포지토리 가져오기 @@ -26,4 +38,4 @@ jobs: run: ./gradlew build -x installLocalGitHook -x spotlessInternalRegisterDependencies -x spotlessJava -x spotlessJavaApply -x spotlessApply -x spotlessJavaCheck -x spotlessCheck -x test - name: ✔️ Gradle test - run: ./gradlew --info test -Dspring.profiles.active=test \ No newline at end of file + run: ./gradlew --info test -Dspring.profiles.active=test diff --git a/.gitignore b/.gitignore index 4b6f09b..2f77959 100644 --- a/.gitignore +++ b/.gitignore @@ -40,4 +40,7 @@ out/ # General .DS_Store .AppleDouble -.LSOverride \ No newline at end of file +.LSOverride + +### aws 및 환경변수 정보 ### +env/ diff --git a/build.gradle b/build.gradle index 5f5c061..bba05d2 100644 --- a/build.gradle +++ b/build.gradle @@ -45,7 +45,7 @@ dependencies { testRuntimeOnly 'com.h2database:h2' // 스웨거 - implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.4.0' // 스프링 시큐리티 implementation 'org.springframework.boot:spring-boot-starter-security' @@ -58,6 +58,13 @@ dependencies { // validation implementation 'org.springframework.boot:spring-boot-starter-validation' + + // s3 + implementation(platform("io.awspring.cloud:spring-cloud-aws-dependencies:3.1.1")) + implementation("io.awspring.cloud:spring-cloud-aws-starter-s3") + + // oauth2 + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' } tasks.named('test') { diff --git a/docker/deploy/deploy.sh b/docker/deploy/deploy.sh index 32e0e1c..e14332f 100644 --- a/docker/deploy/deploy.sh +++ b/docker/deploy/deploy.sh @@ -20,4 +20,23 @@ fi echo "dangling 이미지 삭제" docker image prune -f +echo "멈춘 container 삭제" +docker container prune -f + +for i in {1..10}; do + if [ "$i" -eq 10 ]; then + echo "Health check failed" + docker compose down + exit 1 + fi + + if curl "http://localhost:8080/health"; then + echo "컨테이너가 정상적으로 실행되었습니다..." + break + fi + + echo "spring boot application health check 중..." + sleep 15 +done + echo "모든 작업이 완료되었습니다." \ No newline at end of file diff --git a/docker/deploy/docker-compose.yml b/docker/deploy/docker-compose.yml index bac4dc5..930290a 100644 --- a/docker/deploy/docker-compose.yml +++ b/docker/deploy/docker-compose.yml @@ -30,11 +30,6 @@ services: restart: always depends_on: - database - healthcheck: - test: [ "CMD", "curl", "-f", "http://localhost:8080/health" ] - interval: 20s - timeout: 10s - retries: 5 networks: umc_code_play: diff --git a/env/local-db.env b/env/local-db.env deleted file mode 100644 index d578ea8..0000000 --- a/env/local-db.env +++ /dev/null @@ -1,5 +0,0 @@ -MYSQL_DATABASE=codeplay_local -MYSQL_USER=codeplay -MYSQL_PASSWORD=QWER1234QWER1234XCVBNMASDFGH -MYSQL_ROOT_PASSWORD=QWER1234QWER1234XCVBNMASDFGH -TZ=Asia/Seoul diff --git a/env/local-spring.env b/env/local-spring.env deleted file mode 100644 index d071728..0000000 --- a/env/local-spring.env +++ /dev/null @@ -1,13 +0,0 @@ -# MySQL 설정 -DB_URL=jdbc:mysql://localhost:3306/codeplay_local -MYSQL_USERNAME=codeplay -MYSQL_PASSWORD=QWER1234QWER1234XCVBNMASDFGH - -# Spring Boot 설정 -JWT_SECRET=qwertyuiopokjhSDFGHJKIUYTREDCVBNMKIJKJHGFHYTRFCVBGFDSXCVBHH - -# AWS S3 settings - -# Kakao settings - -# Google settings \ No newline at end of file diff --git a/env/prod-db.env b/env/prod-db.env deleted file mode 100644 index 5bc2231..0000000 --- a/env/prod-db.env +++ /dev/null @@ -1,5 +0,0 @@ -MYSQL_DATABASE=codeplay_prod -MYSQL_USER=codeplay -MYSQL_PASSWORD=QWER1234QWER1234XCVBNMASDFGH -MYSQL_ROOT_PASSWORD=QWER1234QWER1234XCVBNMASDFGH -TZ=Asia/Seoul \ No newline at end of file diff --git a/env/prod-spring.env b/env/prod-spring.env deleted file mode 100644 index b335105..0000000 --- a/env/prod-spring.env +++ /dev/null @@ -1,14 +0,0 @@ -# MySQL 설정 -DB_URL=jdbc:mysql://codeplay-db:3306/codeplay_prod?useSSL=false&allowPublicKeyRetrieval=true -MYSQL_USERNAME=codeplay -MYSQL_PASSWORD=QWER1234QWER1234XCVBNMASDFGH - -# Spring Boot 설정 -SPRING_PROFILES_ACTIVE=prod -JWT_SECRET=qwertyuiopokjhSDFGHJKIUYTREDCVBNMKIJKJHGFHYTRFCVBGFDSXCVBHH - -# AWS S3 settings - -# Kakao settings - -# Google settings \ No newline at end of file diff --git a/src/main/java/umc/codeplay/CodeplayApplication.java b/src/main/java/umc/codeplay/CodeplayApplication.java index ebe4f4d..b2f1000 100644 --- a/src/main/java/umc/codeplay/CodeplayApplication.java +++ b/src/main/java/umc/codeplay/CodeplayApplication.java @@ -2,7 +2,9 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +@EnableJpaAuditing @SpringBootApplication public class CodeplayApplication { diff --git a/src/main/java/umc/codeplay/apiPayLoad/code/status/ErrorStatus.java b/src/main/java/umc/codeplay/apiPayLoad/code/status/ErrorStatus.java index df8f9a0..30ebaa6 100644 --- a/src/main/java/umc/codeplay/apiPayLoad/code/status/ErrorStatus.java +++ b/src/main/java/umc/codeplay/apiPayLoad/code/status/ErrorStatus.java @@ -23,7 +23,21 @@ public enum ErrorStatus implements BaseErrorCode { NOT_AUTHORIZED(HttpStatus.BAD_REQUEST, "AUTH400", "인증되지 않은 요청입니다."), ID_OR_PASSWORD_WRONG(HttpStatus.BAD_REQUEST, "AUTH401", "아이디 혹은 비밀번호가 잘못되었습니다."), - INVALID_REFRESH_TOKEN(HttpStatus.BAD_REQUEST, "AUTH402", "유효하지 않은 리프레시 토큰입니다."); + INVALID_REFRESH_TOKEN(HttpStatus.BAD_REQUEST, "AUTH402", "유효하지 않은 리프레시 토큰입니다."), + OAUTH_TOKEN_REQUEST_FAILED(HttpStatus.BAD_REQUEST, "AUTH403", "외부인증 토큰 요청에 실패했습니다."), + OAUTH_USERINFO_REQUEST_FAILED(HttpStatus.BAD_REQUEST, "AUTH404", "외부인증 유저 정보 요청에 실패했습니다."), + AUTHORIZATION_METHOD_ERROR(HttpStatus.BAD_REQUEST, "AUTH405", "인증 방식이 잘못되었습니다."), + INVALID_OAUTH_PROVIDER(HttpStatus.BAD_REQUEST, "AUTH406", "유효하지 않은 OAuth 제공자입니다."), + + AWS_SERVICE_UNAVAILABLE(HttpStatus.BAD_REQUEST, "AWS400", "AWS S3에 파일을 업로드할 수 없습니다."), + AWS_METHOD_NOT_ALLOWED( + HttpStatus.METHOD_NOT_ALLOWED, + "AWS405", + "AWS S3 presigned url에서 해당 method는 허용되지 않습니다."), + + MUSIC_NOT_FOUND(HttpStatus.BAD_REQUEST, "MUSIC400", "음원을 찾을 수 없습니다."), + + LIKE_NOT_FOUND(HttpStatus.BAD_REQUEST, "LIKE400", "해당 좋아요를 찾을 수 없습니다."); private final HttpStatus httpStatus; private final String code; diff --git a/src/main/java/umc/codeplay/config/AWSConfig.java b/src/main/java/umc/codeplay/config/AWSConfig.java new file mode 100644 index 0000000..dea3721 --- /dev/null +++ b/src/main/java/umc/codeplay/config/AWSConfig.java @@ -0,0 +1,33 @@ +package umc.codeplay.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; + +@Configuration +public class AWSConfig { + + @Value("${spring.cloud.aws.credentials.access-key}") + private String accessKey; + + @Value("${spring.cloud.aws.credentials.secret-key}") + private String secretKey; + + @Value("${spring.cloud.aws.region.static}") + private String region; + + @Bean + public S3Presigner s3Presigner() { + return S3Presigner.builder() + .region(Region.of(region)) + .credentialsProvider( + StaticCredentialsProvider.create( + AwsBasicCredentials.create(accessKey, secretKey))) + .build(); + } +} diff --git a/src/main/java/umc/codeplay/config/SecurityConfig.java b/src/main/java/umc/codeplay/config/SecurityConfig.java index fca2218..7e54f03 100644 --- a/src/main/java/umc/codeplay/config/SecurityConfig.java +++ b/src/main/java/umc/codeplay/config/SecurityConfig.java @@ -60,10 +60,10 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti auth // 로그인, 회원가입 등 토큰 없이 접근해야 하는 API 허용 .requestMatchers( + "/oauth/**", "/health", - "/auth/refresh", - "/auth/signup", - "/auth/login", + "/health/s3", + "/auth/**", "/v2/api-docs", "/v3/api-docs", "/v3/api-docs/**", diff --git a/src/main/java/umc/codeplay/config/properties/BaseOAuthProperties.java b/src/main/java/umc/codeplay/config/properties/BaseOAuthProperties.java new file mode 100644 index 0000000..369bf57 --- /dev/null +++ b/src/main/java/umc/codeplay/config/properties/BaseOAuthProperties.java @@ -0,0 +1,28 @@ +package umc.codeplay.config.properties; + +import lombok.Data; + +@Data +public class BaseOAuthProperties { + + private String clientId; + private String clientSecret; + private String redirectUri; + private String scope; + private String authorizationUri; + private String tokenUri; + private String userInfoUri; + private String additionalParameters; + + public String getUrl() { + return authorizationUri + + "?client_id=" + + clientId + + "&redirect_uri=" + + redirectUri + + "&response_type=code" + + "&scope=" + + scope + + additionalParameters; + } +} diff --git a/src/main/java/umc/codeplay/config/properties/GoogleOAuthProperties.java b/src/main/java/umc/codeplay/config/properties/GoogleOAuthProperties.java new file mode 100644 index 0000000..88ea754 --- /dev/null +++ b/src/main/java/umc/codeplay/config/properties/GoogleOAuthProperties.java @@ -0,0 +1,10 @@ +package umc.codeplay.config.properties; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Component +@ConfigurationProperties(prefix = "google.oauth2") +public class GoogleOAuthProperties extends BaseOAuthProperties { + // BaseOAuthProperties 의 필드를 그대로 상속받아 사용. +} diff --git a/src/main/java/umc/codeplay/config/properties/KakaoOAuthProperties.java b/src/main/java/umc/codeplay/config/properties/KakaoOAuthProperties.java new file mode 100644 index 0000000..544213b --- /dev/null +++ b/src/main/java/umc/codeplay/config/properties/KakaoOAuthProperties.java @@ -0,0 +1,10 @@ +package umc.codeplay.config.properties; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Component +@ConfigurationProperties(prefix = "kakao.oauth2") +public class KakaoOAuthProperties extends BaseOAuthProperties { + // BaseOAuthProperties 의 필드를 그대로 상속받아 사용. +} diff --git a/src/main/java/umc/codeplay/controller/AuthController.java b/src/main/java/umc/codeplay/controller/AuthController.java index a082dae..3dcb448 100644 --- a/src/main/java/umc/codeplay/controller/AuthController.java +++ b/src/main/java/umc/codeplay/controller/AuthController.java @@ -2,6 +2,8 @@ import java.util.Collection; import java.util.stream.Collectors; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; @@ -13,12 +15,12 @@ import lombok.RequiredArgsConstructor; -import io.swagger.v3.oas.annotations.security.SecurityRequirement; import umc.codeplay.apiPayLoad.ApiResponse; import umc.codeplay.apiPayLoad.code.status.ErrorStatus; import umc.codeplay.apiPayLoad.exception.handler.GeneralHandler; import umc.codeplay.converter.MemberConverter; import umc.codeplay.domain.Member; +import umc.codeplay.domain.enums.SocialStatus; import umc.codeplay.dto.MemberRequestDTO; import umc.codeplay.dto.MemberResponseDTO; import umc.codeplay.jwt.JwtUtil; @@ -36,7 +38,11 @@ public class AuthController { @PostMapping("/login") public ApiResponse login( - @RequestBody MemberRequestDTO.LoginDto request) { + @Validated @RequestBody MemberRequestDTO.LoginDto request) { + if (memberService.getSocialStatus(request.getEmail()) != SocialStatus.NONE) { + throw new GeneralHandler(ErrorStatus.AUTHORIZATION_METHOD_ERROR); + } + // 아이디/비밀번호를 사용해 AuthenticationToken 생성 UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(request.getEmail(), request.getPassword()); @@ -62,7 +68,7 @@ public ApiResponse login( @PostMapping("/signup") public ApiResponse join( - @RequestBody MemberRequestDTO.JoinDto request) { + @Validated @RequestBody MemberRequestDTO.JoinDto request) { Member member = memberService.joinMember(request); MemberResponseDTO.JoinResultDTO newJoinResult = MemberConverter.toJoinResultDTO(member); @@ -71,8 +77,8 @@ public ApiResponse join( @PostMapping("/refresh") public ApiResponse refresh( - @RequestHeader("Refresh-Token") String refreshToken, - @RequestParam("email") String email) { + @RequestHeader("Refresh-Token") @NotNull(message = "리프레시 토큰은 필수 헤더입니다.") String refreshToken, + @Validated @RequestParam("email") @NotBlank(message = "이메일은 필수 입력값입니다.") String email) { // 리프레시 토큰 유효성 검사 if (jwtUtil.validateToken(refreshToken) && (jwtUtil.getTypeFromToken(refreshToken).equals("refresh"))) { @@ -98,10 +104,4 @@ public ApiResponse refresh( throw new GeneralHandler(ErrorStatus.INVALID_REFRESH_TOKEN); } } - - @SecurityRequirement(name = "JWT TOKEN") - @GetMapping("/test") - public ApiResponse test() { - return ApiResponse.onSuccess("test"); - } } diff --git a/src/main/java/umc/codeplay/controller/FileController.java b/src/main/java/umc/codeplay/controller/FileController.java new file mode 100644 index 0000000..d22fab0 --- /dev/null +++ b/src/main/java/umc/codeplay/controller/FileController.java @@ -0,0 +1,49 @@ +package umc.codeplay.controller; + +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.*; + +import lombok.RequiredArgsConstructor; + +import io.swagger.v3.oas.annotations.Operation; +import software.amazon.awssdk.http.SdkHttpMethod; +import umc.codeplay.apiPayLoad.ApiResponse; +import umc.codeplay.dto.FileResponseDTO; +import umc.codeplay.service.FileService; + +import static umc.codeplay.service.FileService.buildFilename; + +@RestController +@RequestMapping("/files") +@RequiredArgsConstructor +public class FileController { + + private final FileService fileService; + + @Operation( + summary = "Download용 Presigned URL 생성", + description = "다운로드를 위한 Presigned URL 생성 - 유효시간 존재") + @GetMapping("/download") + public ApiResponse getUrl( + @RequestParam(value = "fileName") String fileName) { + String downloadUrl = fileService.generatePreSignedUrl(fileName, SdkHttpMethod.GET); + FileResponseDTO.DownloadFile result = new FileResponseDTO.DownloadFile(downloadUrl); + + return ApiResponse.onSuccess(result); + } + + @Operation( + summary = "Upload용 Presigned URL 생성", + description = "업로드를 위한 Presigned URL 생성 - 유효시간 존재") + @PostMapping("/upload") + public ApiResponse generateUrl( + @RequestParam(value = "fileName") String fileName) { + String username = SecurityContextHolder.getContext().getAuthentication().getName(); + String newFileName = buildFilename(fileName); + Long musicId = fileService.uploadMusic(newFileName, username); + + String uploadUrl = fileService.generatePreSignedUrl(newFileName, SdkHttpMethod.PUT); + FileResponseDTO.UploadFile result = new FileResponseDTO.UploadFile(uploadUrl, musicId); + return ApiResponse.onSuccess(result); + } +} diff --git a/src/main/java/umc/codeplay/controller/HealthCheck.java b/src/main/java/umc/codeplay/controller/HealthCheck.java index c8f7bcc..ff66ff8 100644 --- a/src/main/java/umc/codeplay/controller/HealthCheck.java +++ b/src/main/java/umc/codeplay/controller/HealthCheck.java @@ -2,12 +2,21 @@ 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.RestController; +import lombok.RequiredArgsConstructor; + +import io.swagger.v3.oas.annotations.Hidden; + +@Hidden @RestController +@RequestMapping("/health") +@RequiredArgsConstructor public class HealthCheck { - @GetMapping("/health") + // 연결 확인 용 + @GetMapping("") public ResponseEntity healthCheck() { return ResponseEntity.ok("UMC 7th CodePlay Well Connected!"); } diff --git a/src/main/java/umc/codeplay/controller/LikeController.java b/src/main/java/umc/codeplay/controller/LikeController.java new file mode 100644 index 0000000..5fda8c8 --- /dev/null +++ b/src/main/java/umc/codeplay/controller/LikeController.java @@ -0,0 +1,46 @@ +package umc.codeplay.controller; + +import jakarta.validation.Valid; + +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +import lombok.RequiredArgsConstructor; + +import umc.codeplay.apiPayLoad.ApiResponse; +import umc.codeplay.converter.MusicLikeConverter; +import umc.codeplay.domain.Music; +import umc.codeplay.domain.mapping.MusicLike; +import umc.codeplay.dto.LikeRequestDTO; +import umc.codeplay.dto.LikeResponseDTO; +import umc.codeplay.service.LikeService; + +@RestController +@RequiredArgsConstructor +public class LikeController { + + private final LikeService likeService; + + @PostMapping("/like/add") + public ApiResponse addLike( + @RequestBody @Valid LikeRequestDTO.addLikeRequestDTO request) { + String username = SecurityContextHolder.getContext().getAuthentication().getName(); + + MusicLike like = likeService.addLike(username, request); + // 로그인 한 username, request 받아서 MusicLike 추가하고 해당 musicId 반환 + + return ApiResponse.onSuccess(MusicLikeConverter.toLikeResponseDTO(like)); + } + + @PostMapping("/like/remove") + public ApiResponse removeLike( + @RequestBody @Valid LikeRequestDTO.removeLikeRequestDTO request) { + String username = SecurityContextHolder.getContext().getAuthentication().getName(); + + Music music = likeService.removeLike(username, request); + + return ApiResponse.onSuccess(MusicLikeConverter.toRemoveLikeResponseDTO(music)); + } +} diff --git a/src/main/java/umc/codeplay/controller/OAuthController.java b/src/main/java/umc/codeplay/controller/OAuthController.java new file mode 100644 index 0000000..a1e3552 --- /dev/null +++ b/src/main/java/umc/codeplay/controller/OAuthController.java @@ -0,0 +1,151 @@ +package umc.codeplay.controller; + +import java.util.List; +import java.util.Map; + +import org.springframework.http.*; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.servlet.view.RedirectView; + +import lombok.RequiredArgsConstructor; + +import umc.codeplay.apiPayLoad.ApiResponse; +import umc.codeplay.apiPayLoad.code.status.ErrorStatus; +import umc.codeplay.apiPayLoad.exception.handler.GeneralHandler; +import umc.codeplay.config.properties.BaseOAuthProperties; +import umc.codeplay.config.properties.GoogleOAuthProperties; +import umc.codeplay.config.properties.KakaoOAuthProperties; +import umc.codeplay.domain.Member; +import umc.codeplay.domain.enums.SocialStatus; +import umc.codeplay.dto.MemberResponseDTO; +import umc.codeplay.jwt.JwtUtil; +import umc.codeplay.service.MemberService; + +@RestController +@RequestMapping("/oauth") +@RequiredArgsConstructor +@Validated +public class OAuthController { + + private final JwtUtil jwtUtil; + private final RestTemplate restTemplate = new RestTemplate(); + private final GoogleOAuthProperties googleOAuthProperties; + private final KakaoOAuthProperties kakaoOAuthProperties; + private final MemberService memberService; + + @GetMapping("/authorize/{provider}") + public RedirectView redirectToOAuth(@PathVariable("provider") String provider) { + // CSRF 방어용 state, PKCE(code_challenge)..는 굳이 + BaseOAuthProperties properties = + switch (provider) { + case "google" -> googleOAuthProperties; + case "kakao" -> kakaoOAuthProperties; + default -> throw new GeneralHandler(ErrorStatus.INVALID_OAUTH_PROVIDER); + }; + + String url = properties.getUrl(); + + RedirectView redirectView = new RedirectView(); + redirectView.setUrl(url); + return redirectView; + } + + @GetMapping("/callback/{provider}") + public ApiResponse OAuthCallback( + @RequestParam("code") String code, @PathVariable("provider") String provider) { + BaseOAuthProperties properties = + switch (provider) { + case "google" -> googleOAuthProperties; + case "kakao" -> kakaoOAuthProperties; + default -> throw new GeneralHandler(ErrorStatus.INVALID_OAUTH_PROVIDER); + }; + // (1) 받은 code 로 구글 토큰 엔드포인트에 Access/ID Token 교환 + Map tokenResponse = requestOAuthToken(code, properties); + + // (2) 받아온 Access Token(or ID Token)을 통해 사용자 정보 가져오기 + // String idToken = (String) tokenResponse.get("id_token"); // OIDC + String accessToken = (String) tokenResponse.get("access_token"); + Map userInfo = requestOAuthUserInfo(accessToken, properties); + String email = null; + String name = null; + switch (provider) { + case "google" -> { + // (3-a) 구글 UserInfo Endpoint 로 이메일, 프로필 등 조회 + email = (String) userInfo.get("email"); + name = (String) userInfo.get("name"); + } + case "kakao" -> { + // (3-b) 카카오 UserInfo Endpoint 로 이메일, 프로필 등 조회 + Map kakaoAccount = + (Map) userInfo.get("kakao_account"); + Map kakaoProperties = + (Map) userInfo.get("properties"); + email = (String) kakaoAccount.get("email"); + name = (String) kakaoProperties.get("nickname"); + } + } + + // (4) 우리 DB에서 회원 조회 or 생성 + Member member = + memberService.findOrCreateOAuthMember( + email, name, SocialStatus.valueOf(provider.toUpperCase())); + + // (5) JWTUtil 이용해서 Access/Refresh 토큰 발급 + var authorities = List.of(new SimpleGrantedAuthority("ROLE_" + member.getRole().name())); + + String serviceAccessToken = jwtUtil.generateToken(email, authorities); + String serviceRefreshToken = jwtUtil.generateRefreshToken(email, authorities); + + // (6) 최종적으로 JWT(액세스/리프레시)를 프론트에 응답 + return ApiResponse.onSuccess( + MemberResponseDTO.LoginResultDTO.builder() + .email(email) + .token(serviceAccessToken) + .refreshToken(serviceRefreshToken) + .build()); + } + + private Map requestOAuthToken(String code, BaseOAuthProperties properties) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("grant_type", "authorization_code"); + params.add("client_id", properties.getClientId()); + params.add("client_secret", properties.getClientSecret()); + params.add("redirect_uri", properties.getRedirectUri()); + params.add("code", code); + + HttpEntity> request = new HttpEntity<>(params, headers); + + ResponseEntity response = + restTemplate.postForEntity(properties.getTokenUri(), request, Map.class); + + if (response.getStatusCode() == HttpStatus.OK) { + return response.getBody(); + } + throw new GeneralHandler(ErrorStatus.OAUTH_TOKEN_REQUEST_FAILED); + } + + private Map requestOAuthUserInfo( + String accessToken, BaseOAuthProperties properties) { + HttpHeaders headers = new HttpHeaders(); + headers.set("Authorization", "Bearer " + accessToken); + + HttpEntity request = new HttpEntity<>(headers); + + ResponseEntity response = + restTemplate.exchange( + properties.getUserInfoUri(), HttpMethod.GET, request, Map.class); + + if (response.getStatusCode() == HttpStatus.OK) { + return response.getBody(); + } + throw new GeneralHandler(ErrorStatus.OAUTH_USERINFO_REQUEST_FAILED); + } +} diff --git a/src/main/java/umc/codeplay/converter/MemberConverter.java b/src/main/java/umc/codeplay/converter/MemberConverter.java index 7c5c0b8..70f43d3 100644 --- a/src/main/java/umc/codeplay/converter/MemberConverter.java +++ b/src/main/java/umc/codeplay/converter/MemberConverter.java @@ -1,6 +1,8 @@ package umc.codeplay.converter; import umc.codeplay.domain.Member; +import umc.codeplay.domain.enums.Role; +import umc.codeplay.domain.enums.SocialStatus; import umc.codeplay.dto.MemberRequestDTO; import umc.codeplay.dto.MemberResponseDTO; @@ -12,7 +14,8 @@ public static Member toMember(MemberRequestDTO.JoinDto request) { .name(request.getName()) .email(request.getEmail()) .password(request.getPassword()) - .role(request.getRole()) + .role(Role.USER) + .socialStatus(SocialStatus.NONE) .build(); } diff --git a/src/main/java/umc/codeplay/converter/MusicLikeConverter.java b/src/main/java/umc/codeplay/converter/MusicLikeConverter.java new file mode 100644 index 0000000..b0f5e31 --- /dev/null +++ b/src/main/java/umc/codeplay/converter/MusicLikeConverter.java @@ -0,0 +1,27 @@ +package umc.codeplay.converter; + +import umc.codeplay.domain.Member; +import umc.codeplay.domain.Music; +import umc.codeplay.domain.mapping.MusicLike; +import umc.codeplay.dto.LikeResponseDTO; + +public class MusicLikeConverter { + + public static MusicLike toMusicLike(Member member, Music music) { + + return MusicLike.builder().member(member).music(music).build(); + } + + public static LikeResponseDTO.addLikeResponseDTO toLikeResponseDTO(MusicLike like) { + + return LikeResponseDTO.addLikeResponseDTO + .builder() + .musicId(like.getMusic().getId()) + .like(like) + .build(); + } + + public static LikeResponseDTO.removeLikeResponseDTO toRemoveLikeResponseDTO(Music music) { + return LikeResponseDTO.removeLikeResponseDTO.builder().musicId(music.getId()).build(); + } +} diff --git a/src/main/java/umc/codeplay/domain/Harmony.java b/src/main/java/umc/codeplay/domain/Harmony.java new file mode 100644 index 0000000..51018ee --- /dev/null +++ b/src/main/java/umc/codeplay/domain/Harmony.java @@ -0,0 +1,38 @@ +package umc.codeplay.domain; + +import jakarta.persistence.*; + +import lombok.*; + +import umc.codeplay.domain.common.BaseEntity; + +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class Harmony extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 50) + private String title; + + private String harmonyKey; + + private String scale; + + private String chord; + + private Integer bpm; + + private Integer soundPressure; + + @Column(columnDefinition = "TEXT") + private String harmonyUrl; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "music_id") + private Music music; +} diff --git a/src/main/java/umc/codeplay/domain/Member.java b/src/main/java/umc/codeplay/domain/Member.java index 8d7d6ed..532e639 100644 --- a/src/main/java/umc/codeplay/domain/Member.java +++ b/src/main/java/umc/codeplay/domain/Member.java @@ -1,24 +1,23 @@ package umc.codeplay.domain; -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; +import java.util.ArrayList; +import java.util.List; +import jakarta.persistence.*; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; +import lombok.*; +import umc.codeplay.domain.common.BaseEntity; import umc.codeplay.domain.enums.Role; +import umc.codeplay.domain.enums.SocialStatus; +import umc.codeplay.domain.mapping.MusicLike; @Entity @Getter +@Setter @Builder @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor -public class Member { +public class Member extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -30,9 +29,19 @@ public class Member { private String email; + @Enumerated(EnumType.STRING) private Role role; + @Enumerated(EnumType.STRING) + private SocialStatus socialStatus; + public void encodePassword(String password) { this.password = password; } + + @Column(columnDefinition = "TEXT") + private String profileUrl; + + @OneToMany(mappedBy = "member", cascade = CascadeType.ALL) + private List likeList = new ArrayList<>(); } diff --git a/src/main/java/umc/codeplay/domain/Music.java b/src/main/java/umc/codeplay/domain/Music.java new file mode 100644 index 0000000..8bd87a8 --- /dev/null +++ b/src/main/java/umc/codeplay/domain/Music.java @@ -0,0 +1,34 @@ +package umc.codeplay.domain; + +import java.util.ArrayList; +import java.util.List; +import jakarta.persistence.*; + +import lombok.*; + +import umc.codeplay.domain.common.BaseEntity; +import umc.codeplay.domain.mapping.MusicLike; + +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class Music extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 50) + private String title; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") + private Member member; + + @Column(columnDefinition = "TEXT") + private String musicUrl; + + @OneToMany(mappedBy = "music", cascade = CascadeType.ALL) + private List likeList = new ArrayList<>(); +} diff --git a/src/main/java/umc/codeplay/domain/Track.java b/src/main/java/umc/codeplay/domain/Track.java new file mode 100644 index 0000000..a211b86 --- /dev/null +++ b/src/main/java/umc/codeplay/domain/Track.java @@ -0,0 +1,35 @@ +package umc.codeplay.domain; + +import jakarta.persistence.*; + +import lombok.*; + +import umc.codeplay.domain.common.BaseEntity; + +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class Track extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 50) + private String title; + + @Column(columnDefinition = "TEXT") + private String guitarUrl; + + @Column(columnDefinition = "TEXT") + private String drumUrl; + + @Column(columnDefinition = "TEXT") + private String keyboardUrl; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "music_id") + private Music music; +} diff --git a/src/main/java/umc/codeplay/domain/common/BaseEntity.java b/src/main/java/umc/codeplay/domain/common/BaseEntity.java new file mode 100644 index 0000000..0dc3cff --- /dev/null +++ b/src/main/java/umc/codeplay/domain/common/BaseEntity.java @@ -0,0 +1,21 @@ +package umc.codeplay.domain.common; + +import java.time.LocalDateTime; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; + +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import lombok.Getter; + +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +@Getter +public abstract class BaseEntity { + + @CreatedDate private LocalDateTime createdAt; + + @LastModifiedDate private LocalDateTime updatedAt; +} diff --git a/src/main/java/umc/codeplay/domain/enums/SocialStatus.java b/src/main/java/umc/codeplay/domain/enums/SocialStatus.java new file mode 100644 index 0000000..9a4015d --- /dev/null +++ b/src/main/java/umc/codeplay/domain/enums/SocialStatus.java @@ -0,0 +1,7 @@ +package umc.codeplay.domain.enums; + +public enum SocialStatus { + GOOGLE, + KAKAO, + NONE +} diff --git a/src/main/java/umc/codeplay/domain/mapping/MusicLike.java b/src/main/java/umc/codeplay/domain/mapping/MusicLike.java new file mode 100644 index 0000000..1a438d4 --- /dev/null +++ b/src/main/java/umc/codeplay/domain/mapping/MusicLike.java @@ -0,0 +1,28 @@ +package umc.codeplay.domain.mapping; + +import jakarta.persistence.*; + +import lombok.*; + +import umc.codeplay.domain.Member; +import umc.codeplay.domain.Music; +import umc.codeplay.domain.common.BaseEntity; + +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class MusicLike extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") + private Member member; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "music_id") + private Music music; +} diff --git a/src/main/java/umc/codeplay/dto/FileResponseDTO.java b/src/main/java/umc/codeplay/dto/FileResponseDTO.java new file mode 100644 index 0000000..3fd5f41 --- /dev/null +++ b/src/main/java/umc/codeplay/dto/FileResponseDTO.java @@ -0,0 +1,20 @@ +package umc.codeplay.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +public class FileResponseDTO { + + @Getter + @AllArgsConstructor + public static class DownloadFile { + private String downloadS3Url; + } + + @Getter + @AllArgsConstructor + public static class UploadFile { + private String uploadS3Url; + private Long musicId; + } +} diff --git a/src/main/java/umc/codeplay/dto/LikeRequestDTO.java b/src/main/java/umc/codeplay/dto/LikeRequestDTO.java new file mode 100644 index 0000000..4592517 --- /dev/null +++ b/src/main/java/umc/codeplay/dto/LikeRequestDTO.java @@ -0,0 +1,16 @@ +package umc.codeplay.dto; + +import lombok.Getter; + +public class LikeRequestDTO { + + @Getter + public static class addLikeRequestDTO { + Long musicId; + } + + @Getter + public static class removeLikeRequestDTO { + Long musicId; + } +} diff --git a/src/main/java/umc/codeplay/dto/LikeResponseDTO.java b/src/main/java/umc/codeplay/dto/LikeResponseDTO.java new file mode 100644 index 0000000..84ade09 --- /dev/null +++ b/src/main/java/umc/codeplay/dto/LikeResponseDTO.java @@ -0,0 +1,28 @@ +package umc.codeplay.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import umc.codeplay.domain.mapping.MusicLike; + +public class LikeResponseDTO { + + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class addLikeResponseDTO { + Long musicId; + MusicLike like; + } + + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class removeLikeResponseDTO { + Long musicId; + } +} diff --git a/src/main/java/umc/codeplay/dto/MemberRequestDTO.java b/src/main/java/umc/codeplay/dto/MemberRequestDTO.java index b2b28eb..cb1d051 100644 --- a/src/main/java/umc/codeplay/dto/MemberRequestDTO.java +++ b/src/main/java/umc/codeplay/dto/MemberRequestDTO.java @@ -1,22 +1,34 @@ package umc.codeplay.dto; -import lombok.Getter; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; -import umc.codeplay.domain.enums.Role; +import lombok.Getter; public class MemberRequestDTO { @Getter public static class JoinDto { + + @NotBlank(message = "이름은 필수 입력값입니다.") String name; + + @NotBlank(message = "이메일은 필수 입력값입니다.") + @Email(message = "이메일 형식이 아닙니다.") String email; + + @NotBlank(message = "비밀번호는 필수 입력값입니다.") String password; - Role role; } @Getter public static class LoginDto { + + @NotBlank(message = "이메일은 필수 입력값입니다.") + @Email(message = "이메일 형식이 아닙니다.") String email; + + @NotBlank(message = "비밀번호는 필수 입력값입니다.") String password; } } diff --git a/src/main/java/umc/codeplay/jwt/JwtUtil.java b/src/main/java/umc/codeplay/jwt/JwtUtil.java index 466e4fb..abd5290 100644 --- a/src/main/java/umc/codeplay/jwt/JwtUtil.java +++ b/src/main/java/umc/codeplay/jwt/JwtUtil.java @@ -18,11 +18,8 @@ @Component public class JwtUtil { - private final String SECRET_KEY; - - public JwtUtil(@Value("${JWT_SECRET}") String secretKey) { - this.SECRET_KEY = secretKey; - } + @Value("${jwt.secret}") + private String SECRET_KEY; private Key getSigningKey() { return Keys.hmacShaKeyFor(SECRET_KEY.getBytes()); diff --git a/src/main/java/umc/codeplay/repository/MusicLikeRepository.java b/src/main/java/umc/codeplay/repository/MusicLikeRepository.java new file mode 100644 index 0000000..569d5ab --- /dev/null +++ b/src/main/java/umc/codeplay/repository/MusicLikeRepository.java @@ -0,0 +1,13 @@ +package umc.codeplay.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import umc.codeplay.domain.Member; +import umc.codeplay.domain.Music; +import umc.codeplay.domain.mapping.MusicLike; + +public interface MusicLikeRepository extends JpaRepository { + Optional findByMemberAndMusic(Member member, Music music); +} diff --git a/src/main/java/umc/codeplay/repository/MusicRepository.java b/src/main/java/umc/codeplay/repository/MusicRepository.java new file mode 100644 index 0000000..4ecef4e --- /dev/null +++ b/src/main/java/umc/codeplay/repository/MusicRepository.java @@ -0,0 +1,7 @@ +package umc.codeplay.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import umc.codeplay.domain.Music; + +public interface MusicRepository extends JpaRepository {} diff --git a/src/main/java/umc/codeplay/service/FileService.java b/src/main/java/umc/codeplay/service/FileService.java new file mode 100644 index 0000000..06e6cd5 --- /dev/null +++ b/src/main/java/umc/codeplay/service/FileService.java @@ -0,0 +1,108 @@ +package umc.codeplay.service; + +import java.text.Normalizer; +import java.time.Duration; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; + +import software.amazon.awssdk.http.SdkHttpMethod; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; +import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest; +import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; +import umc.codeplay.apiPayLoad.code.status.ErrorStatus; +import umc.codeplay.apiPayLoad.exception.handler.GeneralHandler; +import umc.codeplay.domain.Member; +import umc.codeplay.domain.Music; +import umc.codeplay.repository.MemberRepository; +import umc.codeplay.repository.MusicRepository; + +@Service +@RequiredArgsConstructor +public class FileService { + + @Value("${s3.bucket}") + private String bucketName; + + @Value("${spring.cloud.aws.region.static}") + private String region; + + private final S3Presigner s3Presigner; + private final MusicRepository musicRepository; + private final MemberRepository memberRepository; + + // 타임스탬프_파일명 형식으로 파일 이름 저장 + public static String buildFilename(String filename) { + return String.format("%s_%s", System.currentTimeMillis(), sanitizeFileName(filename)); + } + + // 특수 문자나 공백 등을 정리 + private static String sanitizeFileName(String fileName) { + String normalizedFileName = Normalizer.normalize(fileName, Normalizer.Form.NFC); + return normalizedFileName.replaceAll("\\s+", "_").replaceAll("[^a-zA-Z0-9.\\-_]", ""); + } + + // 파일 업로드(HTTP PUT) 또는 다운로드(HTTP GET)를 위한 Presigned URL 생성 + public String generatePreSignedUrl(String fileName, SdkHttpMethod method) { + + return switch (method) { + case GET -> generateGetPresignedUrl(fileName); + case PUT -> generatePutPresignedUrl(fileName); + default -> throw new GeneralHandler(ErrorStatus.AWS_SERVICE_UNAVAILABLE); + }; + } + + // S3에서 파일을 다운로드할 수 있는 Presigned URL 생성 + private String generateGetPresignedUrl(String fileName) { + GetObjectRequest getObjectRequest = + GetObjectRequest.builder().bucket(bucketName).key(fileName).build(); + + GetObjectPresignRequest presignRequest = + GetObjectPresignRequest.builder() + .signatureDuration(Duration.ofMinutes(60)) + .getObjectRequest(getObjectRequest) + .build(); + + PresignedGetObjectRequest presignedRequest = s3Presigner.presignGetObject(presignRequest); + return presignedRequest.url().toString(); + } + + // S3에 파일을 업로드할 수 있는 Presigned URL 생성 + private String generatePutPresignedUrl(String fileName) { + PutObjectRequest putObjectRequest = + PutObjectRequest.builder().bucket(bucketName).key(fileName).build(); + + PutObjectPresignRequest presignRequest = + PutObjectPresignRequest.builder() + .signatureDuration(Duration.ofMinutes(60)) + .putObjectRequest(putObjectRequest) + .build(); + + PresignedPutObjectRequest presignedRequest = s3Presigner.presignPutObject(presignRequest); + return presignedRequest.url().toString(); + } + + // music 레포지토리에 업로드 + public Long uploadMusic(String newFileName, String userEmail) { + Member member = + memberRepository + .findByEmail(userEmail) + .orElseThrow(() -> new GeneralHandler(ErrorStatus.MEMBER_NOT_FOUND)); + + // 저장하는 url은 유효시간이 없는 public + // TODO: 업로드에만 presigned 사용할지 아님 다운로드시에도 사용할지에 따라 변경해야함. + String s3Url = + String.format("https://%s.s3.%s.amazonaws.com/%s", bucketName, region, newFileName); + Music newMusic = Music.builder().title(newFileName).musicUrl(s3Url).member(member).build(); + + return musicRepository.save(newMusic).getId(); + } + + // TODO: 필요시 직접 업로드 방법 구현 필요 +} diff --git a/src/main/java/umc/codeplay/service/LikeService.java b/src/main/java/umc/codeplay/service/LikeService.java new file mode 100644 index 0000000..db9008d --- /dev/null +++ b/src/main/java/umc/codeplay/service/LikeService.java @@ -0,0 +1,61 @@ +package umc.codeplay.service; + +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; + +import umc.codeplay.apiPayLoad.code.status.ErrorStatus; +import umc.codeplay.apiPayLoad.exception.GeneralException; +import umc.codeplay.converter.MusicLikeConverter; +import umc.codeplay.domain.Member; +import umc.codeplay.domain.Music; +import umc.codeplay.domain.mapping.MusicLike; +import umc.codeplay.dto.LikeRequestDTO; +import umc.codeplay.repository.MemberRepository; +import umc.codeplay.repository.MusicLikeRepository; +import umc.codeplay.repository.MusicRepository; + +@Service +@RequiredArgsConstructor +public class LikeService { + + private final MusicRepository musicRepository; + private final MemberRepository memberRepository; + private final MusicLikeRepository musicLikeRepository; + + public MusicLike addLike(String username, LikeRequestDTO.addLikeRequestDTO request) { + + Music music = + musicRepository + .findById(request.getMusicId()) + .orElseThrow(() -> new GeneralException(ErrorStatus.MUSIC_NOT_FOUND)); + + Member member = + memberRepository + .findByEmail(username) + .orElseThrow(() -> new GeneralException(ErrorStatus.MEMBER_NOT_FOUND)); + + MusicLike newLike = MusicLikeConverter.toMusicLike(member, music); + return musicLikeRepository.save(newLike); + } + + public Music removeLike(String username, LikeRequestDTO.removeLikeRequestDTO request) { + Music music = + musicRepository + .findById(request.getMusicId()) + .orElseThrow(() -> new GeneralException(ErrorStatus.MUSIC_NOT_FOUND)); + + Member member = + memberRepository + .findByEmail(username) + .orElseThrow(() -> new GeneralException(ErrorStatus.MEMBER_NOT_FOUND)); + + MusicLike musicLike = + musicLikeRepository + .findByMemberAndMusic(member, music) + .orElseThrow(() -> new GeneralException(ErrorStatus.LIKE_NOT_FOUND)); + + musicLikeRepository.delete(musicLike); + return music; + } +} diff --git a/src/main/java/umc/codeplay/service/MemberService.java b/src/main/java/umc/codeplay/service/MemberService.java index 48d1bc4..dda2090 100644 --- a/src/main/java/umc/codeplay/service/MemberService.java +++ b/src/main/java/umc/codeplay/service/MemberService.java @@ -9,6 +9,8 @@ import umc.codeplay.apiPayLoad.exception.handler.GeneralHandler; import umc.codeplay.converter.MemberConverter; import umc.codeplay.domain.Member; +import umc.codeplay.domain.enums.Role; +import umc.codeplay.domain.enums.SocialStatus; import umc.codeplay.dto.MemberRequestDTO; import umc.codeplay.repository.MemberRepository; @@ -29,4 +31,33 @@ public Member joinMember(MemberRequestDTO.JoinDto request) { newMember.encodePassword(passwordEncoder.encode(request.getPassword())); return memberRepository.save(newMember); } + + public Member findOrCreateOAuthMember(String email, String name, SocialStatus socialStatus) { + + Member member = memberRepository.findByEmail(email).orElse(null); + + if (member == null) { + member = + Member.builder() + .email(email) + .name(name) + .role(Role.USER) + .socialStatus(socialStatus) + .build(); + return memberRepository.save(member); + } else if (member.getSocialStatus() != socialStatus) { + throw new GeneralHandler(ErrorStatus.AUTHORIZATION_METHOD_ERROR); + } else { + return member; + } + } + + public SocialStatus getSocialStatus(String email) { + Member member = memberRepository.findByEmail(email).orElse(null); + if (member == null) { + return SocialStatus.NONE; + } else { + return member.getSocialStatus(); + } + } } diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index e1c85a5..064e089 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -26,4 +26,40 @@ spring: show_sql: true format_sql: true use_sql_comments: true - default_batch_fetch_size: 1000 # 배치 크기 설정 (성능 최적화) \ No newline at end of file + default_batch_fetch_size: 1000 # 배치 크기 설정 (성능 최적화) + + cloud: + aws: + region: + static: ${AWS_DEFAULT_REGION} + credentials: + access-key: ${AWS_ACCESS_KEY_ID} + secret-key: ${AWS_SECRET_ACCESS_KEY} + +s3: + bucket: ${S3_BUCKET} + +jwt: + secret: ${JWT_SECRET} + +google: + oauth2: + client-id: ${GOOGLE_CLIENT_ID} + client-secret: ${GOOGLE_CLIENT_SECRET} + redirect-uri: ${GOOGLE_REDIRECT_URI} + scope: "openid email profile" + authorization-uri: "https://accounts.google.com/o/oauth2/v2/auth" + token-uri: "https://oauth2.googleapis.com/token" + user-info-uri: "https://openidconnect.googleapis.com/v1/userinfo" + additional-parameters: "&access_type=offline&prompt=consent" # refresh token / 동의화면 매번 요청 + +kakao: + oauth2: + client-id: ${KAKAO_CLIENT_ID} + client-secret: ${KAKAO_CLIENT_SECRET} + redirect-uri: ${KAKAO_REDIRECT_URI} + scope: "profile_nickname,account_email" + authorization-uri: "https://kauth.kakao.com/oauth/authorize" + token-uri: "https://kauth.kakao.com/oauth/token" + user-info-uri: "https://kapi.kakao.com/v2/user/me" + additional-parameters: "" diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 30d35b5..75a5102 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -18,7 +18,7 @@ spring: jpa: hibernate: - ddl-auto: update # Hibernate 엔티티 스키마 자동 업데이트 + ddl-auto: create # Hibernate 엔티티 스키마 자동 업데이트 properties: jakarta.persistence.sharedCache.mode: ALL hibernate: @@ -26,4 +26,40 @@ spring: show_sql: true format_sql: true use_sql_comments: true - default_batch_fetch_size: 1000 # 배치 크기 설정 (성능 최적화) \ No newline at end of file + default_batch_fetch_size: 1000 # 배치 크기 설정 (성능 최적화) + + cloud: + aws: + region: + static: ${AWS_DEFAULT_REGION} + credentials: + access-key: ${AWS_ACCESS_KEY_ID} + secret-key: ${AWS_SECRET_ACCESS_KEY} + +s3: + bucket: ${S3_BUCKET} + +jwt: + secret: ${JWT_SECRET} + +google: + oauth2: + client-id: ${GOOGLE_CLIENT_ID} + client-secret: ${GOOGLE_CLIENT_SECRET} + redirect-uri: ${GOOGLE_REDIRECT_URI} + scope: "openid email profile" + authorization-uri: "https://accounts.google.com/o/oauth2/v2/auth" + token-uri: "https://oauth2.googleapis.com/token" + user-info-uri: "https://openidconnect.googleapis.com/v1/userinfo" + additional-parameters: "&access_type=offline&prompt=consent" # refresh token / 동의화면 매번 요청 + +kakao: + oauth2: + client-id: ${KAKAO_CLIENT_ID} + client-secret: ${KAKAO_CLIENT_SECRET} + redirect-uri: ${KAKAO_REDIRECT_URI} + scope: "profile_nickname,account_email" + authorization-uri: "https://kauth.kakao.com/oauth/authorize" + token-uri: "https://kauth.kakao.com/oauth/token" + user-info-uri: "https://kapi.kakao.com/v2/user/me" + additional-parameters: "" \ No newline at end of file diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index ae60a01..efefcfb 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -2,11 +2,13 @@ spring: h2: console: enabled: true + datasource: url: jdbc:h2:mem:testdb;MODE=MYSQL driver-class-name: org.h2.Driver username: sa password: + jpa: show-sql: true properties: