diff --git a/build.gradle b/build.gradle index c91cbc1..e6dc616 100644 --- a/build.gradle +++ b/build.gradle @@ -61,7 +61,6 @@ dependencies { // S3 implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' - implementation 'software.amazon.awssdk:s3:2.17.72' // redis implementation 'org.redisson:redisson-spring-boot-starter:3.36.0' @@ -70,6 +69,15 @@ dependencies { // oauth implementation 'org.springframework.security:spring-security-oauth2-client:6.3.1' + + // QueryDSL + implementation 'com.querydsl:querydsl-jpa:5.1.0:jakarta' + annotationProcessor 'com.querydsl:querydsl-apt:5.1.0:jakarta' + annotationProcessor 'jakarta.persistence:jakarta.persistence-api:3.1.0' + annotationProcessor 'jakarta.annotation:jakarta.annotation-api:3.0.0' + + // DataFaker + implementation 'net.datafaker:datafaker:2.4.4' } tasks.named('test') { diff --git a/src/main/java/onepiece/dailysnapbackend/controller/AdminControllerDocs.java b/src/main/java/onepiece/dailysnapbackend/controller/AdminControllerDocs.java deleted file mode 100644 index d837464..0000000 --- a/src/main/java/onepiece/dailysnapbackend/controller/AdminControllerDocs.java +++ /dev/null @@ -1,37 +0,0 @@ -package onepiece.dailysnapbackend.controller; - -import io.swagger.v3.oas.annotations.Operation; -import onepiece.dailysnapbackend.object.dto.CustomOAuth2User; -import onepiece.dailysnapbackend.object.dto.KeywordRequest; -import org.springframework.http.ResponseEntity; - -public interface AdminControllerDocs { - - @Operation( - summary = "특정 날짜에 제공할 키워드 추가 (관리자 전용)", - description = """ - 특정 날짜에 제공할 키워드를 추가합니다. **관리자 권한이 필요합니다.** - - ### 요청 파라미터 - - `request` (KeywordRequest) → 추가할 키워드 정보 - - ### 반환값 - - `200 OK` → 성공 - """ - ) - ResponseEntity addKeyword(CustomOAuth2User userDetails, KeywordRequest request); - - @Operation( - summary = "특정 키워드 삭제 (관리자 전용)", - description = """ - 특정 키워드를 삭제합니다. **관리자 권한이 필요합니다.** - - ### 요청 파라미터 - - `keyword` (String) → 삭제할 키워드의 ID - - ### 반환값 - - `200 OK` → 성공 - """ - ) - ResponseEntity deleteKeyword(CustomOAuth2User userDetails, String keyword); -} diff --git a/src/main/java/onepiece/dailysnapbackend/controller/AdminController.java b/src/main/java/onepiece/dailysnapbackend/controller/AdminKeywordController.java similarity index 56% rename from src/main/java/onepiece/dailysnapbackend/controller/AdminController.java rename to src/main/java/onepiece/dailysnapbackend/controller/AdminKeywordController.java index 1de8dbf..4b89d05 100644 --- a/src/main/java/onepiece/dailysnapbackend/controller/AdminController.java +++ b/src/main/java/onepiece/dailysnapbackend/controller/AdminKeywordController.java @@ -2,11 +2,14 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; +import java.util.UUID; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; +import onepiece.dailysnapbackend.object.constants.KeywordCategory; import onepiece.dailysnapbackend.object.dto.CustomOAuth2User; import onepiece.dailysnapbackend.object.dto.KeywordRequest; +import onepiece.dailysnapbackend.object.dto.KeywordResponse; import onepiece.dailysnapbackend.service.keyword.AdminKeywordService; +import onepiece.dailysnapbackend.service.keyword.OpenAIKeywordService; import onepiece.dailysnapbackend.util.log.LogMonitoringInvocation; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -17,14 +20,17 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -@Slf4j @RestController @RequiredArgsConstructor @RequestMapping("/admin/keyword") -@Tag(name = "관리자 키워드 API", description = "관리자가 키워드를 관리하는 API") -public class AdminController implements AdminControllerDocs { +@Tag( + name = "관리자 키워드 API", + description = "관리자가 키워드를 관리하는 API" +) +public class AdminKeywordController implements AdminKeywordControllerDocs { private final AdminKeywordService adminKeywordService; + private final OpenAIKeywordService openAIKeywordService; /** * 특정 날짜에 제공할 키워드 추가 (관리자 지정) @@ -32,24 +38,31 @@ public class AdminController implements AdminControllerDocs { @Override @PostMapping @LogMonitoringInvocation - public ResponseEntity addKeyword( - @AuthenticationPrincipal CustomOAuth2User userDetails, + public ResponseEntity addKeyword( + @AuthenticationPrincipal CustomOAuth2User customOAuth2User, @Valid @RequestBody KeywordRequest request) { - adminKeywordService.addKeyword(request); - log.info("[AdminController] 키워드 추가 완료"); - return ResponseEntity.ok().build(); + return ResponseEntity.ok(adminKeywordService.addKeyword(request)); } /** * 특정 키워드 삭제 */ @Override - @DeleteMapping("/{keyword}") + @DeleteMapping("/{keyword-id}") @LogMonitoringInvocation public ResponseEntity deleteKeyword( - @AuthenticationPrincipal CustomOAuth2User userDetails, - @PathVariable String keyword) { - adminKeywordService.deleteKeyword(keyword); + @AuthenticationPrincipal CustomOAuth2User customOAuth2User, + @PathVariable(value = "keyword-id") UUID keywordId) { + adminKeywordService.deleteKeyword(keywordId); + return ResponseEntity.ok().build(); + } + + @Override + @PostMapping("/list") + public ResponseEntity createKeywordList( + @AuthenticationPrincipal CustomOAuth2User customOAuth2User, + KeywordCategory category) { + openAIKeywordService.generateKeywords(category); return ResponseEntity.ok().build(); } } diff --git a/src/main/java/onepiece/dailysnapbackend/controller/AdminKeywordControllerDocs.java b/src/main/java/onepiece/dailysnapbackend/controller/AdminKeywordControllerDocs.java new file mode 100644 index 0000000..f86ecd5 --- /dev/null +++ b/src/main/java/onepiece/dailysnapbackend/controller/AdminKeywordControllerDocs.java @@ -0,0 +1,112 @@ +package onepiece.dailysnapbackend.controller; + +import io.swagger.v3.oas.annotations.Operation; +import java.util.UUID; +import onepiece.dailysnapbackend.object.constants.KeywordCategory; +import onepiece.dailysnapbackend.object.dto.CustomOAuth2User; +import onepiece.dailysnapbackend.object.dto.KeywordRequest; +import onepiece.dailysnapbackend.object.dto.KeywordResponse; +import org.springframework.http.ResponseEntity; + +public interface AdminKeywordControllerDocs { + + @Operation( + summary = "특정 날짜에 제공할 키워드 추가 (관리자 지정)", + description = """ + ### 요청 파라미터 + - `category` (KeywordCategory, required): 키워드 카테고리 (예: ADMIN_SET) + - `koreanKeyword` (String, required): 한국어 키워드 + - `englishKeyword` (String, required): 영어 키워드 + - `specifiedDate` (LocalDate, required): 키워드를 제공할 날짜 (YYYY-MM-DD) + + ### 응답 데이터 + - `keywordId` (UUID): 생성된 키워드 ID + - `koreanKeyword` (String): 등록된 한국어 키워드 + - `englishKeyword` (String): 등록된 영어 키워드 + - `keywordCategory` (KeywordCategory): 키워드 카테고리 + - `providedDate` (LocalDate): 제공 날짜 (YYYY-MM-DD) + - `used` (boolean): 사용 여부 (기본 `false`) + + ### 사용 방법 + 1. 관리자 권한을 가진 클라이언트에서 Authorization 헤더에 `Bearer {accessToken}`을 포함합니다. + 2. 아래 JSON 예시처럼 `/api/...` 엔드포인트로 POST 요청을 보냅니다: + ```json + { + "category": "ADMIN_SET", + "koreanKeyword": "주제", + "englishKeyword": "topic", + "specifiedDate": "2025-08-09" + } + ``` + 3. 서버가 키워드를 저장하고, 생성된 키워드 정보를 반환합니다. + + ### 유의 사항 + - `specifiedDate`는 오늘 이후 날짜여야 합니다. + - 동일한 `koreanKeyword`가 이미 존재할 수 없습니다. + """ + ) + ResponseEntity addKeyword( + CustomOAuth2User customOAuth2User, + KeywordRequest request + ); + + @Operation( + summary = "특정 키워드 삭제", + description = """ + ### 요청 파라미터 + - `keyword-id` (UUID, required, path): 삭제할 키워드의 고유 ID + + ### 응답 데이터 + - 없음 (빈 본문) + + ### 사용 방법 + 1. 관리자 권한을 가진 클라이언트에서 Authorization 헤더에 `Bearer {accessToken}`을 포함합니다. + 2. 아래와 같이 DELETE 요청을 보냅니다: + ``` + DELETE /admin/keyword/{keyword-id} + ``` + + ### 유의 사항 + - 관리자 권한이 필요합니다. + - 성공 시 HTTP 200 OK 응답이 반환됩니다. + """ + ) + ResponseEntity deleteKeyword( + CustomOAuth2User customOAuth2User, + UUID keywordId + ); + + @Operation( + summary = "키워드 자동 생성 및 저장 (OpenAI 연동)", + description = """ + ### 요청 파라미터 + - `category` (KeywordCategory, required, query): 생성할 키워드 카테고리 + 가능한 값: `SPRING`, `SUMMER`, `AUTUMN`, `WINTER`, `TRAVEL`, `DAILY`, `ABSTRACT`, `RANDOM` + + ### 응답 데이터 + - 없음 (빈 본문, HTTP 200 OK) + + ### 사용 방법 + 1. 관리자 권한을 가진 클라이언트에서 Authorization 헤더에 `Bearer {accessToken}`을 포함합니다. + 2. 아래와 같이 카테고리 쿼리 파라미터를 포함하여 POST 요청을 보냅니다: + ``` + POST /api/keyword/list?category=SUMMER + Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... + ``` + 3. 서버가 지정된 카테고리에 대해 OpenAI API를 호출하여 + - 한국어 키워드 및 대응 영어 번역 키워드를 생성 + - 중복 및 제공일 순서에 맞춰 DB에 저장 + 작업을 수행한 후 200 OK를 반환합니다. + + ### 유의 사항 + - 관리자 권한이 필요합니다. + - `category` 값은 `KeywordCategory` enum에 정의된 값만 허용됩니다. + - OpenAI API 호출로 인해 지연이 발생할 수 있습니다. + - 기존 DB에 이미 존재하는 한글 키워드는 저장에서 제외됩니다. + """ + ) + ResponseEntity createKeywordList( + CustomOAuth2User customOAuth2User, + KeywordCategory category + ); +} diff --git a/src/main/java/onepiece/dailysnapbackend/controller/AuthController.java b/src/main/java/onepiece/dailysnapbackend/controller/AuthController.java index fdd55bb..a3ec62e 100644 --- a/src/main/java/onepiece/dailysnapbackend/controller/AuthController.java +++ b/src/main/java/onepiece/dailysnapbackend/controller/AuthController.java @@ -1,45 +1,48 @@ package onepiece.dailysnapbackend.controller; import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import onepiece.dailysnapbackend.object.dto.SignInRequest; +import onepiece.dailysnapbackend.object.dto.LoginRequest; +import onepiece.dailysnapbackend.object.dto.LoginResponse; +import onepiece.dailysnapbackend.object.dto.ReissueRequest; import onepiece.dailysnapbackend.service.MemberService; import onepiece.dailysnapbackend.util.log.LogMonitoringInvocation; import org.springframework.http.ResponseEntity; 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 @RequiredArgsConstructor @Tag( - name = "인증 API", - description = "회원 인증 API 제공" + name = "회원 API", + description = "회원 API 제공" ) +@RequestMapping("/api/auth") public class AuthController implements AuthControllerDocs { private final MemberService memberService; - // =========================== - // 인증 관련 API - // =========================== - // 로그인 @Override @PostMapping(value = "/login") @LogMonitoringInvocation - public ResponseEntity signIn(SignInRequest request, HttpServletResponse response) { - memberService.socialSignIn(request, response); - return ResponseEntity.ok().build(); + public ResponseEntity login( + @Valid @RequestBody LoginRequest request + ) { + return ResponseEntity.ok(memberService.socialSignIn(request)); } + // 액세스 토큰 재발급 @Override - @PostMapping("/api/auth/reissue") + @PostMapping("/reissue") @LogMonitoringInvocation - public ResponseEntity reissue(HttpServletRequest request, HttpServletResponse response) { - memberService.reissue(request, response); - return ResponseEntity.ok().build(); + public ResponseEntity reissue( + @Valid @RequestBody ReissueRequest request + ) { + return ResponseEntity.ok(memberService.reissue(request)); } } diff --git a/src/main/java/onepiece/dailysnapbackend/controller/AuthControllerDocs.java b/src/main/java/onepiece/dailysnapbackend/controller/AuthControllerDocs.java index 6f1d8bc..99d9284 100644 --- a/src/main/java/onepiece/dailysnapbackend/controller/AuthControllerDocs.java +++ b/src/main/java/onepiece/dailysnapbackend/controller/AuthControllerDocs.java @@ -1,90 +1,70 @@ package onepiece.dailysnapbackend.controller; import io.swagger.v3.oas.annotations.Operation; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import jakarta.validation.Valid; -import onepiece.dailysnapbackend.object.dto.SignInRequest; +import onepiece.dailysnapbackend.object.dto.LoginRequest; +import onepiece.dailysnapbackend.object.dto.LoginResponse; +import onepiece.dailysnapbackend.object.dto.ReissueRequest; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.RequestBody; public interface AuthControllerDocs { @Operation( summary = "소셜 로그인", description = """ - 클라이언트에서 받은 accessToken을 이용하여 소셜 로그인 처리 후 JWT 토큰을 발급합니다. - - ### 요청 형식 - - Content-Type: application/json - - ### 요청 바디 예시 - ```json - { - "provider": "KAKAO", - "username": "example@naver.com", - "birth": "2004-01-01", - "nickname": "daily_snap_user" - } - ``` - - ### 응답 - - `200 OK`: 로그인 또는 회원가입 성공 - - ### 응답 형식 - - `Authorization` 헤더에 accessToken 포함 - - 응답 바디에 refreshToken 포함 - - ### 응답 예시 - #### 헤더: - - `Authorization: Bearer your-access-token` - - #### 바디: - ```json - { - "refreshToken": "your-refresh-token" - } - ``` - """ + ### 요청 파라미터 + - `socialPlatform` (SocialPlatform, required): 소셜 플랫폼 종류 (KAKAO, GOOGLE) + - `username` (String, required): 사용자 이메일 (unique) + - `birth` (String, optional): 사용자 생년월일 (형식: YYYY-MM-DD) + - `nickname` (String, optional): 사용자 닉네임 (unique) + + ### 응답 데이터 + - `accessToken` (String): 발급된 액세스 토큰 (JWT) + - `refreshToken` (String): 발급된 리프레시 토큰 (JWT) + + ### 사용 방법 + 1. 클라이언트에서 아래 JSON 예시처럼 서버로 POST 요청을 보냅니다. + ```json + { + "socialPlatform": "KAKAO", + "username": "user@example.com", + "birth": "1990-01-01", + "nickname": "daily_snap_user" + } + ``` + 2. 서버가 회원 정보를 조회(또는 신규 저장) 후 JWT를 발급하여 반환합니다. + + ### 유의 사항 + - `socialPlatform`과 `username` 필드는 필수입니다. + - `socialPlatform` 값은 `SocialPlatform` enum에 정의된 값만 허용됩니다. + - 이미 가입된 이메일(`username`)로 요청할 경우 기존 계정으로 로그인 처리됩니다. + - `birth`, `nickname`은 선택 필드이며, 미전달 시 기본값(빈 문자열 또는 null)으로 처리됩니다. + """ ) - ResponseEntity signIn(@Valid @RequestBody SignInRequest request, HttpServletResponse response); + ResponseEntity login(LoginRequest request); @Operation( - summary = "accessToken 재발급 요청", + summary = "액세스 토큰 재발급", description = """ - - 이 API는 인증이 필요하지 않습니다. - 요청 쿠키에 포함된 RefreshToken만으로 새로운 AccessToken을 발급할 수 있습니다. - ### 요청 파라미터 - - **Cookie**: JSON 형태의 요청 바디에 포함된 리프레시 토큰 - - **Name**: `refresh_token` - - **Value**: `리프레시 토큰 값` - - ### 반환값 - - 새로운 액세스 토큰은 **JSON 응답 바디**에 포함되어 반환됩니다. - - **반환 헤더 예시:** - ``` - json - { - "accessToken": "your-new-access-token" - } - ``` + - `refreshToken` (String, required): 재발급에 사용할 리프레시 토큰 (JWT) - ### 유의사항 - - 이 API는 리프레시 토큰의 유효성을 검증한 후 새로운 액세스 토큰을 발급합니다. - - 리프레시 토큰이 유효하지 않거나 만료되었을 경우, 재로그인이 필요합니다. + ### 응답 데이터 + - `accessToken` (String): 새로 발급된 액세스 토큰 (JWT) + - `refreshToken` (String): 새로 발급된 리프레시 토큰 (JWT) - **응답 코드:** - - **200 OK**: 새로운 액세스 토큰 발급 성공 (헤더에 포함됨) - - **401 Unauthorized**: 리프레시 토큰이 유효하지 않거나 만료됨 - - **400 Bad Request**: 요청에 쿠키가 없거나 리프레시 토큰이 없음 + ### 사용 방법 + 1. 클라이언트에서 아래 JSON 예시처럼 서버로 POST 요청을 보냅니다. + ```json + { + "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + } + ``` + 2. 서버가 전달된 리프레시 토큰의 유효성을 검사한 후, 새로운 액세스 토큰과 리프레시 토큰을 생성하여 반환합니다. - **추가 설명:** - - 이 API는 `HttpServletRequest`의 요청 쿠키에서 `refreshToken`을 추출하여 처리합니다. - - 클라이언트는 `application/json` 형식으로 요청해야 합니다. + ### 유의 사항 + - `refreshToken` 필드는 필수이며, 유효한 토큰이어야 합니다. + - 클라이언트는 반환된 `accessToken`을 Authorization 헤더에 담아 API 호출 시 사용해야 합니다. """ ) - ResponseEntity reissue(HttpServletRequest request, HttpServletResponse response); + ResponseEntity reissue(ReissueRequest request); } diff --git a/src/main/java/onepiece/dailysnapbackend/controller/FollowController.java b/src/main/java/onepiece/dailysnapbackend/controller/FollowController.java index fbfb269..7ec5b0a 100644 --- a/src/main/java/onepiece/dailysnapbackend/controller/FollowController.java +++ b/src/main/java/onepiece/dailysnapbackend/controller/FollowController.java @@ -28,7 +28,7 @@ name = "팔로우 API", description = "사용자 팔로우 관련 API 제공" ) -public class FollowController implements FollowControllerDocs{ +public class FollowController implements FollowControllerDocs { private final FollowService followService; diff --git a/src/main/java/onepiece/dailysnapbackend/controller/keyword/KeywordController.java b/src/main/java/onepiece/dailysnapbackend/controller/KeywordController.java similarity index 59% rename from src/main/java/onepiece/dailysnapbackend/controller/keyword/KeywordController.java rename to src/main/java/onepiece/dailysnapbackend/controller/KeywordController.java index f8f1824..5ab5162 100644 --- a/src/main/java/onepiece/dailysnapbackend/controller/keyword/KeywordController.java +++ b/src/main/java/onepiece/dailysnapbackend/controller/KeywordController.java @@ -1,21 +1,18 @@ -package onepiece.dailysnapbackend.controller.keyword; +package onepiece.dailysnapbackend.controller; import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import onepiece.dailysnapbackend.object.dto.CustomOAuth2User; -import onepiece.dailysnapbackend.object.dto.DailyKeywordResponse; import onepiece.dailysnapbackend.object.dto.KeywordFilterRequest; -import onepiece.dailysnapbackend.object.dto.KeywordFilterResponse; +import onepiece.dailysnapbackend.object.dto.KeywordResponse; import onepiece.dailysnapbackend.service.keyword.KeywordService; import onepiece.dailysnapbackend.util.log.LogMonitoringInvocation; +import org.springdoc.core.annotations.ParameterObject; import org.springframework.data.domain.Page; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; -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; @@ -35,18 +32,11 @@ public class KeywordController implements KeywordControllerDocs { * 필터링 조건(키워드 텍스트, 카테고리, 날짜)을 사용하여 키워드 목록을 Page로 반환. */ @Override - @PostMapping + @GetMapping @LogMonitoringInvocation - public ResponseEntity> filteredKeywords( - @AuthenticationPrincipal CustomOAuth2User userDetails, - @Valid @RequestBody KeywordFilterRequest request) { - return ResponseEntity.ok(keywordService.filteredKeywords (request)); - } - - @Override - @GetMapping("/daily") - @LogMonitoringInvocation - public ResponseEntity getDailyKeyword() { - return ResponseEntity.ok(keywordService.getDailyKeyword()); + public ResponseEntity> filteredKeywords( + @AuthenticationPrincipal CustomOAuth2User customOAuth2User, + @ParameterObject KeywordFilterRequest request) { + return ResponseEntity.ok(keywordService.filteredKeywords(request)); } } diff --git a/src/main/java/onepiece/dailysnapbackend/controller/KeywordControllerDocs.java b/src/main/java/onepiece/dailysnapbackend/controller/KeywordControllerDocs.java new file mode 100644 index 0000000..326b380 --- /dev/null +++ b/src/main/java/onepiece/dailysnapbackend/controller/KeywordControllerDocs.java @@ -0,0 +1,72 @@ +package onepiece.dailysnapbackend.controller; + +import io.swagger.v3.oas.annotations.Operation; +import onepiece.dailysnapbackend.object.dto.CustomOAuth2User; +import onepiece.dailysnapbackend.object.dto.KeywordFilterRequest; +import onepiece.dailysnapbackend.object.dto.KeywordResponse; +import org.springframework.data.domain.Page; +import org.springframework.http.ResponseEntity; + +public interface KeywordControllerDocs { + + @Operation( + summary = "키워드 목록 조회 (동적 필터링)", + description = """ + ### 요청 파라미터 + - `koreanKeyword` (String, optional): 검색할 한국어 키워드 (부분 일치) + - `keywordCategory` (KeywordCategory, optional): 필터링할 키워드 카테고리 + - `providedDate` (LocalDate, optional): 제공 날짜 필터 (형식: yyyy-MM-dd) + - `used` (Boolean, optional): 사용 여부 필터 (true: 사용된 키워드, false: 미사용 키워드) + - `pageNumber` (int, optional): 페이지 번호 (1부터 시작, 기본값: 1) + - `pageSize` (int, optional): 페이지 당 항목 수 (기본값: `PageableConstants.DEFAULT_PAGE_SIZE`) + - `sortField` (KeywordSortField, optional): 정렬 기준 필드 (기본값: CREATED_DATE) + - `sortDirection` (Sort.Direction, optional): 정렬 방향 (ASC 또는 DESC, 기본값: DESC) + + ### 응답 데이터 + - `content` (List): 조회된 키워드 응답 객체 리스트 + - `keywordId` (UUID): 키워드 고유 ID + - `koreanKeyword` (String): 한국어 키워드 + - `englishKeyword` (String): 영어 키워드 + - `keywordCategory` (KeywordCategory): 키워드 카테고리 + - `providedDate` (LocalDate): 제공 날짜 (yyyy-MM-dd) + - `used` (boolean): 사용 여부 + - `pageable` (Object): 페이지 요청 정보 + - `pageNumber` (int) + - `pageSize` (int) + - `offset` (long) + - `paged` (boolean) + - `unpaged` (boolean) + - `totalPages` (int): 전체 페이지 수 + - `totalElements` (long): 전체 항목 수 + - `last` (boolean): 마지막 페이지 여부 + - `first` (boolean): 첫 페이지 여부 + - `sort` (Object): 정렬 정보 + - `numberOfElements` (int): 현재 페이지의 항목 수 + - `empty` (boolean): 결과 비어있는지 여부 + + ### 사용 방법 + 1. 클라이언트에서 아래 예시처럼 쿼리 파라미터를 붙여 GET 요청을 보냅니다. + ``` + GET /api/keyword? + koreanKeyword=주제& + keywordCategory=ADMIN_SET& + providedDate=2025-08-09& + used=false& + pageNumber=1& + pageSize=20& + sortField=CREATED_DATE& + sortDirection=DESC + ``` + 2. 서버는 전달된 필터 조건에 맞춰 키워드를 조회하여 Page 형태로 반환합니다. + + ### 유의 사항 + - 모든 파라미터는 선택 사항이며, 미전달 시 기본값으로 처리됩니다. + - `pageNumber`는 1부터 시작합니다. + - `providedDate` 필터 시 반드시 `yyyy-MM-dd` 형식을 준수해야 합니다. + """ + ) + ResponseEntity> filteredKeywords( + CustomOAuth2User customOAuth2User, + KeywordFilterRequest request + ); +} diff --git a/src/main/java/onepiece/dailysnapbackend/controller/LikeController.java b/src/main/java/onepiece/dailysnapbackend/controller/LikeController.java index 8af0d9d..a34aa36 100644 --- a/src/main/java/onepiece/dailysnapbackend/controller/LikeController.java +++ b/src/main/java/onepiece/dailysnapbackend/controller/LikeController.java @@ -21,7 +21,7 @@ name = "게시물 좋아요 API", description = "게시물 좋아요 관련 API 제공" ) -public class LikeController implements LikeControllerDocs{ +public class LikeController implements LikeControllerDocs { private final LikeService likeService; diff --git a/src/main/java/onepiece/dailysnapbackend/controller/LikeControllerDocs.java b/src/main/java/onepiece/dailysnapbackend/controller/LikeControllerDocs.java index 5c7aa5b..db56190 100644 --- a/src/main/java/onepiece/dailysnapbackend/controller/LikeControllerDocs.java +++ b/src/main/java/onepiece/dailysnapbackend/controller/LikeControllerDocs.java @@ -10,16 +10,16 @@ public interface LikeControllerDocs { @Operation( summary = "게시글 좋아요", description = """ - - 이 API는 인증이 필요합니다. - - ### 요청 파라미터 - - **postId** (Long): 좋아요를 누를 게시글 ID (필수) - - ### 반환값 - - **likeCount** (int): 좋아요 수 - - """ + + 이 API는 인증이 필요합니다. + + ### 요청 파라미터 + - **postId** (Long): 좋아요를 누를 게시글 ID (필수) + + ### 반환값 + - **likeCount** (int): 좋아요 수 + + """ ) ResponseEntity postLike(CustomOAuth2User userDetails, PostDetailRequest request); } diff --git a/src/main/java/onepiece/dailysnapbackend/controller/MockController.java b/src/main/java/onepiece/dailysnapbackend/controller/MockController.java new file mode 100644 index 0000000..5d35836 --- /dev/null +++ b/src/main/java/onepiece/dailysnapbackend/controller/MockController.java @@ -0,0 +1,33 @@ +package onepiece.dailysnapbackend.controller; + +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import onepiece.dailysnapbackend.object.dto.LoginResponse; +import onepiece.dailysnapbackend.object.dto.MockLoginRequest; +import onepiece.dailysnapbackend.service.MockService; +import onepiece.dailysnapbackend.util.log.LogMonitoringInvocation; +import org.springframework.http.ResponseEntity; +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 +@RequiredArgsConstructor +@RequestMapping("/mock") +@Tag( + name = "개발자 편의 API", + description = "개발 편의 관련 API 제공" +) +public class MockController implements MockControllerDocs { + + private final MockService mockService; + + @Override + @PostMapping("/member") + @LogMonitoringInvocation + public ResponseEntity createMockMember( + @RequestBody MockLoginRequest request) { + return ResponseEntity.ok(mockService.mockLogin(request)); + } +} diff --git a/src/main/java/onepiece/dailysnapbackend/controller/MockControllerDocs.java b/src/main/java/onepiece/dailysnapbackend/controller/MockControllerDocs.java new file mode 100644 index 0000000..fe1c17d --- /dev/null +++ b/src/main/java/onepiece/dailysnapbackend/controller/MockControllerDocs.java @@ -0,0 +1,44 @@ +package onepiece.dailysnapbackend.controller; + +import io.swagger.v3.oas.annotations.Operation; +import onepiece.dailysnapbackend.object.dto.LoginResponse; +import onepiece.dailysnapbackend.object.dto.MockLoginRequest; +import org.springframework.http.ResponseEntity; + +public interface MockControllerDocs { + + @Operation( + summary = "모의 회원 생성 및 로그인", + description = """ + ### 요청 파라미터 + - `username` (String, optional): 생성할 회원 이메일. 미전달 또는 빈 문자열 시 랜덤 이메일 생성 + - `nickname` (String, optional): 생성할 회원 닉네임. 미전달 또는 빈 문자열 시 랜덤 닉네임 생성 + - `role` (Role, optional): 부여할 권한 (`ROLE_USER`, `ROLE_ADMIN`). 미전달 시 `ROLE_USER` 설정 + + ### 응답 데이터 + - `accessToken` (String): 발급된 액세스 토큰 (JWT) + - `refreshToken` (String): 발급된 리프레시 토큰 (JWT) + + ### 사용 방법 + 1. 인증 없이 사용할 수 있는 개발자 전용 엔드포인트입니다. + 2. 클라이언트에서 아래 JSON 예시처럼 `/mock/member`로 POST 요청을 보냅니다: + ```json + { + "username": "test@example.com", + "nickname": "tester", + "role": "ROLE_ADMIN" + } + ``` + 3. 서버가 가상의 회원을 생성하여 JWT를 발급하고, `LoginResponse`를 반환합니다. + + ### 유의 사항 + - 실제 운영 환경에서는 사용 금지하며, 개발 및 테스트 목적으로만 사용해야 합니다. + - `username` 또는 `nickname`에 빈 문자열을 전달하면 Faker를 사용해 랜덤 값이 생성됩니다. + - `role`에 허용되지 않는 값 전달 시 서버 측에서 처리되지 않으므로, enum 값만 사용해야 합니다. + """ + ) + ResponseEntity createMockMember( + MockLoginRequest request + ); + +} diff --git a/src/main/java/onepiece/dailysnapbackend/controller/PostController.java b/src/main/java/onepiece/dailysnapbackend/controller/PostController.java index 26c4640..74836ae 100644 --- a/src/main/java/onepiece/dailysnapbackend/controller/PostController.java +++ b/src/main/java/onepiece/dailysnapbackend/controller/PostController.java @@ -6,12 +6,11 @@ import lombok.RequiredArgsConstructor; import onepiece.dailysnapbackend.object.dto.CustomOAuth2User; import onepiece.dailysnapbackend.object.dto.PostFilteredRequest; -import onepiece.dailysnapbackend.object.dto.PostFilteredResponse; import onepiece.dailysnapbackend.object.dto.PostRequest; import onepiece.dailysnapbackend.object.dto.PostResponse; -import onepiece.dailysnapbackend.object.postgres.Member; import onepiece.dailysnapbackend.service.PostService; import onepiece.dailysnapbackend.util.log.LogMonitoringInvocation; +import org.springdoc.core.annotations.ParameterObject; import org.springframework.data.domain.Page; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -35,29 +34,29 @@ public class PostController implements PostControllerDocs { private final PostService postService; @Override - @PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @PostMapping(value = "", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @LogMonitoringInvocation - public ResponseEntity uploadPost( - @AuthenticationPrincipal CustomOAuth2User userDetails, + public ResponseEntity uploadPost( + @AuthenticationPrincipal CustomOAuth2User customOAuth2User, @Valid @ModelAttribute PostRequest request) { - Member member = userDetails.getMember(); - return ResponseEntity.ok(postService.uploadPost(request, member)); + postService.uploadPost(customOAuth2User.getMember(), request); + return ResponseEntity.ok().build(); } @Override - @GetMapping + @GetMapping("/{post-id}") @LogMonitoringInvocation - public ResponseEntity> filteredPosts( + public ResponseEntity getPost( @AuthenticationPrincipal CustomOAuth2User userDetails, - @Valid @ModelAttribute PostFilteredRequest request) { - return ResponseEntity.ok(postService.getFilteredPosts(request)); + @PathVariable(name = "post-id") UUID postId) { + return ResponseEntity.ok(postService.getPost(userDetails.getMember(), postId)); } - @Override - @GetMapping("/detail/{postId}") + @GetMapping("") @LogMonitoringInvocation - public ResponseEntity detailPost( - @PathVariable UUID postId) { - return ResponseEntity.ok(postService.getPostDetails(postId)); + public ResponseEntity> filteredPost( + @ParameterObject PostFilteredRequest request + ) { + return ResponseEntity.ok(postService.filteredPost(request)); } } diff --git a/src/main/java/onepiece/dailysnapbackend/controller/PostControllerDocs.java b/src/main/java/onepiece/dailysnapbackend/controller/PostControllerDocs.java index 3a83564..c28c736 100644 --- a/src/main/java/onepiece/dailysnapbackend/controller/PostControllerDocs.java +++ b/src/main/java/onepiece/dailysnapbackend/controller/PostControllerDocs.java @@ -4,7 +4,6 @@ import java.util.UUID; import onepiece.dailysnapbackend.object.dto.CustomOAuth2User; import onepiece.dailysnapbackend.object.dto.PostFilteredRequest; -import onepiece.dailysnapbackend.object.dto.PostFilteredResponse; import onepiece.dailysnapbackend.object.dto.PostRequest; import onepiece.dailysnapbackend.object.dto.PostResponse; import org.springframework.data.domain.Page; @@ -13,75 +12,137 @@ public interface PostControllerDocs { @Operation( - summary = "사진 업로드", + summary = "게시물 업로드", description = """ + ### 요청 파라미터 + - `image` (file, required): 업로드할 이미지 파일 (MultipartFile) + - `description` (String, optional): 게시물 설명 - 이 API는 인증이 필요합니다 + ### 응답 데이터 + - 없음 (빈 본문) - ### 요청 파라미터 - - **keywordId** (UUID): 키워드 id - - **images** (List): 이미지 - - **content** (String): 사진 설명 (필수X) - - **location** (String): 위치 (필수X) + ### 사용 방법 + 1. 클라이언트에서 Authorization 헤더에 `Bearer {accessToken}`을 포함합니다. + 2. `Content-Type: multipart/form-data` 로 아래와 같이 폼 데이터 요청을 보냅니다: + ``` + POST /api/auth + Content-Type: multipart/form-data + Authorization: Bearer eyJhbGciOiJI... + + --boundary + Content-Disposition: form-data; name="image"; filename="photo.jpg" + Content-Type: image/jpeg - ### 반환값 - - **keyword** (Keyword) : 키워드 - - **images** (List) : 이미지 - - **content** (String): 사진 설명 - - **viewCount** (Integer): 조회수 - - **likeCount** (Integer): 좋아요수 - - **location** (String) : 위치 + (파일 바이너리) + --boundary + Content-Disposition: form-data; name="description" + 오늘의 키워드 사진입니다. + --boundary-- + ``` + 3. 서버가 이미지를 저장하고 200 OK 응답을 반환합니다. + + ### 유의 사항 + - `image` 파일은 반드시 전송해야 합니다. + - `description`은 최대 길이 제한이 없으나, 필요 시 클라이언트에서 적절히 검증해 주세요. + - 파일 업로드 실패 시 4xx/5xx 에러가 발생할 수 있습니다. """ ) - ResponseEntity uploadPost - (CustomOAuth2User userDetails, PostRequest request); + ResponseEntity uploadPost( + CustomOAuth2User customOAuth2User, + PostRequest request + ); @Operation( - summary = "게시글 필터링 (페이징 및 정렬 지원)", + summary = "게시물 상세 조회", description = """ - - 이 API는 인증이 필요합니다. - ### 요청 파라미터 - - **nickname** (String): 닉네임으로 게시글 필터링 (선택, 빈 값일 경우 전체 조회) - - **pageNumber** (int): 페이지 번호 (0부터 시작, 기본값: 0) - - **pageSize** (int): 페이지당 게시글 개수 (기본값: 30) - - **sortField** (String): 정렬 기준 (`created_date`, `like_count` 중 선택, 기본값: `created_date`) - - **sortDirection** (String): 정렬 방향 (`ASC`, `DESC` 중 선택, 기본값: `DESC`) + - `post-id` (UUID, required, path): 조회할 게시물의 고유 ID - ### 반환값 - - **member** (Member) : 회원 - - **keyword** (Keyword) : 키워드 - - **images** (List) : 이미지 - - **content** (String): 사진 설명 - - **viewCount** (Integer): 조회수 - - **likeCount** (Integer) : 좋아요수 - - **location** (String) : 위치 + ### 응답 데이터 + - `postId` (UUID): 게시물 고유 ID + - `nickname` (String): 작성자 닉네임 + - `profileImageUrl` (String): 작성자 프로필 이미지 URL + - `koreanKeyword` (String): 게시물에 사용된 한국어 키워드 + - `englishKeyword` (String): 게시물에 사용된 영어 키워드 + - `keywordCategory` (KeywordCategory): 키워드 카테고리 + - `providedDate` (LocalDate): 키워드 제공 날짜 (YYYY-MM-DD) + - `imageUrl` (String): 게시물 이미지 URL + - `description` (String): 게시물 설명 + - `likeCount` (int): 좋아요 수 + ### 사용 방법 + 1. 클라이언트에서 Authorization 헤더에 `Bearer {accessToken}`을 포함하여 GET 요청을 보냅니다: + ``` + GET /api/auth/{post-id} + ``` + 2. 서버가 해당 `post-id`에 매핑된 게시물을 조회하여 `PostResponse` 형태로 반환합니다. + + ### 유의 사항 + - `post-id`는 UUID 형식이어야 합니다. + - 존재하지 않는 `post-id`로 요청 시 400 Bad Request 응답이 반환됩니다. """ ) - ResponseEntity> filteredPosts - (CustomOAuth2User userDetails, PostFilteredRequest request); + ResponseEntity getPost( + CustomOAuth2User userDetails, + UUID postId + ); @Operation( - summary = "게시글 상세 조회", + summary = "게시물 목록 조회 (동적 필터링)", description = """ - - 이 API는 인증이 필요합니다. - - ### 요청 파라미터 - - **postId** (Long): 좋아요를 누를 게시글 ID (필수) - - ### 반환값 - - **keyword** (String): 게시글 키워드 - - **images** (List): 게시글 이미지 목록 - - **content** (String): 게시글 내용 - - **viewCount** (int): 조회수 - - **likeCount** (int): 좋아요 수 - - **location** (String): 게시글 위치 정보 - - """ + ### 요청 파라미터 + - `keywordId` (UUID, optional, query): 키워드 고유 ID로 필터링 + - `koreanKeyword` (String, optional, query): 한국어 키워드 텍스트로 필터링 (부분 일치) + - `englishKeyword` (String, optional, query): 영어 키워드 텍스트로 필터링 (부분 일치) + - `description` (String, optional, query): 게시물 설명 텍스트로 필터링 (부분 일치) + - `pageNumber` (int, optional, query): 페이지 번호 (1 이상, 기본값: 1) + - `pageSize` (int, optional, query): 페이지 크기 (기본값: `PageableConstants.DEFAULT_PAGE_SIZE`) + - `sortField` (PostSortField, optional, query): 정렬 기준 필드 (CREATED_DATE, LIKE_COUNT; 기본값: CREATED_DATE) + - `sortDirection` (Sort.Direction, optional, query): 정렬 방향 (ASC 또는 DESC; 기본값: DESC) + + ### 응답 데이터 + - `content` (List): 조회된 게시물 리스트 + - `postId` (UUID): 게시물 고유 ID + - `nickname` (String): 작성자 닉네임 + - `profileImageUrl` (String): 작성자 프로필 이미지 URL + - `koreanKeyword` (String): 게시물에 사용된 한국어 키워드 + - `englishKeyword` (String): 게시물에 사용된 영어 키워드 + - `keywordCategory` (KeywordCategory): 키워드 카테고리 + - `providedDate` (LocalDate): 키워드 제공 날짜 (yyyy-MM-dd) + - `imageUrl` (String): 게시물 이미지 URL + - `description` (String): 게시물 설명 + - `likeCount` (int): 좋아요 수 + - `pageable` (Object): 페이지 요청 정보 + - `totalPages` (int): 전체 페이지 수 + - `totalElements` (long): 전체 게시물 수 + - `first` (boolean): 첫 페이지 여부 + - `last` (boolean): 마지막 페이지 여부 + - `numberOfElements` (int): 현재 페이지 게시물 수 + - `empty` (boolean): 결과 비어있는지 여부 + + ### 사용 방법 + 1. 클라이언트에서 Authorization 헤더에 `Bearer {accessToken}`을 포함하여 GET 요청을 보냅니다. + 2. 쿼리 파라미터로 필터·페이징·정렬 조건을 전달합니다. 예: + ``` + GET /api/post? + keywordId=3fa85f64-5717-4562-b3fc-2c963f66afa6& + koreanKeyword=사진& + description=여행& + pageNumber=1& + pageSize=10& + sortField=LIKE_COUNT& + sortDirection=DESC + ``` + + ### 유의 사항 + - 모든 파라미터는 선택 사항이며, 미전달 시 기본값이 적용됩니다. + - `pageNumber`는 1부터 시작합니다. + - `sortField`와 `sortDirection`은 `PostSortField` 및 `Sort.Direction` enum 값만 허용됩니다. + - UUID 형식 필터(`keywordId`) 시 올바른 UUID 형식을 준수해야 합니다. + """ ) - ResponseEntity detailPost(UUID postId); + ResponseEntity> filteredPost( + PostFilteredRequest request + ); } diff --git a/src/main/java/onepiece/dailysnapbackend/controller/keyword/KeywordControllerDocs.java b/src/main/java/onepiece/dailysnapbackend/controller/keyword/KeywordControllerDocs.java deleted file mode 100644 index ea62c3c..0000000 --- a/src/main/java/onepiece/dailysnapbackend/controller/keyword/KeywordControllerDocs.java +++ /dev/null @@ -1,61 +0,0 @@ -package onepiece.dailysnapbackend.controller.keyword; - -import io.swagger.v3.oas.annotations.Operation; -import onepiece.dailysnapbackend.object.dto.CustomOAuth2User; -import onepiece.dailysnapbackend.object.dto.DailyKeywordResponse; -import onepiece.dailysnapbackend.object.dto.KeywordFilterRequest; -import onepiece.dailysnapbackend.object.dto.KeywordFilterResponse; -import org.springframework.data.domain.Page; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; - -public interface KeywordControllerDocs { - - @Operation( - summary = "키워드 필터링", - description = """ - - 이 API는 인증이 필요합니다. - - ### 요청 파라미터 - - **keyword** (String) : 키워드 텍스트 (필수X) - - **category** (KeywordCategory) : 키워드 카테고리 (필수X) - - **providedDate** (LocalDate) : 제공된 날짜 (YYYY-MM-DD) (필수X) - - **isUsed** (Boolean) : 사용 여부 (필수X) - - **pageNumber** (int, 기본값: 0) : 페이지 번호 - - **pageSize** (int, 기본값: 100, 최대 100) : 페이지당 키워드 개수 - - **sortField** (String, 기본값: created_date) : 정렬 기준 (created_date, provided_date, keyword) - - **sortDirection** (String, 기본값: DESC) : 정렬 방향 (ASC, DESC) - - ### 반환값 - - **keyword** (String) : 키워드 텍스트 - - **category** (String) : 키워드 카테고리 - - **providedDate** (LocalDate) : 제공된 날짜 - - """ - ) - @PostMapping - ResponseEntity> filteredKeywords( - CustomOAuth2User userDetails, - @RequestBody KeywordFilterRequest request - ); - - @Operation( - summary = "오늘의 키워드", - description = """ - - 이 API는 인증이 필요합니다. - - ### 요청 파라미터 - - 없음 - - ### 반환값 - - **keyword** (String) : 키워드 - - **category** (KeywordCategory) : 키워드 카테고리 - - **providedDate** (LocalDate) : 제공한 날짜 - - """ - ) - ResponseEntity getDailyKeyword(); -} diff --git a/src/main/java/onepiece/dailysnapbackend/mapper/EntityMapper.java b/src/main/java/onepiece/dailysnapbackend/mapper/EntityMapper.java index 2069f22..e37449f 100644 --- a/src/main/java/onepiece/dailysnapbackend/mapper/EntityMapper.java +++ b/src/main/java/onepiece/dailysnapbackend/mapper/EntityMapper.java @@ -1,6 +1,5 @@ package onepiece.dailysnapbackend.mapper; -import onepiece.dailysnapbackend.object.dto.KeywordFilterResponse; import onepiece.dailysnapbackend.object.dto.KeywordRequest; import onepiece.dailysnapbackend.object.dto.MemberResponse; import onepiece.dailysnapbackend.object.postgres.Keyword; @@ -10,10 +9,10 @@ @Mapper(componentModel = "spring") public interface EntityMapper { + EntityMapper INSTANCE = Mappers.getMapper(EntityMapper.class); // Keyword 관련 - KeywordFilterResponse toKeywordFilterResponse(Keyword keyword); KeywordRequest toKeywordRequest(Keyword keyword); // Member 관련 diff --git a/src/main/java/onepiece/dailysnapbackend/object/constants/AccountStatus.java b/src/main/java/onepiece/dailysnapbackend/object/constants/AccountStatus.java index ffb0cad..a8d53ae 100644 --- a/src/main/java/onepiece/dailysnapbackend/object/constants/AccountStatus.java +++ b/src/main/java/onepiece/dailysnapbackend/object/constants/AccountStatus.java @@ -6,8 +6,8 @@ @AllArgsConstructor @Getter public enum AccountStatus { - ACTIVE_ACCOUNT("활성화된 계정"), - DELETE_ACCOUNT("삭제된 계정"); + ACTIVE_ACCOUNT("활성화된 계정"), + DELETE_ACCOUNT("삭제된 계정"); - private final String description; + private final String description; } diff --git a/src/main/java/onepiece/dailysnapbackend/object/constants/FileExtension.java b/src/main/java/onepiece/dailysnapbackend/object/constants/FileExtension.java new file mode 100644 index 0000000..716da6e --- /dev/null +++ b/src/main/java/onepiece/dailysnapbackend/object/constants/FileExtension.java @@ -0,0 +1,78 @@ +package onepiece.dailysnapbackend.object.constants; + +import java.util.Arrays; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import onepiece.dailysnapbackend.util.CommonUtil; +import onepiece.dailysnapbackend.util.exception.CustomException; +import onepiece.dailysnapbackend.util.exception.ErrorCode; + +@Getter +@RequiredArgsConstructor +public enum FileExtension { + + JPG("jpg", "image/jpeg"), + + JPEG("jpeg", "image/jpeg"), + + PNG("png", "image/png"), + + GIF("gif", "image/gif"), + + BMP("bmp", "image/bmp"), + + WEBP("webp", "image/webp"); + + private final String extension; + private final String contentType; + + /** + * 파일명에서 확장자 추출 + * + * @param filename 파일명 + * @return 파일 확장자 + */ + public static FileExtension fromFilename(String filename) { + if (CommonUtil.nvl(filename, "").isEmpty()) { + throw new CustomException(ErrorCode.INVALID_FILE_REQUEST); + } + + int lastDotIndex = filename.lastIndexOf("."); + if (lastDotIndex < 0) { + throw new CustomException(ErrorCode.INVALID_FILE_REQUEST); + } + + String extension = filename.substring(lastDotIndex + 1).toLowerCase(); + + return Arrays.stream(values()) + .filter(fileExtension -> fileExtension.getExtension().equals(extension)) + .findFirst() + .orElseThrow(() -> new CustomException(ErrorCode.INVALID_FILE_EXTENSION)); + } + + /** + * 파일 확장자가 유효한지 검증 + * + * @param filename 파일명 + * @return 유효한 확장자이면 true, 유효하지 않으면 false + */ + public static boolean isValidExtension(String filename) { + try { + fromFilename(filename); + return true; + } catch (CustomException e) { + return false; + } + } + + /** + * 확장자에 해당하는 ContentType 반환 + * + * @param filename 파일명 + * @return ContentType + */ + public static String getContentTypeByFilename(String filename) { + return fromFilename(filename).getContentType(); + } + +} diff --git a/src/main/java/onepiece/dailysnapbackend/object/constants/KeywordCategory.java b/src/main/java/onepiece/dailysnapbackend/object/constants/KeywordCategory.java index 3440322..e623bda 100644 --- a/src/main/java/onepiece/dailysnapbackend/object/constants/KeywordCategory.java +++ b/src/main/java/onepiece/dailysnapbackend/object/constants/KeywordCategory.java @@ -16,6 +16,5 @@ public enum KeywordCategory { ABSTRACT("추상"), // 감성적인 표현 RANDOM("랜덤"); // 기타 키워드 - private final String prompt; } \ No newline at end of file diff --git a/src/main/java/onepiece/dailysnapbackend/object/constants/KeywordSortField.java b/src/main/java/onepiece/dailysnapbackend/object/constants/KeywordSortField.java new file mode 100644 index 0000000..78180d1 --- /dev/null +++ b/src/main/java/onepiece/dailysnapbackend/object/constants/KeywordSortField.java @@ -0,0 +1,28 @@ +package onepiece.dailysnapbackend.object.constants; + +import com.fasterxml.jackson.annotation.JsonCreator; +import lombok.AllArgsConstructor; +import lombok.Getter; +import onepiece.dailysnapbackend.util.CommonUtil; +import onepiece.dailysnapbackend.util.SortField; + +@Getter +@AllArgsConstructor +public enum KeywordSortField implements SortField { + + CREATED_DATE("createdDate"), + + ; + + private final String property; + + /** + * Jackson이 JSON -> Java 객체로 역직렬화 (deserialization)할 때 자동 호출 + * 컨트롤러에서 들어온 {"sortField": "TICKET_OPEN_DATE"} 같은 문자열을 변환 + * 만약 {"sortField": "ticketOpenDate"}와 같이 카멜케이스로 들어와도 f.property와 비교하여 자동 매칭 + */ + @JsonCreator + public static KeywordSortField from(String value) { + return CommonUtil.stringToSortField(KeywordSortField.class, value); + } +} diff --git a/src/main/java/onepiece/dailysnapbackend/object/constants/PostSortField.java b/src/main/java/onepiece/dailysnapbackend/object/constants/PostSortField.java new file mode 100644 index 0000000..992c166 --- /dev/null +++ b/src/main/java/onepiece/dailysnapbackend/object/constants/PostSortField.java @@ -0,0 +1,30 @@ +package onepiece.dailysnapbackend.object.constants; + +import com.fasterxml.jackson.annotation.JsonCreator; +import lombok.AllArgsConstructor; +import lombok.Getter; +import onepiece.dailysnapbackend.util.CommonUtil; +import onepiece.dailysnapbackend.util.SortField; + +@Getter +@AllArgsConstructor +public enum PostSortField implements SortField { + + CREATED_DATE("createdDate"), + + LIKE_COUNT("likeCount"), + + ; + + private final String property; + + /** + * Jackson이 JSON -> Java 객체로 역직렬화 (deserialization)할 때 자동 호출 + * 컨트롤러에서 들어온 {"sortField": "TICKET_OPEN_DATE"} 같은 문자열을 변환 + * 만약 {"sortField": "ticketOpenDate"}와 같이 카멜케이스로 들어와도 f.property와 비교하여 자동 매칭 + */ + @JsonCreator + public static PostSortField from(String value) { + return CommonUtil.stringToSortField(PostSortField.class, value); + } +} diff --git a/src/main/java/onepiece/dailysnapbackend/object/constants/UploadType.java b/src/main/java/onepiece/dailysnapbackend/object/constants/UploadType.java new file mode 100644 index 0000000..d932265 --- /dev/null +++ b/src/main/java/onepiece/dailysnapbackend/object/constants/UploadType.java @@ -0,0 +1,11 @@ +package onepiece.dailysnapbackend.object.constants; + +import lombok.Getter; + +@Getter +public enum UploadType { + + MEMBER, + + POST +} diff --git a/src/main/java/onepiece/dailysnapbackend/object/dto/FollowRequest.java b/src/main/java/onepiece/dailysnapbackend/object/dto/FollowRequest.java index 09b6051..3b9b39d 100644 --- a/src/main/java/onepiece/dailysnapbackend/object/dto/FollowRequest.java +++ b/src/main/java/onepiece/dailysnapbackend/object/dto/FollowRequest.java @@ -15,28 +15,25 @@ @AllArgsConstructor public class FollowRequest { - public FollowRequest() { - this.pageNumber = 0; - this.pageSize = 30; - this.sortField = "created_date"; - this.sortDirection = "DESC"; - } - @Schema(defaultValue = "0") @Min(value = 0, message = "페이지 번호는 0 이상이어야 합니다.") @Max(value = Integer.MAX_VALUE, message = "페이지 번호가 정수 최대값을 초과할 수 없습니다.") private Integer pageNumber; - @Schema(defaultValue = "30") @Min(value = 1, message = "페이지 사이즈는 1 이상이어야 합니다.") @Max(value = 100, message = "페이지 사이즈는 100을 초과할 수 없습니다.") private Integer pageSize; - @Schema(defaultValue = "createdDate") @Pattern(regexp = "^(createdDate)$") private String sortField; // 정렬 조건 (생성일) - @Schema(defaultValue = "DESC") @Pattern(regexp = "^(ASC|DESC)$", message = "sortDirection 에는 'ASC', 'DESC' 만 입력 가능합니다.") private String sortDirection; // ASC, DESC + + public FollowRequest() { + this.pageNumber = 0; + this.pageSize = 30; + this.sortField = "created_date"; + this.sortDirection = "DESC"; + } } diff --git a/src/main/java/onepiece/dailysnapbackend/object/dto/KeywordFilterRequest.java b/src/main/java/onepiece/dailysnapbackend/object/dto/KeywordFilterRequest.java index 7c9aa6e..667a4fc 100644 --- a/src/main/java/onepiece/dailysnapbackend/object/dto/KeywordFilterRequest.java +++ b/src/main/java/onepiece/dailysnapbackend/object/dto/KeywordFilterRequest.java @@ -1,14 +1,18 @@ package onepiece.dailysnapbackend.object.dto; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.Max; -import jakarta.validation.constraints.Min; -import jakarta.validation.constraints.Pattern; +import com.fasterxml.jackson.annotation.JsonFormat; +import java.time.LocalDate; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; import lombok.Setter; +import onepiece.dailysnapbackend.object.constants.KeywordCategory; +import onepiece.dailysnapbackend.object.constants.KeywordSortField; +import onepiece.dailysnapbackend.util.PageableConstants; +import onepiece.dailysnapbackend.util.PageableUtil; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.domain.Sort.Direction; @Getter @Setter @@ -16,45 +20,37 @@ @AllArgsConstructor public class KeywordFilterRequest { - public KeywordFilterRequest() { - this.pageNumber = 0; - this.pageSize = 30; - this.sortField = "created_date"; - this.sortDirection = "DESC"; - } + private String koreanKeyword; + + private KeywordCategory keywordCategory; - @Schema(defaultValue="바다") - private String keyword; + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd") + private LocalDate providedDate; - @Schema(defaultValue="SUMMER") - private String category; + private Boolean used; - @Pattern(regexp = "\\d{4}-\\d{2}-\\d{2}", message = "'YYYY-MM-DD' 형식이어야 합니다.") - @Schema(defaultValue = "2025-03-07") - private String providedDate; + private int pageNumber; - @Builder.Default - private Boolean isUsed = null; + private int pageSize; - @Schema(defaultValue = "0") - @Min(value = 0, message = "페이지 번호는 0 이상이어야 합니다.") - @Max(value = Integer.MAX_VALUE, message = "페이지 번호가 정수 최대값을 초과할 수 없습니다.") - @Parameter(description = "페이지 번호 (0부터 시작)", required = false) - private Integer pageNumber; + private KeywordSortField sortField; - @Schema(defaultValue = "100") - @Min(value = 1, message = "페이지 사이즈는 1 이상이어야 합니다.") - @Max(value = 100, message = "페이지 사이즈는 100을 초과할 수 없습니다.") - @Parameter(description = "페이지 크기", required = false) - private Integer pageSize; + private Sort.Direction sortDirection; - @Schema(defaultValue = "created_date") - @Pattern(regexp = "^(created_date|provided_date|keyword)$", message = "정렬 필드는 'created_date', 'provided_date', 'keyword' 중 하나여야 합니다.") - @Parameter(description = "정렬 기준 (created_date, provided_date, keyword)", required = false) - private String sortField; + public KeywordFilterRequest() { + this.pageNumber = 1; + this.pageSize = PageableConstants.DEFAULT_PAGE_SIZE; + this.sortField = KeywordSortField.CREATED_DATE; + this.sortDirection = Direction.DESC; + } - @Schema(defaultValue = "DESC") - @Pattern(regexp = "^(ASC|DESC)$", message = "정렬 방향은 'ASC' 또는 'DESC'만 입력 가능합니다.") - @Parameter(description = "정렬 방향 (ASC 또는 DESC)", required = false) - private String sortDirection; + public Pageable toPageable() { + return PageableUtil.createPageable( + pageNumber, + pageSize, + PageableConstants.DEFAULT_PAGE_SIZE, + sortField, + sortDirection + ); + } } diff --git a/src/main/java/onepiece/dailysnapbackend/object/dto/KeywordFilterResponse.java b/src/main/java/onepiece/dailysnapbackend/object/dto/KeywordFilterResponse.java deleted file mode 100644 index 87f1087..0000000 --- a/src/main/java/onepiece/dailysnapbackend/object/dto/KeywordFilterResponse.java +++ /dev/null @@ -1,21 +0,0 @@ -package onepiece.dailysnapbackend.object.dto; - -import java.time.LocalDate; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; -import onepiece.dailysnapbackend.object.constants.KeywordCategory; - -@Getter -@Setter -@Builder -@AllArgsConstructor -@NoArgsConstructor -public class KeywordFilterResponse { - - private String keyword; // 키워드 텍스트 필터 - private KeywordCategory category; // 카테고리 필터 - private LocalDate providedDate; // 날짜 필터 -} \ No newline at end of file diff --git a/src/main/java/onepiece/dailysnapbackend/object/dto/KeywordPair.java b/src/main/java/onepiece/dailysnapbackend/object/dto/KeywordPair.java new file mode 100644 index 0000000..58a494c --- /dev/null +++ b/src/main/java/onepiece/dailysnapbackend/object/dto/KeywordPair.java @@ -0,0 +1,8 @@ +package onepiece.dailysnapbackend.object.dto; + +public record KeywordPair( + String koreanKeyword, + String englishKeyword +) { + +} diff --git a/src/main/java/onepiece/dailysnapbackend/object/dto/KeywordRequest.java b/src/main/java/onepiece/dailysnapbackend/object/dto/KeywordRequest.java index fa3c191..61f8adf 100644 --- a/src/main/java/onepiece/dailysnapbackend/object/dto/KeywordRequest.java +++ b/src/main/java/onepiece/dailysnapbackend/object/dto/KeywordRequest.java @@ -1,5 +1,7 @@ package onepiece.dailysnapbackend.object.dto; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; import java.time.LocalDate; import lombok.AllArgsConstructor; import lombok.Builder; @@ -15,10 +17,16 @@ @AllArgsConstructor public class KeywordRequest { - private KeywordCategory category; // 카테고리 필터링 + @NotNull(message = "키워드 카테고리를 입력하세요") + private KeywordCategory category; - private String keyword; // 키워드 필터링 + @NotBlank(message = "키워드(한국어)를 입력하세요") + private String koreanKeyword; - private LocalDate specifiedDate; // 특정 날짜에 제공될 키워드 + @NotBlank(message = "키워드(영어)를 입력하세요") + private String englishKeyword; + + @NotNull(message = "키워드를 제공할 날짜를 입력하세요") + private LocalDate specifiedDate; } \ No newline at end of file diff --git a/src/main/java/onepiece/dailysnapbackend/object/dto/KeywordResponse.java b/src/main/java/onepiece/dailysnapbackend/object/dto/KeywordResponse.java index 3767cc8..55bdf57 100644 --- a/src/main/java/onepiece/dailysnapbackend/object/dto/KeywordResponse.java +++ b/src/main/java/onepiece/dailysnapbackend/object/dto/KeywordResponse.java @@ -1,22 +1,31 @@ package onepiece.dailysnapbackend.object.dto; +import com.fasterxml.jackson.annotation.JsonFormat; import java.time.LocalDate; -import lombok.AllArgsConstructor; +import java.util.UUID; import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; import onepiece.dailysnapbackend.object.constants.KeywordCategory; +import onepiece.dailysnapbackend.object.postgres.Keyword; -@Getter -@Setter @Builder -@NoArgsConstructor -@AllArgsConstructor -public class KeywordResponse { +public record KeywordResponse( + UUID keywordId, + String koreanKeyword, + String englishKeyword, + KeywordCategory keywordCategory, + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd") + LocalDate providedDate, + boolean used +) { - private String keyword; - private KeywordCategory category; - private LocalDate specifiedDate; - private LocalDate providedDate; -} + public static KeywordResponse of(Keyword keyword) { + return new KeywordResponse( + keyword.getKeywordId(), + keyword.getKoreanKeyword(), + keyword.getEnglishKeyword(), + keyword.getKeywordCategory(), + keyword.getProvidedDate(), + keyword.isUsed() + ); + } +} \ No newline at end of file diff --git a/src/main/java/onepiece/dailysnapbackend/object/dto/SignInRequest.java b/src/main/java/onepiece/dailysnapbackend/object/dto/LoginRequest.java similarity index 57% rename from src/main/java/onepiece/dailysnapbackend/object/dto/SignInRequest.java rename to src/main/java/onepiece/dailysnapbackend/object/dto/LoginRequest.java index ab4f55a..e622d79 100644 --- a/src/main/java/onepiece/dailysnapbackend/object/dto/SignInRequest.java +++ b/src/main/java/onepiece/dailysnapbackend/object/dto/LoginRequest.java @@ -1,29 +1,25 @@ package onepiece.dailysnapbackend.object.dto; -import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; import lombok.Setter; +import onepiece.dailysnapbackend.object.constants.SocialPlatform; @Getter @Setter @Builder @AllArgsConstructor -public class SignInRequest { +public class LoginRequest { @NotBlank(message = "소셜 로그인 플렛폼을 입력하세요 (예: KAKAO, GOOGLE)") - @Schema(defaultValue = "KAKAO") - private String provider; + private SocialPlatform socialPlatform; @NotBlank(message = "이메일을 입력하세요") - @Schema(defaultValue = "example@naver.com") private String username; - @Schema(description = "생년월일 (선택)", defaultValue = "2004-01-01") private String birth; - @Schema(description = "닉네임 (선택)", defaultValue = "daily_snap_user") private String nickname; } diff --git a/src/main/java/onepiece/dailysnapbackend/object/dto/LoginResponse.java b/src/main/java/onepiece/dailysnapbackend/object/dto/LoginResponse.java new file mode 100644 index 0000000..23af712 --- /dev/null +++ b/src/main/java/onepiece/dailysnapbackend/object/dto/LoginResponse.java @@ -0,0 +1,8 @@ +package onepiece.dailysnapbackend.object.dto; + +public record LoginResponse( + String accessToken, + String refreshToken +) { + +} diff --git a/src/main/java/onepiece/dailysnapbackend/object/dto/MockLoginRequest.java b/src/main/java/onepiece/dailysnapbackend/object/dto/MockLoginRequest.java new file mode 100644 index 0000000..9e535de --- /dev/null +++ b/src/main/java/onepiece/dailysnapbackend/object/dto/MockLoginRequest.java @@ -0,0 +1,20 @@ +package onepiece.dailysnapbackend.object.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import onepiece.dailysnapbackend.object.constants.Role; + +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +public class MockLoginRequest { + + private String username; + + private String nickname; + + private Role role; +} diff --git a/src/main/java/onepiece/dailysnapbackend/object/dto/PostFilteredRequest.java b/src/main/java/onepiece/dailysnapbackend/object/dto/PostFilteredRequest.java index 22e8a35..a9f869f 100644 --- a/src/main/java/onepiece/dailysnapbackend/object/dto/PostFilteredRequest.java +++ b/src/main/java/onepiece/dailysnapbackend/object/dto/PostFilteredRequest.java @@ -1,13 +1,16 @@ package onepiece.dailysnapbackend.object.dto; -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.Max; -import jakarta.validation.constraints.Min; -import jakarta.validation.constraints.Pattern; +import java.util.UUID; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; import lombok.Setter; +import onepiece.dailysnapbackend.object.constants.PostSortField; +import onepiece.dailysnapbackend.util.PageableConstants; +import onepiece.dailysnapbackend.util.PageableUtil; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.domain.Sort.Direction; @Getter @Setter @@ -15,30 +18,36 @@ @AllArgsConstructor public class PostFilteredRequest { - public PostFilteredRequest() { - this.pageNumber = 0; - this.pageSize = 30; - this.sortField = "created_date"; - this.sortDirection = "DESC"; - } + private UUID keywordId; + + private String koreanKeyword; + + private String englishKeyword; + + private String description; - private String nickname; + private int pageNumber; - @Schema(defaultValue = "0") - @Min(value = 0, message = "페이지 번호 인덱스에 음수는 입력될 수 없습니다.") - @Max(value = Integer.MAX_VALUE, message = "정수 최대 범위를 넘을 수 없습니다.") - private Integer pageNumber; // 페이지 번호 + private int pageSize; - @Schema(defaultValue = "100") - @Min(value = 0, message = "페이지 사이즈에 음수는 입력될 수 없습니다.") - @Max(value = Integer.MAX_VALUE, message = "정수 최대 범위를 넘을 수 없습니다.") - private Integer pageSize; // 페이지 사이즈 + private PostSortField sortField; - @Schema(defaultValue = "created_date") - @Pattern(regexp = "^(created_date|like_count)") - private String sortField; // 정렬 조건 (생성일, 좋아요) + private Sort.Direction sortDirection; - @Schema(defaultValue = "DESC") - @Pattern(regexp = "^(ASC|DESC)$", message = "sortDirection에는 'ASC', 'DESC' 만 입력 가능합니다.") - private String sortDirection; // ASC, DESC + public PostFilteredRequest() { + this.pageNumber = 1; + this.pageSize = PageableConstants.DEFAULT_PAGE_SIZE; + this.sortField = PostSortField.CREATED_DATE; + this.sortDirection = Direction.DESC; + } + + public Pageable toPageable() { + return PageableUtil.createPageable( + pageNumber, + pageSize, + PageableConstants.DEFAULT_PAGE_SIZE, + sortField, + sortDirection + ); + } } diff --git a/src/main/java/onepiece/dailysnapbackend/object/dto/PostFilteredResponse.java b/src/main/java/onepiece/dailysnapbackend/object/dto/PostFilteredResponse.java deleted file mode 100644 index e127c2a..0000000 --- a/src/main/java/onepiece/dailysnapbackend/object/dto/PostFilteredResponse.java +++ /dev/null @@ -1,27 +0,0 @@ -package onepiece.dailysnapbackend.object.dto; - -import java.util.List; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; -import onepiece.dailysnapbackend.object.postgres.Image; -import onepiece.dailysnapbackend.object.postgres.Keyword; -import onepiece.dailysnapbackend.object.postgres.Member; - -@Getter -@Setter -@Builder -@AllArgsConstructor -@NoArgsConstructor -public class PostFilteredResponse { - - private Member member; - private Keyword keyword; - private List images; - private String content; - private Integer viewCount; - private Integer likeCount; - private String location; -} diff --git a/src/main/java/onepiece/dailysnapbackend/object/dto/PostRequest.java b/src/main/java/onepiece/dailysnapbackend/object/dto/PostRequest.java index 0c31639..0df1569 100644 --- a/src/main/java/onepiece/dailysnapbackend/object/dto/PostRequest.java +++ b/src/main/java/onepiece/dailysnapbackend/object/dto/PostRequest.java @@ -1,8 +1,6 @@ package onepiece.dailysnapbackend.object.dto; import jakarta.validation.constraints.NotNull; -import java.util.List; -import java.util.UUID; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -17,13 +15,8 @@ @AllArgsConstructor public class PostRequest { - @NotNull(message = "키워드를 입력하세요") - private UUID keywordId; + @NotNull + private MultipartFile image; - @NotNull(message = "이미지를 업로드하세요") - private List images; - - private String content; - - private String location; + private String description; } diff --git a/src/main/java/onepiece/dailysnapbackend/object/dto/PostResponse.java b/src/main/java/onepiece/dailysnapbackend/object/dto/PostResponse.java index 3e4dd9b..e353bc3 100644 --- a/src/main/java/onepiece/dailysnapbackend/object/dto/PostResponse.java +++ b/src/main/java/onepiece/dailysnapbackend/object/dto/PostResponse.java @@ -1,25 +1,39 @@ package onepiece.dailysnapbackend.object.dto; -import java.util.List; -import lombok.AllArgsConstructor; +import java.time.LocalDate; +import java.util.UUID; import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; -import onepiece.dailysnapbackend.object.postgres.Image; +import onepiece.dailysnapbackend.object.constants.KeywordCategory; import onepiece.dailysnapbackend.object.postgres.Keyword; +import onepiece.dailysnapbackend.object.postgres.Member; +import onepiece.dailysnapbackend.object.postgres.Post; -@Getter -@Setter @Builder -@AllArgsConstructor -@NoArgsConstructor -public class PostResponse { +public record PostResponse( + UUID postId, + String nickname, + String profileImageUrl, + String koreanKeyword, + String englishKeyword, + KeywordCategory keywordCategory, + LocalDate providedDate, + String imageUrl, + String description, + int likeCount +) { - private Keyword keyword; - private List images; - private String content; - private Integer viewCount; - private Integer likeCount; - private String location; -} + public static PostResponse from(Post post, Member member, Keyword keyword) { + return PostResponse.builder() + .postId(post.getPostId()) + .nickname(member.getNickname()) + .profileImageUrl(member.getProfileImageUrl()) + .koreanKeyword(keyword.getKoreanKeyword()) + .englishKeyword(keyword.getEnglishKeyword()) + .keywordCategory(keyword.getKeywordCategory()) + .providedDate(keyword.getProvidedDate()) + .imageUrl(post.getImageUrl()) + .description(post.getDescription()) + .likeCount(post.getLikeCount()) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/onepiece/dailysnapbackend/object/dto/ReissueRequest.java b/src/main/java/onepiece/dailysnapbackend/object/dto/ReissueRequest.java new file mode 100644 index 0000000..44feb98 --- /dev/null +++ b/src/main/java/onepiece/dailysnapbackend/object/dto/ReissueRequest.java @@ -0,0 +1,17 @@ +package onepiece.dailysnapbackend.object.dto; + +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Builder +@AllArgsConstructor +public class ReissueRequest { + + @NotBlank + private String refreshToken; +} diff --git a/src/main/java/onepiece/dailysnapbackend/object/postgres/DailyBest.java b/src/main/java/onepiece/dailysnapbackend/object/postgres/DailyBest.java index ff89bdd..b454e4e 100644 --- a/src/main/java/onepiece/dailysnapbackend/object/postgres/DailyBest.java +++ b/src/main/java/onepiece/dailysnapbackend/object/postgres/DailyBest.java @@ -20,12 +20,12 @@ @NoArgsConstructor @AllArgsConstructor @Builder -public class DailyBest extends BasePostgresEntity{ +public class DailyBest extends BasePostgresEntity { // 일간 우수작 ID @Id - @GeneratedValue(strategy = GenerationType.AUTO) - @Column(columnDefinition = "uuid DEFAULT uuid_generate_v4()", updatable = false, nullable = false) + @GeneratedValue(strategy = GenerationType.UUID) + @Column(updatable = false, nullable = false) private UUID dailyBestId; // 연관된 키워드 diff --git a/src/main/java/onepiece/dailysnapbackend/object/postgres/Follow.java b/src/main/java/onepiece/dailysnapbackend/object/postgres/Follow.java index 9b814b2..11b2ffb 100644 --- a/src/main/java/onepiece/dailysnapbackend/object/postgres/Follow.java +++ b/src/main/java/onepiece/dailysnapbackend/object/postgres/Follow.java @@ -24,8 +24,8 @@ public class Follow extends BasePostgresEntity { // 팔로우 ID @Id - @GeneratedValue(strategy = GenerationType.AUTO) - @Column(columnDefinition = "uuid DEFAULT uuid_generate_v4()", updatable = false, nullable = false) + @GeneratedValue(strategy = GenerationType.UUID) + @Column(updatable = false, nullable = false) private UUID followId; // 팔로우를 하는 회원 diff --git a/src/main/java/onepiece/dailysnapbackend/object/postgres/Image.java b/src/main/java/onepiece/dailysnapbackend/object/postgres/Image.java deleted file mode 100644 index be6f8b3..0000000 --- a/src/main/java/onepiece/dailysnapbackend/object/postgres/Image.java +++ /dev/null @@ -1,34 +0,0 @@ -package onepiece.dailysnapbackend.object.postgres; - -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.FetchType; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.ManyToOne; -import java.util.UUID; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; -import lombok.experimental.SuperBuilder; - -@Entity -@Getter -@Setter -@NoArgsConstructor -@AllArgsConstructor -@SuperBuilder -public class Image extends BasePostgresEntity{ - @Id - @GeneratedValue(strategy = GenerationType.AUTO) - @Column(columnDefinition = "uuid DEFAULT uuid_generate_v4()", updatable = false, nullable = false) - private UUID imageId; - - @Column(nullable = false) - private String imageUrl; - - @ManyToOne(fetch = FetchType.LAZY) - private Post post; -} diff --git a/src/main/java/onepiece/dailysnapbackend/object/postgres/Keyword.java b/src/main/java/onepiece/dailysnapbackend/object/postgres/Keyword.java index b8b8de2..bc0e510 100644 --- a/src/main/java/onepiece/dailysnapbackend/object/postgres/Keyword.java +++ b/src/main/java/onepiece/dailysnapbackend/object/postgres/Keyword.java @@ -22,30 +22,29 @@ @NoArgsConstructor @AllArgsConstructor @Builder -public class Keyword extends BasePostgresEntity{ +public class Keyword extends BasePostgresEntity { // 키워드 ID @Id - @GeneratedValue(strategy = GenerationType.AUTO) - @Column(columnDefinition = "uuid DEFAULT uuid_generate_v4()", updatable = false, nullable = false) + @GeneratedValue(strategy = GenerationType.UUID) + @Column(updatable = false, nullable = false) private UUID keywordId; - // 키워드 이름 + // 키워드 이름 (한글) @Column(nullable = false, unique = true) - private String keyword; + private String koreanKeyword; + + @Column(nullable = false) + private String englishKeyword; // 키워드 카테고리 (계절, 여행, 일상 등) @Enumerated(EnumType.STRING) @Column(nullable = false) - private KeywordCategory category; - - // 특정 날짜에 제공할 키워드 (ADMIN_SET에서 사용) - private LocalDate specifiedDate; + private KeywordCategory keywordCategory; // 제공한 키워드 날짜 private LocalDate providedDate; // 사용 여부 (이미 제공된 키워드인지 여부) - @Column(nullable = false) - private boolean isUsed; + private boolean used; } \ No newline at end of file diff --git a/src/main/java/onepiece/dailysnapbackend/object/postgres/Member.java b/src/main/java/onepiece/dailysnapbackend/object/postgres/Member.java index 796c7bc..53a5209 100644 --- a/src/main/java/onepiece/dailysnapbackend/object/postgres/Member.java +++ b/src/main/java/onepiece/dailysnapbackend/object/postgres/Member.java @@ -23,12 +23,12 @@ @NoArgsConstructor @AllArgsConstructor @Builder -public class Member extends BasePostgresEntity{ +public class Member extends BasePostgresEntity { // 회원 ID @Id - @GeneratedValue(strategy = GenerationType.AUTO) - @Column(columnDefinition = "uuid DEFAULT uuid_generate_v4()", updatable = false, nullable = false) + @GeneratedValue(strategy = GenerationType.UUID) + @Column(updatable = false, nullable = false) private UUID memberId; // 이메일 @@ -41,34 +41,31 @@ public class Member extends BasePostgresEntity{ private SocialPlatform socialPlatform; // 닉네임 - @Column(unique = true, nullable = true) + @Column(unique = true) private String nickname; - // 생년월일 - @Column(nullable = true) - private String birth; - // 프로필 사진 URL private String profileImageUrl; // 권한 (유저, 관리자) @Enumerated(EnumType.STRING) - private Role role; + @Builder.Default + private Role role = Role.ROLE_USER; @Enumerated(EnumType.STRING) @Builder.Default private AccountStatus accountStatus = AccountStatus.ACTIVE_ACCOUNT; - // 일일 최대 업로드 수 @Column(nullable = false) - private Integer dailyUploadCount; + @Builder.Default + private int dailyUploadCount = 0; // 첫 로그인 여부 @Builder.Default - private Boolean isFirstLogin = true; + private boolean firstLogin = true; // 과금 여부 - @Column(nullable = false) - private boolean isPaid; - } + @Builder.Default + private boolean paid = false; +} diff --git a/src/main/java/onepiece/dailysnapbackend/object/postgres/MemberInfo.java b/src/main/java/onepiece/dailysnapbackend/object/postgres/MemberInfo.java deleted file mode 100644 index e4a2e56..0000000 --- a/src/main/java/onepiece/dailysnapbackend/object/postgres/MemberInfo.java +++ /dev/null @@ -1,55 +0,0 @@ -package onepiece.dailysnapbackend.object.postgres; - -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.FetchType; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.MapsId; -import jakarta.persistence.OneToOne; -import java.util.UUID; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; - -@Entity -@Getter -@Setter -@NoArgsConstructor -@AllArgsConstructor -@Builder -public class MemberInfo extends BasePostgresEntity{ - - // 회원 ID - @Id - @GeneratedValue(strategy = GenerationType.AUTO) - @Column(columnDefinition = "uuid DEFAULT uuid_generate_v4()", updatable = false, nullable = false) - private UUID memberInfoId; - - // 회원 엔티티와의 매핑 - @OneToOne(fetch = FetchType.LAZY) - @MapsId - private Member member; - - // 총 업로드 사진 수 - @Column(nullable = false) - private int totalUploadCount; - - // 한줄 소개 - private String introduction; - - // 받은 좋아요 개수 - @Column(nullable = false) - private Integer totalLikeCount; - - // 상위 퍼센트 - @Column(nullable = false) - private double percent; - - // 어제 우수작 다시보기 여부 - @Column(nullable = false) - private boolean isViewableBest; -} \ No newline at end of file diff --git a/src/main/java/onepiece/dailysnapbackend/object/postgres/MonthlyBest.java b/src/main/java/onepiece/dailysnapbackend/object/postgres/MonthlyBest.java index 96caed9..e10309c 100644 --- a/src/main/java/onepiece/dailysnapbackend/object/postgres/MonthlyBest.java +++ b/src/main/java/onepiece/dailysnapbackend/object/postgres/MonthlyBest.java @@ -5,10 +5,10 @@ import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; import jakarta.persistence.ManyToOne; import java.time.LocalDate; import java.util.UUID; -import jakarta.persistence.Id; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -21,12 +21,12 @@ @NoArgsConstructor @AllArgsConstructor @Builder -public class MonthlyBest extends BasePostgresEntity{ +public class MonthlyBest extends BasePostgresEntity { // 월간 우수작 ID @Id - @GeneratedValue(strategy = GenerationType.AUTO) - @Column(columnDefinition = "uuid DEFAULT uuid_generate_v4()", updatable = false, nullable = false) + @GeneratedValue(strategy = GenerationType.UUID) + @Column(updatable = false, nullable = false) private UUID monthlyBestId; // 주간 우수작 diff --git a/src/main/java/onepiece/dailysnapbackend/object/postgres/Post.java b/src/main/java/onepiece/dailysnapbackend/object/postgres/Post.java index 001208b..a41dc5f 100644 --- a/src/main/java/onepiece/dailysnapbackend/object/postgres/Post.java +++ b/src/main/java/onepiece/dailysnapbackend/object/postgres/Post.java @@ -1,57 +1,48 @@ - package onepiece.dailysnapbackend.object.postgres; - - import jakarta.persistence.Column; - import jakarta.persistence.Entity; - import jakarta.persistence.FetchType; - import jakarta.persistence.GeneratedValue; - import jakarta.persistence.GenerationType; - import jakarta.persistence.Id; - import jakarta.persistence.ManyToOne; - import java.util.UUID; - import lombok.AllArgsConstructor; - import lombok.Getter; - import lombok.NoArgsConstructor; - import lombok.Setter; - import lombok.experimental.SuperBuilder; - - @Entity - @Getter - @Setter - @NoArgsConstructor - @AllArgsConstructor - @SuperBuilder - public class Post extends BasePostgresEntity{ - - // 게시물 ID - @Id - @GeneratedValue(strategy = GenerationType.AUTO) - @Column(columnDefinition = "uuid DEFAULT uuid_generate_v4()", updatable = false, nullable = false) - private UUID postId; - - // 작성자 회원 - @ManyToOne(fetch= FetchType.LAZY) - private Member member; - - // 키워드 - @ManyToOne(fetch = FetchType.LAZY) - private Keyword keyword; - - // 일간 우수작 - @ManyToOne(fetch = FetchType.LAZY) - private DailyBest dailyBest; - - // 사진 설명 - @Column(nullable = true) - private String content; - - // 조회수 - @Column(nullable = false) - private Integer viewCount; - - // 좋아요 수 - @Column(nullable = false) - private Integer likeCount; - - // 위치 - private String location; - } +package onepiece.dailysnapbackend.object.postgres; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.ManyToOne; +import java.util.UUID; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.SuperBuilder; + +@Entity +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@SuperBuilder +public class Post extends BasePostgresEntity { + + // 게시물 ID + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(updatable = false, nullable = false) + private UUID postId; + + // 작성자 회원 + @ManyToOne(fetch = FetchType.LAZY) + private Member member; + + // 키워드 + @ManyToOne(fetch = FetchType.LAZY) + private Keyword keyword; + + @Column(nullable = false) + private String imageUrl; + + // 사진 설명 + private String description; + + // 좋아요 수 + @Column(nullable = false) + private int likeCount; +} diff --git a/src/main/java/onepiece/dailysnapbackend/object/postgres/SnsUrl.java b/src/main/java/onepiece/dailysnapbackend/object/postgres/SnsUrl.java index 88a5c86..e176b4d 100644 --- a/src/main/java/onepiece/dailysnapbackend/object/postgres/SnsUrl.java +++ b/src/main/java/onepiece/dailysnapbackend/object/postgres/SnsUrl.java @@ -15,8 +15,8 @@ public class SnsUrl extends BasePostgresEntity { // 소셜 하이퍼링크 ID @Id - @GeneratedValue(strategy = GenerationType.AUTO) - @Column(columnDefinition = "uuid DEFAULT uuid_generate_v4()", updatable = false, nullable = false) + @GeneratedValue(strategy = GenerationType.UUID) + @Column(updatable = false, nullable = false) private UUID snsUrlId; // 회원과의 관계 diff --git a/src/main/java/onepiece/dailysnapbackend/object/postgres/WeeklyBest.java b/src/main/java/onepiece/dailysnapbackend/object/postgres/WeeklyBest.java index 546d1f3..f72b62a 100644 --- a/src/main/java/onepiece/dailysnapbackend/object/postgres/WeeklyBest.java +++ b/src/main/java/onepiece/dailysnapbackend/object/postgres/WeeklyBest.java @@ -21,12 +21,12 @@ @NoArgsConstructor @AllArgsConstructor @Builder -public class WeeklyBest extends BasePostgresEntity{ +public class WeeklyBest extends BasePostgresEntity { // 주간 우수작 ID @Id - @GeneratedValue(strategy = GenerationType.AUTO) - @Column(columnDefinition = "uuid DEFAULT uuid_generate_v4()", updatable = false, nullable = false) + @GeneratedValue(strategy = GenerationType.UUID) + @Column(updatable = false, nullable = false) private UUID weeklyBestId; // 일간 우수작 diff --git a/src/main/java/onepiece/dailysnapbackend/repository/mongo/RefreshTokenRepository.java b/src/main/java/onepiece/dailysnapbackend/repository/mongo/RefreshTokenRepository.java index f51ca42..d4b6d1f 100644 --- a/src/main/java/onepiece/dailysnapbackend/repository/mongo/RefreshTokenRepository.java +++ b/src/main/java/onepiece/dailysnapbackend/repository/mongo/RefreshTokenRepository.java @@ -1,12 +1,16 @@ package onepiece.dailysnapbackend.repository.mongo; -import onepiece.dailysnapbackend.object.mongo.RefreshToken; import java.util.Optional; +import onepiece.dailysnapbackend.object.mongo.RefreshToken; import org.springframework.data.mongodb.repository.MongoRepository; public interface RefreshTokenRepository extends MongoRepository { + Optional findByToken(String token); + Optional findByMemberId(Long memberId); + void deleteByToken(String token); + void deleteByMemberId(Long memberId); } diff --git a/src/main/java/onepiece/dailysnapbackend/repository/postgres/ImageRepository.java b/src/main/java/onepiece/dailysnapbackend/repository/postgres/ImageRepository.java deleted file mode 100644 index 36e980c..0000000 --- a/src/main/java/onepiece/dailysnapbackend/repository/postgres/ImageRepository.java +++ /dev/null @@ -1,12 +0,0 @@ -package onepiece.dailysnapbackend.repository.postgres; - -import java.util.List; -import java.util.UUID; -import onepiece.dailysnapbackend.object.postgres.Image; -import onepiece.dailysnapbackend.object.postgres.Post; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface ImageRepository extends JpaRepository { - - List findByPost(Post post); -} diff --git a/src/main/java/onepiece/dailysnapbackend/repository/postgres/KeywordQueryDslRepository.java b/src/main/java/onepiece/dailysnapbackend/repository/postgres/KeywordQueryDslRepository.java new file mode 100644 index 0000000..32ecd98 --- /dev/null +++ b/src/main/java/onepiece/dailysnapbackend/repository/postgres/KeywordQueryDslRepository.java @@ -0,0 +1,52 @@ +package onepiece.dailysnapbackend.repository.postgres; + +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import onepiece.dailysnapbackend.object.dto.KeywordFilterRequest; +import onepiece.dailysnapbackend.object.postgres.Keyword; +import onepiece.dailysnapbackend.object.postgres.QKeyword; +import onepiece.dailysnapbackend.util.QueryDslUtil; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class KeywordQueryDslRepository { + + private static final QKeyword KEYWORD = QKeyword.keyword; + + private final JPAQueryFactory queryFactory; + + public Page filteredKeyword(KeywordFilterRequest request) { + BooleanExpression whereClause = QueryDslUtil.allOf( + QueryDslUtil.likeIgnoreCase(KEYWORD.koreanKeyword, request.getKoreanKeyword()), + QueryDslUtil.eqIfNotNull(KEYWORD.keywordCategory, request.getKeywordCategory()), + QueryDslUtil.eqIfNotNull(KEYWORD.providedDate, request.getProvidedDate()), + QueryDslUtil.eqIfNotNull(KEYWORD.used, request.getUsed()) + ); + + Pageable pageable = request.toPageable(); + + JPAQuery contentQuery = queryFactory + .select(KEYWORD) + .from(KEYWORD) + .where(whereClause); + + QueryDslUtil.applySorting( + contentQuery, + pageable, + Keyword.class, + KEYWORD.getMetadata().getName() + ); + + JPAQuery countQuery = queryFactory + .select(KEYWORD.count()) + .from(KEYWORD) + .where(whereClause); + + return QueryDslUtil.fetchPage(contentQuery, countQuery, pageable); + } +} diff --git a/src/main/java/onepiece/dailysnapbackend/repository/postgres/KeywordRepository.java b/src/main/java/onepiece/dailysnapbackend/repository/postgres/KeywordRepository.java index 46cae74..3ca2211 100644 --- a/src/main/java/onepiece/dailysnapbackend/repository/postgres/KeywordRepository.java +++ b/src/main/java/onepiece/dailysnapbackend/repository/postgres/KeywordRepository.java @@ -13,27 +13,21 @@ public interface KeywordRepository extends JpaRepository { - Optional findByCategoryAndSpecifiedDate(KeywordCategory category, LocalDate specifiedDate); - - Optional findFirstByCategoryAndIsUsedFalse(KeywordCategory category); - Optional findByProvidedDate(LocalDate providedDate); - long countByCategoryAndIsUsedFalse(@Param("category") String category); - - void deleteKeywordByKeyword(String keyword); - - Optional findKeywordByKeywordId(UUID keywordId); + boolean existsByKoreanKeyword(String koreanKeyword); - boolean existsByKeyword(String keyword); + // 특정 카테고리에서 가장 마지막에 저장된 providedDate 를 조회 + @Query("SELECT MAX(k.providedDate) FROM Keyword k WHERE k.keywordCategory = :category") + LocalDate findMaxProvidedDateByCategory(@Param("category") KeywordCategory category); @Query(value = """ - SELECT k.* FROM keyword k - WHERE (:keyword = '' OR k.keyword ILIKE CONCAT('%', TRIM(:keyword), '%')) - AND (:category = '' OR k.category = :category) - AND (:providedDate = '' OR k.provided_date = :proviedDate) - AND (:isUsed IS NULL OR k.is_used = CAST(:isUsed AS BOOLEAN)) - """, nativeQuery = true) + SELECT k.* FROM keyword k + WHERE (:keyword = '' OR k.keyword ILIKE CONCAT('%', TRIM(:keyword), '%')) + AND (:category = '' OR k.category = :category) + AND (:providedDate = '' OR k.provided_date = :proviedDate) + AND (:isUsed IS NULL OR k.is_used = CAST(:isUsed AS BOOLEAN)) + """, nativeQuery = true) Page filteredKeyword( String keyword, String category, diff --git a/src/main/java/onepiece/dailysnapbackend/repository/postgres/PostQueryDslRepository.java b/src/main/java/onepiece/dailysnapbackend/repository/postgres/PostQueryDslRepository.java new file mode 100644 index 0000000..846eb54 --- /dev/null +++ b/src/main/java/onepiece/dailysnapbackend/repository/postgres/PostQueryDslRepository.java @@ -0,0 +1,55 @@ +package onepiece.dailysnapbackend.repository.postgres; + +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import onepiece.dailysnapbackend.object.dto.PostFilteredRequest; +import onepiece.dailysnapbackend.object.postgres.Post; +import onepiece.dailysnapbackend.object.postgres.QPost; +import onepiece.dailysnapbackend.util.QueryDslUtil; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class PostQueryDslRepository { + + private static final QPost POST = QPost.post; + + private final JPAQueryFactory queryFactory; + + public Page filteredPost(PostFilteredRequest request) { + + BooleanExpression whereClause = QueryDslUtil.allOf( + QueryDslUtil.eqIfNotNull(POST.keyword.keywordId, request.getKeywordId()), + QueryDslUtil.likeIgnoreCase(POST.keyword.koreanKeyword, request.getKoreanKeyword()), + QueryDslUtil.likeIgnoreCase(POST.keyword.englishKeyword, request.getEnglishKeyword()), + QueryDslUtil.likeIgnoreCase(POST.description, request.getDescription()) + ); + + Pageable pageable = request.toPageable(); + + JPAQuery contentQuery = queryFactory + .select(POST) + .from(POST) + .join(POST.member).fetchJoin() + .join(POST.keyword).fetchJoin() + .where(whereClause); + + QueryDslUtil.applySorting( + contentQuery, + pageable, + Post.class, + POST.getMetadata().getName() + ); + + JPAQuery countQuery = queryFactory + .select(POST.count()) + .from(POST) + .where(whereClause); + + return QueryDslUtil.fetchPage(contentQuery, countQuery, pageable); + } +} diff --git a/src/main/java/onepiece/dailysnapbackend/repository/postgres/PostRepository.java b/src/main/java/onepiece/dailysnapbackend/repository/postgres/PostRepository.java index 3790877..5f31fb5 100644 --- a/src/main/java/onepiece/dailysnapbackend/repository/postgres/PostRepository.java +++ b/src/main/java/onepiece/dailysnapbackend/repository/postgres/PostRepository.java @@ -11,19 +11,19 @@ public interface PostRepository extends JpaRepository { @Query(value = """ - SELECT p.* - FROM post p - JOIN member m ON p.member_id = m.member_id - WHERE trim(:nickname) = '' - OR lower(m.nickname) LIKE lower(concat('%', trim(:nickname), '%')) - """, + SELECT p.* + FROM post p + JOIN member m ON p.member_id = m.member_id + WHERE trim(:nickname) = '' + OR lower(m.nickname) LIKE lower(concat('%', trim(:nickname), '%')) + """, countQuery = """ - SELECT count(*) - FROM post p - JOIN member m ON p.member_id = m.member_id - WHERE trim(:nickname) = '' - OR lower(m.nickname) LIKE lower(concat('%', trim(:nickname), '%')) - """, + SELECT count(*) + FROM post p + JOIN member m ON p.member_id = m.member_id + WHERE trim(:nickname) = '' + OR lower(m.nickname) LIKE lower(concat('%', trim(:nickname), '%')) + """, nativeQuery = true) Page filterPosts(@Param("nickname") String nickname, Pageable pageable); } \ No newline at end of file diff --git a/src/main/java/onepiece/dailysnapbackend/service/LikeService.java b/src/main/java/onepiece/dailysnapbackend/service/LikeService.java index b0a9bb3..a36e22c 100644 --- a/src/main/java/onepiece/dailysnapbackend/service/LikeService.java +++ b/src/main/java/onepiece/dailysnapbackend/service/LikeService.java @@ -8,7 +8,6 @@ import onepiece.dailysnapbackend.object.postgres.Member; import onepiece.dailysnapbackend.object.postgres.Post; import onepiece.dailysnapbackend.repository.mongo.LikeHistoryRepository; -import onepiece.dailysnapbackend.repository.postgres.ImageRepository; import onepiece.dailysnapbackend.repository.postgres.PostRepository; import onepiece.dailysnapbackend.util.exception.CustomException; import onepiece.dailysnapbackend.util.exception.ErrorCode; @@ -22,7 +21,6 @@ public class LikeService { private final PostRepository postRepository; private final LikeHistoryRepository likeHistoryRepository; - private final ImageRepository imageRepository; private final RedisLockService redisLockService; @Transactional diff --git a/src/main/java/onepiece/dailysnapbackend/service/MemberService.java b/src/main/java/onepiece/dailysnapbackend/service/MemberService.java index 9fe4492..dc08d1d 100644 --- a/src/main/java/onepiece/dailysnapbackend/service/MemberService.java +++ b/src/main/java/onepiece/dailysnapbackend/service/MemberService.java @@ -1,29 +1,18 @@ package onepiece.dailysnapbackend.service; -import static onepiece.dailysnapbackend.util.exception.ErrorCode.COOKIES_NOT_FOUND; -import static onepiece.dailysnapbackend.util.exception.ErrorCode.DUPLICATE_USERNAME; -import static onepiece.dailysnapbackend.util.exception.ErrorCode.REFRESH_TOKEN_EMPTY; -import static onepiece.dailysnapbackend.util.exception.ErrorCode.REFRESH_TOKEN_NOT_FOUND; - -import io.micrometer.common.util.StringUtils; -import jakarta.servlet.http.Cookie; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; import jakarta.transaction.Transactional; -import java.io.IOException; import java.util.Map; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import onepiece.dailysnapbackend.object.constants.AccountStatus; import onepiece.dailysnapbackend.object.constants.Role; -import onepiece.dailysnapbackend.object.constants.SocialPlatform; import onepiece.dailysnapbackend.object.dto.CustomOAuth2User; -import onepiece.dailysnapbackend.object.dto.SignInRequest; +import onepiece.dailysnapbackend.object.dto.LoginRequest; +import onepiece.dailysnapbackend.object.dto.LoginResponse; +import onepiece.dailysnapbackend.object.dto.ReissueRequest; import onepiece.dailysnapbackend.object.postgres.Member; import onepiece.dailysnapbackend.repository.postgres.MemberRepository; import onepiece.dailysnapbackend.util.JwtUtil; -import onepiece.dailysnapbackend.util.exception.CustomException; -import onepiece.dailysnapbackend.util.exception.ErrorCode; import org.springframework.stereotype.Service; @Service @@ -35,25 +24,19 @@ public class MemberService { private final JwtUtil jwtUtil; @Transactional - public void socialSignIn(SignInRequest request, HttpServletResponse response) { - SocialPlatform socialPlatform = SocialPlatform.valueOf(request.getProvider()); - - if (memberRepository.existsByUsername(request.getUsername())) { - log.info("이미 존재하는 사용자입니다. username: {}", request.getUsername()); - throw new CustomException(DUPLICATE_USERNAME); - } + public LoginResponse socialSignIn(LoginRequest request) { // DB에서 회원 조회 Member member = memberRepository.findByUsername(request.getUsername()) .orElseGet(() -> memberRepository.save(Member.builder() .username(request.getUsername()) - .socialPlatform(socialPlatform) + .socialPlatform(request.getSocialPlatform()) .nickname(request.getNickname()) - .birth(request.getBirth()) .role(Role.ROLE_USER) .accountStatus(AccountStatus.ACTIVE_ACCOUNT) .dailyUploadCount(0) - .isPaid(false) + .firstLogin(true) + .paid(false) .build() )); @@ -63,75 +46,22 @@ public void socialSignIn(SignInRequest request, HttpServletResponse response) { String accessToken = jwtUtil.createAccessToken(userDetails); String refreshToken = jwtUtil.createRefreshToken(userDetails); - // 응답 헤더에 토큰 설정 - response.setHeader("Authorization", "Bearer " + accessToken); - - try { - response.setContentType("application/json"); - response.getWriter().write(String.format("{\"refreshToken\": \"%s\"}", refreshToken)); - } catch (IOException e) { - log.error("리프레시 토큰 응답 작성 중 오류 발생", e); - throw new CustomException(ErrorCode.INTERNAL_SERVER_ERROR); - } - - log.info("accessToken 헤더 설정 및 refreshToken body 응답 성공: accessToken={}, refreshToken={}: ", accessToken, - refreshToken); + return new LoginResponse(accessToken, refreshToken); } - // 리프레시 토큰을 통해 액세스 토큰 재발급 + // 리프레시 토큰을 통해 액세스 & 리프레시 토큰 재발급 @Transactional - public void reissue(HttpServletRequest request, HttpServletResponse response) { - - // 리프레시 토큰 추출 - String refresh = extractRefreshTokenFromRequest(request); + public LoginResponse reissue(ReissueRequest request) { // 만료 여부 확인 - jwtUtil.validateToken(refresh); - - // 토큰이 유효한지 확인 (발급 시 페이로드에 명시) - String category = jwtUtil.getCategory(refresh); - if (!category.equals("refresh")) { - log.error("유효하지 않은 토큰입니다. 요청된 토큰 카테고리: {}", category); - throw new CustomException(ErrorCode.INVALID_REFRESH_TOKEN); - } + jwtUtil.validateToken(request.getRefreshToken()); // 새 액세스 토큰 발급 - CustomOAuth2User customOAuth2User = (CustomOAuth2User) jwtUtil.getAuthentication(refresh).getPrincipal(); - String newAccess = jwtUtil.createAccessToken(customOAuth2User); + CustomOAuth2User customOAuth2User = (CustomOAuth2User) jwtUtil.getAuthentication(request.getRefreshToken()).getPrincipal(); + String newAccessToken = jwtUtil.createAccessToken(customOAuth2User); + String newRefreshToken = jwtUtil.createRefreshToken(customOAuth2User); // JSON 응답 바디로 액세스 토큰 반환 - response.setContentType("application/json"); - try { - response.getWriter().write(String.format("{\"accessToken\": \"%s\"}", newAccess)); - } catch (IOException e) { - log.error("액세스 토큰 응답 작성 중 오류 발생", e); - throw new CustomException(ErrorCode.INTERNAL_SERVER_ERROR); - } - - log.info("access token 재발급 성공: accessToken={}", newAccess); - } - - // 리프레시 토큰 추출 - private String extractRefreshTokenFromRequest(HttpServletRequest request) { - Cookie[] cookies = request.getCookies(); - if (cookies == null) { - log.error("쿠키가 존재하지 않습니다."); - throw new CustomException(COOKIES_NOT_FOUND); - } - - for (Cookie cookie : cookies) { - if ("refresh_token".equals(cookie.getName())) { - String refreshToken = cookie.getValue(); - if (StringUtils.isBlank(refreshToken)) { - log.error("리프레시 토큰이 비어있습니다."); - throw new CustomException(REFRESH_TOKEN_EMPTY); - } - log.info("refresh token 추출 성공: {}", refreshToken); - return refreshToken; - } - } - - log.error("요청 쿠키에 refresh token 이 없습니다."); - throw new CustomException(REFRESH_TOKEN_NOT_FOUND); + return new LoginResponse(newAccessToken, newRefreshToken); } } diff --git a/src/main/java/onepiece/dailysnapbackend/service/MockService.java b/src/main/java/onepiece/dailysnapbackend/service/MockService.java new file mode 100644 index 0000000..1aca04c --- /dev/null +++ b/src/main/java/onepiece/dailysnapbackend/service/MockService.java @@ -0,0 +1,56 @@ +package onepiece.dailysnapbackend.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import net.datafaker.Faker; +import onepiece.dailysnapbackend.object.constants.AccountStatus; +import onepiece.dailysnapbackend.object.constants.Role; +import onepiece.dailysnapbackend.object.constants.SocialPlatform; +import onepiece.dailysnapbackend.object.dto.CustomOAuth2User; +import onepiece.dailysnapbackend.object.dto.LoginResponse; +import onepiece.dailysnapbackend.object.dto.MockLoginRequest; +import onepiece.dailysnapbackend.object.postgres.Member; +import onepiece.dailysnapbackend.repository.postgres.MemberRepository; +import onepiece.dailysnapbackend.util.JwtUtil; +import org.springframework.stereotype.Service; + +@Service +@Slf4j +@RequiredArgsConstructor +public class MockService { + + private final MemberRepository memberRepository; + private final Faker koFaker; + private final Faker enFaker; + private final JwtUtil jwtUtil; + + public LoginResponse mockLogin(MockLoginRequest request) { + String username = request.getUsername().isBlank() + ? enFaker.internet().emailAddress() + koFaker.random().nextInt(1000) + : request.getUsername(); + String nickname = request.getNickname().isBlank() + ? koFaker.name().name() + koFaker.random().nextInt(1000) + : request.getNickname(); + Role role = request.getRole() == null ? + Role.ROLE_USER : request.getRole(); + + Member member = Member.builder() + .username(username) + .socialPlatform(SocialPlatform.KAKAO) + .nickname(nickname) + .profileImageUrl(koFaker.internet().image()) + .role(role) + .accountStatus(AccountStatus.ACTIVE_ACCOUNT) + .dailyUploadCount(0) + .firstLogin(true) + .paid(false) + .build(); + memberRepository.save(member); + CustomOAuth2User customOAuth2User = new CustomOAuth2User(member, null); + String accessToken = jwtUtil.createAccessToken(customOAuth2User); + String refreshToken = jwtUtil.createRefreshToken(customOAuth2User); + + return new LoginResponse(accessToken, refreshToken); + } + +} diff --git a/src/main/java/onepiece/dailysnapbackend/service/PostService.java b/src/main/java/onepiece/dailysnapbackend/service/PostService.java index 1cd764a..4a9c4fa 100644 --- a/src/main/java/onepiece/dailysnapbackend/service/PostService.java +++ b/src/main/java/onepiece/dailysnapbackend/service/PostService.java @@ -1,162 +1,74 @@ package onepiece.dailysnapbackend.service; -import java.util.List; +import java.time.LocalDate; +import java.time.ZoneId; import java.util.UUID; -import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import onepiece.dailysnapbackend.object.constants.UploadType; import onepiece.dailysnapbackend.object.dto.PostFilteredRequest; -import onepiece.dailysnapbackend.object.dto.PostFilteredResponse; import onepiece.dailysnapbackend.object.dto.PostRequest; import onepiece.dailysnapbackend.object.dto.PostResponse; -import onepiece.dailysnapbackend.object.postgres.Image; import onepiece.dailysnapbackend.object.postgres.Keyword; import onepiece.dailysnapbackend.object.postgres.Member; import onepiece.dailysnapbackend.object.postgres.Post; -import onepiece.dailysnapbackend.repository.postgres.ImageRepository; -import onepiece.dailysnapbackend.repository.postgres.KeywordRepository; +import onepiece.dailysnapbackend.repository.postgres.PostQueryDslRepository; import onepiece.dailysnapbackend.repository.postgres.PostRepository; -import onepiece.dailysnapbackend.util.CommonUtil; +import onepiece.dailysnapbackend.service.keyword.KeywordService; +import onepiece.dailysnapbackend.util.FileUtil; import onepiece.dailysnapbackend.util.exception.CustomException; import onepiece.dailysnapbackend.util.exception.ErrorCode; import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.multipart.MultipartFile; @Service @RequiredArgsConstructor @Slf4j public class PostService { - private final S3UploadService s3UploadService; private final PostRepository postRepository; - private final KeywordRepository keywordRepository; - private final ImageRepository imageRepository; - private final RedisLockService redisLockService; - - private static final int MAX_IMAGE_COUNT = 10; + private final KeywordService keywordService; + private final StorageService storageService; + private final PostQueryDslRepository postQueryDslRepository; // 이미지 업로드 @Transactional - public PostResponse uploadPost(PostRequest request, Member member) { - List images = request.getImages(); - - // 이미지 개수 검사 - if (images.size() > MAX_IMAGE_COUNT) { - throw new CustomException(ErrorCode.FILE_COUNT_EXCEED); - } + public void uploadPost(Member member, PostRequest request) { - // 키워드 엔티티 조회 - Keyword keyword = keywordRepository.findById(request.getKeywordId()) - .orElseThrow(() -> new CustomException(ErrorCode.KEYWORD_NOT_FOUND)); + Keyword keyword = keywordService.findKeywordByProvidedDate(LocalDate.now(ZoneId.of("Asia/Seoul"))); + FileUtil.isNullOrEmpty(request.getImage()); // Post 엔티티 생성 및 저장 Post post = Post.builder() .member(member) .keyword(keyword) - .content(request.getContent()) - .viewCount(0) + .imageUrl(storageService.uploadFile(request.getImage(), UploadType.POST)) + .description(request.getDescription()) .likeCount(0) - .location(request.getLocation()) - .build(); - - postRepository.save(post); - - // S3에 파일 업로드 후 URL 리스트 받기 - List imageUrls = s3UploadService.upload(images); - - // Image 엔티티 생성 및 저장 - List imageEntities = imageUrls.stream() - .map(url -> Image.builder() - .imageUrl(url) - .post(post) - .build()) - .collect(Collectors.toList()); - - imageRepository.saveAll(imageEntities); - log.info("게시물 업로드 성공: postId={}", post.getPostId()); - - return PostResponse.builder() - .keyword(post.getKeyword()) - .images(imageEntities) - .content(post.getContent()) - .viewCount(post.getViewCount()) - .likeCount(post.getLikeCount()) - .location(post.getLocation()) .build(); + Post savedPost = postRepository.save(post); + log.info("게시물 업로드 성공: postId={}", savedPost.getPostId()); } - /** - * 게시글 필터링 정렬 조건 : created_date, like_count - * - * @param request nickname 닉네임 (null 또는 빈 값이면 전체 게시글 조회) - */ @Transactional(readOnly = true) - public Page getFilteredPosts(PostFilteredRequest request) { - // null 이거나 created_date/like_count 가 아닐 경우 created_date 를 기본값으로 설정 - String sortField = CommonUtil.nvl(request.getSortField(), "created_date"); - if (!sortField.matches("created_date|like_count")) { - sortField = "created_date"; - } - // "ASC" 를 제외한 모든 값이 들어오면 "DESC"로 설정 - String direction = CommonUtil.nvl(request.getSortDirection(), "DESC"); - Sort.Direction sortDirection; - if ("ASC".equalsIgnoreCase(direction)) { - sortDirection = Sort.Direction.ASC; - } else { - sortDirection = Sort.Direction.DESC; - } - - Sort sort = Sort.by(sortDirection, sortField); - - // 페이징 설정 - Pageable pageable = PageRequest.of( - request.getPageNumber(), - request.getPageSize(), - sort - ); - Page posts = postRepository.filterPosts(request.getNickname(), pageable); - - log.info("게시물 필터링 성공: totalElements={}", posts.getTotalElements()); - return posts.map(post -> PostFilteredResponse.builder() - .member(post.getMember()) - .keyword(post.getKeyword()) - .images(imageRepository.findByPost(post)) - .content(post.getContent()) - .viewCount(post.getViewCount()) - .likeCount(post.getLikeCount()) - .location(post.getLocation()) - .build()); + public Page filteredPost(PostFilteredRequest request) { + Page postPage = postQueryDslRepository.filteredPost(request); + return postPage.map(post -> PostResponse.from(post, post.getMember(), post.getKeyword())); } - // 게시물 상세 조회 - @Transactional - public PostResponse getPostDetails(UUID postId) { - String lockKey = "post_lock:" + postId; - - Post post = postRepository.findById(postId) - .orElseThrow(() -> new CustomException(ErrorCode.POST_NOT_FOUND)); - - Integer updatedViewCount = redisLockService.executeWithLock(lockKey, () -> { - post.setViewCount(post.getViewCount() + 1); - postRepository.save(post); - log.info("{} 게시물 조회수 증가: viewCount={}", postId, post.getViewCount()); - return post.getViewCount(); - }); - - List images = imageRepository.findByPost(post); + // 특정 글 조회 + @Transactional(readOnly = true) + public PostResponse getPost(Member member, UUID postId) { + Post post = findPostById(postId); + return PostResponse.from(post, member, post.getKeyword()); + } - return PostResponse.builder() - .keyword(post.getKeyword()) - .images(images) - .content(post.getContent()) - .viewCount(updatedViewCount) - .likeCount(post.getLikeCount()) - .location(post.getLocation()) - .build(); + public Post findPostById(UUID postId) { + return postRepository.findById(postId) + .orElseThrow(() -> { + log.error("요청 PK: {}에 해당하는 게시글을 찾을 수 없습니다", postId); + return new CustomException(ErrorCode.POST_NOT_FOUND); + }); } } \ No newline at end of file diff --git a/src/main/java/onepiece/dailysnapbackend/service/RedisLockService.java b/src/main/java/onepiece/dailysnapbackend/service/RedisLockService.java index 309886d..d89aba1 100644 --- a/src/main/java/onepiece/dailysnapbackend/service/RedisLockService.java +++ b/src/main/java/onepiece/dailysnapbackend/service/RedisLockService.java @@ -1,22 +1,21 @@ package onepiece.dailysnapbackend.service; +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.redisson.api.RLock; import org.redisson.api.RedissonClient; import org.springframework.stereotype.Service; -import java.util.concurrent.TimeUnit; -import java.util.function.Supplier; - @Service @RequiredArgsConstructor @Slf4j public class RedisLockService { - private final RedissonClient redissonClient; private static final long WAIT_TIMEOUT_SECONDS = 1L; // 락 대기 시간 (1초) private static final long LEASE_TIMEOUT_SECONDS = 5L; // 락 임대 시간 (5초) + private final RedissonClient redissonClient; // 락을 사용한 작업 실행 public T executeWithLock(String lockKey, Supplier task) { diff --git a/src/main/java/onepiece/dailysnapbackend/service/S3Service.java b/src/main/java/onepiece/dailysnapbackend/service/S3Service.java new file mode 100644 index 0000000..12bd4ee --- /dev/null +++ b/src/main/java/onepiece/dailysnapbackend/service/S3Service.java @@ -0,0 +1,195 @@ +package onepiece.dailysnapbackend.service; + +import com.amazonaws.AmazonClientException; +import com.amazonaws.AmazonServiceException; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.model.DeleteObjectRequest; +import com.amazonaws.services.s3.model.ObjectMetadata; +import java.io.IOException; +import java.io.InputStream; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import onepiece.dailysnapbackend.object.constants.FileExtension; +import onepiece.dailysnapbackend.object.constants.UploadType; +import onepiece.dailysnapbackend.util.CommonUtil; +import onepiece.dailysnapbackend.util.FileUtil; +import onepiece.dailysnapbackend.util.config.S3Properties; +import onepiece.dailysnapbackend.util.exception.CustomException; +import onepiece.dailysnapbackend.util.exception.ErrorCode; +import org.springframework.context.annotation.Primary; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +@Primary +@Service +@Slf4j +@RequiredArgsConstructor +public class S3Service implements StorageService { + + // 날짜 포멧: yyyyMMdd + private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); + private final AmazonS3 amazonS3; + private final S3Properties s3Properties; + + /** + * 파일 검증 및 원본 파일명 반환 + * + * @param file 요청된 MultipartFile + * @return 원본 파일명 + */ + private static String validateAndExtractFilename(MultipartFile file) { + // 파일 검증 + if (FileUtil.isNullOrEmpty(file)) { + throw new CustomException(ErrorCode.INVALID_FILE_REQUEST); + } + + // 원본 파일 명 검증 + String originalFilename = file.getOriginalFilename(); + if (CommonUtil.nvl(originalFilename, "").isEmpty()) { + throw new CustomException(ErrorCode.INVALID_FILE_REQUEST); + } + return originalFilename; + } + + /** + * S3 파일 업로드 후 파일 URL 반환 + * + * @param file 업로드할 MultipartFile + * @param uploadType 업로드할 파일의 도메인 구분 + * @return 업로드된 파일에 접근 가능한 URL + */ + @Override + public String uploadFile(MultipartFile file, UploadType uploadType) { + + // 파일 검증 및 원본 파일명 추출 + String originalFilename = validateAndExtractFilename(file); + + // 파일 확장자 검증 및 확장자 가져오기 + FileExtension fileExtension = FileExtension.fromFilename(originalFilename); + + // 파일 prefix 설정 + String prefix = determinePrefix(uploadType); + + // 중복이 되지 않는 고유한 파일이름 생성 + String filename = generateFilename(originalFilename, prefix); + log.debug("생성된 파일명: {}", filename); + + ObjectMetadata metadata = new ObjectMetadata(); + metadata.setContentLength(file.getSize()); + metadata.setContentType(fileExtension.getContentType()); + + // S3 파일 업로드 + try (InputStream inputStream = file.getInputStream()) { + amazonS3.putObject(s3Properties.s3().bucket(), filename, inputStream, metadata); + log.debug("S3 파일 업로드 성공: {}", filename); + } catch (AmazonServiceException ase) { + log.error("AmazonServiceException - S3 파일 업로드 실패. 버킷: {}, 파일명: {}, 에러: {}", s3Properties.s3().bucket(), filename, ase.getMessage()); + throw new CustomException(ErrorCode.S3_UPLOAD_AMAZON_SERVICE_ERROR); + } catch (AmazonClientException ace) { + log.error("AmazonClientException - S3 클라이언트 에러 발생. 버킷: {}, 파일명: {}, 에러: {}", s3Properties.s3().bucket(), filename, ace.getMessage()); + throw new CustomException(ErrorCode.S3_UPLOAD_AMAZON_CLIENT_ERROR); + } catch (IOException ioe) { + log.error("IOException - 파일 스트림 처리 중 에러 발생. 원본 파일명: {}, 파일명: {} 에러: {}", originalFilename, filename, ioe.getMessage()); + throw new CustomException(ErrorCode.S3_UPLOAD_ERROR); + } + + // 파일 URL 생성 및 반환 + return generateFilePath(filename); + } + + /** + * S3에서 파일 삭제 + * + * @param fileUrl 삭제할 파일의 URL + */ + @Override + public void deleteFile(String fileUrl) { + if (CommonUtil.nvl(fileUrl, "").isEmpty()) { + log.warn("삭제할 파일 URL이 없습니다."); + return; + } + + // URL에서 도메인 제거 (filename만 추출) + String filename = extractFilenameFromFilepath(fileUrl); + + // S3 파일 삭제 + try { + amazonS3.deleteObject(new DeleteObjectRequest(s3Properties.s3().bucket(), filename)); + log.debug("S3 파일 삭제 성공: {}", filename); + } catch (AmazonServiceException ase) { + log.error("AmazonServiceException - S3 파일 삭제 실패. 버킷: {}, 파일명: {}, 에러: {}", s3Properties.s3().bucket(), filename, ase.getMessage()); + throw new CustomException(ErrorCode.S3_DELETE_AMAZON_SERVICE_ERROR); + } catch (AmazonClientException ace) { + log.error("AmazonClientException - S3 파일 삭제 실패. 버킷: {}, 파일명: {}, 에러: {}", s3Properties.s3().bucket(), filename, ace.getMessage()); + throw new CustomException(ErrorCode.S3_DELETE_AMAZON_CLIENT_ERROR); + } catch (Exception e) { + log.error("S3 파일 삭제 실패. 버킷: {}, 파일명: {}, 에러: {}", s3Properties.s3().bucket(), filename, e.getMessage()); + throw new CustomException(ErrorCode.S3_DELETE_ERROR); + } + } + + /** + * 파일 명 생성 (prefix/yyyyMMdd-UUID-파일명.jpg) + * + * @param originalFilename 원본 파일명 + * @return UUID-originalFilename: 가공된 파일명 + */ + private String generateFilename(String originalFilename, String prefix) { + String datePart = LocalDateTime.now().format(DATE_TIME_FORMATTER); + // 파일명에서 경로 구분자와 특수 문자 제거 + String sanitizedFilename = originalFilename.replaceAll("[/\\\\:*?\"<>|]", "_"); + return String.format("%s/%s-%s-%s", prefix, datePart, UUID.randomUUID(), sanitizedFilename); + } + + /** + * 파일 URL 생성 + * + * @param filename 파일명 (prefix/yyyyMMdd-UUID-파일명.jpg) + * @return 파일 URL + */ + private String generateFilePath(String filename) { + return s3Properties.s3().domain() + filename; // 예: "member/20250605-a1b2c3-원본이미지.jpg" + } + + /** + * 파일 URL에서 파일명 추출 + * + * @param filePath 파일 URL + * @return filename + */ + private String extractFilenameFromFilepath(String filePath) { + String filename = filePath; + if (filePath.startsWith(s3Properties.s3().domain())) { + filename = filePath.substring(s3Properties.s3().domain().length()); + } + if (CommonUtil.nvl(filename, "").isEmpty()) { + log.error("파일명 추출 실패: {}", filePath); + throw new CustomException(ErrorCode.INVALID_FILE_PATH); + } + return filename; + } + + /** + * FileUploadType에 따라 S3 내부에 붙일 prefix(=하위폴더) 반환 + */ + private String determinePrefix(UploadType type) { + return switch (type) { + case MEMBER -> s3Properties.s3().path().member(); + case POST -> s3Properties.s3().path().post(); + }; + } + + /** + * S3에 파일은 업로드 되어있지만 서버 내부적으로 오류 발생시 이미지 롤백(삭제) + */ + public void safeDeleteFile(String path) { + try { + deleteFile(path); + } catch (Exception ex) { + log.warn("S3 파일 롤백 실패: {}", path, ex); + } + } +} diff --git a/src/main/java/onepiece/dailysnapbackend/service/S3UploadService.java b/src/main/java/onepiece/dailysnapbackend/service/S3UploadService.java deleted file mode 100644 index 1a0c4ad..0000000 --- a/src/main/java/onepiece/dailysnapbackend/service/S3UploadService.java +++ /dev/null @@ -1,79 +0,0 @@ -package onepiece.dailysnapbackend.service; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.List; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import onepiece.dailysnapbackend.object.constants.MimeType; -import onepiece.dailysnapbackend.object.dto.FileResponse; -import onepiece.dailysnapbackend.util.FileUtil; -import onepiece.dailysnapbackend.util.exception.CustomException; -import onepiece.dailysnapbackend.util.exception.ErrorCode; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Service; -import org.springframework.web.multipart.MultipartFile; -import software.amazon.awssdk.core.sync.RequestBody; -import software.amazon.awssdk.services.s3.S3Client; -import software.amazon.awssdk.services.s3.model.PutObjectRequest; - -@Service -@RequiredArgsConstructor -@Slf4j -public class S3UploadService { - - @Value("${cloud.aws.s3.bucket}") - private String bucketName; - private final S3Client s3Client; - - public List upload(List files) { - List uploadFileNames = new ArrayList<>(); - for (MultipartFile file : files) { - uploadFileNames.add(upload(file)); - } - - return uploadFileNames; - } - - public String upload(MultipartFile file) { - String fileType = file.getContentType(); - // 파일 형식 제한 검사 - if (fileType == null || !MimeType.isAllowedMimeType(fileType)) { - log.error("허용되지 않은 파일 형식: {}", fileType); - throw new CustomException(ErrorCode.INVALID_FILE_TYPE); - } - - try { - // Webp 로 변환 시도 - FileResponse fileResponse = FileUtil.convertToWebp(file); - String fileUrl = uploadToS3(fileResponse.getFileName(), MimeType.WEBP.getMimeType(), fileResponse.getBytes()); - - log.info("WebP 이미지 업로드 완료: imageUrl={}", fileUrl); - return fileUrl; - } catch (Exception e) { - log.warn("WebP 변환 실패, 원본 형식으로 업로드 시도: {}", e.getMessage()); - try { - return uploadToS3(FileUtil.generateFileName(file.getOriginalFilename()), fileType, file.getBytes()); - } catch (IOException ex) { - throw new CustomException(ErrorCode.FILE_UPLOAD_FAILED); - } - } - } - - private String uploadToS3(String fileName, String contentType, byte[] bytes) { - try { - s3Client.putObject( - PutObjectRequest.builder() - .bucket(bucketName) - .key(fileName) - .contentType(contentType) - .build(), - RequestBody.fromBytes(bytes) - ); - return "https://" + bucketName + ".s3.amazonaws.com/" + fileName; - } catch (Exception e) { - log.error("S3 업로드 실패: fileName={}, error={}", fileName, e.getMessage(), e); - throw new CustomException(ErrorCode.FILE_UPLOAD_FAILED); - } - } -} diff --git a/src/main/java/onepiece/dailysnapbackend/service/StorageService.java b/src/main/java/onepiece/dailysnapbackend/service/StorageService.java new file mode 100644 index 0000000..002e8cf --- /dev/null +++ b/src/main/java/onepiece/dailysnapbackend/service/StorageService.java @@ -0,0 +1,23 @@ +package onepiece.dailysnapbackend.service; + +import onepiece.dailysnapbackend.object.constants.UploadType; +import org.springframework.web.multipart.MultipartFile; + +public interface StorageService { + + /** + * 파일 업로드 + * + * @param file 업로드할 MultipartFile + * @param uploadType 업로드할 파일의 도메인 구분 + * @return 업로드된 파일에 접근 가능한 URL + */ + String uploadFile(MultipartFile file, UploadType uploadType); + + /** + * 파일 삭제 + * + * @param fileUrl 삭제할 파일의 URL + */ + void deleteFile(String fileUrl); +} diff --git a/src/main/java/onepiece/dailysnapbackend/service/keyword/AdminKeywordService.java b/src/main/java/onepiece/dailysnapbackend/service/keyword/AdminKeywordService.java index 73c8a8a..82c5eac 100644 --- a/src/main/java/onepiece/dailysnapbackend/service/keyword/AdminKeywordService.java +++ b/src/main/java/onepiece/dailysnapbackend/service/keyword/AdminKeywordService.java @@ -1,6 +1,7 @@ package onepiece.dailysnapbackend.service.keyword; import java.time.LocalDate; +import java.util.UUID; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import onepiece.dailysnapbackend.object.constants.KeywordCategory; @@ -19,22 +20,13 @@ public class AdminKeywordService { private final KeywordRepository keywordRepository; + private final KeywordService keywordService; /** - * 특정 날짜에 제공할 키워드 추가 (관리자 전용) + * 특정 날짜에 제공할 키워드 추가 (관리자 전용) */ @Transactional public KeywordResponse addKeyword(KeywordRequest request) { - // 입력 유효성 검사 - if (request.getKeyword() == null || request.getKeyword().trim().isEmpty()) { - log.error("키워드가 null이거나 빈 값: request={}", request); - throw new CustomException(ErrorCode.INVALID_REQUEST); - } - - if (request.getSpecifiedDate() == null) { - log.error("specifiedDate가 null: request={}", request); - throw new CustomException(ErrorCode.INVALID_REQUEST); - } // specifiedDate가 오늘 이후인지 확인 LocalDate today = LocalDate.now(); @@ -44,46 +36,38 @@ public KeywordResponse addKeyword(KeywordRequest request) { } // 중복 키워드 체크 - if (keywordRepository.existsByKeyword(request.getKeyword())) { - log.error("이미 존재하는 키워드: {}", request.getKeyword()); + if (keywordRepository.existsByKoreanKeyword(request.getKoreanKeyword())) { + log.error("이미 존재하는 키워드: {}", request.getKoreanKeyword()); throw new CustomException(ErrorCode.KEYWORD_ALREADY_EXISTS); } - Keyword keywordEntity = Keyword.builder() - .keyword(request.getKeyword()) - .category(KeywordCategory.ADMIN_SET) - .specifiedDate(request.getSpecifiedDate()) - .isUsed(false) + Keyword keyword = Keyword.builder() + .koreanKeyword(request.getKoreanKeyword()) + .englishKeyword(request.getEnglishKeyword()) + .keywordCategory(KeywordCategory.ADMIN_SET) + .providedDate(request.getSpecifiedDate()) + .used(false) .build(); - log.debug("저장 전 키워드 객체: keyword={}, specifiedDate={}", - keywordEntity.getKeyword(), keywordEntity.getSpecifiedDate()); - - Keyword savedKeyword = keywordRepository.save(keywordEntity); - - log.info("'{}' 날짜에 제공될 키워드 '{}' 추가 완료, savedId={}", - savedKeyword.getSpecifiedDate(), savedKeyword.getKeywordId()); + Keyword savedKeyword = keywordRepository.save(keyword); return KeywordResponse.builder() - .keyword(savedKeyword.getKeyword()) - .category(savedKeyword.getCategory()) - .specifiedDate(savedKeyword.getSpecifiedDate()) + .keywordId(savedKeyword.getKeywordId()) + .koreanKeyword(savedKeyword.getKoreanKeyword()) + .englishKeyword(savedKeyword.getEnglishKeyword()) + .keywordCategory(savedKeyword.getKeywordCategory()) .providedDate(savedKeyword.getProvidedDate()) + .used(savedKeyword.isUsed()) .build(); } - /** - * 특정 키워드 삭제 (관리자 전용) + * 특정 키워드 삭제 (관리자 전용) */ @Transactional - public void deleteKeyword(String keyword) { - if (!keywordRepository.existsByKeyword(keyword)) { - log.error("삭제 요청한 키워드를 찾을 수 없음: {}", keyword); - throw new CustomException(ErrorCode.KEYWORD_NOT_FOUND); - } - - keywordRepository.deleteKeywordByKeyword(keyword); - log.info("삭제된 키워드: {}", keyword); + public void deleteKeyword(UUID keywordId) { + Keyword keyword = keywordService.findKeywordById(keywordId); + keywordRepository.deleteById(keywordId); + log.info("삭제된 키워드: {}", keyword.getKoreanKeyword()); } } \ No newline at end of file diff --git a/src/main/java/onepiece/dailysnapbackend/service/keyword/KeywordSelectionService.java b/src/main/java/onepiece/dailysnapbackend/service/keyword/KeywordSelectionService.java index b2b53e7..bef72bf 100644 --- a/src/main/java/onepiece/dailysnapbackend/service/keyword/KeywordSelectionService.java +++ b/src/main/java/onepiece/dailysnapbackend/service/keyword/KeywordSelectionService.java @@ -1,146 +1,146 @@ -package onepiece.dailysnapbackend.service.keyword; - -import static onepiece.dailysnapbackend.object.constants.KeywordCategory.ADMIN_SET; - -import jakarta.transaction.Transactional; -import java.time.LocalDate; -import java.util.Arrays; -import java.util.List; -import java.util.Optional; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import onepiece.dailysnapbackend.mapper.EntityMapper; -import onepiece.dailysnapbackend.object.constants.KeywordCategory; -import onepiece.dailysnapbackend.object.dto.KeywordRequest; -import onepiece.dailysnapbackend.object.postgres.Keyword; -import onepiece.dailysnapbackend.repository.postgres.KeywordRepository; -import onepiece.dailysnapbackend.util.exception.CustomException; -import onepiece.dailysnapbackend.util.exception.ErrorCode; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -@Slf4j -public class KeywordSelectionService { - - private final KeywordRepository keywordRepository; - private final OpenAIKeywordService openAIKeywordService; - private final EntityMapper entityMapper; - - private static final List allCategories = Arrays.stream(KeywordCategory.values()) - .filter(category -> category != KeywordCategory.ADMIN_SET) - .toList(); - - @Transactional - public KeywordRequest getTodayKeyword() { - LocalDate today = LocalDate.now(); - LocalDate yesterday = today.minusDays(1); - log.info("오늘의 키워드 조회 시작: date={}", today); - - // ADMIN_SET 키워드 확인 (특정 날짜 키워드 우선 제공) - Optional optionalAdminKeyword = keywordRepository.findByCategoryAndSpecifiedDate(ADMIN_SET, today); - if (optionalAdminKeyword.isPresent()) { - Keyword adminKeyword = optionalAdminKeyword.get(); - if (!adminKeyword.isUsed()) { - log.info("ADMIN_SET 키워드 발견: keyword={}", adminKeyword.getKeyword()); - markKeywordAsUsed(adminKeyword); - return toKeywordRequest(adminKeyword); - } - } - - // 어제 제공된 키워드 확인 → 그 다음 카테고리 선택 - KeywordCategory selectedCategory = getNextCategory(yesterday); - log.info("선택된 카테고리: category={}", selectedCategory); - - // 선택된 카테고리에서 미사용 키워드 조회 - Keyword unusedKeyword = keywordRepository.findFirstByCategoryAndIsUsedFalse(selectedCategory) - .orElseGet(() -> { - log.warn("사용 가능한 키워드 없음, OpenAI로 새 키워드 생성: category={}", selectedCategory); - openAIKeywordService.generateKeywords(selectedCategory); - return keywordRepository.findFirstByCategoryAndIsUsedFalse(selectedCategory) - .orElseThrow(() -> { - log.error("키워드 조회 실패: 사용 가능한 키워드 없음"); - return new CustomException(ErrorCode.KEYWORD_NOT_FOUND); - }); - }); - - log.info("선택된 키워드: keyword={}", unusedKeyword.getKeyword()); - markKeywordAsUsed(unusedKeyword); - return toKeywordRequest(unusedKeyword); - } - - @Transactional - public void markKeywordAsUsed(Keyword keyword) { - keyword.setUsed(true); - keyword.setProvidedDate(LocalDate.now()); - keywordRepository.save(keyword); - log.info("키워드 사용 처리 완료: keyword={}, isUsed={}, providedDate={}", - keyword.getKeyword(), keyword.isUsed(), keyword.getProvidedDate()); - } - - /** - * 어제 카테고리를 기반으로 순환 방식으로 다음 카테고리 선택 - * - allCategories 배열 순서대로 순환 - * - 계절 카테고리라면 현재 월과 일치하는지 확인 후 선택 - */ - private KeywordCategory getNextCategory(LocalDate yesterday) { - // 어제 제공된 키워드 조회 - Optional optionalLastKeyword = keywordRepository.findByProvidedDate(yesterday); - if (!optionalLastKeyword.isPresent()) { - log.info("어제 키워드 없음 → 현재 시즌 카테고리 선택"); - return getSeasonCategory(); - } - - // 어제 사용된 카테고리 찾기 - Keyword lastKeyword = optionalLastKeyword.get(); - int lastIndex = indexOfCategory(lastKeyword.getCategory()); - int nextIndex = (lastIndex + 1) % allCategories.size(); - - // 다음 카테고리가 계절이면 현재 월과 비교 - while (isSeasonCategory(allCategories.get(nextIndex)) && allCategories.get(nextIndex) != getSeasonCategory()) { - log.info("계절 불일치 → 다음 카테고리로 이동: {} → {}", allCategories.get(nextIndex), allCategories.get((nextIndex + 1) % allCategories.size())); - nextIndex = (nextIndex + 1) % allCategories.size(); - } - - log.info("최종 선택된 카테고리: {}", allCategories.get(nextIndex)); - return allCategories.get(nextIndex); - } - - /** - * 현재 월에 맞는 계절 카테고리 반환 - */ - private KeywordCategory getSeasonCategory() { - int month = LocalDate.now().getMonthValue(); - if (month >= 3 && month <= 5) return KeywordCategory.SPRING; - if (month >= 6 && month <= 8) return KeywordCategory.SUMMER; - if (month >= 9 && month <= 11) return KeywordCategory.AUTUMN; - return KeywordCategory.WINTER; - } - - /** - * 계절 카테고리인지 여부 확인 - */ - private boolean isSeasonCategory(KeywordCategory category) { - return category == KeywordCategory.SPRING || - category == KeywordCategory.SUMMER || - category == KeywordCategory.AUTUMN || - category == KeywordCategory.WINTER; - } - - /** - * 특정 카테고리의 인덱스를 반환 - */ - private int indexOfCategory(KeywordCategory category) { - for (int i = 0; i < allCategories.size(); i++) { - if (allCategories.get(i) == category) return i; - } - return 0; - } - - /** - * 추후에 mapstruct로 변환 예정 - */ - private KeywordRequest toKeywordRequest(Keyword keyword) { - return entityMapper.toKeywordRequest(keyword); - } -} +//package onepiece.dailysnapbackend.service.keyword; +// +//import static onepiece.dailysnapbackend.object.constants.KeywordCategory.ADMIN_SET; +// +//import jakarta.transaction.Transactional; +//import java.time.LocalDate; +//import java.util.Arrays; +//import java.util.List; +//import java.util.Optional; +//import lombok.RequiredArgsConstructor; +//import lombok.extern.slf4j.Slf4j; +//import onepiece.dailysnapbackend.mapper.EntityMapper; +//import onepiece.dailysnapbackend.object.constants.KeywordCategory; +//import onepiece.dailysnapbackend.object.dto.KeywordRequest; +//import onepiece.dailysnapbackend.object.postgres.Keyword; +//import onepiece.dailysnapbackend.repository.postgres.KeywordRepository; +//import onepiece.dailysnapbackend.util.exception.CustomException; +//import onepiece.dailysnapbackend.util.exception.ErrorCode; +//import org.springframework.stereotype.Service; +// +//@Service +//@RequiredArgsConstructor +//@Slf4j +//public class KeywordSelectionService { +// +// private final KeywordRepository keywordRepository; +// private final OpenAIKeywordService openAIKeywordService; +// private final EntityMapper entityMapper; +// +// private static final List allCategories = Arrays.stream(KeywordCategory.values()) +// .filter(category -> category != KeywordCategory.ADMIN_SET) +// .toList(); +// +// @Transactional +// public KeywordRequest getTodayKeyword() { +// LocalDate today = LocalDate.now(); +// LocalDate yesterday = today.minusDays(1); +// log.info("오늘의 키워드 조회 시작: date={}", today); +// +// // ADMIN_SET 키워드 확인 (특정 날짜 키워드 우선 제공) +// Optional optionalAdminKeyword = keywordRepository.findByKeywordCategoryAndProvidedDate(ADMIN_SET, today); +// if (optionalAdminKeyword.isPresent()) { +// Keyword adminKeyword = optionalAdminKeyword.get(); +// if (!adminKeyword.isUsed()) { +// log.info("ADMIN_SET 키워드 발견: keyword={}", adminKeyword.getKoreanKeyword()); +// markKeywordAsUsed(adminKeyword); +// return toKeywordRequest(adminKeyword); +// } +// } +// +// // 어제 제공된 키워드 확인 → 그 다음 카테고리 선택 +// KeywordCategory selectedCategory = getNextCategory(yesterday); +// log.info("선택된 카테고리: category={}", selectedCategory); +// +// // 선택된 카테고리에서 미사용 키워드 조회 +// Keyword unusedKeyword = keywordRepository.findFirstByKeywordCategoryAndUsedFalse(selectedCategory) +// .orElseGet(() -> { +// log.warn("사용 가능한 키워드 없음, OpenAI로 새 키워드 생성: category={}", selectedCategory); +// openAIKeywordService.generateKeywords(selectedCategory); +// return keywordRepository.findFirstByKeywordCategoryAndUsedFalse(selectedCategory) +// .orElseThrow(() -> { +// log.error("키워드 조회 실패: 사용 가능한 키워드 없음"); +// return new CustomException(ErrorCode.KEYWORD_NOT_FOUND); +// }); +// }); +// +// log.info("선택된 키워드: keyword={}", unusedKeyword.getKoreanKeyword()); +// markKeywordAsUsed(unusedKeyword); +// return toKeywordRequest(unusedKeyword); +// } +// +// @Transactional +// public void markKeywordAsUsed(Keyword keyword) { +// keyword.setUsed(true); +// keyword.setProvidedDate(LocalDate.now()); +// keywordRepository.save(keyword); +// log.info("키워드 사용 처리 완료: keyword={}, isUsed={}, providedDate={}", +// keyword.getKoreanKeyword(), keyword.isUsed(), keyword.getProvidedDate()); +// } +// +// /** +// * 어제 카테고리를 기반으로 순환 방식으로 다음 카테고리 선택 +// * - allCategories 배열 순서대로 순환 +// * - 계절 카테고리라면 현재 월과 일치하는지 확인 후 선택 +// */ +// private KeywordCategory getNextCategory(LocalDate yesterday) { +// // 어제 제공된 키워드 조회 +// Optional optionalLastKeyword = keywordRepository.findByProvidedDate(yesterday); +// if (!optionalLastKeyword.isPresent()) { +// log.info("어제 키워드 없음 → 현재 시즌 카테고리 선택"); +// return getSeasonCategory(); +// } +// +// // 어제 사용된 카테고리 찾기 +// Keyword lastKeyword = optionalLastKeyword.get(); +// int lastIndex = indexOfCategory(lastKeyword.getKeywordCategory()); +// int nextIndex = (lastIndex + 1) % allCategories.size(); +// +// // 다음 카테고리가 계절이면 현재 월과 비교 +// while (isSeasonCategory(allCategories.get(nextIndex)) && allCategories.get(nextIndex) != getSeasonCategory()) { +// log.info("계절 불일치 → 다음 카테고리로 이동: {} → {}", allCategories.get(nextIndex), allCategories.get((nextIndex + 1) % allCategories.size())); +// nextIndex = (nextIndex + 1) % allCategories.size(); +// } +// +// log.info("최종 선택된 카테고리: {}", allCategories.get(nextIndex)); +// return allCategories.get(nextIndex); +// } +// +// /** +// * 현재 월에 맞는 계절 카테고리 반환 +// */ +// private KeywordCategory getSeasonCategory() { +// int month = LocalDate.now().getMonthValue(); +// if (month >= 3 && month <= 5) return KeywordCategory.SPRING; +// if (month >= 6 && month <= 8) return KeywordCategory.SUMMER; +// if (month >= 9 && month <= 11) return KeywordCategory.AUTUMN; +// return KeywordCategory.WINTER; +// } +// +// /** +// * 계절 카테고리인지 여부 확인 +// */ +// private boolean isSeasonCategory(KeywordCategory category) { +// return category == KeywordCategory.SPRING || +// category == KeywordCategory.SUMMER || +// category == KeywordCategory.AUTUMN || +// category == KeywordCategory.WINTER; +// } +// +// /** +// * 특정 카테고리의 인덱스를 반환 +// */ +// private int indexOfCategory(KeywordCategory category) { +// for (int i = 0; i < allCategories.size(); i++) { +// if (allCategories.get(i) == category) return i; +// } +// return 0; +// } +// +// /** +// * 추후에 mapstruct로 변환 예정 +// */ +// private KeywordRequest toKeywordRequest(Keyword keyword) { +// return entityMapper.toKeywordRequest(keyword); +// } +//} diff --git a/src/main/java/onepiece/dailysnapbackend/service/keyword/KeywordService.java b/src/main/java/onepiece/dailysnapbackend/service/keyword/KeywordService.java index 721533c..d4b8b13 100644 --- a/src/main/java/onepiece/dailysnapbackend/service/keyword/KeywordService.java +++ b/src/main/java/onepiece/dailysnapbackend/service/keyword/KeywordService.java @@ -1,31 +1,19 @@ package onepiece.dailysnapbackend.service.keyword; import java.time.LocalDate; -import java.util.Collections; -import java.util.Optional; import java.util.UUID; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import onepiece.dailysnapbackend.mapper.EntityMapper; -import onepiece.dailysnapbackend.object.dto.DailyKeywordResponse; import onepiece.dailysnapbackend.object.dto.KeywordFilterRequest; -import onepiece.dailysnapbackend.object.dto.KeywordFilterResponse; -import onepiece.dailysnapbackend.object.dto.KeywordRequest; +import onepiece.dailysnapbackend.object.dto.KeywordResponse; import onepiece.dailysnapbackend.object.postgres.Keyword; +import onepiece.dailysnapbackend.repository.postgres.KeywordQueryDslRepository; import onepiece.dailysnapbackend.repository.postgres.KeywordRepository; -import onepiece.dailysnapbackend.util.CommonUtil; import onepiece.dailysnapbackend.util.exception.CustomException; import onepiece.dailysnapbackend.util.exception.ErrorCode; import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageImpl; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.util.StringUtils; @Service @RequiredArgsConstructor @@ -33,142 +21,30 @@ public class KeywordService { private final KeywordRepository keywordRepository; - private final KeywordSelectionService keywordSelectionService; - private final EntityMapper entityMapper; - private final RedisTemplate redisTemplate; - - private static final String KEYWORD_CACHE_KEY = "daily_keyword"; + private final KeywordQueryDslRepository keywordQueryDslRepository; /** - * 키워드 필터링 및 조회 + * 키워드 필터링 조회 */ - @Transactional - public Page filteredKeywords(KeywordFilterRequest request) { - - Pageable pageable = createPageable(request); - LocalDate providedDate = StringUtils.hasText(request.getProvidedDate()) ? LocalDate.parse(request.getProvidedDate()) : null; - LocalDate today = LocalDate.now(); - - return (providedDate != null) - ? handleProvidedDateFiltering(providedDate, today, pageable) - : performFiltering(request, pageable); - } - - /** - * 페이지네이션 및 정렬 설정 - */ - private Pageable createPageable(KeywordFilterRequest request) { - return PageRequest.of( - request.getPageNumber(), - request.getPageSize(), - Sort.by(Sort.Direction.fromString(request.getSortDirection()), request.getSortField()) - ); - } - - /** - * 특정 날짜(providedDate) 필터링 처리 - */ - private Page handleProvidedDateFiltering(LocalDate providedDate, LocalDate today, Pageable pageable) { - if (providedDate.isAfter(today)) { - log.error("미래 날짜({}) 조회 불가", providedDate); - throw new CustomException(ErrorCode.INVALID_DATE_REQUEST); - } - - Optional optionalKeyword = keywordRepository.findByProvidedDate(providedDate); - if (optionalKeyword.isPresent()) { - Keyword existingKeyword = optionalKeyword.get(); - log.info("providedDate={}에 키워드 존재: keyword={}", providedDate, existingKeyword.getKeyword()); - return new PageImpl<>(Collections.singletonList(entityMapper.toKeywordFilterResponse(existingKeyword)), pageable, 1); } - - if (providedDate.isBefore(today)) { - log.info("과거 날짜({})에 키워드 없음", providedDate); - return Page.empty(pageable); - } - - return generateTodayKeywordPage(pageable); - } - - /** - * 필터링 수행 (providedDate가 없는 경우) - */ - private Page performFiltering(KeywordFilterRequest request, Pageable pageable) { - String keyword = CommonUtil.nvl(request.getKeyword(), ""); - String category = CommonUtil.nvl(request.getCategory(), ""); - String providedDate = CommonUtil.nvl(request.getProvidedDate(), ""); - Boolean isUsed = request.getIsUsed(); - - Page page = keywordRepository.filteredKeyword(keyword, category, providedDate, isUsed, pageable); - if (page.isEmpty()) { - log.error("필터링 결과 없음: keyword={}, category={}", keyword, category); - return Page.empty(pageable); - } - - log.info("필터링 완료: totalElements={}", page.getTotalElements()); - return page.map(entityMapper::toKeywordFilterResponse); - } - - /** - * 오늘 날짜에 키워드가 없는 경우 새 키워드 생성 - */ - @Transactional - public Page generateTodayKeywordPage(Pageable pageable) { - log.info("오늘 날짜에 키워드가 없음 → 새 키워드 생성 시도"); - KeywordRequest newKeyword = keywordSelectionService.getTodayKeyword(); - log.info("새 키워드 생성 완료: keyword={}", newKeyword.getKeyword()); - - boolean isUsed = true; - Page page = keywordRepository.filteredKeyword( - newKeyword.getKeyword(), - newKeyword.getCategory().name(), - LocalDate.now().toString(), - isUsed, - pageable - ); - - if (page.isEmpty()) { - log.error("생성된 키워드 조회 실패: keyword={}", newKeyword.getKeyword()); - return Page.empty(pageable); - } - - return page.map(entityMapper::toKeywordFilterResponse); - } - @Transactional(readOnly = true) - public DailyKeywordResponse getDailyKeyword() { - String keywordId = redisTemplate.opsForValue().get(KEYWORD_CACHE_KEY); - // redis 에 오늘의 키워드가 없다면 DB 에서 조회 후 업데이트 - if (keywordId == null) { - keywordId = fetchKeywordFromDB().toString(); - redisTemplate.opsForValue().set(KEYWORD_CACHE_KEY, keywordId); - } - - Keyword keyword = keywordRepository.findKeywordByKeywordId(UUID.fromString(keywordId)) - .orElseThrow(() -> new CustomException(ErrorCode.KEYWORD_NOT_FOUND)); - - log.info("오늘의 키워드: {}", keyword.getKeyword()); - return DailyKeywordResponse.builder() - .keyword(keyword.getKeyword()) - .category(keyword.getCategory()) - .providedDate(keyword.getProvidedDate()) - .build(); + public Page filteredKeywords(KeywordFilterRequest request) { + Page keywordPage = keywordQueryDslRepository.filteredKeyword(request); + return keywordPage.map(KeywordResponse::of); } - // DB 에서 오늘의 키워드 id 받아오기 - private UUID fetchKeywordFromDB() { - LocalDate today = LocalDate.now(); - Keyword keyword = keywordRepository.findByProvidedDate(today) - .orElseThrow(() -> new CustomException(ErrorCode.KEYWORD_NOT_FOUND)); - - log.info("{} 키워드: {}", today, keyword.getKeyword()); - return keyword.getKeywordId(); + public Keyword findKeywordById(UUID keywordId) { + return keywordRepository.findById(keywordId) + .orElseThrow(() -> { + log.error("요청 PK: {}에 해당하는 키워드를 찾을 수 없음", keywordId); + return new CustomException(ErrorCode.KEYWORD_NOT_FOUND); + }); } - // 매일 자정 Redis 에 업데이트 - @Scheduled(cron = "0 0 0 * * ?") - @Transactional(readOnly = true) - public void refreshDailyKeyword() { - UUID keywordId = fetchKeywordFromDB(); - log.info("오늘의 키워드를 업데이트했습니다. dailyKeyword: {}", keywordId); - redisTemplate.opsForValue().set(KEYWORD_CACHE_KEY, keywordId.toString()); + public Keyword findKeywordByProvidedDate(LocalDate providedDate) { + return keywordRepository.findByProvidedDate(providedDate) + .orElseThrow(() -> { + log.error("날짜: {}에 해당하는 키워드를 찾을 수 없음", providedDate); + return new CustomException(ErrorCode.KEYWORD_NOT_FOUND); + }); } } \ No newline at end of file diff --git a/src/main/java/onepiece/dailysnapbackend/service/keyword/OpenAIKeywordService.java b/src/main/java/onepiece/dailysnapbackend/service/keyword/OpenAIKeywordService.java index 2040fe4..a3db481 100644 --- a/src/main/java/onepiece/dailysnapbackend/service/keyword/OpenAIKeywordService.java +++ b/src/main/java/onepiece/dailysnapbackend/service/keyword/OpenAIKeywordService.java @@ -2,9 +2,16 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import onepiece.dailysnapbackend.object.constants.KeywordCategory; +import onepiece.dailysnapbackend.object.dto.KeywordPair; import onepiece.dailysnapbackend.object.postgres.Keyword; import onepiece.dailysnapbackend.repository.postgres.KeywordRepository; import onepiece.dailysnapbackend.util.OpenAIUtil; @@ -17,117 +24,130 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.web.reactive.function.client.WebClient; -import java.util.*; - @Service @RequiredArgsConstructor @Slf4j public class OpenAIKeywordService { + private static final String OPENAI_URL = "https://api.openai.com/v1/chat/completions"; + private static final String MODEL = "gpt-4"; private final KeywordRepository keywordRepository; private final WebClient webClient = WebClient.builder().build(); private final ObjectMapper objectMapper = new ObjectMapper(); - @Value("${openai.api.key}") private String openAiApiKey; - private static final String OPENAI_URL = "https://api.openai.com/v1/chat/completions"; - private static final String MODEL = "gpt-4"; - @Transactional public void generateKeywords(KeywordCategory category) { log.info("'{}' 카테고리 키워드 생성 시작", category); - List keywords = requestOpenAI(category); + List pairs = requestOpenAI(category); - if (keywords.isEmpty()) { + if (pairs.isEmpty()) { log.error("'{}' 카테고리 키워드 생성 실패 (응답 없음)", category); throw new CustomException(ErrorCode.INVALID_OPENAI_RESPONSE); } - saveKeywords(category, keywords); + saveKeywords(category, pairs); } - private List requestOpenAI(KeywordCategory category) { + private List requestOpenAI(KeywordCategory category) { String prompt = OpenAIUtil.getPrompt(category); String requestBody = OpenAIUtil.buildRequestBody(MODEL, prompt, 5000, objectMapper); try { - log.info("OpenAI 요청 JSON: {}", requestBody); + log.debug("OpenAI 요청 JSON: {}", requestBody); String response = webClient.post() .uri(OPENAI_URL) - .headers(headers -> headers.addAll(createHeaders())) + .headers(this::applyAuthHeaders) .contentType(MediaType.APPLICATION_JSON) .bodyValue(requestBody) .retrieve() .bodyToMono(String.class) .block(); - log.info("OpenAI 응답 수신 완료"); - return parseKeywords(category, response); + log.debug("OpenAI 응답 수신 완료"); + return parseKeywords(response); } catch (Exception e) { log.error("OpenAI API 요청 오류: {}", e.getMessage(), e); throw new CustomException(ErrorCode.OPENAI_SERVICE_UNAVAILABLE); } } - private HttpHeaders createHeaders() { - HttpHeaders headers = new HttpHeaders(); + private void applyAuthHeaders(HttpHeaders headers) { headers.setContentType(MediaType.APPLICATION_JSON); headers.setBearerAuth(openAiApiKey); - return headers; } - private List parseKeywords(KeywordCategory category, String response) { + private List parseKeywords(String response) { try { JsonNode root = objectMapper.readTree(response); - JsonNode choicesNode = root.path("choices"); - - if (!choicesNode.isArray() || choicesNode.isEmpty()) { - log.error("'{}' 카테고리 응답에서 키워드를 찾을 수 없음", category); + JsonNode choices = root.path("choices"); + if (!choices.isArray() || choices.isEmpty()) { throw new CustomException(ErrorCode.INVALID_OPENAI_RESPONSE); } - - String content = choicesNode.get(0).path("message").path("content").asText(); - JsonNode keywordArray = objectMapper.readTree(content); - - List keywords = new ArrayList<>(); - keywordArray.forEach(node -> keywords.add(node.asText().trim())); - - log.info("'{}' 카테고리 키워드 {}개 파싱 완료", category, keywords.size()); - return keywords; + String content = choices.get(0).path("message").path("content").asText(); + JsonNode arrayNode = objectMapper.readTree(content); + + List list = new ArrayList<>(); + arrayNode.forEach(node -> { + String ko = node.path("koreanKeyword").asText(null); + String en = node.path("englishKeyword").asText(null); + if (ko != null && en != null) { + list.add(new KeywordPair(ko.trim(), en.trim())); + } + }); + return list; } catch (Exception e) { log.error("OpenAI 응답 파싱 오류: {}", e.getMessage(), e); throw new CustomException(ErrorCode.INVALID_OPENAI_RESPONSE); } } - /** - * 중복 체크 후 키워드 저장 - */ - private void saveKeywords(KeywordCategory category, List keywords) { + private void saveKeywords(KeywordCategory category, List pairs) { log.info("'{}' 카테고리 키워드 저장 시작", category); - Set uniqueKeywords = new HashSet<>(keywords); - List keywordEntities = new ArrayList<>(); - - uniqueKeywords.forEach(keyword -> { - if (!keywordRepository.existsByKeyword(keyword)) { - keywordEntities.add(Keyword.builder() - .keyword(keyword) - .category(category) - .isUsed(false) + // 1) DB 에서 해당 카테고리의 가장 마지막 제공일 조회 + LocalDate lastDate = keywordRepository.findMaxProvidedDateByCategory(category); + + // 2) 첫 키워드의 제공일: 마지막일+1일 혹은 오늘 + LocalDate currentDate = (lastDate != null) + ? lastDate.plusDays(1) + : LocalDate.now(ZoneId.of("Asia/Seoul")); + + Set seen = new HashSet<>(); + List entities = new ArrayList<>(); + + for (KeywordPair pair : pairs) { + String ko = pair.koreanKeyword(); + + // 중복 한글 키워드 필터링 + if (seen.add(ko) && !keywordRepository.existsByKoreanKeyword(ko)) { + // 3) 하나의 키워드를 저장할 때마다 currentDate 를 세팅 + entities.add(Keyword.builder() + .koreanKeyword(ko) + .englishKeyword(pair.englishKeyword()) + .keywordCategory(category) + .providedDate(currentDate) + .used(false) .build()); + + // 4) 다음 키워드는 하루 뒤 날짜로 설정 + currentDate = currentDate.plusDays(1); } else { - log.error("'{}' 키워드는 이미 존재하여 저장하지 않음", keyword); + log.debug("'{}' 키워드는 중복 또는 이미 존재하여 저장하지 않음", ko); } - }); + } - if (!keywordEntities.isEmpty()) { - keywordRepository.saveAll(keywordEntities); + if (!entities.isEmpty()) { + keywordRepository.saveAll(entities); keywordRepository.flush(); - log.info("'{}' 카테고리 {}개 키워드 저장 완료", category, keywordEntities.size()); + log.info("'{}' 카테고리에 {}개 키워드 저장 완료 ({} ~ {})", + category, + entities.size(), + entities.get(0).getProvidedDate(), + entities.get(entities.size() - 1).getProvidedDate()); } else { - log.error("'{}' 카테고리에 추가 저장할 키워드 없음", category); + log.warn("'{}' 카테고리에 추가 저장할 키워드 없음", category); } } -} +} \ No newline at end of file diff --git a/src/main/java/onepiece/dailysnapbackend/util/CommonUtil.java b/src/main/java/onepiece/dailysnapbackend/util/CommonUtil.java index b05e1ac..875fe8c 100644 --- a/src/main/java/onepiece/dailysnapbackend/util/CommonUtil.java +++ b/src/main/java/onepiece/dailysnapbackend/util/CommonUtil.java @@ -1,14 +1,23 @@ package onepiece.dailysnapbackend.util; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.BeanUtils; +import java.text.Normalizer; +import java.text.Normalizer.Form; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import onepiece.dailysnapbackend.util.exception.CustomException; +import onepiece.dailysnapbackend.util.exception.ErrorCode; /** * 공통 메서드 */ -@Slf4j public class CommonUtil { + // 모든 유니코드 글자 + 숫자 + 공백 허용 + private static final Pattern SPECIAL_CHARS = Pattern.compile("[^\\p{L}\\p{N}\\s]"); + /** * null 문자 처리 -> str1이 null 인 경우 str2 반환 * "null" 문자열 처리 -> str1이 "null" 인 경우 str2 반환 @@ -43,25 +52,111 @@ public static int null2ZeroInt(Integer val) { } /** - * Entity 객체를 지정된 DTO 타입으로 변환합니다. + * 리스트가 null이거나 비어있는지 여부를 반환 + * + * @param list 검증할 list + * @return 리스트가 null이거나 비어있으면 true, 그 외에는 false + */ + public static boolean nullOrEmpty(List list) { + return list == null || list.isEmpty(); + } + + /** + * Enum 값을 String 으로 변환 + * + * @param enumValue 변환한 Enum 값 + * @return Enum의 name() 또는 빈 문자열 + */ + public static String enumToString(Enum enumValue) { + return enumValue != null ? enumValue.name() : ""; + } + + /** + * enumClass의 값 중 value와 매칭된 상수 반환 * - * @param entity 변환할 Entity 객체 (null이면 null 반환) - * @param dtoClass DTO 클래스 타입 - * @param DTO 타입 - * @param Entity 타입 - * @return Entity의 프로퍼티를 복사한 DTO 객체 + * @param enum 타입 + * @param enumClass 해당 enum 클래스 + * @param value 문자열 + * @return 매칭된 enum 상수 */ - public static D convertEntityToDto(E entity, Class dtoClass) { - if (entity == null) { - return null; + public static > E stringToEnum(Class enumClass, String value) { + if (nvl(value, "").isEmpty()) { + throw new CustomException(ErrorCode.INVALID_REQUEST); } - try { - D dto = dtoClass.getDeclaredConstructor().newInstance(); - BeanUtils.copyProperties(entity, dto); - return dto; - } catch (Exception e) { - log.error("Entity를 DTO로 변환하는 중 오류가 발생했습니다.", e); - throw new RuntimeException("Entity를 DTO로 변환하는 중 오류가 발생했습니다.", e); + + return Arrays.stream(enumClass.getEnumConstants()) + .filter(e -> e.name().equalsIgnoreCase(value)) + .findFirst() + .orElseThrow(() -> new CustomException(ErrorCode.INVALID_REQUEST)); + } + + /** + * SortField를 구현한 enumClass의 값 중 value와 매칭된 상수 반환 + * + * @param enum 타입 (Enum이면서 SortField 구현) + * @param enumClass SortField를 구현한 해당 enum 클래스 + * @param value 문자열 + * @return 매칭된 enum 상수 + */ + public static & SortField> E stringToSortField(Class enumClass, String value) { + if (nvl(value, "").isEmpty()) { + throw new CustomException(ErrorCode.INVALID_SORT_FIELD); } + + return Arrays.stream(enumClass.getEnumConstants()) + .filter(e -> + e.name().equalsIgnoreCase(value) || + e.getProperty().equals(value) + ) + .findFirst() + .orElseThrow(() -> new CustomException(ErrorCode.INVALID_SORT_FIELD)); + } + + /** + * 특수문자 제거 + * 영숫자 (a-z, A-Z, 0-9)와 공백을 제외한 모든 값을 제거합니다 + */ + public static String normalizeAndRemoveSpecialCharacters(String input) { + return normalize(input, ""); + } + + /** + * 특수문자 변환 + * 영숫자 (a-z, A-Z, 0-9)와 공백을 제외한 모든 값을 원하는 값으로 변환합니다. + */ + public static String normalizeAndReplaceSpecialCharacters(String input, String replacement) { + return normalize(input, replacement); + } + + /** + * Unicode 정규화 + * 텍스트 내 모든 특수문자 (문자(letter), 숫자(number), 공백 제외) 제거/치환 + * 연속 공백 -> 단일 공백 + * trim() + * + * @param input 정규화 할 문자열 + * @param specialReplacement 특수문자를 치환할 문자열 (제거 시 "" 입력, 치환 시 원하는 문자열 입력) + * @return 정규화 된 문자열 + */ + private static String normalize(String input, String specialReplacement) { + return Optional.ofNullable(input) + .filter(s -> !s.isBlank()) + .map(s -> Normalizer.normalize(s, Form.NFKC)) // Unicode 정규화 + .map(s -> SPECIAL_CHARS.matcher(s).replaceAll(specialReplacement)) // 특수문자 제거/치환 + .map(s -> s.replaceAll("\\s+", " ").trim()) // 공백 정리 & trim + .orElse(""); + } + + /** + * 여러 문자열을 하나의 텍스트로 결합 + * null이나 빈 문자열은 제외하고 공백으로 구분 + * + * @param texts 결합할 문자열 + * @return 공백으로 구분된 하나의 문자열 + */ + public static String combineTexts(String... texts) { + return Arrays.stream(texts) + .filter(text -> !nvl(text, "").isEmpty()) + .collect(Collectors.joining(" ")); } } diff --git a/src/main/java/onepiece/dailysnapbackend/util/FileUtil.java b/src/main/java/onepiece/dailysnapbackend/util/FileUtil.java index 8bb6f25..3607a28 100644 --- a/src/main/java/onepiece/dailysnapbackend/util/FileUtil.java +++ b/src/main/java/onepiece/dailysnapbackend/util/FileUtil.java @@ -1,50 +1,16 @@ package onepiece.dailysnapbackend.util; -import java.awt.image.BufferedImage; -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.util.UUID; -import javax.imageio.ImageIO; -import lombok.extern.slf4j.Slf4j; -import onepiece.dailysnapbackend.object.constants.MimeType; -import onepiece.dailysnapbackend.object.dto.FileResponse; -import onepiece.dailysnapbackend.util.exception.CustomException; -import onepiece.dailysnapbackend.util.exception.ErrorCode; -import org.springframework.util.StringUtils; +import lombok.experimental.UtilityClass; import org.springframework.web.multipart.MultipartFile; -@Slf4j +@UtilityClass public class FileUtil { - // Webp로 변환 - public static FileResponse convertToWebp(MultipartFile file) throws IOException { - if (MimeType.WEBP.getMimeType().equals(file.getContentType())) { - String fileName = generateFileName(file.getOriginalFilename()); - return new FileResponse(fileName, file.getBytes()); - } - - BufferedImage image = ImageIO.read(new ByteArrayInputStream(file.getBytes())); - if (image == null) { - log.error("파일이 유효하지 않음: fileName={}", file.getOriginalFilename()); - throw new CustomException(ErrorCode.FILE_UPLOAD_FAILED); - } - - String fileName = generateFileName(file.getOriginalFilename()); - String webpFileName = fileName.replaceAll("\\.[^.]+$", ".webp"); - - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - boolean success = ImageIO.write(image, "webp", baos); - if (!success) { - throw new IOException("WebP 형식으로 변환 실패"); - } - return new FileResponse(webpFileName, baos.toByteArray()); + /** + * Multipartfile이 null이거나 빈 파일인지 체크 + */ + public boolean isNullOrEmpty(MultipartFile file) { + return file == null || file.isEmpty() || file.getOriginalFilename() == null; } - // 파일명 생성 - public static String generateFileName(String originalFileName) { - return StringUtils.hasText(originalFileName) - ? UUID.randomUUID() + "_" + originalFileName - : UUID.randomUUID() + "_" + System.currentTimeMillis(); - } } diff --git a/src/main/java/onepiece/dailysnapbackend/util/JwtUtil.java b/src/main/java/onepiece/dailysnapbackend/util/JwtUtil.java index fd0881a..f6b5e47 100644 --- a/src/main/java/onepiece/dailysnapbackend/util/JwtUtil.java +++ b/src/main/java/onepiece/dailysnapbackend/util/JwtUtil.java @@ -30,26 +30,21 @@ @RequiredArgsConstructor public class JwtUtil { + private static final String ACCESS_CATEGORY = "access"; + private static final String REFRESH_CATEGORY = "refresh"; + private static final String BLACKLIST_KEY = "BL:"; + private static final String BLACKLIST_VALUE = "blacklist"; private final CustomOAuth2UserService customOAuth2UserService; private final RedisTemplate redisTemplate; - @Value("${jwt.secret-key}") private String secretKey; - @Value("${jwt.access-exp-time}") private Long accessTokenExpTime; // AccessToken 만료 시간 - @Value("${jwt.refresh-exp-time}") private Long refreshTokenExpTime; // RefreshToken 만료 시간 - @Value("${jwt.issuer}") private String issuer; // JWT 발급자 - private static final String ACCESS_CATEGORY = "access"; - private static final String REFRESH_CATEGORY = "refresh"; - private static final String BLACKLIST_KEY = "BL:"; - private static final String BLACKLIST_VALUE = "blacklist"; - // 토큰에서 username 파싱 public String getUsername(String token) { return Jwts.parser() diff --git a/src/main/java/onepiece/dailysnapbackend/util/OpenAIUtil.java b/src/main/java/onepiece/dailysnapbackend/util/OpenAIUtil.java index 84e16d8..8233957 100644 --- a/src/main/java/onepiece/dailysnapbackend/util/OpenAIUtil.java +++ b/src/main/java/onepiece/dailysnapbackend/util/OpenAIUtil.java @@ -3,68 +3,40 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.experimental.UtilityClass; import onepiece.dailysnapbackend.object.constants.KeywordCategory; import onepiece.dailysnapbackend.util.exception.CustomException; import onepiece.dailysnapbackend.util.exception.ErrorCode; -import java.util.Map; - +@UtilityClass public class OpenAIUtil { - private static final Map PROMPTS = Map.of( - KeywordCategory.SPRING, """ - 봄과 관련된 사진 촬영 키워드 100개를 추천해줘. - JSON 배열 형식으로 반환하고, 반드시 한 단어로 구성해야 해. - 예: ["벚꽃", "봄", "피크닉"] - """, - KeywordCategory.SUMMER, """ - 여름과 관련된 사진 촬영 키워드 100개를 추천해줘. - JSON 배열 형식으로 반환하고, 반드시 한 단어로 구성해야 해. - 예: ["해변", "태양", "수영"] - """, - KeywordCategory.AUTUMN, """ - 가을과 관련된 사진 촬영 키워드 100개를 추천해줘. - JSON 배열 형식으로 반환하고, 반드시 한 단어로 구성해야 해. - 예: ["단풍", "낙엽", "캠핑"] - """, - KeywordCategory.WINTER, """ - 겨울과 관련된 사진 촬영 키워드 100개를 추천해줘. - JSON 배열 형식으로 반환하고, 반드시 한 단어로 구성해야 해. - 예: ["눈", "크리스마스", "코트"] - """, - KeywordCategory.TRAVEL, """ - 여행지에서 사진을 찍기 좋은 키워드 100개를 추천해줘. - JSON 배열 형식으로 반환하고, 반드시 한 단어로 구성해야 해. - 예: ["랜드마크", "야경", "자연"] - """, - KeywordCategory.DAILY, """ - 일상에서 찍을 수 있는 사진 촬영 키워드 100개를 추천해줘. - JSON 배열 형식으로 반환하고, 반드시 한 단어로 구성해야 해. - 예: ["커피", "독서", "거리"] - """, - KeywordCategory.ABSTRACT, """ - 추상적인 사진 촬영 키워드 100개를 추천해줘. - JSON 배열 형식으로 반환하고, 반드시 한 단어로 구성해야 해. - 예: ["패턴", "반사", "그림자"] - """, - KeywordCategory.RANDOM, """ - 무작위로 사진을 찍기 좋은 키워드 100개를 추천해줘. - JSON 배열 형식으로 반환하고, 반드시 한 단어로 구성해야 해. - 예: ["고요", "역동", "감성"] - """ - ); - /** - * KeywordCategory에 맞는 프롬프트 반환 + * 주어진 카테고리에 맞춰, 한국어 키워드 & 영어 번역 키워드 쌍을 + * JSON 배열(객체 요소)로 반환해 달라는 프롬프트를 생성합니다. */ - public static String getPrompt(KeywordCategory category) { - return PROMPTS.getOrDefault(category, "무작위 키워드를 추천해줘. JSON 배열 형식으로 반환해야 해."); + public String getPrompt(KeywordCategory category) { + return String.format(""" + '%s' 카테고리에 해당하는 사진 촬영 키워드 100개를 추천하고, + 각각의 한글 키워드에 대응하는 영어 번역을 함께 제공합니다. + 결과를 JSON 배열 형식으로 반환해주세요. + 각 요소는 객체이며, "koreanKeyword"와 "englishKeyword" 필드를 가집니다. + 해당 키워드에 맞는 사진을 업로드하는 어플리케이션이므로, 키워드 선택이 매우 중요합니다. + 사진을 업로드 하기 적합한 키워드 위주로 출력해주세요. + 한국어 단어를 먼저 출력하며, 이후 해당 한국어 단어에 맞는 영어 단어를 번역하여 출력합니다. + 예: + [ + {"koreanKeyword":"벚꽃","englishKeyword":"cherry blossom"}, + {"koreanKeyword":"봄","englishKeyword":"spring"}, + ... + ] + """, category); } /** * OpenAI 요청 JSON 문자열 생성 */ - public static String buildRequestBody(String model, String prompt, int maxTokens, ObjectMapper mapper) { + public String buildRequestBody(String model, String prompt, int maxTokens, ObjectMapper mapper) { ObjectNode requestJson = mapper.createObjectNode(); requestJson.put("model", model); requestJson.put("max_tokens", maxTokens); diff --git a/src/main/java/onepiece/dailysnapbackend/util/PageableConstants.java b/src/main/java/onepiece/dailysnapbackend/util/PageableConstants.java new file mode 100644 index 0000000..e9a2300 --- /dev/null +++ b/src/main/java/onepiece/dailysnapbackend/util/PageableConstants.java @@ -0,0 +1,10 @@ +package onepiece.dailysnapbackend.util; + +import lombok.experimental.UtilityClass; + +@UtilityClass +public final class PageableConstants { + + public static final int MAX_PAGE_SIZE = 100; + public static final int DEFAULT_PAGE_SIZE = 30; +} diff --git a/src/main/java/onepiece/dailysnapbackend/util/PageableUtil.java b/src/main/java/onepiece/dailysnapbackend/util/PageableUtil.java new file mode 100644 index 0000000..d59981b --- /dev/null +++ b/src/main/java/onepiece/dailysnapbackend/util/PageableUtil.java @@ -0,0 +1,70 @@ +package onepiece.dailysnapbackend.util; + +import lombok.experimental.UtilityClass; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; + +/** + * 페이지네이션을 중앙에서 관리하는 유틸리티 클래스 + * 사용자는 1부터 시작하는 페이지 번호를 입력하고, 내부적으로 인덱스 기반으로 변환 + */ +@UtilityClass +public class PageableUtil { + + /** + * 사용자 입력 페이지 번호(1부터 시작)를 인덱스 기반으로 변환 + * + * @param pageNumber 사용자 입력 페이지 번호 (1부터 시작) + * @return 인덱스 기반 페이지 번호 (0부터 시작) + */ + public int convertToPageIndex(Integer pageNumber) { + if (pageNumber == null || pageNumber < 1) { + return 0; // 기본값은 첫 번째 페이지 (인덱스 0) + } + return pageNumber - 1; // 1부터 시작하는 페이지 번호를 0부터 시작하는 인덱스로 변환 + } + + /** + * 페이지 크기 검증 및 기본값 설정 + * + * @param pageSize 사용자 입력 페이지 크기 + * @param defaultPageSize 도메인별 기본 페이지 크기(ex: 채팅메시지의 경우 20) + * @return 검증된 페이지 크기 + */ + public int validatePageSize(Integer pageSize, Integer defaultPageSize) { + if (pageSize == null || pageSize < 1) { + return defaultPageSize; + } + return Math.min(pageSize, PageableConstants.MAX_PAGE_SIZE); // 최대값 제한 + } + + /** + * Pageable 객체 생성 (사용자 입력 페이지 번호를 인덱스로 변환) + * + * @param enum 타입, SortField를 구현해야함 + * @param pageNumber 사용자 입력 페이지 번호 (1부터 시작) + * @param pageSize 페이지 크기 + * @param defaultPageSize 도메인별 기본 페이지 사이즈 + * @param sortField 정렬 필드 + * @param sortDirection 정렬 방향 + * @return Pageable 객체 + */ + public Pageable createPageable( + Integer pageNumber, + Integer pageSize, + Integer defaultPageSize, + SF sortField, + Sort.Direction sortDirection) { + + int pageIndex = convertToPageIndex(pageNumber); + int validatedSize = validatePageSize(pageSize, defaultPageSize); + + if (sortField == null || sortDirection == null) { + return PageRequest.of(pageIndex, validatedSize, Sort.unsorted()); + } + + Sort sort = Sort.by(sortDirection, sortField.getProperty()); + return PageRequest.of(pageIndex, validatedSize, sort); + } +} \ No newline at end of file diff --git a/src/main/java/onepiece/dailysnapbackend/util/QueryDslUtil.java b/src/main/java/onepiece/dailysnapbackend/util/QueryDslUtil.java new file mode 100644 index 0000000..dd82f73 --- /dev/null +++ b/src/main/java/onepiece/dailysnapbackend/util/QueryDslUtil.java @@ -0,0 +1,233 @@ +package onepiece.dailysnapbackend.util; + +import com.querydsl.core.types.Order; +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.Predicate; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.ComparableExpression; +import com.querydsl.core.types.dsl.PathBuilder; +import com.querydsl.core.types.dsl.SimpleExpression; +import com.querydsl.core.types.dsl.StringExpression; +import com.querydsl.jpa.impl.JPAQuery; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import lombok.experimental.UtilityClass; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; +import org.springframework.data.domain.Sort; + +@UtilityClass +public class QueryDslUtil { + + /** + * 두 BooleanExpression을 AND 조건으로 결합 + * - baseClause가 null이면 additionalClause 그대로 반환 + * - additionalClause가 null이면 baseClause 그대로 반환 + * - 둘 다 null이 아니면 baseClause.and(additionalClause) 반환 + * + * @param baseClause 기존 WHERE 절 + * @param additionalClause 추가할 WHERE 절 + * @return 조합된 최종 WHERE 절 + * @see BooleanExpression#and(Predicate) + */ + public BooleanExpression combineWhereClause(BooleanExpression baseClause, BooleanExpression additionalClause) { + if (additionalClause == null) { + return baseClause; + } + return (baseClause == null) ? additionalClause : baseClause.and(additionalClause); + } + + /** + * 여러 BooleanExpression을 하나의 AND 절로 결합 + * - 전달된 expressions 순서대로 null 체크 후 AND 연산 + * - 모든 expression이 null이면 null반환 + * + * @param expressions 결합할 BooleanExpression 배열 + * @return 결합된 BooleanExpression 또는 모두 null 일 경우 null + */ + public BooleanExpression allOf(BooleanExpression... expressions) { + BooleanExpression result = null; + if (expressions == null || expressions.length == 0) { + return null; + } + for (BooleanExpression expression : expressions) { + result = combineWhereClause(result, expression); + } + return result; + } + + /** + * 여러 BooleanExpression을 하나의 OR 절로 결합 + * - 전달된 expressions 순서대로 null 체크 후 OR 연산 + * - 모든 expression이 null이면 null반환 + * + * @param expressions 결합할 BooleanExpression 배열 + * @return 결합된 BooleanExpression 또는 모두 null 일 경우 null + */ + public BooleanExpression anyOf(BooleanExpression... expressions) { + BooleanExpression result = null; + if (expressions == null || expressions.length == 0) { + return null; + } + for (BooleanExpression expression : expressions) { + if (expression != null) { + result = (result == null) ? expression : result.or(expression); + } + } + return result; + } + + /** + * 주어진 값(value)이 null이 아닐 때만 EQ(=) 조건 생성 + * - value가 null이면 null을 반환하여 WHERE 절에 해당 조건이 포함되지 않게 합니다 + * - value가 null이 아니면 path.eq(value) 조건을 반환합니다 + * + * @param SimpleExpression이 처리하는 값의 타입 + * @param path QueryDSL의 SimpleExpression 필드 경로 ex) QEntity.field + * @param value 비교할 실제 값 + * @return value가 null이 아닐 경우 path.eq(value), null 일 경우 null + * @see SimpleExpression#eq(Object) + */ + public BooleanExpression eqIfNotNull(SimpleExpression path, T value) { + if (value == null) { + return null; + } + return path.eq(value); + } + + /** + * value가 null 또는 빈 문자열이 아닐 때만 대소문자 구문 없는 LIKE 조건 생성 + * - value가 null 이거나 공백만 있을 경우 null을 반환하여 WHERE 절에서 생략 + * - value가 유효하면 path.lower().like("%value%") 반환 + * + * @param path Q 클래스의 StringExpression 경로 + * @param value 검색할 키워드 + * @return 대소문자 구분 없는 LIKE 조건 혹은 null + * @see StringExpression#lower() + * @see StringExpression#like(String) + */ + public BooleanExpression likeIgnoreCase(StringExpression path, String value) { + if (CommonUtil.nvl(value, "").isEmpty()) { + return null; + } + return path.lower().like("%" + value.trim().toLowerCase() + "%"); + } + + /** + * ComparableExpression 경로와 정렬 방향으로 OrderSpecifier 생성 + * + * @param ComparableExpression의 타입 + * @param path 정렬 대상 경로 (예: QEntity.createdDate) + * @param asc true이면 오름차순, false이면 내림차순 + * @return OrderSpecifier 객체 + */ + public > OrderSpecifier createOrderSpecifier(ComparableExpression path, boolean asc) { + return new OrderSpecifier<>(asc ? Order.ASC : Order.DESC, path); + } + + /** + * JPAQuery에 동적 정렬을 적용 + * PathBuilder를 통해 프로퍼티명을 직접 참조 + * 각 엔티티별 공통 활용 + * + * @param query의 projection(DTO) 타입 + * @param 정렬 대상이 되는 엔티티 타입 + * @param query 정렬할 JPAQuery + * @param pageable Spring Data Pageable + * @param entityClass 정렬할 필드를 가진 엔티티 클래스 + * @param alias QueryDSL 별명 (ex. "entity") + */ + public void applySorting(JPAQuery query, Pageable pageable, Class entityClass, String alias) { + applySorting(query, pageable, entityClass, alias, Collections.emptyMap()); + } + + /** + * JPAQuery에 동적 정렬을 적용 + * PathBuilder를 통해 프로퍼티명을 직접 참조 + * customSortMap에 프로퍼티명이 매핑된 ComparableExpression이 있으면 사용 + * + * @param query의 projection(DTO) 타입 + * @param 정렬 대상이 되는 엔티티 타입 + * @param query 정렬할 JPAQuery + * @param pageable Spring Data Pageable + * @param entityClass 정렬할 필드를 가진 엔티티 클래스 + * @param alias QueryDSL에서 사용되는 엔티티 별칭 (ex. "entity") + * @param customSortMap 프로퍼티명과 {@link ComparableExpression}을 매핑한 사용자 정의 정렬 Map + */ + public void applySorting( + JPAQuery query, + Pageable pageable, + Class entityClass, String alias, + Map> customSortMap + ) { + if (pageable.getSort().isEmpty()) { + return; + } + PathBuilder builder = new PathBuilder<>(entityClass, alias); + for (Sort.Order order : pageable.getSort()) { + String property = order.getProperty(); + boolean asc = order.isAscending(); + + // enum.getProperty() 로 넘어온 커스텀 표현식이 있으면 우선 사용 + ComparableExpression expression = customSortMap.get(property); + if (expression == null) { + // 없으면 엔티티 필드로 처리 + expression = builder.getComparable(property, Comparable.class); + } + query.orderBy(createOrderSpecifier(expression, asc)); + } + } + + /** + * QueryDSL JPAQuery를 이용한 페이징 처리 + * - contentQuery: 페이징 설정(offset, limit) 및 정렬이 적용된 JPAQuery + * - countQuery: 전체 건수를 조회하기 위한 JPAQuery (select count) + * - 두 개의 쿼리를 실행하여 Page 객체로 반환 + * + * @param 조회 엔티티 또는 DTO 타입 + * @param contentQuery offset, limit, orderBy가 설정된 JPAQuery + * @param countQuery 전체 레코드 수를 조회하는 JPAQuery + * @param pageable Spring Data Pageable + * @return 페이징된 결과 + */ + public Page fetchPage(JPAQuery contentQuery, JPAQuery countQuery, Pageable pageable) { + List content = contentQuery + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + Long total = countQuery.fetchOne(); + total = (total == null) ? 0L : total; + + return new PageImpl<>(content, pageable, total); + } + + /** + * QueryDSL JPAQuery를 이용한 Slice 페이징 처리 + * - count 쿼리 없이, '다음 페이지 존재 여부'만 확인 + * - 무한 스크롤 방식에 최적화 + * + * @param 조회 엔티티 또는 DTO 타입 + * @param contentQuery offset, limit, orderBy가 설정된 JPAQuery + * @param pageable Spring Data Pageable + * @return Slice 페이징 결과 + */ + public Slice fetchSlice(JPAQuery contentQuery, Pageable pageable) { + List content = contentQuery + .offset(pageable.getOffset()) + .limit(pageable.getPageSize() + 1) + .fetch(); + + boolean hasNext = false; + if (content.size() > pageable.getPageSize()) { + content.remove(pageable.getPageSize()); + hasNext = true; + } + + return new SliceImpl<>(content, pageable, hasNext); + } +} diff --git a/src/main/java/onepiece/dailysnapbackend/util/SortField.java b/src/main/java/onepiece/dailysnapbackend/util/SortField.java new file mode 100644 index 0000000..436168d --- /dev/null +++ b/src/main/java/onepiece/dailysnapbackend/util/SortField.java @@ -0,0 +1,10 @@ +package onepiece.dailysnapbackend.util; + +/** + * 각 도메인이 제공하는 정렬 키 enum 이 구현해야 할 인터페이스 + */ +public interface SortField { + + // 실제 Sort.by() 에 넘길 프로퍼티 이름 (엔티티 속성명 ex.createdDate) + String getProperty(); +} diff --git a/src/main/java/onepiece/dailysnapbackend/util/config/FakerConfig.java b/src/main/java/onepiece/dailysnapbackend/util/config/FakerConfig.java new file mode 100644 index 0000000..c89f87a --- /dev/null +++ b/src/main/java/onepiece/dailysnapbackend/util/config/FakerConfig.java @@ -0,0 +1,20 @@ +package onepiece.dailysnapbackend.util.config; + +import java.util.Locale; +import net.datafaker.Faker; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class FakerConfig { + + @Bean + public Faker koFaker() { + return new Faker(new Locale("ko", "KR")); + } + + @Bean + public Faker enFaker() { + return new Faker(new Locale("en")); + } +} diff --git a/src/main/java/onepiece/dailysnapbackend/util/config/QueryDslConfig.java b/src/main/java/onepiece/dailysnapbackend/util/config/QueryDslConfig.java new file mode 100644 index 0000000..2258001 --- /dev/null +++ b/src/main/java/onepiece/dailysnapbackend/util/config/QueryDslConfig.java @@ -0,0 +1,15 @@ +package onepiece.dailysnapbackend.util.config; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class QueryDslConfig { + + @Bean + public JPAQueryFactory jpaQueryFactory(EntityManager entityManager) { + return new JPAQueryFactory(entityManager); + } +} diff --git a/src/main/java/onepiece/dailysnapbackend/util/config/S3Config.java b/src/main/java/onepiece/dailysnapbackend/util/config/S3Config.java index b6e49bd..bdb3f98 100644 --- a/src/main/java/onepiece/dailysnapbackend/util/config/S3Config.java +++ b/src/main/java/onepiece/dailysnapbackend/util/config/S3Config.java @@ -1,34 +1,33 @@ package onepiece.dailysnapbackend.util.config; -import org.springframework.beans.factory.annotation.Value; +import com.amazonaws.auth.AWSCredentials; +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.BasicAWSCredentials; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.AmazonS3ClientBuilder; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.EnableConfigurationProperties; 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.S3Client; @Configuration +@RequiredArgsConstructor +@EnableConfigurationProperties(S3Properties.class) public class S3Config { - @Value("${cloud.aws.credentials.access-key}") - private String accessKey; - - @Value("${cloud.aws.credentials.secret-key}") - private String secretKey; - - @Value("${cloud.aws.region.static}") - private String region; + private final S3Properties properties; @Bean - public S3Client s3Client() { - return S3Client.builder() - .region(Region.of(region)) - .credentialsProvider( - StaticCredentialsProvider.create( - AwsBasicCredentials.create(accessKey, secretKey) - ) - ) + public AmazonS3 amazonS3() { + AWSCredentials credentials = new BasicAWSCredentials( + properties.credentials().accessKey(), + properties.credentials().secretKey() + ); + + return AmazonS3ClientBuilder + .standard() + .withCredentials(new AWSStaticCredentialsProvider(credentials)) + .withRegion(properties.region().staticRegion()) .build(); } -} +} \ No newline at end of file diff --git a/src/main/java/onepiece/dailysnapbackend/util/config/S3Properties.java b/src/main/java/onepiece/dailysnapbackend/util/config/S3Properties.java new file mode 100644 index 0000000..6c4109c --- /dev/null +++ b/src/main/java/onepiece/dailysnapbackend/util/config/S3Properties.java @@ -0,0 +1,49 @@ +package onepiece.dailysnapbackend.util.config; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +@Validated +@ConfigurationProperties(prefix = "cloud.aws") +public record S3Properties( + @Valid S3 s3, + @Valid Credentials credentials, + @Valid Region region, + @Valid Stack stack +) { + + public record S3( + @NotBlank String bucket, + @NotBlank String domain, + @Valid Path path + ) { + + } + + public record Path( + @NotBlank String member, + @NotBlank String post + ) { + + } + + public record Credentials( + @NotBlank String accessKey, + @NotBlank String secretKey + ) { + + } + + public record Region( + @NotBlank String staticRegion, + boolean auto + ) { + + } + + public record Stack(boolean auto) { + + } +} diff --git a/src/main/java/onepiece/dailysnapbackend/util/config/SecurityConfig.java b/src/main/java/onepiece/dailysnapbackend/util/config/SecurityConfig.java index 68ee6c3..bfedee9 100644 --- a/src/main/java/onepiece/dailysnapbackend/util/config/SecurityConfig.java +++ b/src/main/java/onepiece/dailysnapbackend/util/config/SecurityConfig.java @@ -30,14 +30,6 @@ @RequiredArgsConstructor public class SecurityConfig { - private final JwtUtil jwtUtil; - private final CustomOAuth2UserService customOAuth2UserService; - private final AuthenticationConfiguration authenticationConfiguration; - private final RedisTemplate redisTemplate; - private final CustomLogoutHandler customLogoutHandler; - private final ObjectMapper objectMapper; - private final MemberService memberService; - /** * 허용된 CORS Origin 목록 */ @@ -52,6 +44,13 @@ public class SecurityConfig { "https://api.dailysnap.store", // 메인 API 서버 "https://test.dailysnap.store" // 테스트 API 서버 }; + private final JwtUtil jwtUtil; + private final CustomOAuth2UserService customOAuth2UserService; + private final AuthenticationConfiguration authenticationConfiguration; + private final RedisTemplate redisTemplate; + private final CustomLogoutHandler customLogoutHandler; + private final ObjectMapper objectMapper; + private final MemberService memberService; /** * Security Filter Chain 설정 diff --git a/src/main/java/onepiece/dailysnapbackend/util/config/SecurityUrls.java b/src/main/java/onepiece/dailysnapbackend/util/config/SecurityUrls.java index c2d18f4..517ce98 100644 --- a/src/main/java/onepiece/dailysnapbackend/util/config/SecurityUrls.java +++ b/src/main/java/onepiece/dailysnapbackend/util/config/SecurityUrls.java @@ -17,7 +17,7 @@ public class SecurityUrls { "/login", // 로그인 "/api/auth/reissue", // 액세스 토큰 재발급 "/", - + "/mock/**", // Swagger "/docs/**", // Swagger UI @@ -30,7 +30,6 @@ public class SecurityUrls { */ public static final List ADMIN_PATHS = Arrays.asList( - ); } diff --git a/src/main/java/onepiece/dailysnapbackend/util/config/SwaggerConfig.java b/src/main/java/onepiece/dailysnapbackend/util/config/SwaggerConfig.java index 585e50f..c3feaf2 100644 --- a/src/main/java/onepiece/dailysnapbackend/util/config/SwaggerConfig.java +++ b/src/main/java/onepiece/dailysnapbackend/util/config/SwaggerConfig.java @@ -17,8 +17,8 @@ info = @Info( title = "\uD83D\uDCF7DailySnap", description = """ - ### 데일리스냅 - #### [Github](https://github.com/Team-0nePiece/DailySnap-BE)""", + ### 데일리스냅 + #### [Github](https://github.com/Team-0nePiece/DailySnap-BE)""", version = "1.0v" ), servers = { diff --git a/src/main/java/onepiece/dailysnapbackend/util/exception/CustomException.java b/src/main/java/onepiece/dailysnapbackend/util/exception/CustomException.java index 7fa91eb..c34a036 100644 --- a/src/main/java/onepiece/dailysnapbackend/util/exception/CustomException.java +++ b/src/main/java/onepiece/dailysnapbackend/util/exception/CustomException.java @@ -3,7 +3,7 @@ import lombok.Getter; @Getter -public class CustomException extends RuntimeException{ +public class CustomException extends RuntimeException { private final ErrorCode errorCode; diff --git a/src/main/java/onepiece/dailysnapbackend/util/exception/ErrorCode.java b/src/main/java/onepiece/dailysnapbackend/util/exception/ErrorCode.java index b490101..891c16c 100644 --- a/src/main/java/onepiece/dailysnapbackend/util/exception/ErrorCode.java +++ b/src/main/java/onepiece/dailysnapbackend/util/exception/ErrorCode.java @@ -74,6 +74,28 @@ public enum ErrorCode { POST_NOT_FOUND(HttpStatus.BAD_REQUEST, "게시물을 찾을 수 없습니다."), + // S3 + + INVALID_FILE_EXTENSION(HttpStatus.BAD_REQUEST, "유효하지 않은 파일 확장자입니다."), + + FILE_UPLOAD_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "파일 업로드 중 오류가 발생했습니다."), + + S3_UPLOAD_AMAZON_SERVICE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "S3 서비스 에러로 인해 파일 업로드에 실패했습니다."), + + S3_UPLOAD_AMAZON_CLIENT_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "S3 클라이언트 에러로 인해 파일 업로드에 실패했습니다."), + + S3_UPLOAD_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "S3 파일 업로드 중 오류 발생"), + + S3_DELETE_AMAZON_SERVICE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "S3 서비스 에러로 인해 파일 삭제에 실패했습니다."), + + S3_DELETE_AMAZON_CLIENT_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "S3 클라이언트 에러로 인해 파일 삭제에 실패했습니다."), + + S3_DELETE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "S3 파일 삭제 중 오류 발생"), + + INVALID_FILE_REQUEST(HttpStatus.BAD_REQUEST, "유효하지 않은 파일 요청입니다."), + + INVALID_FILE_PATH(HttpStatus.BAD_REQUEST, "유효하지 않은 파일 URL 요청입니다."), + // LIKE ALREADY_LIKED(HttpStatus.BAD_REQUEST, "이미 좋아요를 눌렀습니다."), @@ -84,7 +106,13 @@ public enum ErrorCode { ALREADY_FOLLOWED(HttpStatus.BAD_REQUEST, "이미 팔로우한 사용자입니다."), - FOLLOW_RELATIONSHIP_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 팔로우 관계를 찾을 수 없습니다."); + FOLLOW_RELATIONSHIP_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 팔로우 관계를 찾을 수 없습니다."), + + // PAGEABLE + + INVALID_SORT_FIELD(HttpStatus.BAD_REQUEST, "필터링 조회 시 정렬 필드 요청이 잘못되었습니다."), + + ; private final HttpStatus status; private final String message; diff --git a/src/main/java/onepiece/dailysnapbackend/util/exception/ErrorDetail.java b/src/main/java/onepiece/dailysnapbackend/util/exception/ErrorDetail.java index 54ffdba..5e32a9b 100644 --- a/src/main/java/onepiece/dailysnapbackend/util/exception/ErrorDetail.java +++ b/src/main/java/onepiece/dailysnapbackend/util/exception/ErrorDetail.java @@ -1,15 +1,15 @@ package onepiece.dailysnapbackend.util.exception; +import java.util.Map; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; -import java.util.Map; - @Getter @AllArgsConstructor @Builder public class ErrorDetail { + private final String errorCode; private final String errorMessage; private final Map validation; diff --git a/src/main/java/onepiece/dailysnapbackend/util/exception/ValidErrorResponse.java b/src/main/java/onepiece/dailysnapbackend/util/exception/ValidErrorResponse.java index 5a08ea2..54bf208 100644 --- a/src/main/java/onepiece/dailysnapbackend/util/exception/ValidErrorResponse.java +++ b/src/main/java/onepiece/dailysnapbackend/util/exception/ValidErrorResponse.java @@ -1,11 +1,10 @@ package onepiece.dailysnapbackend.util.exception; +import java.util.Map; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; -import java.util.Map; - /** * 예시 응답값 * "code" : "400" @@ -16,6 +15,7 @@ @Builder @AllArgsConstructor public class ValidErrorResponse { + private final String errorCode; private final String errorMessage; private final Map validation; diff --git a/src/main/java/onepiece/dailysnapbackend/util/exception/controller/GlobalExceptionHandler.java b/src/main/java/onepiece/dailysnapbackend/util/exception/controller/GlobalExceptionHandler.java index 4b23dd9..25c3939 100644 --- a/src/main/java/onepiece/dailysnapbackend/util/exception/controller/GlobalExceptionHandler.java +++ b/src/main/java/onepiece/dailysnapbackend/util/exception/controller/GlobalExceptionHandler.java @@ -71,7 +71,6 @@ public ResponseEntity handleException(Exception e) { .errorMessage(ErrorCode.INTERNAL_SERVER_ERROR.getMessage()) .build(); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response); } } \ No newline at end of file diff --git a/src/main/java/onepiece/dailysnapbackend/util/filter/TokenAuthenticationFilter.java b/src/main/java/onepiece/dailysnapbackend/util/filter/TokenAuthenticationFilter.java index e144bd2..8df7018 100644 --- a/src/main/java/onepiece/dailysnapbackend/util/filter/TokenAuthenticationFilter.java +++ b/src/main/java/onepiece/dailysnapbackend/util/filter/TokenAuthenticationFilter.java @@ -27,9 +27,9 @@ @Slf4j public class TokenAuthenticationFilter extends OncePerRequestFilter { + private static final AntPathMatcher pathMatcher = new AntPathMatcher(); private final JwtUtil jwtUtil; private final CustomOAuth2UserService customOAuth2UserService; - private static final AntPathMatcher pathMatcher = new AntPathMatcher(); @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index e6960a9..34c5ead 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -6,7 +6,7 @@ spring: jpa: generate-ddl: true hibernate: - ddl-auto: update + ddl-auto: create properties: hibernate: dialect: org.hibernate.dialect.PostgreSQLDialect