diff --git a/src/main/java/com/movelog/domain/news/application/NewsService.java b/src/main/java/com/movelog/domain/news/application/NewsService.java index 3233444..aef9679 100644 --- a/src/main/java/com/movelog/domain/news/application/NewsService.java +++ b/src/main/java/com/movelog/domain/news/application/NewsService.java @@ -5,6 +5,7 @@ import com.movelog.domain.news.dto.request.CreateNewsReq; import com.movelog.domain.news.dto.request.NewsHeadLineReq; import com.movelog.domain.news.dto.response.HeadLineRes; +import com.movelog.domain.news.dto.response.RecentKeywordsRes; import com.movelog.domain.record.domain.Keyword; import com.movelog.domain.record.domain.VerbType; import com.movelog.domain.record.exception.KeywordNotFoundException; @@ -29,6 +30,7 @@ public class NewsService { private final HeadLineGeneratorService headLineGeneratorService; private final UserService userService; + private final UserRepository userRepository; private final KeywordRepository keywordRepository; private final NewsRepository newsRepository; private final S3Util s3Util; @@ -64,6 +66,20 @@ public void createNews(UserPrincipal userPrincipal, Long keywordId, CreateNewsRe } + public List getRecentKeywords(UserPrincipal userPrincipal) { + User user = validateUser(userPrincipal); + // id가 5인 유저 정보(테스트용) + // User user = userRepository.findById(5L).orElseThrow(UserNotFoundException::new); + List recentKeywords = keywordRepository.findTop5ByUserOrderByCreatedAtDesc(user); + + return recentKeywords.stream() + .map(keyword -> RecentKeywordsRes.builder() + .keywordId(keyword.getKeywordId()) + .verb(VerbType.getStringVerbType(keyword.getVerbType())) + .noun(keyword.getKeyword()) + .build()) + .toList(); + } // User 정보 검증 @@ -80,5 +96,4 @@ private Keyword validateKeyword(Long keywordId) { return keywordOptional.get(); } - } diff --git a/src/main/java/com/movelog/domain/news/dto/response/RecentKeywordsRes.java b/src/main/java/com/movelog/domain/news/dto/response/RecentKeywordsRes.java new file mode 100644 index 0000000..6a2f068 --- /dev/null +++ b/src/main/java/com/movelog/domain/news/dto/response/RecentKeywordsRes.java @@ -0,0 +1,23 @@ +package com.movelog.domain.news.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Builder +@AllArgsConstructor +@NoArgsConstructor +@Getter +public class RecentKeywordsRes { + @Schema( type = "int", example ="1", description="키워드 ID") + private Long keywordId; + + @Schema( type = "String", example ="클라이밍", description="명사") + private String noun; + + @Schema( type = "String", example ="시작하다", description="동사") + private String verb; + +} diff --git a/src/main/java/com/movelog/domain/news/presentation/NewsController.java b/src/main/java/com/movelog/domain/news/presentation/NewsController.java index 3b0e599..097ff5a 100644 --- a/src/main/java/com/movelog/domain/news/presentation/NewsController.java +++ b/src/main/java/com/movelog/domain/news/presentation/NewsController.java @@ -4,6 +4,7 @@ import com.movelog.domain.news.dto.request.CreateNewsReq; import com.movelog.domain.news.dto.request.NewsHeadLineReq; import com.movelog.domain.news.dto.response.HeadLineRes; +import com.movelog.domain.news.dto.response.RecentKeywordsRes; import com.movelog.global.config.security.token.CurrentUser; import com.movelog.global.config.security.token.UserPrincipal; import com.movelog.global.payload.Message; @@ -38,7 +39,8 @@ public class NewsController { @Operation(summary = "뉴스 헤드라인 생성 API", description = "뉴스 헤드라인을 생성하는 API입니다.") @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "뉴스 헤드라인 생성 성공", - content = @Content(mediaType = "application/json", schema = @Schema(implementation = List.class))), + content = @Content(mediaType = "application/json", + schema = @Schema(type = "array", implementation = HeadLineRes.class))), @ApiResponse(responseCode = "400", description = "뉴스 헤드라인 생성 실패", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))) }) @@ -72,6 +74,24 @@ public ResponseEntity createNews( } + @Operation(summary = "뉴스 추천 키워드 조회 API", description = "뉴스 생성 시 최근 생성된 5개의 동사-명사 쌍 목록을 조회합니다. ") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "뉴스 추천 기록 조회 성공", + content = @Content(mediaType = "application/json", + schema = @Schema(type = "array", implementation = RecentKeywordsRes.class))), + @ApiResponse(responseCode = "400", description = "뉴스 추천 기록 조회 실패", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))) + }) + @GetMapping("/recommend") + public ResponseEntity getRecentKeywords( + @Parameter(description = "Access Token을 입력해주세요.", required = true) @AuthenticationPrincipal UserPrincipal userPrincipal + ) { + List response = newsService.getRecentKeywords(userPrincipal); + return ResponseEntity.ok(ApiResponseUtil.success(response)); + } + + + diff --git a/src/main/java/com/movelog/domain/record/domain/Keyword.java b/src/main/java/com/movelog/domain/record/domain/Keyword.java index 31ee192..66774c3 100644 --- a/src/main/java/com/movelog/domain/record/domain/Keyword.java +++ b/src/main/java/com/movelog/domain/record/domain/Keyword.java @@ -32,10 +32,10 @@ public class Keyword extends BaseEntity { @JoinColumn(name = "user_id") private User user; - @OneToMany(mappedBy = "keyword") + @OneToMany(mappedBy = "keyword", cascade = CascadeType.ALL, orphanRemoval = true) private List records = new ArrayList<>(); - @OneToMany(mappedBy = "keyword") + @OneToMany(mappedBy = "keyword", cascade = CascadeType.ALL, orphanRemoval = true) private List news = new ArrayList<>(); @Builder diff --git a/src/main/java/com/movelog/domain/record/dto/response/RecentRecordImagesRes.java b/src/main/java/com/movelog/domain/record/dto/response/RecentRecordImagesRes.java new file mode 100644 index 0000000..0b05009 --- /dev/null +++ b/src/main/java/com/movelog/domain/record/dto/response/RecentRecordImagesRes.java @@ -0,0 +1,23 @@ +package com.movelog.domain.record.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Builder +@AllArgsConstructor +@NoArgsConstructor +@Getter +public class RecentRecordImagesRes { + + @Schema( type = "String", example ="https://movelog.s3.ap-northeast-2.amazonaws.com/record/2021-08-01/1.jpg", description="최근 기록 이미지 URL") + private String imageUrl; + + @Schema( type = "LocalDateTime", example ="2021-08-01T00:00:00", description="최근 기록 이미지 생성 시간") + private LocalDateTime createdAt; + +} diff --git a/src/main/java/com/movelog/domain/record/dto/response/TodayRecordStatus.java b/src/main/java/com/movelog/domain/record/dto/response/TodayRecordStatus.java new file mode 100644 index 0000000..562e149 --- /dev/null +++ b/src/main/java/com/movelog/domain/record/dto/response/TodayRecordStatus.java @@ -0,0 +1,24 @@ +package com.movelog.domain.record.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Builder +@AllArgsConstructor +@NoArgsConstructor +@Getter +public class TodayRecordStatus { + + @Schema( type = "boolean", example ="true", description="했어요에 대한 기록이 있는지에 대한 여부") + private boolean isDo; + + @Schema( type = "boolean", example ="false", description="먹었어요에 대한 기록이 있는지에 대한 여부") + private boolean isEat; + + @Schema( type = "boolean", example ="true", description="갔어요에 대한 기록이 있는지에 대한 여부") + private boolean isGo; + +} diff --git a/src/main/java/com/movelog/domain/record/presentation/RecordController.java b/src/main/java/com/movelog/domain/record/presentation/RecordController.java index b73c872..74f078b 100644 --- a/src/main/java/com/movelog/domain/record/presentation/RecordController.java +++ b/src/main/java/com/movelog/domain/record/presentation/RecordController.java @@ -1,46 +1,85 @@ package com.movelog.domain.record.presentation; import com.movelog.domain.record.dto.request.CreateRecordReq; +import com.movelog.domain.record.dto.response.RecentRecordImagesRes; +import com.movelog.domain.record.dto.response.TodayRecordStatus; import com.movelog.domain.record.service.RecordService; import com.movelog.global.config.security.token.UserPrincipal; -import com.movelog.global.payload.ApiResponse; +import com.movelog.global.payload.Message; +import com.movelog.global.util.ApiResponseUtil; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.ErrorResponse; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; +import java.util.List; +import java.util.Set; + @RestController @RequestMapping("api/v1/record") @RequiredArgsConstructor public class RecordController { private final RecordService recordService; @Operation(summary = "기록 추가 API", description = "기록을 추가하는 API입니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "기록 추가 성공", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = Message.class))), + @ApiResponse(responseCode = "400", description = "기록 추가 실패", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "500", description = "기록 추가 실패(서버 에러), Request Body 내용을 확인해주세요.", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))) + }) @PostMapping - public ResponseEntity createRecord( + public ResponseEntity createRecord( @Parameter(description = "User의 토큰을 입력해주세요.", required = true) @AuthenticationPrincipal UserPrincipal userPrincipal, @Parameter(description = "Schemas의 CreateRecordReq를 참고해주세요.", required = true) @RequestPart CreateRecordReq createRecordReq, - @RequestPart(value = "img", required = false) MultipartFile img) { + @RequestPart(value = "img", required = false) MultipartFile img + ) { recordService.createRecord(userPrincipal.getId(), createRecordReq, img); - - ApiResponse result = ApiResponse.builder() - .check(true) - .information("기록을 추가했어요") - .build(); - return ResponseEntity.ok(result); + return ResponseEntity.ok(ApiResponseUtil.success(Message.builder().message("기록이 생성되었습니다.").build())); } + @Operation(summary = "오늘 기준 기록 현황 API", description = "오늘 기준 기록 확인하는 API입니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "오늘 기준 기록 현황 조회 성공", + content = @Content(mediaType = "application/json", + schema = @Schema(type = "array", implementation = TodayRecordStatus.class))), + @ApiResponse(responseCode = "400", description = "오늘 기준 기록 현황 조회 실패", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))) + }) @GetMapping("/today") - public ResponseEntity retrieveTodayRecord( + public ResponseEntity retrieveTodayRecord( @Parameter(description = "User의 토큰을 입력해주세요.", required = false) @AuthenticationPrincipal UserPrincipal userPrincipal ) {; + TodayRecordStatus result = recordService.retrieveTodayRecord(5L); + return ResponseEntity.ok(ApiResponseUtil.success(result)); + } - ApiResponse result = ApiResponse.builder() - .check(true) - .information(recordService.retrieveTodayRecord(5L)) - .build(); - return ResponseEntity.ok(result); + @Operation(summary = "최근 기록 이미지 조회 API", description = "사용자가 선택한 명사-동사 쌍에 해당하는 최근 기록 이미지(5개)를 조회하는 API입니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "최근 기록 이미지 조회 성공", + content = @Content(mediaType = "application/json", + schema = @Schema(type = "array", implementation = RecentRecordImagesRes.class))), + @ApiResponse(responseCode = "400", description = "최근 기록 이미지 조회 실패", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))) + }) + @GetMapping("/image/{keywordId}") + public ResponseEntity retrieveRecentRecordImages( + @Parameter(description = "User의 토큰을 입력해주세요.", required = true) @AuthenticationPrincipal UserPrincipal userPrincipal, + @Parameter(description = "키워드 ID(동사-명사 쌍에 대한 ID)를 입력해주세요.", required = true) @PathVariable Long keywordId + ) { + List result = recordService.retrieveRecentRecordImages(userPrincipal, keywordId); + return ResponseEntity.ok(ApiResponseUtil.success(result)); } + + + } diff --git a/src/main/java/com/movelog/domain/record/repository/KeywordRepository.java b/src/main/java/com/movelog/domain/record/repository/KeywordRepository.java index d4f5e6c..ec569ce 100644 --- a/src/main/java/com/movelog/domain/record/repository/KeywordRepository.java +++ b/src/main/java/com/movelog/domain/record/repository/KeywordRepository.java @@ -2,6 +2,7 @@ import com.movelog.domain.record.domain.Keyword; import com.movelog.domain.record.domain.Record; +import com.movelog.domain.record.domain.VerbType; import com.movelog.domain.user.domain.User; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @@ -11,4 +12,10 @@ @Repository public interface KeywordRepository extends JpaRepository { List findByUser(User user); + + List findTop5ByUserOrderByCreatedAtDesc(User user); + + boolean existsByUserAndKeywordAndVerbType(User user, String noun, VerbType verbType); + + Keyword findByUserAndKeywordAndVerbType(User user, String noun, VerbType verbType); } diff --git a/src/main/java/com/movelog/domain/record/repository/RecordRepository.java b/src/main/java/com/movelog/domain/record/repository/RecordRepository.java index 3482844..a30d621 100644 --- a/src/main/java/com/movelog/domain/record/repository/RecordRepository.java +++ b/src/main/java/com/movelog/domain/record/repository/RecordRepository.java @@ -11,5 +11,8 @@ @Repository public interface RecordRepository extends JpaRepository { + List findByKeywordInAndActionTimeBetween(List keywords, LocalDateTime startTime, LocalDateTime endTime); + + List findTop5ByKeywordOrderByActionTimeDesc(Keyword keyword); } diff --git a/src/main/java/com/movelog/domain/record/service/RecordService.java b/src/main/java/com/movelog/domain/record/service/RecordService.java index 9b4dc1e..576c48a 100644 --- a/src/main/java/com/movelog/domain/record/service/RecordService.java +++ b/src/main/java/com/movelog/domain/record/service/RecordService.java @@ -4,11 +4,15 @@ import com.movelog.domain.record.domain.Record; import com.movelog.domain.record.domain.VerbType; import com.movelog.domain.record.dto.request.CreateRecordReq; +import com.movelog.domain.record.dto.response.RecentRecordImagesRes; +import com.movelog.domain.record.dto.response.TodayRecordStatus; import com.movelog.domain.record.repository.KeywordRepository; import com.movelog.domain.record.repository.RecordRepository; import com.movelog.domain.user.domain.User; import com.movelog.domain.user.domain.repository.UserRepository; +import com.movelog.global.config.security.token.UserPrincipal; import com.movelog.global.util.S3Util; +import jakarta.validation.ConstraintViolation; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -35,20 +39,31 @@ public class RecordService { public void createRecord(Long userId, CreateRecordReq createRecordReq, MultipartFile img) { User user = validUserById(userId); // User user = validUserById(5L); + validateCreateRecordReq(createRecordReq); + String recordImgUrl = s3Util.uploadToRecordFolder(img); log.info("recordImgUrl: {}", recordImgUrl); String verb = createRecordReq.getVerbType(); try { VerbType verbType = VerbType.fromValue(verb); + String noun = createRecordReq.getNoun(); - Keyword keyword = Keyword.builder() - .user(user) - .keyword(createRecordReq.getNoun()) - .verbType(verbType) - .build(); + Keyword keyword; - keywordRepository.save(keyword); + // 사용자의 키워드에 존재하지 않는 경우 (새로 등록) + if (!keywordRepository.existsByUserAndKeywordAndVerbType(user, noun, verbType)) { + keyword = Keyword.builder() + .user(user) + .keyword(noun) + .verbType(verbType) + .build(); + + keywordRepository.save(keyword); + } + else{ + keyword = keywordRepository.findByUserAndKeywordAndVerbType(user, noun, verbType); + } Record record = Record.builder() .keyword(keyword) @@ -57,18 +72,15 @@ public void createRecord(Long userId, CreateRecordReq createRecordReq, Multipart .build(); recordRepository.save(record); + } catch (IllegalArgumentException e) { throw new RuntimeException("Invalid verb type: " + verb, e); } } - private User validUserById(Long userId) { - Optional userOptional = userRepository.findById(userId); - return userOptional.get(); - } - public Map retrieveTodayRecord(Long userId) { + public TodayRecordStatus retrieveTodayRecord(Long userId) { // 유저 유효성 검사 및 조회 User user = validUserById(userId); @@ -94,13 +106,52 @@ public Map retrieveTodayRecord(Long userId) { log.info("Today VerbTypes: {}", todayVerbTypes); // 모든 VerbType에 대해 존재 여부를 반환 - return Arrays.stream(VerbType.values()) - .collect(Collectors.toMap( - VerbType::getVerbType, // 키: VerbType의 문자열 값 - todayVerbTypes::contains // 값: 오늘 VerbType에 포함 여부 - )); + TodayRecordStatus todayRecordStatus = TodayRecordStatus.builder() + .isDo(verbTypeExists(todayVerbTypes, VerbType.DO)) + .isEat(verbTypeExists(todayVerbTypes, VerbType.EAT)) + .isGo(verbTypeExists(todayVerbTypes, VerbType.GO)) + .build(); + + return todayRecordStatus; + + } + + public List retrieveRecentRecordImages(UserPrincipal userPrincipal, Long keywordId) { + User user = validUserById(userPrincipal.getId()); + // User user = validUserById(5L); + Keyword keyword = validKeywordById(keywordId); + List records = recordRepository.findTop5ByKeywordOrderByActionTimeDesc(keyword); + + return records.stream() + .map(record -> RecentRecordImagesRes.builder() + .imageUrl(record.getRecordImage()) + .createdAt(record.getCreatedAt()) + .build()) + .collect(Collectors.toList()); + } + private User validUserById(Long userId) { + Optional userOptional = userRepository.findById(userId); + return userOptional.get(); + } + private Keyword validKeywordById(Long keywordId) { + Optional keywordOptional = keywordRepository.findById(keywordId); + return keywordOptional.get(); + } + + private boolean verbTypeExists(Set todayVerbTypes, VerbType verbType) { + return todayVerbTypes.contains(verbType); + } + + private void validateCreateRecordReq(CreateRecordReq createRecordReq) { + if (createRecordReq.getVerbType() == null || createRecordReq.getVerbType().isEmpty()) { + throw new IllegalArgumentException("verbType is required."); + } + if (createRecordReq.getNoun() == null || createRecordReq.getNoun().isEmpty()) { + throw new IllegalArgumentException("noun is required."); + } + } }