diff --git a/backend/.env.sample b/backend/.env.sample index 621e94f0e..01aeeba19 100644 --- a/backend/.env.sample +++ b/backend/.env.sample @@ -1 +1,2 @@ none + diff --git a/backend/baguni-api/build.gradle b/backend/baguni-api/build.gradle index 7d53ab637..f6706aec9 100644 --- a/backend/baguni-api/build.gradle +++ b/backend/baguni-api/build.gradle @@ -11,11 +11,16 @@ repositories { dependencies { implementation project(":baguni-common") - implementation project(":baguni-entity") + implementation project(":baguni-domain") // package for [@Transactional] + [@Aspect] + [Slice from PickSliceResponse.java] implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + + // rabbitMQ + implementation 'org.springframework.boot:spring-boot-starter-amqp' + implementation 'org.springframework.amqp:spring-amqp:3.2.0' + // Querydsl for [pickQuery.java] implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta" diff --git a/backend/baguni-api/src/main/java/baguni/api/application/chromebookmark/controller/ChromeBookmarkController.java b/backend/baguni-api/src/main/java/baguni/api/application/chromebookmark/controller/ChromeBookmarkController.java index f1aa1af75..f4f2b3157 100644 --- a/backend/baguni-api/src/main/java/baguni/api/application/chromebookmark/controller/ChromeBookmarkController.java +++ b/backend/baguni-api/src/main/java/baguni/api/application/chromebookmark/controller/ChromeBookmarkController.java @@ -21,6 +21,9 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; + +import baguni.common.event.events.LinkEvent; +import baguni.common.event.messenger.CrawlingEventMessenger; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.responses.ApiResponse; @@ -29,8 +32,7 @@ import lombok.RequiredArgsConstructor; import baguni.api.service.chromebookmark.dto.ChromeImportResult; import baguni.api.service.chromebookmark.service.ChromeBookmarkService; -import baguni.api.service.folder.dto.FolderCommand; -import baguni.api.service.link.service.LinkService; +import baguni.domain.infrastructure.folder.dto.FolderCommand; import baguni.security.annotation.LoginUserId; @RestController @@ -38,8 +40,9 @@ @RequestMapping("/api/chrome") @Tag(name = "Chrome API", description = "Chrome Bookmark Import / Export API") public class ChromeBookmarkController { + private final ChromeBookmarkService chromeBookmarkService; - private final LinkService linkService; + private final CrawlingEventMessenger crawlingEventMessenger; @GetMapping("/{folderId}/export") @Operation(summary = "특정 폴더 다운로드", description = "사용자의 특정 폴더를 크롬 브라우저 북마크에 import 가능한 형태로 다운로드 받습니다.") @@ -101,7 +104,7 @@ public ResponseEntity> importBookmark(@LoginUserId Long userId, @Re int maxThreadPoolSize = 5; ExecutorService executor = Executors.newFixedThreadPool(maxThreadPoolSize); for (String url : result.ogTagUpdateUrls()) { - CompletableFuture.runAsync(() -> linkService.updateOgTag(url), executor) + CompletableFuture.runAsync(() -> crawlingEventMessenger.send(new LinkEvent(url)), executor) .orTimeout(60, TimeUnit.SECONDS); } diff --git a/backend/baguni-api/src/main/java/baguni/api/application/event/controller/EventApiController.java b/backend/baguni-api/src/main/java/baguni/api/application/event/controller/EventApiController.java index 1cab0cb3e..59dbca37c 100644 --- a/backend/baguni-api/src/main/java/baguni/api/application/event/controller/EventApiController.java +++ b/backend/baguni-api/src/main/java/baguni/api/application/event/controller/EventApiController.java @@ -14,7 +14,7 @@ import lombok.RequiredArgsConstructor; import baguni.api.application.event.dto.EventApiRequest; import baguni.api.service.link.service.LinkService; -import baguni.common.event.EventMessenger; +import baguni.common.event.messenger.EventMessenger; import baguni.common.event.events.PickViewEvent; import baguni.common.event.events.SharedFolderLinkViewEvent; import baguni.common.event.events.SuggestionViewEvent; diff --git a/backend/baguni-api/src/main/java/baguni/api/application/folder/controller/FolderApiController.java b/backend/baguni-api/src/main/java/baguni/api/application/folder/controller/FolderApiController.java index 2fcb311ea..dd8e37360 100644 --- a/backend/baguni-api/src/main/java/baguni/api/application/folder/controller/FolderApiController.java +++ b/backend/baguni-api/src/main/java/baguni/api/application/folder/controller/FolderApiController.java @@ -22,7 +22,7 @@ import baguni.api.application.folder.dto.FolderApiMapper; import baguni.api.application.folder.dto.FolderApiRequest; import baguni.api.application.folder.dto.FolderApiResponse; -import baguni.api.service.folder.dto.FolderResult; +import baguni.domain.infrastructure.folder.dto.FolderResult; import baguni.api.service.folder.service.FolderService; import baguni.api.service.sharedFolder.service.SharedFolderService; import baguni.security.annotation.LoginUserId; diff --git a/backend/baguni-api/src/main/java/baguni/api/application/folder/dto/FolderApiMapper.java b/backend/baguni-api/src/main/java/baguni/api/application/folder/dto/FolderApiMapper.java index a0d7ade57..679e364ec 100644 --- a/backend/baguni-api/src/main/java/baguni/api/application/folder/dto/FolderApiMapper.java +++ b/backend/baguni-api/src/main/java/baguni/api/application/folder/dto/FolderApiMapper.java @@ -5,8 +5,9 @@ import org.mapstruct.Mapping; import org.mapstruct.ReportingPolicy; -import baguni.api.service.folder.dto.FolderCommand; -import baguni.api.service.folder.dto.FolderResult; +import baguni.domain.infrastructure.folder.dto.FolderCommand; +import baguni.domain.infrastructure.folder.dto.FolderResult; + @Mapper( componentModel = "spring", diff --git a/backend/baguni-api/src/main/java/baguni/api/application/folder/dto/FolderApiResponse.java b/backend/baguni-api/src/main/java/baguni/api/application/folder/dto/FolderApiResponse.java index 1835a434d..3edc0470c 100644 --- a/backend/baguni-api/src/main/java/baguni/api/application/folder/dto/FolderApiResponse.java +++ b/backend/baguni-api/src/main/java/baguni/api/application/folder/dto/FolderApiResponse.java @@ -4,7 +4,9 @@ import java.util.List; import io.swagger.v3.oas.annotations.media.Schema; -import baguni.entity.model.folder.FolderType; + +import baguni.domain.model.folder.FolderType; + public record FolderApiResponse( Long id, diff --git a/backend/baguni-api/src/main/java/baguni/api/application/link/controller/LinkApiController.java b/backend/baguni-api/src/main/java/baguni/api/application/link/controller/LinkApiController.java index f52066a00..3f7e0c4e5 100644 --- a/backend/baguni-api/src/main/java/baguni/api/application/link/controller/LinkApiController.java +++ b/backend/baguni-api/src/main/java/baguni/api/application/link/controller/LinkApiController.java @@ -7,6 +7,7 @@ import org.springframework.web.bind.annotation.RestController; import baguni.common.annotation.MeasureTime; +import baguni.domain.infrastructure.link.dto.LinkInfo; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.responses.ApiResponse; @@ -28,7 +29,7 @@ public class LinkApiController { @MeasureTime @GetMapping - @Operation(summary = "해당 링크 og 데이터 조회", description = "해당 링크의 og 태그 데이터를 스크래핑을 통해 가져옵니다.") + @Operation(summary = "링크 정보 조회", description = "해당 링크의 데이터를 DB에서 가져옵니다. 해당 메서드에서 더 이상 스크래핑하지 않습니다.") @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "조회 성공") }) @@ -36,9 +37,9 @@ public class LinkApiController { public ResponseEntity getLinkData( @Parameter(description = "og 태그 데이터 가져올 url") @RequestParam String url ) { - var result = linkService.saveLinkAndUpdateOgTag(url); - var response = linkApiMapper.toLinkResponse(result); - + // Selenium 사용하도록 변경 + LinkInfo linkInfo = linkService.getLinkInfo(url); + var response = linkApiMapper.toLinkResponse(linkInfo); return ResponseEntity.ok(response); } } diff --git a/backend/baguni-api/src/main/java/baguni/api/application/link/dto/LinkApiMapper.java b/backend/baguni-api/src/main/java/baguni/api/application/link/dto/LinkApiMapper.java index ec7baca6b..6cd3ff049 100644 --- a/backend/baguni-api/src/main/java/baguni/api/application/link/dto/LinkApiMapper.java +++ b/backend/baguni-api/src/main/java/baguni/api/application/link/dto/LinkApiMapper.java @@ -4,7 +4,7 @@ import org.mapstruct.Mapper; import org.mapstruct.ReportingPolicy; -import baguni.api.service.link.dto.LinkInfo; +import baguni.domain.infrastructure.link.dto.LinkInfo; @Mapper( componentModel = "spring", diff --git a/backend/baguni-api/src/main/java/baguni/api/application/pick/controller/PickApiController.java b/backend/baguni-api/src/main/java/baguni/api/application/pick/controller/PickApiController.java index 3b12fe2a1..202328e27 100644 --- a/backend/baguni-api/src/main/java/baguni/api/application/pick/controller/PickApiController.java +++ b/backend/baguni-api/src/main/java/baguni/api/application/pick/controller/PickApiController.java @@ -15,9 +15,10 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import baguni.api.service.link.dto.LinkInfo; -import baguni.api.service.link.service.LinkService; import baguni.common.annotation.MeasureTime; +import baguni.common.event.events.CrawlingEvent; +import baguni.common.event.messenger.CrawlingEventMessenger; +import baguni.common.event.messenger.RankingEventMessenger; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.responses.ApiResponse; @@ -30,12 +31,10 @@ import baguni.api.application.pick.dto.PickApiRequest; import baguni.api.application.pick.dto.PickApiResponse; import baguni.api.application.pick.dto.PickSliceResponse; -import baguni.api.service.pick.dto.PickResult; -import baguni.api.service.pick.exception.ApiPickException; -import baguni.api.service.pick.service.PickBulkService; +import baguni.domain.infrastructure.pick.dto.PickResult; +import baguni.domain.exception.pick.ApiPickException; import baguni.api.service.pick.service.PickSearchService; import baguni.api.service.pick.service.PickService; -import baguni.common.event.EventMessenger; import baguni.common.event.events.PickCreateEvent; import baguni.security.annotation.LoginUserId; @@ -49,9 +48,8 @@ public class PickApiController { private final PickService pickService; private final PickApiMapper pickApiMapper; private final PickSearchService pickSearchService; - private final PickBulkService pickBulkService; - private final LinkService linkService; - private final EventMessenger eventMessenger; + private final RankingEventMessenger rankingEventMessenger; + private final CrawlingEventMessenger crawlingEventMessenger; @GetMapping @Operation(summary = "폴더 리스트 내 픽 리스트 조회", description = "해당 폴더 리스트 각각의 픽 리스트를 조회합니다.") @@ -60,8 +58,10 @@ public class PickApiController { }) public ResponseEntity> getFolderChildPickList( @LoginUserId Long userId, - @Parameter(description = "조회할 폴더 ID 목록", example = "1, 2, 3") @RequestParam(required = false, defaultValue = - "") List folderIdList) { + @Parameter(description = "조회할 폴더 ID 목록", example = "1, 2, 3") + @RequestParam(required = false, defaultValue = "") + List folderIdList + ) { var folderPickList = pickService.getFolderListChildPickList( pickApiMapper.toReadListCommand(userId, folderIdList) ); @@ -170,29 +170,7 @@ public ResponseEntity savePick(@LoginUserId Long userId, var command = pickApiMapper.toCreateCommand(userId, request); var result = pickService.saveNewPick(command); var event = new PickCreateEvent(userId, result.id(), result.linkInfo().url()); - eventMessenger.send(event); - var response = pickApiMapper.toApiResponse(result); - return ResponseEntity.ok(response); - } - - @MeasureTime - @PostMapping("/unclassified") - @Operation( - summary = "미분류 폴더로 픽 생성", - description = "익스텐션에서 미분류로 바로 픽 생성합니다. 또한, 픽 생성 이벤트가 랭킹 서버에 집계됩니다." - ) - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "픽 생성 성공"), - @ApiResponse(responseCode = "404", description = "OG 태그 업데이트를 위한 크롤링 요청 실패") - }) - public ResponseEntity savePickAsUnclassified(@LoginUserId Long userId, - @Valid @RequestBody PickApiRequest.Extension request) { - // Jsoup으로 og 데이터를 가져옵니다. - LinkInfo linkInfo = linkService.getOgTag(request.url(), request.title()); - var command = pickApiMapper.toExtensionCommand(userId, request.title(), linkInfo); - var result = pickService.saveExtensionPick(command); - var event = new PickCreateEvent(userId, result.id(), result.linkInfo().url()); - eventMessenger.send(event); + rankingEventMessenger.send(event); var response = pickApiMapper.toApiResponse(result); return ResponseEntity.ok(response); } @@ -206,8 +184,10 @@ public ResponseEntity savePickAsUnclassified(@LoginUserId @ApiResponse(responseCode = "200", description = "픽 생성 성공"), @ApiResponse(responseCode = "403", description = "접근할 수 없는 폴더") }) - public ResponseEntity savePickFromRecommend(@LoginUserId Long userId, - @Valid @RequestBody PickApiRequest.Create request) { + public ResponseEntity savePickFromRecommend( + @LoginUserId Long userId, + @Valid @RequestBody PickApiRequest.Create request + ) { boolean existPick; PickResult.Pick result; if (pickService.existPickByUrl(userId, request.linkInfo().url())) { @@ -219,23 +199,69 @@ public ResponseEntity savePickFromRecommend result = pickService.saveNewPick(command); } var event = new PickCreateEvent(userId, result.id(), result.linkInfo().url()); - eventMessenger.send(event); + rankingEventMessenger.send(event); return ResponseEntity.ok(new PickApiResponse.CreateFromRecommend(existPick, result)); } - @PatchMapping - @Operation(summary = "픽 내용 수정", description = "픽 내용 수정 및 폴더 이동까지 지원합니다.") + @MeasureTime + @PostMapping("/extension") + @Operation( + summary = "[익스텐션 전용] 미분류 폴더로 픽 생성", + description = "익스텐션에서 미분류로 바로 픽 생성합니다. 또한, 픽 생성 이벤트가 랭킹 서버에 집계됩니다." + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "픽 생성 성공"), + @ApiResponse(responseCode = "404", description = "OG 태그 업데이트를 위한 크롤링 요청 실패") + }) + public ResponseEntity savePickAsUnclassified( + @LoginUserId Long userId, + @Valid @RequestBody PickApiRequest.CreateFromExtension request + ) { + var command = pickApiMapper.toExtensionCommand(userId, request.title(), request.url()); + var result = pickService.savePickToUnclassified(command); + var rankingEvent = new PickCreateEvent(userId, result.id(), request.url()); + var crawlingEvent = new CrawlingEvent(result.linkId(), request.url(), request.title()); + rankingEventMessenger.send(rankingEvent); + crawlingEventMessenger.send(crawlingEvent); + var response = pickApiMapper.toApiExtensionResponse(result); + return ResponseEntity.ok(response); + } + + // TODO: 다루는 도메인이 pick 외에 생길 경우 extension 컨트롤러로 빼기 + @PatchMapping("/extension") + @Operation(summary = "[익스텐션 전용] 픽 수정", description = "픽 내용 수정 및 폴더 이동까지 지원합니다.") @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "픽 내용 수정 성공") }) - public ResponseEntity updatePick(@LoginUserId Long userId, - @Valid @RequestBody PickApiRequest.Update request) { - if (!Objects.isNull(request.title()) && 200 < request.title().length()) { + public ResponseEntity updatePickFromChromeExtension( + @LoginUserId Long userId, + @Valid @RequestBody PickApiRequest.UpdateFromExtension request + ) { + if (Objects.nonNull(request.title()) && (200 < request.title().length())) { throw ApiPickException.PICK_TITLE_TOO_LONG(); } + var command = pickApiMapper.toUpdateCommand(userId, request); + var result = pickService.updatePick(command); + var response = pickApiMapper.toApiResponse(result); + return ResponseEntity.ok(response); + } - return ResponseEntity.ok( - pickApiMapper.toApiResponse(pickService.updatePick(pickApiMapper.toUpdateCommand(userId, request)))); + @PatchMapping + @Operation(summary = "웹사이트에서 픽 내용만 수정 (폴더 이동 X)", description = "픽 내용 수정 및 폴더 이동까지 지원합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "픽 내용 수정 성공") + }) + public ResponseEntity updatePick( + @LoginUserId Long userId, + @Valid @RequestBody PickApiRequest.Update request + ) { + if (Objects.nonNull(request.title()) && (200 < request.title().length())) { + throw ApiPickException.PICK_TITLE_TOO_LONG(); + } + var command = pickApiMapper.toUpdateCommand(userId, request); + var result = pickService.updatePick(command); + var response = pickApiMapper.toApiResponse(result); + return ResponseEntity.ok(response); } @PatchMapping("/location") @@ -244,9 +270,12 @@ public ResponseEntity updatePick(@LoginUserId Long userId, @ApiResponse(responseCode = "204", description = "픽 이동 성공"), @ApiResponse(responseCode = "400", description = "폴더가 존재하지 않음.") }) - public ResponseEntity movePick(@LoginUserId Long userId, - @Valid @RequestBody PickApiRequest.Move request) { - pickService.movePick(pickApiMapper.toMoveCommand(userId, request)); + public ResponseEntity movePick( + @LoginUserId Long userId, + @Valid @RequestBody PickApiRequest.Move request + ) { + var command = pickApiMapper.toMoveCommand(userId, request); + pickService.movePick(command); return ResponseEntity.noContent().build(); } @@ -257,16 +286,12 @@ public ResponseEntity movePick(@LoginUserId Long userId, @ApiResponse(responseCode = "406", description = "휴지통이 아닌 폴더에서 픽 삭제 불가"), @ApiResponse(responseCode = "500", description = "미확인 서버 에러 혹은 존재하지 않는 픽 삭제") }) - public ResponseEntity deletePick(@LoginUserId Long userId, - @Valid @RequestBody PickApiRequest.Delete request) { - pickService.deletePick(pickApiMapper.toDeleteCommand(userId, request)); + public ResponseEntity deletePick( + @LoginUserId Long userId, + @Valid @RequestBody PickApiRequest.Delete request + ) { + var command = pickApiMapper.toDeleteCommand(userId, request); + pickService.deletePick(command); return ResponseEntity.noContent().build(); } - - @PostMapping("/bulk") - @Operation(summary = "픽 10000개 insert", description = "픽 10000개 insert - 1회만 가능합니다.") - public ResponseEntity bulkInsertPick(@LoginUserId Long userId, @RequestParam Long folderId) { - pickBulkService.saveBulkPick(userId, folderId); - return ResponseEntity.ok().build(); - } } diff --git a/backend/baguni-api/src/main/java/baguni/api/application/pick/dto/PickApiMapper.java b/backend/baguni-api/src/main/java/baguni/api/application/pick/dto/PickApiMapper.java index 8ce523a1f..f143b0699 100644 --- a/backend/baguni-api/src/main/java/baguni/api/application/pick/dto/PickApiMapper.java +++ b/backend/baguni-api/src/main/java/baguni/api/application/pick/dto/PickApiMapper.java @@ -10,9 +10,8 @@ import org.springframework.data.domain.Slice; import org.springframework.data.domain.SliceImpl; -import baguni.api.service.link.dto.LinkInfo; -import baguni.api.service.pick.dto.PickCommand; -import baguni.api.service.pick.dto.PickResult; +import baguni.domain.infrastructure.pick.dto.PickCommand; +import baguni.domain.infrastructure.pick.dto.PickResult; @Mapper( componentModel = "spring", @@ -30,9 +29,11 @@ PickCommand.SearchPagination toSearchPaginationCommand(Long userId, List f PickCommand.Create toCreateCommand(Long userId, PickApiRequest.Create request); - @Mapping(source = "linkInfo", target = "linkInfo") - PickCommand.Extension toExtensionCommand(Long userId, String title, LinkInfo linkInfo); + PickCommand.Unclassified toExtensionCommand(Long userId, String title, String url); + PickCommand.Update toUpdateCommand(Long userId, PickApiRequest.UpdateFromExtension request); + + @Mapping(target = "parentFolderId", ignore = true) PickCommand.Update toUpdateCommand(Long userId, PickApiRequest.Update request); PickCommand.Move toMoveCommand(Long userId, PickApiRequest.Move request); @@ -43,10 +44,9 @@ PickCommand.SearchPagination toSearchPaginationCommand(Long userId, List f PickApiResponse.Pick toApiResponse(PickResult.Pick pickResult); - PickApiResponse.Exist toApiExistResponse(Boolean exist); + PickApiResponse.Extension toApiExtensionResponse(PickResult.Extension pickResult); - @Mapping(target = "pickList", source = "pickList", qualifiedByName = "mapPickList") - PickApiResponse.FolderPickList toApiFolderPickList(PickResult.FolderPickList folderPickList); + PickApiResponse.Exist toApiExistResponse(Boolean exist); @Named("mapPickList") default List mapPickList(List pickList) { diff --git a/backend/baguni-api/src/main/java/baguni/api/application/pick/dto/PickApiRequest.java b/backend/baguni-api/src/main/java/baguni/api/application/pick/dto/PickApiRequest.java index a2617d4a3..d65aa7040 100644 --- a/backend/baguni-api/src/main/java/baguni/api/application/pick/dto/PickApiRequest.java +++ b/backend/baguni-api/src/main/java/baguni/api/application/pick/dto/PickApiRequest.java @@ -4,10 +4,15 @@ import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotNull; -import baguni.api.service.link.dto.LinkInfo; +import baguni.domain.infrastructure.link.dto.LinkInfo; public class PickApiRequest { + public record Read( + @Schema(example = "1") @NotNull(message = "{id.notNull}") Long id + ) { + } + public record Create( @Schema(example = "Record란?") String title, @Schema(example = "[4, 5, 2, 1, 3]") List tagIdOrderedList, @@ -16,18 +21,20 @@ public record Create( ) { } - public record Extension( - @Schema(example = "https://d2.naver.com/helloworld/8149881") String url, - @Schema(example = "GitHub Actions를 이용한 코드 리뷰 문화 개선기") String title + public record Update( + @Schema(example = "1") @NotNull(message = "{id.notNull}") Long id, + @Schema(example = "Record란 뭘까?") String title, + @Schema(example = "[4, 5, 2, 1]") List tagIdOrderedList ) { } - public record Read( - @Schema(example = "1") @NotNull(message = "{id.notNull}") Long id + public record CreateFromExtension( + @Schema(example = "https://d2.naver.com/helloworld/8149881") String url, + @Schema(example = "GitHub Actions를 이용한 코드 리뷰 문화 개선기") String title ) { } - public record Update( + public record UpdateFromExtension( @Schema(example = "1") @NotNull(message = "{id.notNull}") Long id, @Schema(example = "Record란 뭘까?") String title, @Schema(example = "3") Long parentFolderId, diff --git a/backend/baguni-api/src/main/java/baguni/api/application/pick/dto/PickApiResponse.java b/backend/baguni-api/src/main/java/baguni/api/application/pick/dto/PickApiResponse.java index 8a5ce9fbc..f159d1b07 100644 --- a/backend/baguni-api/src/main/java/baguni/api/application/pick/dto/PickApiResponse.java +++ b/backend/baguni-api/src/main/java/baguni/api/application/pick/dto/PickApiResponse.java @@ -4,8 +4,8 @@ import java.util.List; import jakarta.validation.constraints.NotNull; -import baguni.api.service.link.dto.LinkInfo; -import baguni.api.service.pick.dto.PickResult; +import baguni.domain.infrastructure.link.dto.LinkInfo; +import baguni.domain.infrastructure.pick.dto.PickResult; public class PickApiResponse { @@ -20,6 +20,16 @@ public record Pick( ) { } + public record Extension( + Long id, + String title, + Long parentFolderId, + List tagIdOrderedList, + LocalDateTime createdAt, + LocalDateTime updatedAt + ){ + } + public record PickWithViewCount( Long id, String title, diff --git a/backend/baguni-api/src/main/java/baguni/api/application/ranking/controller/RankingApiController.java b/backend/baguni-api/src/main/java/baguni/api/application/ranking/controller/RankingApiController.java index 0d8db7969..94f4a1b62 100644 --- a/backend/baguni-api/src/main/java/baguni/api/application/ranking/controller/RankingApiController.java +++ b/backend/baguni-api/src/main/java/baguni/api/application/ranking/controller/RankingApiController.java @@ -19,7 +19,7 @@ import baguni.api.application.ranking.dto.LinkInfoWithCount; import baguni.api.application.ranking.dto.RankingApiMapper; import baguni.api.application.ranking.dto.RankingResponse; -import baguni.api.service.link.exception.ApiLinkException; +import baguni.domain.exception.link.ApiLinkException; import baguni.api.service.link.service.LinkService; import baguni.api.service.ranking.service.RankingService; import baguni.common.annotation.MeasureTime; diff --git a/backend/baguni-api/src/main/java/baguni/api/application/ranking/dto/RankingApiMapper.java b/backend/baguni-api/src/main/java/baguni/api/application/ranking/dto/RankingApiMapper.java index 1d9cea2f5..4606cd4d1 100644 --- a/backend/baguni-api/src/main/java/baguni/api/application/ranking/dto/RankingApiMapper.java +++ b/backend/baguni-api/src/main/java/baguni/api/application/ranking/dto/RankingApiMapper.java @@ -5,7 +5,7 @@ import org.mapstruct.Mapping; import org.mapstruct.ReportingPolicy; -import baguni.api.service.link.dto.LinkInfo; +import baguni.domain.infrastructure.link.dto.LinkInfo; import baguni.common.dto.UrlWithCount; @Mapper( diff --git a/backend/baguni-api/src/main/java/baguni/api/application/sharedFolder/dto/SharedFolderApiMapper.java b/backend/baguni-api/src/main/java/baguni/api/application/sharedFolder/dto/SharedFolderApiMapper.java index af0314298..b435f2753 100644 --- a/backend/baguni-api/src/main/java/baguni/api/application/sharedFolder/dto/SharedFolderApiMapper.java +++ b/backend/baguni-api/src/main/java/baguni/api/application/sharedFolder/dto/SharedFolderApiMapper.java @@ -9,7 +9,7 @@ import org.mapstruct.Named; import org.mapstruct.ReportingPolicy; -import baguni.api.service.sharedFolder.dto.SharedFolderResult; +import baguni.domain.infrastructure.sharedFolder.dto.SharedFolderResult; @Mapper( componentModel = "spring", diff --git a/backend/baguni-api/src/main/java/baguni/api/application/sharedFolder/dto/SharedFolderApiResponse.java b/backend/baguni-api/src/main/java/baguni/api/application/sharedFolder/dto/SharedFolderApiResponse.java index df0d27a9b..693dd1fb6 100644 --- a/backend/baguni-api/src/main/java/baguni/api/application/sharedFolder/dto/SharedFolderApiResponse.java +++ b/backend/baguni-api/src/main/java/baguni/api/application/sharedFolder/dto/SharedFolderApiResponse.java @@ -6,7 +6,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; -import baguni.api.service.sharedFolder.dto.SharedFolderResult; +import baguni.domain.infrastructure.sharedFolder.dto.SharedFolderResult; public class SharedFolderApiResponse { diff --git a/backend/baguni-api/src/main/java/baguni/api/application/tag/controller/TagApiController.java b/backend/baguni-api/src/main/java/baguni/api/application/tag/controller/TagApiController.java index bc076a855..28a2ea617 100644 --- a/backend/baguni-api/src/main/java/baguni/api/application/tag/controller/TagApiController.java +++ b/backend/baguni-api/src/main/java/baguni/api/application/tag/controller/TagApiController.java @@ -23,7 +23,7 @@ import baguni.api.application.tag.dto.TagApiMapper; import baguni.api.application.tag.dto.TagApiRequest; import baguni.api.application.tag.dto.TagApiResponse; -import baguni.api.service.tag.exception.ApiTagException; +import baguni.domain.exception.tag.ApiTagException; import baguni.api.service.tag.service.TagService; import baguni.security.annotation.LoginUserId; diff --git a/backend/baguni-api/src/main/java/baguni/api/application/tag/dto/TagApiMapper.java b/backend/baguni-api/src/main/java/baguni/api/application/tag/dto/TagApiMapper.java index 0cb3ebe79..6dcb50e54 100644 --- a/backend/baguni-api/src/main/java/baguni/api/application/tag/dto/TagApiMapper.java +++ b/backend/baguni-api/src/main/java/baguni/api/application/tag/dto/TagApiMapper.java @@ -4,8 +4,8 @@ import org.mapstruct.Mapper; import org.mapstruct.ReportingPolicy; -import baguni.api.service.tag.dto.TagCommand; -import baguni.api.service.tag.dto.TagResult; +import baguni.domain.infrastructure.tag.dto.TagCommand; +import baguni.domain.infrastructure.tag.dto.TagResult; @Mapper( componentModel = "spring", diff --git a/backend/baguni-api/src/main/java/baguni/api/application/user/controller/dto/UserApiMapper.java b/backend/baguni-api/src/main/java/baguni/api/application/user/controller/dto/UserApiMapper.java index 0019caba5..2808f53a3 100644 --- a/backend/baguni-api/src/main/java/baguni/api/application/user/controller/dto/UserApiMapper.java +++ b/backend/baguni-api/src/main/java/baguni/api/application/user/controller/dto/UserApiMapper.java @@ -5,7 +5,7 @@ import org.mapstruct.Mapping; import org.mapstruct.ReportingPolicy; -import baguni.api.service.user.dto.UserInfo; +import baguni.domain.infrastructure.user.dto.UserInfo; @Mapper( componentModel = "spring", diff --git a/backend/baguni-api/src/main/java/baguni/api/config/HttpApiConfiguration.java b/backend/baguni-api/src/main/java/baguni/api/config/HttpApiConfiguration.java index eebfd2369..acee49223 100644 --- a/backend/baguni-api/src/main/java/baguni/api/config/HttpApiConfiguration.java +++ b/backend/baguni-api/src/main/java/baguni/api/config/HttpApiConfiguration.java @@ -12,7 +12,7 @@ import org.springframework.web.client.support.RestClientAdapter; import org.springframework.web.service.invoker.HttpServiceProxyFactory; -import baguni.api.infrastructure.ranking.RankingApi; +import baguni.domain.infrastructure.ranking.RankingApi; /** * 외부 서버와 통신하는 것을 Http Interface 방식으로 사용하기 위한 설정.
diff --git a/backend/baguni-api/src/main/java/baguni/api/infrastructure/user/UserDataHandler.java b/backend/baguni-api/src/main/java/baguni/api/infrastructure/user/UserDataHandler.java index 4839e4ce8..b6de7acf7 100644 --- a/backend/baguni-api/src/main/java/baguni/api/infrastructure/user/UserDataHandler.java +++ b/backend/baguni-api/src/main/java/baguni/api/infrastructure/user/UserDataHandler.java @@ -6,18 +6,18 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; -import baguni.entity.model.folder.FolderRepository; -import baguni.entity.model.pick.PickRepository; -import baguni.entity.model.pick.PickTagRepository; -import baguni.entity.model.sharedFolder.SharedFolderRepository; -import baguni.entity.model.tag.TagRepository; -import baguni.entity.model.user.SocialProvider; -import baguni.entity.model.util.IDToken; +import baguni.domain.infrastructure.folder.FolderRepository; +import baguni.domain.infrastructure.pick.PickRepository; +import baguni.domain.infrastructure.pick.PickTagRepository; +import baguni.domain.infrastructure.sharedFolder.SharedFolderRepository; +import baguni.domain.infrastructure.tag.TagRepository; +import baguni.domain.model.user.SocialProvider; +import baguni.domain.model.util.IDToken; import baguni.security.model.OAuth2UserInfo; import lombok.RequiredArgsConstructor; -import baguni.api.service.user.exception.ApiUserException; -import baguni.entity.model.user.User; -import baguni.entity.model.user.UserRepository; +import baguni.domain.exception.user.ApiUserException; +import baguni.domain.model.user.User; +import baguni.domain.infrastructure.user.UserRepository; @Component @RequiredArgsConstructor diff --git a/backend/baguni-api/src/main/java/baguni/api/service/chromebookmark/dto/ChromeMapper.java b/backend/baguni-api/src/main/java/baguni/api/service/chromebookmark/dto/ChromeMapper.java index bab26e593..5893e550a 100644 --- a/backend/baguni-api/src/main/java/baguni/api/service/chromebookmark/dto/ChromeMapper.java +++ b/backend/baguni-api/src/main/java/baguni/api/service/chromebookmark/dto/ChromeMapper.java @@ -4,9 +4,9 @@ import org.springframework.stereotype.Component; -import baguni.api.service.folder.dto.FolderCommand; -import baguni.api.service.link.dto.LinkInfo; -import baguni.api.service.pick.dto.PickCommand; +import baguni.domain.infrastructure.folder.dto.FolderCommand; +import baguni.domain.infrastructure.link.dto.LinkInfo; +import baguni.domain.infrastructure.pick.dto.PickCommand; @Component public class ChromeMapper { diff --git a/backend/baguni-api/src/main/java/baguni/api/service/chromebookmark/service/ChromeBookmarkService.java b/backend/baguni-api/src/main/java/baguni/api/service/chromebookmark/service/ChromeBookmarkService.java index 9d2a1847f..161a76d6e 100644 --- a/backend/baguni-api/src/main/java/baguni/api/service/chromebookmark/service/ChromeBookmarkService.java +++ b/backend/baguni-api/src/main/java/baguni/api/service/chromebookmark/service/ChromeBookmarkService.java @@ -14,14 +14,14 @@ import baguni.api.service.chromebookmark.dto.ChromeFolder; import baguni.api.service.chromebookmark.dto.ChromeImportResult; import baguni.api.service.chromebookmark.dto.ChromeMapper; -import baguni.api.service.folder.dto.FolderCommand; -import baguni.api.service.folder.exception.ApiFolderException; -import baguni.api.infrastructure.folder.FolderDataHandler; -import baguni.api.infrastructure.link.LinkDataHandler; -import baguni.api.infrastructure.pick.PickDataHandler; -import baguni.entity.model.folder.Folder; -import baguni.entity.model.link.Link; -import baguni.entity.model.pick.Pick; +import baguni.domain.infrastructure.folder.dto.FolderCommand; +import baguni.domain.exception.folder.ApiFolderException; +import baguni.domain.infrastructure.folder.FolderDataHandler; +import baguni.domain.infrastructure.link.LinkDataHandler; +import baguni.domain.infrastructure.pick.PickDataHandler; +import baguni.domain.model.folder.Folder; +import baguni.domain.model.link.Link; +import baguni.domain.model.pick.Pick; /** * 폴더 Import와 Export를 담당하는 서비스 diff --git a/backend/baguni-api/src/main/java/baguni/api/service/folder/service/FolderService.java b/backend/baguni-api/src/main/java/baguni/api/service/folder/service/FolderService.java index d3d1303c2..2968872bb 100644 --- a/backend/baguni-api/src/main/java/baguni/api/service/folder/service/FolderService.java +++ b/backend/baguni-api/src/main/java/baguni/api/service/folder/service/FolderService.java @@ -13,16 +13,16 @@ import org.springframework.transaction.annotation.Transactional; import lombok.RequiredArgsConstructor; -import baguni.entity.annotation.LoginUserIdDistributedLock; -import baguni.api.service.folder.dto.FolderCommand; -import baguni.api.service.folder.dto.FolderMapper; -import baguni.api.service.folder.dto.FolderResult; -import baguni.api.service.folder.exception.ApiFolderException; -import baguni.api.infrastructure.folder.FolderDataHandler; -import baguni.api.infrastructure.pick.PickDataHandler; -import baguni.api.infrastructure.sharedFolder.SharedFolderDataHandler; -import baguni.entity.model.folder.Folder; -import baguni.entity.model.folder.FolderType; +import baguni.domain.annotation.LoginUserIdDistributedLock; +import baguni.domain.infrastructure.folder.dto.FolderCommand; +import baguni.domain.infrastructure.folder.dto.FolderMapper; +import baguni.domain.infrastructure.folder.dto.FolderResult; +import baguni.domain.exception.folder.ApiFolderException; +import baguni.domain.infrastructure.folder.FolderDataHandler; +import baguni.domain.infrastructure.pick.PickDataHandler; +import baguni.domain.infrastructure.sharedFolder.SharedFolderDataHandler; +import baguni.domain.model.folder.Folder; +import baguni.domain.model.folder.FolderType; @Service @RequiredArgsConstructor diff --git a/backend/baguni-api/src/main/java/baguni/api/service/link/service/LinkService.java b/backend/baguni-api/src/main/java/baguni/api/service/link/service/LinkService.java index fa042b099..64a4d956b 100644 --- a/backend/baguni-api/src/main/java/baguni/api/service/link/service/LinkService.java +++ b/backend/baguni-api/src/main/java/baguni/api/service/link/service/LinkService.java @@ -1,21 +1,14 @@ package baguni.api.service.link.service; -import java.net.MalformedURLException; -import java.net.URL; - import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import baguni.entity.model.link.Link; +import baguni.domain.model.link.Link; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import baguni.api.service.link.dto.LinkInfo; -import baguni.api.service.link.dto.LinkMapper; -import baguni.api.service.link.exception.ApiLinkException; -import baguni.api.infrastructure.link.LinkDataHandler; -import baguni.common.lib.opengraph.Metadata; -import baguni.common.lib.opengraph.OpenGraph; -import baguni.common.lib.opengraph.OpenGraphException; +import baguni.domain.infrastructure.link.dto.LinkInfo; +import baguni.domain.infrastructure.link.dto.LinkMapper; +import baguni.domain.infrastructure.link.LinkDataHandler; @Slf4j @Service @@ -32,98 +25,8 @@ public LinkInfo getLinkInfo(String url) { } @Transactional - public LinkInfo getOgTag(String url, String title) { - Link link = linkDataHandler.getOptionalLink(url).orElseGet(() -> Link.createLinkByUrl(url)); - try { - Link updatedLink = updateOpengraph(url, link); - updatedLink.updateTitle(title); - return linkMapper.of(updatedLink); - } catch (Exception e) { - throw ApiLinkException.LINK_OG_TAG_UPDATE_FAILURE(); - } - } - - @Transactional - public void updateOgTag(String url) { + public LinkInfo saveLink(String url) { Link link = linkDataHandler.getOptionalLink(url).orElseGet(() -> Link.createLinkByUrl(url)); - try { - var updatedLink = updateOpengraph(url, link); - linkDataHandler.saveLink(updatedLink); - } catch (Exception e) { - log.info("url : {} 의 og tag 추출에 실패했습니다.", url, e); - } + return linkMapper.of(linkDataHandler.saveLink(link)); } - - @Transactional - public LinkInfo saveLinkAndUpdateOgTag(String url) { - Link link = linkDataHandler.getOptionalLink(url).orElseGet(() -> Link.createLinkByUrl(url)); - try { - var updatedLink = updateOpengraph(url, link); - return linkMapper.toLinkInfo(linkDataHandler.saveLink(updatedLink)); - } catch (Exception e) { - log.info("saveLinkAndUpdateOgTag : ", e); - throw ApiLinkException.LINK_OG_TAG_UPDATE_FAILURE(); - } - } - - /** - * property 속성에 og 데이터가 없는 경우, name 속성에 있는 데이터 활용 - */ - private Link updateOpengraph(String url, Link link) throws OpenGraphException { - var openGraph = new OpenGraph(url); - link.updateMetadata( - openGraph.getTag(Metadata.OG_TITLE) - .orElse(openGraph.getTag(Metadata.TITLE) - .orElse("")), - openGraph.getTag(Metadata.OG_DESCRIPTION) - .orElse(openGraph.getTag(Metadata.DESCRIPTION) - .orElse("")), - correctImageUrl(url, openGraph.getTag(Metadata.OG_IMAGE) - .orElse(openGraph.getTag(Metadata.IMAGE) - .orElse(""))) - ); - return link; - } - - /** - * og:image 가 완전한 url 형식이 아닐 수 있어 보정 - * 추론 불가능한 image url 일 경우 빈스트링("")으로 대치 - * - * @author sangwon - * protocol : https - * host : blog.dongolab.com - */ - private String correctImageUrl(String baseUrl, String imageUrl) { - if (imageUrl == null || imageUrl.trim().isEmpty()) { - return ""; - } - - if (imageUrl.startsWith("://")) { - return "https" + imageUrl; - } - if (imageUrl.startsWith("//")) { - return "https:" + imageUrl; - } - - try { - URL url = new URL(baseUrl); - // ex) https://blog.dongolab.com - String domain = url.getProtocol() + "://" + url.getHost(); - - // ex) /og-image.png -> https://blog.dongholab.com/og-image.png - if (imageUrl.startsWith("/")) { - return domain + imageUrl; - } - - if (!imageUrl.startsWith("http://") && !imageUrl.startsWith("https://")) { - return domain + "/" + imageUrl; - } - - return imageUrl; - } catch (MalformedURLException e) { - // baseUrl이 올바르지 않은 경우 빈 문자열 반환 - return ""; - } - } - } diff --git a/backend/baguni-api/src/main/java/baguni/api/service/pick/service/PickSearchService.java b/backend/baguni-api/src/main/java/baguni/api/service/pick/service/PickSearchService.java index 4ebad609d..ee703e564 100644 --- a/backend/baguni-api/src/main/java/baguni/api/service/pick/service/PickSearchService.java +++ b/backend/baguni-api/src/main/java/baguni/api/service/pick/service/PickSearchService.java @@ -11,16 +11,16 @@ import org.springframework.transaction.annotation.Transactional; import lombok.RequiredArgsConstructor; -import baguni.api.service.folder.exception.ApiFolderException; -import baguni.api.service.pick.dto.PickCommand; -import baguni.api.service.pick.dto.PickResult; -import baguni.api.service.tag.exception.ApiTagException; -import baguni.api.infrastructure.folder.FolderDataHandler; -import baguni.api.infrastructure.pick.PickQuery; -import baguni.api.infrastructure.tag.TagDataHandler; -import baguni.entity.model.folder.Folder; -import baguni.entity.model.folder.FolderType; -import baguni.entity.model.tag.Tag; +import baguni.domain.exception.folder.ApiFolderException; +import baguni.domain.infrastructure.pick.dto.PickCommand; +import baguni.domain.infrastructure.pick.dto.PickResult; +import baguni.domain.exception.tag.ApiTagException; +import baguni.domain.infrastructure.folder.FolderDataHandler; +import baguni.domain.infrastructure.pick.PickQuery; +import baguni.domain.infrastructure.tag.TagDataHandler; +import baguni.domain.model.folder.Folder; +import baguni.domain.model.folder.FolderType; +import baguni.domain.model.tag.Tag; @Service @RequiredArgsConstructor diff --git a/backend/baguni-api/src/main/java/baguni/api/service/pick/service/PickService.java b/backend/baguni-api/src/main/java/baguni/api/service/pick/service/PickService.java index 71b37b9ba..091a749b3 100644 --- a/backend/baguni-api/src/main/java/baguni/api/service/pick/service/PickService.java +++ b/backend/baguni-api/src/main/java/baguni/api/service/pick/service/PickService.java @@ -13,23 +13,23 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import baguni.entity.annotation.LoginUserIdDistributedLock; -import baguni.api.service.folder.exception.ApiFolderException; -import baguni.api.service.pick.dto.PickCommand; -import baguni.api.service.pick.dto.PickMapper; -import baguni.api.service.pick.dto.PickResult; -import baguni.api.service.pick.exception.ApiPickException; +import baguni.domain.annotation.LoginUserIdDistributedLock; +import baguni.domain.exception.folder.ApiFolderException; +import baguni.domain.infrastructure.pick.dto.PickCommand; +import baguni.domain.infrastructure.pick.dto.PickMapper; +import baguni.domain.infrastructure.pick.dto.PickResult; +import baguni.domain.exception.pick.ApiPickException; import baguni.api.service.ranking.service.RankingService; -import baguni.api.service.tag.exception.ApiTagException; -import baguni.api.infrastructure.folder.FolderDataHandler; -import baguni.api.infrastructure.link.LinkDataHandler; -import baguni.api.infrastructure.pick.PickDataHandler; -import baguni.api.infrastructure.tag.TagDataHandler; +import baguni.domain.exception.tag.ApiTagException; +import baguni.domain.infrastructure.folder.FolderDataHandler; +import baguni.domain.infrastructure.link.LinkDataHandler; +import baguni.domain.infrastructure.pick.PickDataHandler; +import baguni.domain.infrastructure.tag.TagDataHandler; import baguni.common.dto.UrlWithCount; -import baguni.entity.model.folder.Folder; -import baguni.entity.model.folder.FolderType; -import baguni.entity.model.pick.Pick; -import baguni.entity.model.tag.Tag; +import baguni.domain.model.folder.Folder; +import baguni.domain.model.folder.FolderType; +import baguni.domain.model.pick.Pick; +import baguni.domain.model.tag.Tag; @Slf4j @Service @@ -46,13 +46,13 @@ public class PickService { @Transactional(readOnly = true) public boolean existPickByUrl(Long userId, String url) { return linkDataHandler.getOptionalLink(url) - .map(link -> pickDataHandler.existsByUserIdAndLink(userId, link)) - .orElse(false); + .map(link -> pickDataHandler.existsByUserIdAndLink(userId, link)) + .orElse(false); } @Transactional(readOnly = true) public PickResult.Pick getPick(PickCommand.Read command) { - validatePickAccess(command.userId(), command.id()); + assertUserIsPickOwner(command.userId(), command.id()); var pick = pickDataHandler.getPick(command.id()); return pickMapper.toPickResult(pick); } @@ -72,7 +72,7 @@ public Optional findPickUrl(Long userId, String url) { // 구현은 해두었지만, 추후 사용되지 않을 때 삭제 예정 @Transactional(readOnly = true) public List getFolderChildPickList(Long userId, Long folderId) { - validateFolderAccess(userId, folderId); + assertUserIsFolderOwner(userId, folderId); Folder folder = folderDataHandler.getFolder(folderId); List pickList = pickDataHandler.getPickListPreservingOrder(folder.getChildPickIdOrderedList()); @@ -85,7 +85,7 @@ public List getFolderChildPickList(Long userId, Long folderId) @Transactional(readOnly = true) public List getFolderListChildPickList(PickCommand.ReadList command) { return command.folderIdList().stream() - .peek(folderId -> validateFolderAccess(command.userId(), folderId)) // 폴더 접근 유효성 검사 + .peek(folderId -> assertUserIsFolderOwner(command.userId(), folderId)) // 폴더 접근 유효성 검사 .map(this::getFolderChildPickResultList) .toList(); } @@ -93,36 +93,39 @@ public List getFolderListChildPickList(P @LoginUserIdDistributedLock @Transactional public PickResult.Pick saveNewPick(PickCommand.Create command) { - validateRootAccess(command.parentFolderId()); - validateFolderAccess(command.userId(), command.parentFolderId()); - validateTagListAccess(command.userId(), command.tagIdOrderedList()); - + assertParentFolderIsNotRoot(command.parentFolderId()); + assertUserIsFolderOwner(command.userId(), command.parentFolderId()); + assertUserIsTagOwner(command.userId(), command.tagIdOrderedList()); return pickMapper.toPickResult(pickDataHandler.savePick(command)); } @LoginUserIdDistributedLock @Transactional - public PickResult.Pick saveExtensionPick(PickCommand.Extension command) { - return pickMapper.toPickResult(pickDataHandler.saveExtensionPick(command)); + public PickResult.Extension savePickToUnclassified(PickCommand.Unclassified command) { + return pickMapper.toExtensionResult(pickDataHandler.savePickToUnclassified(command)); } @LoginUserIdDistributedLock @Transactional public PickResult.Pick updatePick(PickCommand.Update command) { - validatePickAccess(command.userId(), command.id()); - validateFolderAccess(command.userId(), command.parentFolderId()); - validateRootAccess(command.parentFolderId()); - validateTagListAccess(command.userId(), command.tagIdOrderedList()); - return pickMapper.toPickResult(pickDataHandler.updatePick(command)); + if (Objects.nonNull(command.parentFolderId())) { + assertUserIsFolderOwner(command.userId(), command.parentFolderId()); + assertParentFolderIsNotRoot(command.parentFolderId()); + } + assertUserIsPickOwner(command.userId(), command.id()); + assertUserIsTagOwner(command.userId(), command.tagIdOrderedList()); + + var pick = pickDataHandler.updatePick(command); + return pickMapper.toPickResult(pick); } @LoginUserIdDistributedLock @Transactional public void movePick(PickCommand.Move command) { - validateRootAccess(command.destinationFolderId()); + assertParentFolderIsNotRoot(command.destinationFolderId()); List pickList = pickDataHandler.getPickListPreservingOrder(command.idList()); for (Pick pick : pickList) { - validatePickAccess(command.userId(), pick.getId()); + assertUserIsPickOwner(command.userId(), pick.getId()); } if (isParentFolderChanged(pickList.get(0).getParentFolder().getId(), command.destinationFolderId())) { @@ -137,7 +140,7 @@ public void movePick(PickCommand.Move command) { public void deletePick(PickCommand.Delete command) { List pickList = pickDataHandler.getPickList(command.idList()); for (Pick pick : pickList) { - validatePickAccess(command.userId(), pick.getId()); + assertUserIsPickOwner(command.userId(), pick.getId()); if (pick.getParentFolder().getFolderType() != FolderType.RECYCLE_BIN) { throw ApiPickException.PICK_DELETE_NOT_ALLOWED(); } @@ -189,14 +192,14 @@ private boolean isParentFolderChanged(Long originalFolderId, Long destinationFol return ObjectUtils.notEqual(originalFolderId, destinationFolderId); } - private void validatePickAccess(Long userId, Long pickId) { + private void assertUserIsPickOwner(Long userId, Long pickId) { var pick = pickDataHandler.getPick(pickId); if (ObjectUtils.notEqual(userId, pick.getUser().getId())) { throw ApiPickException.PICK_UNAUTHORIZED_USER_ACCESS(); } } - private void validateFolderAccess(Long userId, Long folderId) { + private void assertUserIsFolderOwner(Long userId, Long folderId) { // folderId가 null인 경우 변경이 없는 것이니 검증하지 않음 if (folderId == null) { return; @@ -207,18 +210,14 @@ private void validateFolderAccess(Long userId, Long folderId) { } } - private void validateRootAccess(Long parentFolderId) { + private void assertParentFolderIsNotRoot(Long parentFolderId) { Folder parentFolder = folderDataHandler.getFolder(parentFolderId); if (Objects.isNull(parentFolder.getId()) || parentFolder.getFolderType() == FolderType.ROOT) { throw ApiPickException.PICK_UNAUTHORIZED_ROOT_ACCESS(); } } - private void validateTagListAccess(Long userId, List tagIdList) { - // tagIdList가 null인 경우 변경이 없는 것이니 검증하지 않음 - if (tagIdList == null) { - return; - } + private void assertUserIsTagOwner(Long userId, List tagIdList) { for (Tag tag : tagDataHandler.getTagList(tagIdList)) { if (ObjectUtils.notEqual(userId, tag.getUser().getId())) { throw ApiTagException.UNAUTHORIZED_TAG_ACCESS(); diff --git a/backend/baguni-api/src/main/java/baguni/api/service/ranking/service/RankingService.java b/backend/baguni-api/src/main/java/baguni/api/service/ranking/service/RankingService.java index 898abef31..18fa7eea9 100644 --- a/backend/baguni-api/src/main/java/baguni/api/service/ranking/service/RankingService.java +++ b/backend/baguni-api/src/main/java/baguni/api/service/ranking/service/RankingService.java @@ -6,7 +6,7 @@ import lombok.RequiredArgsConstructor; import baguni.api.service.ranking.dto.RankingResult; -import baguni.api.infrastructure.ranking.RankingApi; +import baguni.domain.infrastructure.ranking.RankingApi; @Service @RequiredArgsConstructor diff --git a/backend/baguni-api/src/main/java/baguni/api/service/sharedFolder/service/SharedFolderService.java b/backend/baguni-api/src/main/java/baguni/api/service/sharedFolder/service/SharedFolderService.java index b734dd9dc..0edf6e2e0 100644 --- a/backend/baguni-api/src/main/java/baguni/api/service/sharedFolder/service/SharedFolderService.java +++ b/backend/baguni-api/src/main/java/baguni/api/service/sharedFolder/service/SharedFolderService.java @@ -9,21 +9,21 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import baguni.entity.model.folder.Folder; -import baguni.entity.model.folder.FolderType; -import baguni.entity.model.pick.Pick; -import baguni.entity.model.sharedFolder.SharedFolder; +import baguni.domain.model.folder.Folder; +import baguni.domain.model.folder.FolderType; +import baguni.domain.model.pick.Pick; +import baguni.domain.model.sharedFolder.SharedFolder; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import baguni.api.service.folder.exception.ApiFolderException; -import baguni.api.service.link.dto.LinkMapper; -import baguni.api.service.sharedFolder.dto.SharedFolderMapper; -import baguni.api.service.sharedFolder.dto.SharedFolderResult; -import baguni.api.service.sharedFolder.exception.ApiSharedFolderException; -import baguni.api.infrastructure.folder.FolderDataHandler; -import baguni.api.infrastructure.pick.PickDataHandler; -import baguni.api.infrastructure.sharedFolder.SharedFolderDataHandler; -import baguni.api.infrastructure.tag.TagDataHandler; +import baguni.domain.exception.folder.ApiFolderException; +import baguni.domain.infrastructure.link.dto.LinkMapper; +import baguni.domain.infrastructure.sharedFolder.dto.SharedFolderMapper; +import baguni.domain.infrastructure.sharedFolder.dto.SharedFolderResult; +import baguni.domain.exception.sharedFolder.ApiSharedFolderException; +import baguni.domain.infrastructure.folder.FolderDataHandler; +import baguni.domain.infrastructure.pick.PickDataHandler; +import baguni.domain.infrastructure.sharedFolder.SharedFolderDataHandler; +import baguni.domain.infrastructure.tag.TagDataHandler; import baguni.common.annotation.MeasureTime; @Slf4j diff --git a/backend/baguni-api/src/main/java/baguni/api/service/tag/service/TagService.java b/backend/baguni-api/src/main/java/baguni/api/service/tag/service/TagService.java index b5dea3b10..29f401c35 100644 --- a/backend/baguni-api/src/main/java/baguni/api/service/tag/service/TagService.java +++ b/backend/baguni-api/src/main/java/baguni/api/service/tag/service/TagService.java @@ -6,13 +6,13 @@ import org.springframework.transaction.annotation.Transactional; import lombok.RequiredArgsConstructor; -import baguni.entity.annotation.LoginUserIdDistributedLock; -import baguni.api.service.tag.dto.TagCommand; -import baguni.api.service.tag.dto.TagMapper; -import baguni.api.service.tag.dto.TagResult; -import baguni.api.service.tag.exception.ApiTagException; -import baguni.api.infrastructure.tag.TagDataHandler; -import baguni.entity.model.tag.Tag; +import baguni.domain.annotation.LoginUserIdDistributedLock; +import baguni.domain.infrastructure.tag.dto.TagCommand; +import baguni.domain.infrastructure.tag.dto.TagMapper; +import baguni.domain.infrastructure.tag.dto.TagResult; +import baguni.domain.exception.tag.ApiTagException; +import baguni.domain.infrastructure.tag.TagDataHandler; +import baguni.domain.model.tag.Tag; @Service @RequiredArgsConstructor diff --git a/backend/baguni-api/src/main/java/baguni/api/service/user/service/UserService.java b/backend/baguni-api/src/main/java/baguni/api/service/user/service/UserService.java index 8d46c0c1f..94566fd95 100644 --- a/backend/baguni-api/src/main/java/baguni/api/service/user/service/UserService.java +++ b/backend/baguni-api/src/main/java/baguni/api/service/user/service/UserService.java @@ -3,10 +3,10 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import baguni.api.infrastructure.folder.FolderDataHandler; +import baguni.domain.infrastructure.folder.FolderDataHandler; import baguni.api.infrastructure.user.UserDataHandler; -import baguni.api.service.user.dto.UserInfo; -import baguni.entity.model.util.IDToken; +import baguni.domain.infrastructure.user.dto.UserInfo; +import baguni.domain.model.util.IDToken; import baguni.security.exception.ApiAuthException; import baguni.security.model.OAuth2UserInfo; import lombok.RequiredArgsConstructor; diff --git a/backend/baguni-api/src/main/java/baguni/api/service/user/service/strategy/ContentInitStrategy.java b/backend/baguni-api/src/main/java/baguni/api/service/user/service/strategy/ContentInitStrategy.java index d39aed26d..f4fdb37ad 100644 --- a/backend/baguni-api/src/main/java/baguni/api/service/user/service/strategy/ContentInitStrategy.java +++ b/backend/baguni-api/src/main/java/baguni/api/service/user/service/strategy/ContentInitStrategy.java @@ -1,6 +1,6 @@ package baguni.api.service.user.service.strategy; -import baguni.api.service.user.dto.UserInfo; +import baguni.domain.infrastructure.user.dto.UserInfo; public interface ContentInitStrategy { void initContent(UserInfo info, Long folderId); diff --git a/backend/baguni-api/src/main/java/baguni/api/service/user/service/strategy/ManualInitStrategy.java b/backend/baguni-api/src/main/java/baguni/api/service/user/service/strategy/ManualInitStrategy.java index 418581bec..7f0857cf3 100644 --- a/backend/baguni-api/src/main/java/baguni/api/service/user/service/strategy/ManualInitStrategy.java +++ b/backend/baguni-api/src/main/java/baguni/api/service/user/service/strategy/ManualInitStrategy.java @@ -6,13 +6,13 @@ import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.stereotype.Component; -import baguni.api.service.user.dto.UserInfo; +import baguni.domain.infrastructure.user.dto.UserInfo; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import baguni.api.service.link.dto.LinkInfo; -import baguni.api.service.link.exception.ApiLinkException; +import baguni.domain.infrastructure.link.dto.LinkInfo; +import baguni.domain.exception.link.ApiLinkException; import baguni.api.service.link.service.LinkService; -import baguni.api.service.pick.dto.PickCommand; +import baguni.domain.infrastructure.pick.dto.PickCommand; import baguni.api.service.pick.service.PickService; @Slf4j @@ -43,7 +43,7 @@ public void initContent(UserInfo info, Long folderId) { try { linkInfo = linkService.getLinkInfo(url); } catch (ApiLinkException exception) { - linkInfo = linkService.saveLinkAndUpdateOgTag(url); + linkInfo = linkService.saveLink(url); // url 외에 다른 필드는 모두 빈 문자열인 Link 생성 } var command = new PickCommand.Create( info.id(), linkInfo.title(), new ArrayList<>(), folderId, linkInfo diff --git a/backend/baguni-api/src/main/java/baguni/api/service/user/service/strategy/RankingInitStrategy.java b/backend/baguni-api/src/main/java/baguni/api/service/user/service/strategy/RankingInitStrategy.java index 1c085e962..9f8707d33 100644 --- a/backend/baguni-api/src/main/java/baguni/api/service/user/service/strategy/RankingInitStrategy.java +++ b/backend/baguni-api/src/main/java/baguni/api/service/user/service/strategy/RankingInitStrategy.java @@ -8,15 +8,15 @@ import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.stereotype.Component; -import baguni.api.service.user.dto.UserInfo; +import baguni.domain.infrastructure.user.dto.UserInfo; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import baguni.api.service.link.dto.LinkInfo; -import baguni.api.service.link.exception.ApiLinkException; +import baguni.domain.infrastructure.link.dto.LinkInfo; +import baguni.domain.exception.link.ApiLinkException; import baguni.api.service.link.service.LinkService; -import baguni.api.service.pick.dto.PickCommand; +import baguni.domain.infrastructure.pick.dto.PickCommand; import baguni.api.service.pick.service.PickService; -import baguni.api.infrastructure.ranking.RankingApi; +import baguni.domain.infrastructure.ranking.RankingApi; import baguni.common.dto.UrlWithCount; @Slf4j @@ -64,7 +64,7 @@ private void savePickFromRankingList(Long userId, List rankingList try { linkInfo = linkService.getLinkInfo(curr.url()); } catch (ApiLinkException exception) { - linkInfo = linkService.saveLinkAndUpdateOgTag(curr.url()); + linkInfo = linkService.saveLink(curr.url()); // url 외에 다른 필드는 모두 빈 문자열인 Link 생성 } if (linkInfo.title().isBlank()) { continue; diff --git a/backend/baguni-api/src/main/java/baguni/api/service/user/service/strategy/StarterFolderStrategy.java b/backend/baguni-api/src/main/java/baguni/api/service/user/service/strategy/StarterFolderStrategy.java index 35fcaba6f..85d074e84 100644 --- a/backend/baguni-api/src/main/java/baguni/api/service/user/service/strategy/StarterFolderStrategy.java +++ b/backend/baguni-api/src/main/java/baguni/api/service/user/service/strategy/StarterFolderStrategy.java @@ -2,12 +2,12 @@ import org.springframework.stereotype.Component; -import baguni.api.service.user.dto.UserInfo; +import baguni.domain.infrastructure.user.dto.UserInfo; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import baguni.api.service.folder.dto.FolderCommand; -import baguni.api.infrastructure.folder.FolderDataHandler; -import baguni.entity.model.folder.Folder; +import baguni.domain.infrastructure.folder.dto.FolderCommand; +import baguni.domain.infrastructure.folder.FolderDataHandler; +import baguni.domain.model.folder.Folder; @Slf4j @Component diff --git a/backend/baguni-api/src/main/java/baguni/security/model/OAuth2UserInfo.java b/backend/baguni-api/src/main/java/baguni/security/model/OAuth2UserInfo.java index a8184e762..8d45c31ce 100644 --- a/backend/baguni-api/src/main/java/baguni/security/model/OAuth2UserInfo.java +++ b/backend/baguni-api/src/main/java/baguni/security/model/OAuth2UserInfo.java @@ -8,9 +8,9 @@ import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.oauth2.core.user.OAuth2User; -import baguni.entity.model.user.SocialProvider; +import baguni.domain.model.user.SocialProvider; import baguni.security.exception.ApiAuthException; -import baguni.entity.model.user.Role; +import baguni.domain.model.user.Role; public class OAuth2UserInfo implements OAuth2User { diff --git a/backend/baguni-api/src/main/java/baguni/security/util/AccessToken.java b/backend/baguni-api/src/main/java/baguni/security/util/AccessToken.java index aa4a9d3e4..115f32046 100644 --- a/backend/baguni-api/src/main/java/baguni/security/util/AccessToken.java +++ b/backend/baguni-api/src/main/java/baguni/security/util/AccessToken.java @@ -7,8 +7,8 @@ import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.authority.SimpleGrantedAuthority; -import baguni.entity.model.user.Role; -import baguni.entity.model.util.IDToken; +import baguni.domain.model.user.Role; +import baguni.domain.model.util.IDToken; import baguni.security.config.JwtProperties; import baguni.security.exception.ApiAuthException; import io.jsonwebtoken.Claims; diff --git a/backend/baguni-api/src/main/java/baguni/security/util/LoginUserArgumentResolver.java b/backend/baguni-api/src/main/java/baguni/security/util/LoginUserArgumentResolver.java index b902d48b6..0ff3604d3 100644 --- a/backend/baguni-api/src/main/java/baguni/security/util/LoginUserArgumentResolver.java +++ b/backend/baguni-api/src/main/java/baguni/security/util/LoginUserArgumentResolver.java @@ -11,7 +11,7 @@ import org.springframework.web.method.support.ModelAndViewContainer; import baguni.api.service.user.service.UserService; -import baguni.entity.model.util.IDToken; +import baguni.domain.model.util.IDToken; import baguni.security.annotation.LoginUserId; import baguni.security.exception.ApiAuthException; import lombok.RequiredArgsConstructor; diff --git a/backend/baguni-api/src/main/resources/application.yaml b/backend/baguni-api/src/main/resources/application.yaml index 417a146b5..dbaba7c15 100644 --- a/backend/baguni-api/src/main/resources/application.yaml +++ b/backend/baguni-api/src/main/resources/application.yaml @@ -1,7 +1,7 @@ spring: profiles: include: - - entity + - domain - common application: name: baguni.${PROFILE_MODE}.server.api @@ -18,10 +18,8 @@ springdoc: --- spring: jwt: - secret: - ${JWT_SECRET} - issuer: - ${JWT_ISSUER} + secret: ${JWT_SECRET} + issuer: ${JWT_ISSUER} security: oauth2: client: @@ -48,11 +46,11 @@ spring: oauth2-attribute-config-provider: attributeConfig: google: - name: "sub" # use `sub` attribute as name - email: "email" + name: 'sub' # use `sub` attribute as name + email: 'email' kakao: - name: "id" # use `id` attribute as name - email: "email" + name: 'id' # use `id` attribute as name + email: 'email' --- spring: config: @@ -125,4 +123,5 @@ springdoc: # 운영 환경에서 스웨거 접근 못하도록 막는 설정 enabled: false # false로 변경하면, swagger 접근 불가 server: port: 8080 ---- \ No newline at end of file +--- + diff --git a/backend/baguni-api/src/main/resources/logback-spring.xml b/backend/baguni-api/src/main/resources/logback-spring.xml index dc05a430a..de010c4c6 100644 --- a/backend/baguni-api/src/main/resources/logback-spring.xml +++ b/backend/baguni-api/src/main/resources/logback-spring.xml @@ -2,7 +2,7 @@ - + @@ -70,14 +70,19 @@ - - - - + + + + + + + + + diff --git a/backend/baguni-api/src/test/java/baguni/api/domain/folder/service/FolderServiceTest.java b/backend/baguni-api/src/test/java/baguni/api/domain/folder/service/FolderServiceTest.java index 815e93e34..f5b1b1770 100644 --- a/backend/baguni-api/src/test/java/baguni/api/domain/folder/service/FolderServiceTest.java +++ b/backend/baguni-api/src/test/java/baguni/api/domain/folder/service/FolderServiceTest.java @@ -20,15 +20,15 @@ import jakarta.persistence.OptimisticLockException; import lombok.extern.slf4j.Slf4j; import baguni.BaguniApiApplication; -import baguni.api.service.folder.dto.FolderCommand; -import baguni.api.service.folder.dto.FolderResult; -import baguni.api.infrastructure.folder.FolderDataHandler; -import baguni.entity.model.folder.Folder; -import baguni.entity.model.folder.FolderRepository; -import baguni.entity.model.user.Role; -import baguni.entity.model.user.SocialProvider; -import baguni.entity.model.user.User; -import baguni.entity.model.user.UserRepository; +import baguni.domain.infrastructure.folder.dto.FolderCommand; +import baguni.domain.infrastructure.folder.dto.FolderResult; +import baguni.domain.infrastructure.folder.FolderDataHandler; +import baguni.domain.model.folder.Folder; +import baguni.domain.infrastructure.folder.FolderRepository; +import baguni.domain.model.user.Role; +import baguni.domain.model.user.SocialProvider; +import baguni.domain.model.user.User; +import baguni.domain.infrastructure.user.UserRepository; @Slf4j @SpringBootTest(classes = BaguniApiApplication.class) diff --git a/backend/baguni-api/src/test/java/baguni/api/domain/folder/service/FolderServiceUnitTest.java b/backend/baguni-api/src/test/java/baguni/api/domain/folder/service/FolderServiceUnitTest.java index f9e7aeeaa..4376bdfb8 100644 --- a/backend/baguni-api/src/test/java/baguni/api/domain/folder/service/FolderServiceUnitTest.java +++ b/backend/baguni-api/src/test/java/baguni/api/domain/folder/service/FolderServiceUnitTest.java @@ -15,17 +15,17 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import baguni.api.service.folder.dto.FolderCommand; -import baguni.api.service.folder.dto.FolderMapper; -import baguni.api.service.folder.exception.ApiFolderErrorCode; -import baguni.api.service.folder.exception.ApiFolderException; +import baguni.domain.infrastructure.folder.dto.FolderCommand; +import baguni.domain.infrastructure.folder.dto.FolderMapper; +import baguni.domain.exception.folder.ApiFolderErrorCode; +import baguni.domain.exception.folder.ApiFolderException; import baguni.api.fixture.FolderFixture; import baguni.api.fixture.UserFixture; -import baguni.api.infrastructure.folder.FolderDataHandler; -import baguni.api.infrastructure.pick.PickDataHandler; -import baguni.entity.model.folder.Folder; -import baguni.entity.model.folder.FolderType; -import baguni.entity.model.user.User; +import baguni.domain.infrastructure.folder.FolderDataHandler; +import baguni.domain.infrastructure.pick.PickDataHandler; +import baguni.domain.model.folder.Folder; +import baguni.domain.model.folder.FolderType; +import baguni.domain.model.user.User; import baguni.api.service.folder.service.FolderService; @DisplayName("폴더 서비스 단위 테스트") diff --git a/backend/baguni-api/src/test/java/baguni/api/domain/pick/service/PickBulkInsertTest.java b/backend/baguni-api/src/test/java/baguni/api/domain/pick/service/PickBulkInsertTest.java index 87f2b8b2e..5c6eb0b3e 100644 --- a/backend/baguni-api/src/test/java/baguni/api/domain/pick/service/PickBulkInsertTest.java +++ b/backend/baguni-api/src/test/java/baguni/api/domain/pick/service/PickBulkInsertTest.java @@ -13,14 +13,14 @@ import baguni.api.service.pick.service.PickService; import lombok.extern.slf4j.Slf4j; import baguni.BaguniApiApplication; -import baguni.api.service.link.dto.LinkInfo; -import baguni.api.service.pick.dto.PickCommand; -import baguni.entity.model.folder.Folder; -import baguni.entity.model.folder.FolderRepository; -import baguni.entity.model.user.Role; -import baguni.entity.model.user.SocialProvider; -import baguni.entity.model.user.User; -import baguni.entity.model.user.UserRepository; +import baguni.domain.infrastructure.link.dto.LinkInfo; +import baguni.domain.infrastructure.pick.dto.PickCommand; +import baguni.domain.model.folder.Folder; +import baguni.domain.infrastructure.folder.FolderRepository; +import baguni.domain.model.user.Role; +import baguni.domain.model.user.SocialProvider; +import baguni.domain.model.user.User; +import baguni.domain.infrastructure.user.UserRepository; @Slf4j @SpringBootTest(classes = BaguniApiApplication.class) diff --git a/backend/baguni-api/src/test/java/baguni/api/domain/pick/service/PickSearchTest.java b/backend/baguni-api/src/test/java/baguni/api/domain/pick/service/PickSearchTest.java index 697954257..49e51ac1e 100644 --- a/backend/baguni-api/src/test/java/baguni/api/domain/pick/service/PickSearchTest.java +++ b/backend/baguni-api/src/test/java/baguni/api/domain/pick/service/PickSearchTest.java @@ -21,21 +21,21 @@ import baguni.api.service.pick.service.PickSearchService; import baguni.api.service.pick.service.PickService; -import baguni.entity.model.user.SocialProvider; +import baguni.domain.model.user.SocialProvider; import lombok.extern.slf4j.Slf4j; import baguni.BaguniApiApplication; -import baguni.api.service.folder.exception.ApiFolderException; -import baguni.api.service.link.dto.LinkInfo; -import baguni.api.service.pick.dto.PickCommand; -import baguni.api.service.pick.dto.PickResult; -import baguni.api.service.tag.exception.ApiTagException; -import baguni.entity.model.folder.Folder; -import baguni.entity.model.folder.FolderRepository; -import baguni.entity.model.tag.Tag; -import baguni.entity.model.tag.TagRepository; -import baguni.entity.model.user.Role; -import baguni.entity.model.user.User; -import baguni.entity.model.user.UserRepository; +import baguni.domain.exception.folder.ApiFolderException; +import baguni.domain.infrastructure.link.dto.LinkInfo; +import baguni.domain.infrastructure.pick.dto.PickCommand; +import baguni.domain.infrastructure.pick.dto.PickResult; +import baguni.domain.exception.tag.ApiTagException; +import baguni.domain.model.folder.Folder; +import baguni.domain.infrastructure.folder.FolderRepository; +import baguni.domain.model.tag.Tag; +import baguni.domain.infrastructure.tag.TagRepository; +import baguni.domain.model.user.Role; +import baguni.domain.model.user.User; +import baguni.domain.infrastructure.user.UserRepository; @Slf4j @TestInstance(TestInstance.Lifecycle.PER_CLASS) diff --git a/backend/baguni-api/src/test/java/baguni/api/domain/pick/service/PickServiceTest.java b/backend/baguni-api/src/test/java/baguni/api/domain/pick/service/PickServiceTest.java index 48f513657..08eb87fc9 100644 --- a/backend/baguni-api/src/test/java/baguni/api/domain/pick/service/PickServiceTest.java +++ b/backend/baguni-api/src/test/java/baguni/api/domain/pick/service/PickServiceTest.java @@ -20,28 +20,28 @@ import org.springframework.test.context.ActiveProfiles; import baguni.api.service.pick.service.PickService; -import baguni.entity.model.user.SocialProvider; +import baguni.domain.model.user.SocialProvider; import lombok.extern.slf4j.Slf4j; import baguni.BaguniApiApplication; import baguni.api.application.pick.dto.PickApiMapper; -import baguni.api.service.link.dto.LinkInfo; -import baguni.api.service.pick.dto.PickCommand; -import baguni.api.service.pick.dto.PickResult; -import baguni.api.service.pick.exception.ApiPickException; -import baguni.api.service.tag.dto.TagCommand; +import baguni.domain.infrastructure.link.dto.LinkInfo; +import baguni.domain.infrastructure.pick.dto.PickCommand; +import baguni.domain.infrastructure.pick.dto.PickResult; +import baguni.domain.exception.pick.ApiPickException; +import baguni.domain.infrastructure.tag.dto.TagCommand; import baguni.api.service.tag.service.TagService; -import baguni.api.infrastructure.pick.PickDataHandler; -import baguni.entity.model.folder.Folder; -import baguni.entity.model.folder.FolderRepository; -import baguni.entity.model.link.LinkRepository; -import baguni.entity.model.pick.PickRepository; -import baguni.entity.model.pick.PickTag; -import baguni.entity.model.pick.PickTagRepository; -import baguni.entity.model.tag.Tag; -import baguni.entity.model.tag.TagRepository; -import baguni.entity.model.user.Role; -import baguni.entity.model.user.User; -import baguni.entity.model.user.UserRepository; +import baguni.domain.infrastructure.pick.PickDataHandler; +import baguni.domain.model.folder.Folder; +import baguni.domain.infrastructure.folder.FolderRepository; +import baguni.domain.infrastructure.link.LinkRepository; +import baguni.domain.infrastructure.pick.PickRepository; +import baguni.domain.model.pick.PickTag; +import baguni.domain.infrastructure.pick.PickTagRepository; +import baguni.domain.model.tag.Tag; +import baguni.domain.infrastructure.tag.TagRepository; +import baguni.domain.model.user.Role; +import baguni.domain.model.user.User; +import baguni.domain.infrastructure.user.UserRepository; @Slf4j @SpringBootTest(classes = BaguniApiApplication.class) diff --git a/backend/baguni-api/src/test/java/baguni/api/domain/tag/service/TagServiceTest.java b/backend/baguni-api/src/test/java/baguni/api/domain/tag/service/TagServiceTest.java index b391554e1..421e4a405 100644 --- a/backend/baguni-api/src/test/java/baguni/api/domain/tag/service/TagServiceTest.java +++ b/backend/baguni-api/src/test/java/baguni/api/domain/tag/service/TagServiceTest.java @@ -21,14 +21,14 @@ import baguni.api.service.tag.service.TagService; import lombok.extern.slf4j.Slf4j; import baguni.BaguniApiApplication; -import baguni.api.service.tag.dto.TagCommand; -import baguni.api.service.tag.dto.TagResult; -import baguni.api.service.tag.exception.ApiTagException; +import baguni.domain.infrastructure.tag.dto.TagCommand; +import baguni.domain.infrastructure.tag.dto.TagResult; +import baguni.domain.exception.tag.ApiTagException; import baguni.api.infrastructure.user.UserDataHandler; -import baguni.entity.model.user.Role; -import baguni.entity.model.user.SocialProvider; -import baguni.entity.model.user.User; -import baguni.entity.model.user.UserRepository; +import baguni.domain.model.user.Role; +import baguni.domain.model.user.SocialProvider; +import baguni.domain.model.user.User; +import baguni.domain.infrastructure.user.UserRepository; @Slf4j @TestInstance(TestInstance.Lifecycle.PER_CLASS) diff --git a/backend/baguni-api/src/test/java/baguni/api/fixture/FolderFixture.java b/backend/baguni-api/src/test/java/baguni/api/fixture/FolderFixture.java index e99389dba..7d82231df 100644 --- a/backend/baguni-api/src/test/java/baguni/api/fixture/FolderFixture.java +++ b/backend/baguni-api/src/test/java/baguni/api/fixture/FolderFixture.java @@ -9,9 +9,9 @@ import lombok.Builder; import lombok.Getter; -import baguni.entity.model.folder.Folder; -import baguni.entity.model.folder.FolderType; -import baguni.entity.model.user.User; +import baguni.domain.model.folder.Folder; +import baguni.domain.model.folder.FolderType; +import baguni.domain.model.user.User; @Builder @Getter diff --git a/backend/baguni-api/src/test/java/baguni/api/fixture/PickFixture.java b/backend/baguni-api/src/test/java/baguni/api/fixture/PickFixture.java index 8d445fe67..e729064ab 100644 --- a/backend/baguni-api/src/test/java/baguni/api/fixture/PickFixture.java +++ b/backend/baguni-api/src/test/java/baguni/api/fixture/PickFixture.java @@ -8,10 +8,10 @@ import lombok.Builder; import lombok.Getter; -import baguni.entity.model.folder.Folder; -import baguni.entity.model.link.Link; -import baguni.entity.model.pick.Pick; -import baguni.entity.model.user.User; +import baguni.domain.model.folder.Folder; +import baguni.domain.model.link.Link; +import baguni.domain.model.pick.Pick; +import baguni.domain.model.user.User; @Builder @Getter diff --git a/backend/baguni-api/src/test/java/baguni/api/fixture/UserFixture.java b/backend/baguni-api/src/test/java/baguni/api/fixture/UserFixture.java index 2f7cacb6d..b12d5737f 100644 --- a/backend/baguni-api/src/test/java/baguni/api/fixture/UserFixture.java +++ b/backend/baguni-api/src/test/java/baguni/api/fixture/UserFixture.java @@ -7,11 +7,11 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.databind.ObjectMapper; -import baguni.entity.model.user.SocialProvider; +import baguni.domain.model.user.SocialProvider; import lombok.Builder; import lombok.Getter; -import baguni.entity.model.user.Role; -import baguni.entity.model.user.User; +import baguni.domain.model.user.Role; +import baguni.domain.model.user.User; @Builder @Getter diff --git a/backend/baguni-batch/build.gradle b/backend/baguni-batch/build.gradle index 7af24003a..ed1556a0c 100644 --- a/backend/baguni-batch/build.gradle +++ b/backend/baguni-batch/build.gradle @@ -11,11 +11,15 @@ repositories { dependencies { implementation project(":baguni-common") - implementation project(":baguni-entity") + implementation project(":baguni-domain") // spring jpa implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + // rabbitMQ + implementation 'org.springframework.boot:spring-boot-starter-amqp' + implementation 'org.springframework.amqp:spring-amqp:3.2.0' + // for batch retry logic implementation 'org.springframework.retry:spring-retry' } diff --git a/backend/baguni-batch/src/main/java/baguni/batch/domain/link/service/CrawlingEventListener.java b/backend/baguni-batch/src/main/java/baguni/batch/domain/link/service/CrawlingEventListener.java new file mode 100644 index 000000000..c0e2a7866 --- /dev/null +++ b/backend/baguni-batch/src/main/java/baguni/batch/domain/link/service/CrawlingEventListener.java @@ -0,0 +1,55 @@ +package baguni.batch.domain.link.service; + +import org.apache.commons.lang3.StringUtils; +import org.springframework.amqp.rabbit.annotation.RabbitHandler; +import org.springframework.amqp.rabbit.annotation.RabbitListener; +import org.springframework.stereotype.Component; + +import baguni.common.config.RabbitmqConfig; +import baguni.common.event.events.CrawlingEvent; +import baguni.common.event.events.LinkEvent; +import baguni.domain.infrastructure.link.dto.LinkResult; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * @author sangwon + * 익스텐션을 이용하여 미분류로 픽을 담을 때 이벤트를 메세지 큐에 담음. + * 메세지 큐에 담긴 데이터를 꺼내 imageUrl, description이 비어있을 때 크롤링함. + */ +@Slf4j +@Component +@RequiredArgsConstructor +@RabbitListener(queues = {RabbitmqConfig.QUEUE.PICK_CRAWLING}) +public class CrawlingEventListener { + + private final LinkCrawlingService linkCrawlingService; + + @RabbitHandler + public void linkCrawlingEvent(CrawlingEvent event) { + LinkResult link = linkCrawlingService.getLinkResult(event.getLinkId(), event.getUrl(), event.getTitle()); + + // imageUrl, description이 비어있는 경우에만 OG 태그 업데이트 시도 + if (StringUtils.isEmpty(link.imageUrl()) || StringUtils.isEmpty(link.description())) { + try { + linkCrawlingService.saveLinkAndUpdateOgTagBySelenium(link.url(), link.title()); + } catch (Exception e) { + log.info("메세지 큐에서 꺼낸 Link OG 크롤링 실패 : ", e); + } + } + } + + @RabbitListener + public void linkUrlEvent(LinkEvent event) { + LinkResult link = linkCrawlingService.getLinkResultByUrl(event.getUrl()); + + // imageUrl, description이 비어있는 경우에만 OG 태그 업데이트 시도 + if (StringUtils.isEmpty(link.imageUrl()) || StringUtils.isEmpty(link.description())) { + try { + linkCrawlingService.saveLinkAndUpdateOgTagBySelenium(link.url(), link.title()); + } catch (Exception e) { + log.info("메세지 큐에서 꺼낸 Link OG 크롤링 실패 : ", e); + } + } + } +} diff --git a/backend/baguni-batch/src/main/java/baguni/batch/domain/link/service/LinkCrawlingService.java b/backend/baguni-batch/src/main/java/baguni/batch/domain/link/service/LinkCrawlingService.java new file mode 100644 index 000000000..f7c6d791c --- /dev/null +++ b/backend/baguni-batch/src/main/java/baguni/batch/domain/link/service/LinkCrawlingService.java @@ -0,0 +1,163 @@ +package baguni.batch.domain.link.service; + +import java.net.MalformedURLException; +import java.net.URL; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import baguni.common.lib.opengraph.Metadata; +import baguni.common.lib.opengraph.OpenGraph; +import baguni.common.lib.opengraph.OpenGraphException; +import baguni.common.lib.opengraph.OpenGraphOption; +import baguni.common.lib.opengraph.OpenGraphReader; +import baguni.common.lib.opengraph.OpenGraphReaderJsoup; +import baguni.common.lib.opengraph.OpenGraphReaderSelenium; +import baguni.domain.exception.link.ApiLinkException; +import baguni.domain.model.link.Link; +import baguni.domain.infrastructure.link.LinkDataHandler; +import baguni.domain.infrastructure.link.dto.LinkInfo; +import baguni.domain.infrastructure.link.dto.LinkMapper; +import baguni.domain.infrastructure.link.dto.LinkResult; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class LinkCrawlingService { + + private static final int TIMEOUT_SECONDS = 10; + + private final LinkDataHandler linkDataHandler; + private final LinkMapper linkMapper; + + @Transactional(readOnly = true) + public LinkResult getLinkResultByUrl(String url) { + Link link = linkDataHandler.getOptionalLink(url).orElseGet(() -> Link.createLinkByUrl(url)); + return linkMapper.toLinkResult(link); + } + + @Transactional(readOnly = true) + public LinkResult getLinkResult(Long id, String url, String title) { + Link link = linkDataHandler.getOptionalLinkById(id).orElseGet(() -> Link.createLinkByUrlAndTitle(url, title)); + return linkMapper.toLinkResult(link); + } + + @Transactional + public void updateOgTag(String url) { + Link link = linkDataHandler.getOptionalLink(url).orElseGet(() -> Link.createLinkByUrl(url)); + try { + var updatedLink = updateOgTagByJsoup(url, link); + linkDataHandler.saveLink(updatedLink); + } catch (Exception e) { + log.info("url : {} 의 og tag 추출에 실패했습니다.", url, e); + } + } + + @Transactional + public LinkInfo saveLinkAndUpdateOgTagByJsoup(String url) { + Link link = linkDataHandler.getOptionalLink(url).orElseGet(() -> Link.createLinkByUrl(url)); + try { + var updatedLink = updateOgTagByJsoup(url, link); + return linkMapper.toLinkInfo(linkDataHandler.saveLink(updatedLink)); + } catch (Exception e) { + log.info("OG Tag Jsoup으로 업데이트 : ", e); + throw ApiLinkException.LINK_OG_TAG_UPDATE_FAILURE(); + } + } + + /** + * title은 update 하지 않음. + * 크롤링 시 image_url, description이 비어있을 때 해당 메서드 호출 + */ + @Transactional + public LinkInfo saveLinkAndUpdateOgTagBySelenium(String url, String title) { + Link link = linkDataHandler.getOptionalLink(url).orElseGet(() -> Link.createLinkByUrlAndTitle(url, title)); + try { + var updatedLink = updateOgTagBySelenium(url, link); + return linkMapper.toLinkInfo(linkDataHandler.saveLink(updatedLink)); + } catch (Exception e) { + log.info("OG Tag Selenium으로 업데이트 : ", e); + throw ApiLinkException.LINK_OG_TAG_UPDATE_FAILURE(); + } + } + + /** + * Jsoup으로 크롤링 + */ + private Link updateOgTagByJsoup(String url, Link link) throws OpenGraphException { + var openGraphOption = new OpenGraphOption(TIMEOUT_SECONDS); + var openGraphReader = new OpenGraphReaderJsoup(openGraphOption); // Jsoup + return updateOgTagCommon(url, link, openGraphReader); + } + + /** + * Selenium으로 크롤링 + */ + private Link updateOgTagBySelenium(String url, Link link) throws OpenGraphException { + var openGraphOption = new OpenGraphOption(TIMEOUT_SECONDS); + var openGraphReader = new OpenGraphReaderSelenium(openGraphOption); // Selenium + return updateOgTagCommon(url, link, openGraphReader); + } + + private Link updateOgTagCommon(String url, Link link, OpenGraphReader openGraphReader) throws OpenGraphException { + var openGraph = new OpenGraph(url, openGraphReader); + link.updateMetadata( + openGraph.getTag(Metadata.OG_TITLE) + .orElse(openGraph.getTag(Metadata.TITLE) + .orElse("")), + openGraph.getTag(Metadata.OG_DESCRIPTION) + .orElse(openGraph.getTag(Metadata.DESCRIPTION) + .orElse("")), + correctImageUrl(url, openGraph.getTag(Metadata.OG_IMAGE) + .orElse(openGraph.getTag(Metadata.IMAGE) + .orElse(openGraph.getTag(Metadata.ICON) + .orElse("")))) + ); + return link; + } + + /** + * og:image 가 완전한 url 형식이 아닐 수 있어 보정 + * 추론 불가능한 image url 일 경우 빈스트링("")으로 대치 + * + * @author sangwon + * protocol : https + * host : blog.dongolab.com + */ + private String correctImageUrl(String baseUrl, String imageUrl) { + // "null"이 넘어오는 경우가 있음. + // favicon 가져올 때 -> "null"로 넘어옴 + if (imageUrl == null || imageUrl.trim().isEmpty() || imageUrl.equals("null")) { + return ""; + } + + if (imageUrl.startsWith("://")) { + return "https" + imageUrl; + } + if (imageUrl.startsWith("//")) { + return "https:" + imageUrl; + } + + try { + URL url = new URL(baseUrl); + // ex) https://blog.dongolab.com + String domain = url.getProtocol() + "://" + url.getHost(); + + // ex) /og-image.png -> https://blog.dongholab.com/og-image.png + if (imageUrl.startsWith("/")) { + return domain + imageUrl; + } + + if (!imageUrl.startsWith("http://") && !imageUrl.startsWith("https://")) { + return domain + "/" + imageUrl; + } + + return imageUrl; + } catch (MalformedURLException e) { + // baseUrl이 올바르지 않은 경우 빈 문자열 반환 + return ""; + } + } +} diff --git a/backend/baguni-batch/src/main/java/baguni/batch/domain/rss/dto/RssMapper.java b/backend/baguni-batch/src/main/java/baguni/batch/domain/rss/dto/RssMapper.java index 627ac486d..28a042ed0 100644 --- a/backend/baguni-batch/src/main/java/baguni/batch/domain/rss/dto/RssMapper.java +++ b/backend/baguni-batch/src/main/java/baguni/batch/domain/rss/dto/RssMapper.java @@ -8,8 +8,8 @@ import org.mapstruct.Named; import org.mapstruct.ReportingPolicy; -import baguni.entity.model.rss.RssBlog; -import baguni.entity.model.rss.RssFeed; +import baguni.domain.model.rss.RssBlog; +import baguni.domain.model.rss.RssFeed; @Mapper( componentModel = "spring", diff --git a/backend/baguni-batch/src/main/java/baguni/batch/domain/rss/service/RssServiceImpl.java b/backend/baguni-batch/src/main/java/baguni/batch/domain/rss/service/RssServiceImpl.java index 2b30b1a85..54538f1b0 100644 --- a/backend/baguni-batch/src/main/java/baguni/batch/domain/rss/service/RssServiceImpl.java +++ b/backend/baguni-batch/src/main/java/baguni/batch/domain/rss/service/RssServiceImpl.java @@ -24,8 +24,8 @@ import baguni.batch.domain.rss.exception.ApiRssException; import baguni.batch.infrastructure.rss.RssAdaptor; import baguni.common.annotation.MeasureTime; -import baguni.entity.model.rss.RssBlog; -import baguni.entity.model.rss.RssFeed; +import baguni.domain.model.rss.RssBlog; +import baguni.domain.model.rss.RssFeed; @Slf4j @Service diff --git a/backend/baguni-batch/src/main/java/baguni/batch/infrastructure/rss/RssAdaptor.java b/backend/baguni-batch/src/main/java/baguni/batch/infrastructure/rss/RssAdaptor.java index c8f833c95..acaa56207 100644 --- a/backend/baguni-batch/src/main/java/baguni/batch/infrastructure/rss/RssAdaptor.java +++ b/backend/baguni-batch/src/main/java/baguni/batch/infrastructure/rss/RssAdaptor.java @@ -4,8 +4,8 @@ import java.util.List; import baguni.batch.domain.rss.dto.RssBlogCommand; -import baguni.entity.model.rss.RssBlog; -import baguni.entity.model.rss.RssFeed; +import baguni.domain.model.rss.RssBlog; +import baguni.domain.model.rss.RssFeed; public interface RssAdaptor { diff --git a/backend/baguni-batch/src/main/java/baguni/batch/infrastructure/rss/RssAdaptorImpl.java b/backend/baguni-batch/src/main/java/baguni/batch/infrastructure/rss/RssAdaptorImpl.java index c1fb7dc02..5a4511d28 100644 --- a/backend/baguni-batch/src/main/java/baguni/batch/infrastructure/rss/RssAdaptorImpl.java +++ b/backend/baguni-batch/src/main/java/baguni/batch/infrastructure/rss/RssAdaptorImpl.java @@ -12,10 +12,10 @@ import lombok.RequiredArgsConstructor; import baguni.batch.domain.rss.dto.RssBlogCommand; import baguni.batch.domain.rss.dto.RssMapper; -import baguni.entity.model.rss.RssBlog; -import baguni.entity.model.rss.RssBlogRepository; -import baguni.entity.model.rss.RssFeed; -import baguni.entity.model.rss.RssFeedRepository; +import baguni.domain.model.rss.RssBlog; +import baguni.domain.infrastructure.rss.RssBlogRepository; +import baguni.domain.model.rss.RssFeed; +import baguni.domain.infrastructure.rss.RssFeedRepository; @Component @RequiredArgsConstructor diff --git a/backend/baguni-batch/src/main/resources/application.yaml b/backend/baguni-batch/src/main/resources/application.yaml index 0c2e0b3a3..bf37bc3d1 100644 --- a/backend/baguni-batch/src/main/resources/application.yaml +++ b/backend/baguni-batch/src/main/resources/application.yaml @@ -2,7 +2,7 @@ spring: profiles: include: - - entity + - domain - common application: name: baguni.${PROFILE_MODE}.server.batch @@ -26,4 +26,4 @@ spring: activate: on-profile: prod server: - port: 8080 \ No newline at end of file + port: 8080 diff --git a/backend/baguni-batch/src/main/resources/logback-spring.xml b/backend/baguni-batch/src/main/resources/logback-spring.xml index 7b1f40dd7..b092d1cb2 100644 --- a/backend/baguni-batch/src/main/resources/logback-spring.xml +++ b/backend/baguni-batch/src/main/resources/logback-spring.xml @@ -79,20 +79,21 @@ - - - - - + + + + + + + + + - - \ No newline at end of file diff --git a/backend/baguni-common/build.gradle b/backend/baguni-common/build.gradle index 74b6a2a8c..8026e6dcc 100644 --- a/backend/baguni-common/build.gradle +++ b/backend/baguni-common/build.gradle @@ -17,6 +17,10 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-amqp' implementation 'org.springframework.amqp:spring-amqp:3.2.0' + // selenium for opengraph + implementation 'org.seleniumhq.selenium:selenium-java:4.25.0' + implementation 'io.github.bonigarcia:webdrivermanager:5.8.0' + // jsoup for opengraph implementation 'org.jsoup:jsoup:1.18.3' } diff --git a/backend/baguni-common/src/main/java/baguni/common/config/RabbitmqConfig.java b/backend/baguni-common/src/main/java/baguni/common/config/RabbitmqConfig.java index 5a652cf5b..79f8897d4 100644 --- a/backend/baguni-common/src/main/java/baguni/common/config/RabbitmqConfig.java +++ b/backend/baguni-common/src/main/java/baguni/common/config/RabbitmqConfig.java @@ -17,11 +17,13 @@ public class RabbitmqConfig { public static final class EXCHANGE { - public static final String EVENT = "exchange.baguni-event"; + public static final String RANKING_EVENT = "exchange.ranking-event"; + public static final String CRAWLING_EVENT = "exchange.crawling-event"; } public static final class QUEUE { - public static final String Q1 = "queue.event-q1"; + public static final String PICK_RANKING = "queue.pick-ranking"; + public static final String PICK_CRAWLING = "queue.pick-crawling"; } @Value("${spring.application.name}") @@ -39,24 +41,43 @@ public static final class QUEUE { /** * 1. Exchange 구성 */ @Bean - DirectExchange directExchange() { - return new DirectExchange(EXCHANGE.EVENT); + DirectExchange rankingDirectExchange() { + return new DirectExchange(EXCHANGE.RANKING_EVENT); + } + + @Bean + DirectExchange crawlingDirectExchange() { + return new DirectExchange(EXCHANGE.CRAWLING_EVENT); } /** * 2. 큐 구성 */ @Bean - Queue queue1() { - return new Queue(QUEUE.Q1, false); + Queue pickRanking() { + return new Queue(QUEUE.PICK_RANKING, false); + } + + @Bean + Queue pickCrawling() { + return new Queue(QUEUE.PICK_CRAWLING, false); } + /** * 3. 큐와 DirectExchange를 바인딩 */ @Bean - Binding directBinding(DirectExchange directExchange, Queue queue1) { + Binding rankingDirectBinding(DirectExchange rankingDirectExchange, Queue pickRanking) { + return BindingBuilder + .bind(pickRanking) + .to(rankingDirectExchange) + .with(""); // 라우팅 키는 필요 없음 + } + + @Bean + Binding crawlingDirectBinding(DirectExchange crawlingDirectExchange, Queue pickCrawling) { return BindingBuilder - .bind(queue1) - .to(directExchange) + .bind(pickCrawling) + .to(crawlingDirectExchange) .with(""); // 라우팅 키는 필요 없음 } diff --git a/backend/baguni-common/src/main/java/baguni/common/event/events/CrawlingEvent.java b/backend/baguni-common/src/main/java/baguni/common/event/events/CrawlingEvent.java new file mode 100644 index 000000000..184079da7 --- /dev/null +++ b/backend/baguni-common/src/main/java/baguni/common/event/events/CrawlingEvent.java @@ -0,0 +1,23 @@ +package baguni.common.event.events; + +import lombok.Getter; + +/** + * @author sangwon + * 픽 생성 시 해당 링크의 크롤링을 하기 위한 이벤트 클래스 + */ +@Getter +public class CrawlingEvent extends Event { + + private final Long linkId; + + private final String url; + + private final String title; + + public CrawlingEvent(Long linkId, String url, String title) { + this.linkId = linkId; + this.url = url; + this.title = title; + } +} diff --git a/backend/baguni-common/src/main/java/baguni/common/event/events/LinkEvent.java b/backend/baguni-common/src/main/java/baguni/common/event/events/LinkEvent.java new file mode 100644 index 000000000..4b65a327a --- /dev/null +++ b/backend/baguni-common/src/main/java/baguni/common/event/events/LinkEvent.java @@ -0,0 +1,13 @@ +package baguni.common.event.events; + +import lombok.Getter; + +@Getter +public class LinkEvent extends Event { + + private final String url; + + public LinkEvent(String url) { + this.url = url; + } +} diff --git a/backend/baguni-common/src/main/java/baguni/common/event/messenger/CrawlingEventMessenger.java b/backend/baguni-common/src/main/java/baguni/common/event/messenger/CrawlingEventMessenger.java new file mode 100644 index 000000000..03aecb408 --- /dev/null +++ b/backend/baguni-common/src/main/java/baguni/common/event/messenger/CrawlingEventMessenger.java @@ -0,0 +1,27 @@ +package baguni.common.event.messenger; + +import org.springframework.amqp.AmqpException; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.stereotype.Component; + +import baguni.common.config.RabbitmqConfig; +import baguni.common.event.events.Event; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class CrawlingEventMessenger implements EventMessenger { + + private final RabbitTemplate rabbitTemplate; + + @Override + public void send(Event event) { + try { + rabbitTemplate.convertAndSend(RabbitmqConfig.EXCHANGE.CRAWLING_EVENT, "", event); + } catch (AmqpException e) { + log.error(e.getMessage(), e); + } + } +} diff --git a/backend/baguni-common/src/main/java/baguni/common/event/messenger/EventMessenger.java b/backend/baguni-common/src/main/java/baguni/common/event/messenger/EventMessenger.java new file mode 100644 index 000000000..3c6e0b498 --- /dev/null +++ b/backend/baguni-common/src/main/java/baguni/common/event/messenger/EventMessenger.java @@ -0,0 +1,8 @@ +package baguni.common.event.messenger; + +import baguni.common.event.events.Event; + +public interface EventMessenger { + + void send(Event event); +} diff --git a/backend/baguni-common/src/main/java/baguni/common/event/messenger/RankingEventMessenger.java b/backend/baguni-common/src/main/java/baguni/common/event/messenger/RankingEventMessenger.java new file mode 100644 index 000000000..355f5b870 --- /dev/null +++ b/backend/baguni-common/src/main/java/baguni/common/event/messenger/RankingEventMessenger.java @@ -0,0 +1,30 @@ +package baguni.common.event.messenger; + +import org.springframework.amqp.AmqpException; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.context.annotation.Primary; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import baguni.common.config.RabbitmqConfig; +import baguni.common.event.events.Event; + +@Slf4j +@Primary +@Component +@RequiredArgsConstructor +public class RankingEventMessenger implements EventMessenger { + + private final RabbitTemplate rabbitTemplate; + + @Override + public void send(Event event) { + try { + rabbitTemplate.convertAndSend(RabbitmqConfig.EXCHANGE.RANKING_EVENT, "", event); + log.info("이벤트 전송 {}", event); + } catch (AmqpException e) { + log.error(e.getMessage(), e); + } + } +} diff --git a/backend/baguni-common/src/main/java/baguni/common/lib/opengraph/Metadata.java b/backend/baguni-common/src/main/java/baguni/common/lib/opengraph/Metadata.java index d8f2e17ba..7a816b96f 100644 --- a/backend/baguni-common/src/main/java/baguni/common/lib/opengraph/Metadata.java +++ b/backend/baguni-common/src/main/java/baguni/common/lib/opengraph/Metadata.java @@ -21,6 +21,8 @@ public class Metadata { public static final MetadataTag TYPE = MetadataTag.of("og:type"); // An image URL which should represent your object within the graph. + public static final MetadataTag ICON = MetadataTag.of("icon"); + public static final MetadataTag IMAGE = MetadataTag.of("image"); public static final MetadataTag OG_IMAGE = MetadataTag.of("og:image"); diff --git a/backend/baguni-common/src/main/java/baguni/common/lib/opengraph/OpenGraph.java b/backend/baguni-common/src/main/java/baguni/common/lib/opengraph/OpenGraph.java index c61120686..6cdf74b1a 100644 --- a/backend/baguni-common/src/main/java/baguni/common/lib/opengraph/OpenGraph.java +++ b/backend/baguni-common/src/main/java/baguni/common/lib/opengraph/OpenGraph.java @@ -11,19 +11,12 @@ */ public class OpenGraph { - /** - * timeout setting for connection - */ - private static final int TIMEOUT_SECONDS = 2; - private final Map openGraphTags; - public OpenGraph(String uri) throws OpenGraphException { + public OpenGraph(String uri, OpenGraphReader openGraphReader) throws OpenGraphException { try { - var URI = new URI(uri); - var openGraphOption = new OpenGraphOption(TIMEOUT_SECONDS); - OpenGraphReader openGraphReader = new OpenGraphReader(openGraphOption); - this.openGraphTags = openGraphReader.read(URI); + var parsedUri = new URI(uri); + this.openGraphTags = openGraphReader.read(parsedUri); } catch (URISyntaxException e) { throw new OpenGraphException("Invalid URI: " + uri, e); } diff --git a/backend/baguni-common/src/main/java/baguni/common/lib/opengraph/OpenGraphReader.java b/backend/baguni-common/src/main/java/baguni/common/lib/opengraph/OpenGraphReader.java index bf9cf0a8c..b1ebcbea1 100644 --- a/backend/baguni-common/src/main/java/baguni/common/lib/opengraph/OpenGraphReader.java +++ b/backend/baguni-common/src/main/java/baguni/common/lib/opengraph/OpenGraphReader.java @@ -1,68 +1,8 @@ package baguni.common.lib.opengraph; -import java.io.IOException; import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.nio.charset.Charset; -import java.util.HashMap; import java.util.Map; -import org.jsoup.Jsoup; -import org.jsoup.nodes.Document; -import org.jsoup.nodes.Element; -import org.jsoup.select.Elements; - -/** - * @author minkyeu kim - * 현재 구현은 Jsoup으로 Head, Body 등 전체 페이지를 파싱합니다. - * 최적화를 고려한다면 부분만 input stream으로 읽어서 - * 필요한 부분만 파싱 하는 방식으로 개선이 필요합니다. - * TODO: 추후 HttpClient 말고 RestClient로 리팩토링 - */ -public class OpenGraphReader { - - private final OpenGraphOption openGraphOption; - - public OpenGraphReader(OpenGraphOption openGraphOption) { - this.openGraphOption = openGraphOption; - } - - public Map read(URI uri) throws OpenGraphException { - Map result = new HashMap<>(); - HttpRequest httpRequest = HttpRequest.newBuilder(uri) - .header("User-Agent", openGraphOption.getUserAgent()) - .build(); - HttpClient httpClient = HttpClient.newBuilder() - .connectTimeout(openGraphOption.getHttpRequestTimeoutyDuration()) - .followRedirects(HttpClient.Redirect.NORMAL) - .build(); - try { - HttpResponse response = httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofString( - Charset.forName(openGraphOption.getHttpResponseDefaultCharsetName()))); - Document htmlResponse = Jsoup.parse(response.body()); - Element head = htmlResponse.head(); - - String title = head.select("title").text(); - result.put("title", title); - - Elements metaTags = head.select("meta"); - metaTags.forEach(meta -> { - // og 데이터가 없는 경우, meta name 속성 값(image, description)을 활용한다. - boolean isOgProperty = meta.hasAttr("property") && meta.attr("property").startsWith("og:"); - boolean isOgNameImage = meta.hasAttr("name") && meta.attr("name").contains("image"); - boolean isOgNameDescription = meta.hasAttr("name") && meta.attr("name").contains("description"); - - if (isOgProperty || isOgNameImage || isOgNameDescription) { - String key = isOgProperty ? meta.attr("property") : meta.attr("name"); - String value = meta.attr("content"); - result.put(key, value); - } - }); - } catch (IOException | InterruptedException e) { - throw new OpenGraphException("Error occurred when reading OG tags.", e); - } - return result; - } +public interface OpenGraphReader { + Map read(URI uri) throws OpenGraphException; } diff --git a/backend/baguni-common/src/main/java/baguni/common/lib/opengraph/OpenGraphReaderJsoup.java b/backend/baguni-common/src/main/java/baguni/common/lib/opengraph/OpenGraphReaderJsoup.java new file mode 100644 index 000000000..8f785c1e2 --- /dev/null +++ b/backend/baguni-common/src/main/java/baguni/common/lib/opengraph/OpenGraphReaderJsoup.java @@ -0,0 +1,69 @@ +package baguni.common.lib.opengraph; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.Charset; +import java.util.HashMap; +import java.util.Map; + +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; +import org.jsoup.nodes.Element; +import org.jsoup.select.Elements; + +/** + * @author minkyeu kim + * 현재 구현은 Jsoup으로 Head, Body 등 전체 페이지를 파싱합니다. + * 최적화를 고려한다면 부분만 input stream으로 읽어서 + * 필요한 부분만 파싱 하는 방식으로 개선이 필요합니다. + * TODO: 추후 HttpClient 말고 RestClient로 리팩토링 + */ +public class OpenGraphReaderJsoup implements OpenGraphReader { + + private final OpenGraphOption openGraphOption; + + public OpenGraphReaderJsoup(OpenGraphOption openGraphOption) { + this.openGraphOption = openGraphOption; + } + + @Override + public Map read(URI uri) throws OpenGraphException { + Map result = new HashMap<>(); + HttpRequest httpRequest = HttpRequest.newBuilder(uri) + .header("User-Agent", openGraphOption.getUserAgent()) + .build(); + HttpClient httpClient = HttpClient.newBuilder() + .connectTimeout(openGraphOption.getHttpRequestTimeoutyDuration()) + .followRedirects(HttpClient.Redirect.NORMAL) + .build(); + try { + HttpResponse response = httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofString( + Charset.forName(openGraphOption.getHttpResponseDefaultCharsetName()))); + Document htmlResponse = Jsoup.parse(response.body()); + Element head = htmlResponse.head(); + + String title = head.select("title").text(); + result.put("title", title); + + Elements metaTags = head.select("meta"); + metaTags.forEach(meta -> { + // og 데이터가 없는 경우, meta name 속성 값(image, description)을 활용한다. + boolean isOgProperty = meta.hasAttr("property") && meta.attr("property").startsWith("og:"); + boolean isOgNameImage = meta.hasAttr("name") && meta.attr("name").contains("image"); + boolean isOgNameDescription = meta.hasAttr("name") && meta.attr("name").contains("description"); + + if (isOgProperty || isOgNameImage || isOgNameDescription) { + String key = isOgProperty ? meta.attr("property") : meta.attr("name"); + String value = meta.attr("content"); + result.put(key, value); + } + }); + } catch (IOException | InterruptedException e) { + throw new OpenGraphException("Error occurred when reading OG tags.", e); + } + return result; + } +} diff --git a/backend/baguni-common/src/main/java/baguni/common/lib/opengraph/OpenGraphReaderSelenium.java b/backend/baguni-common/src/main/java/baguni/common/lib/opengraph/OpenGraphReaderSelenium.java new file mode 100644 index 000000000..22f403382 --- /dev/null +++ b/backend/baguni-common/src/main/java/baguni/common/lib/opengraph/OpenGraphReaderSelenium.java @@ -0,0 +1,115 @@ +package baguni.common.lib.opengraph; + +import java.net.URI; +import java.time.Duration; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.openqa.selenium.By; +import org.openqa.selenium.JavascriptExecutor; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; +import org.openqa.selenium.chrome.ChromeDriver; +import org.openqa.selenium.chrome.ChromeOptions; +import org.openqa.selenium.support.ui.ExpectedConditions; +import org.openqa.selenium.support.ui.WebDriverWait; + +import io.github.bonigarcia.wdm.WebDriverManager; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class OpenGraphReaderSelenium implements OpenGraphReader { + + private final OpenGraphOption openGraphOption; + + public OpenGraphReaderSelenium(OpenGraphOption openGraphOption) { + this.openGraphOption = openGraphOption; + } + + /** + * @author sangwon + * Selenium을 이용하여 JavaScript에 의해 동적으로 만들어지는 OG 데이터가 있는 경우도 처리가 가능 + */ + @Override + public Map read(URI uri) throws OpenGraphException { + Map result = new HashMap<>(); + + // 1. WebDriver 설정 + // WebDriverManager 사용 시, 별도의 드라이버 설치 및 경로 지정 없이 자동 세팅 + WebDriverManager.chromedriver().setup(); + + // 2. 크롬 옵션 설정 + WebDriver driver = chromeOptionSetting(); + + try { + // 페이지 로딩 타임아웃 설정 + driver.manage().timeouts().pageLoadTimeout(openGraphOption.getHttpRequestTimeoutyDuration()); + + // 3. 페이지 로딩 + driver.get(uri.toString()); + + WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10)); + wait.until(webDriver -> ((JavascriptExecutor) driver) + .executeScript("return document.readyState").equals("complete")); // 초기 로딩 완료될 때까지 대기 + Thread.sleep(300); + + // 제목 저장 + String title = driver.getTitle(); + result.put("title", title); + + // favicon 저장 + // 모든 사이트에 /favicon.ico가 있는 것이 아닌 것으로 확인하여 selenium으로 favicon url을 가져오도록 함. + try { + WebElement icon = driver.findElement(By.cssSelector("link[rel='icon'], link[rel='shortcut icon']")); + result.put("icon", icon.getAttribute("href")); + } catch (org.openqa.selenium.NoSuchElementException e) { + log.info("해당 사이트에 icon 없음 : {}", uri); + } + + // 4. og 태그, 메타태그 가져오기 + List metaTags = driver.findElements(By.tagName("meta")); + for (WebElement meta : metaTags) { + String propertyAttr = meta.getAttribute("property"); // property 속성 + String nameAttr = meta.getAttribute("name"); // name 속성 + String itempropAttr = meta.getAttribute("itemprop"); // itemprop 속성 + + boolean isOgProperty = propertyAttr != null && propertyAttr.startsWith("og:"); + boolean isOgNameImage = nameAttr != null && nameAttr.contains("image"); + boolean isOgNameDescription = nameAttr != null && nameAttr.contains("description"); + boolean isItempropImage = itempropAttr != null && itempropAttr.equals("image"); + + if (isOgProperty || isOgNameImage || isItempropImage) { + String key = isOgProperty ? propertyAttr : (isOgNameImage ? nameAttr : itempropAttr); + String value = meta.getAttribute("content"); + result.put(key, value); + } + + if (isOgNameDescription) { + String value = meta.getAttribute("content"); + result.put(nameAttr, value); + } + } + } catch (Exception e) { + throw new OpenGraphException("Error occurred when reading OG tags via Selenium, url : " + uri, e); + } finally { + // 5. 리소스 해제 + driver.quit(); + } + + return result; + } + + private WebDriver chromeOptionSetting() { + ChromeOptions options = new ChromeOptions(); + options.addArguments("--headless"); // 브라우저 UI 없이 백그라운드로 동작 + options.addArguments("--user-agent=" + openGraphOption.getUserAgent()); + options.addArguments("--start-maximized"); + options.addArguments("--disable-popup-blocking"); // 팝업 안뜨게 + options.addArguments("--remote-allow-origins=*"); // 모든 출처에서의 연결을 허용, 자동화된 테스트나 CORS 제한을 우회할 때 유용 + // options.addArguments("--disable-dev-shm-usage"); // Chrome이 /dev/shm 대신 /tmp 디렉토리를 사용, /tmp는 일반적인 파일 시스템으로, 크기 제한이 없어 메모리 부족 문제를 방지 + + // WebDriver 생성 + return new ChromeDriver(options); + } +} diff --git a/backend/baguni-domain/build.gradle b/backend/baguni-domain/build.gradle new file mode 100644 index 000000000..2264dde1c --- /dev/null +++ b/backend/baguni-domain/build.gradle @@ -0,0 +1,43 @@ +plugins { + id 'java' +} + +group = 'kernel360' +version = '0.0.1-SNAPSHOT' + +repositories { + mavenCentral() +} + +dependencies { + implementation project(":baguni-common") + + // for @Entity + @Repository + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + runtimeOnly 'com.mysql:mysql-connector-j' + + // Querydsl Class Generation (ex. QPick.java) + implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' + annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta" + annotationProcessor "jakarta.annotation:jakarta.annotation-api" + annotationProcessor "jakarta.persistence:jakarta.persistence-api" + + // Sql logging formatter / https://www.baeldung.com/java-p6spy-intercept-sql-logging + implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.9.2' + + // flyway + implementation 'org.flywaydb:flyway-core' + implementation "org.flywaydb:flyway-mysql" +} + +tasks.named('test') { + enabled = false +} + +bootJar { + enabled = false +} + +jar { + enabled = true +} \ No newline at end of file diff --git a/backend/baguni-domain/src/main/java/baguni/domain/annotation/LoginUserIdDistributedLock.java b/backend/baguni-domain/src/main/java/baguni/domain/annotation/LoginUserIdDistributedLock.java new file mode 100644 index 000000000..2891a74aa --- /dev/null +++ b/backend/baguni-domain/src/main/java/baguni/domain/annotation/LoginUserIdDistributedLock.java @@ -0,0 +1,18 @@ +package baguni.domain.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * @author sangwon + * key : 락을 걸 이름을 지정합니다. + * 실제로 락 이름은 key + _ + userId로 설정합니다. + * 분산 락을 걸어서 동시성 문제를 제어합니다. + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface LoginUserIdDistributedLock { + long timeout() default 3000; +} diff --git a/backend/baguni-domain/src/main/java/baguni/domain/config/JpaAuditingConfig.java b/backend/baguni-domain/src/main/java/baguni/domain/config/JpaAuditingConfig.java new file mode 100644 index 000000000..869deaee3 --- /dev/null +++ b/backend/baguni-domain/src/main/java/baguni/domain/config/JpaAuditingConfig.java @@ -0,0 +1,9 @@ +package baguni.domain.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@Configuration +@EnableJpaAuditing +public class JpaAuditingConfig { +} diff --git a/backend/baguni-domain/src/main/java/baguni/domain/config/P6SpySqlLoggerConfig.java b/backend/baguni-domain/src/main/java/baguni/domain/config/P6SpySqlLoggerConfig.java new file mode 100644 index 000000000..45014bde8 --- /dev/null +++ b/backend/baguni-domain/src/main/java/baguni/domain/config/P6SpySqlLoggerConfig.java @@ -0,0 +1,62 @@ +package baguni.domain.config; + +import static org.springframework.util.StringUtils.*; + +import java.sql.SQLException; +import java.util.Locale; + +import org.hibernate.engine.jdbc.internal.FormatStyle; +import org.springframework.stereotype.Component; + +import com.p6spy.engine.common.ConnectionInformation; +import com.p6spy.engine.event.JdbcEventListener; +import com.p6spy.engine.logging.Category; +import com.p6spy.engine.spy.P6SpyOptions; +import com.p6spy.engine.spy.appender.MessageFormattingStrategy; + +/** + * Jdbc가 DB Connection을 얻은 이후에 로깅 포맷을 P6SpyOptions가 가로채도록 하는 Bean입니다. + */ +@Component +public class P6SpySqlLoggerConfig extends JdbcEventListener implements MessageFormattingStrategy { + + @Override + public void onAfterGetConnection(ConnectionInformation connectionInformation, SQLException e) { + P6SpyOptions.getActiveInstance().setLogMessageFormat(this.getClass().getName()); + } + + @Override + public String formatMessage(int connectionId, String now, long elapsed, String category, + String prepared, String sql, String url) { + return highlight(format(category, sql)); + } + + private String highlight(String sql) { + return FormatStyle.HIGHLIGHT.getFormatter().format(sql); + } + + private String format(String category, String sql) { + if (hasText(sql) && isStatement(category)) { + if (isDdl(trim(sql))) { + return FormatStyle.DDL.getFormatter().format(sql); + } + return FormatStyle.BASIC.getFormatter().format(sql); + } + return sql; + } + + private static boolean isDdl(String trimmedSql) { + return trimmedSql.startsWith("create") + || trimmedSql.startsWith("alter") + || trimmedSql.startsWith("drop") + || trimmedSql.startsWith("comment"); + } + + private static String trim(String sql) { + return sql.trim().toLowerCase(Locale.ROOT); + } + + private static boolean isStatement(String category) { + return Category.STATEMENT.getName().equals(category); + } +} diff --git a/backend/baguni-domain/src/main/java/baguni/domain/config/QuerydslConfig.java b/backend/baguni-domain/src/main/java/baguni/domain/config/QuerydslConfig.java new file mode 100644 index 000000000..f8cf77f73 --- /dev/null +++ b/backend/baguni-domain/src/main/java/baguni/domain/config/QuerydslConfig.java @@ -0,0 +1,17 @@ +package baguni.domain.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.querydsl.jpa.impl.JPAQueryFactory; + +import jakarta.persistence.EntityManager; + +@Configuration +public class QuerydslConfig { + + @Bean + JPAQueryFactory jpaQueryFactory(EntityManager em) { + return new JPAQueryFactory(em); + } +} diff --git a/backend/baguni-domain/src/main/java/baguni/domain/exception/folder/ApiFolderErrorCode.java b/backend/baguni-domain/src/main/java/baguni/domain/exception/folder/ApiFolderErrorCode.java new file mode 100644 index 000000000..303c08ce9 --- /dev/null +++ b/backend/baguni-domain/src/main/java/baguni/domain/exception/folder/ApiFolderErrorCode.java @@ -0,0 +1,70 @@ +package baguni.domain.exception.folder; + +import org.springframework.http.HttpStatus; + +import baguni.common.exception.base.ApiErrorCode; +import baguni.common.exception.level.ErrorLevel; + +public enum ApiFolderErrorCode implements ApiErrorCode { + + /** + * Folder Error Code (FO) + */ + FOLDER_NOT_FOUND + ("FO-000", HttpStatus.BAD_REQUEST, "존재하지 않는 폴더", ErrorLevel.SHOULD_NOT_HAPPEN()), + FOLDER_ALREADY_EXIST + ("FO-001", HttpStatus.BAD_REQUEST, "이미 존재하는 폴더 이름", ErrorLevel.CAN_HAPPEN()), + FOLDER_ACCESS_DENIED + ("FO-002", HttpStatus.FORBIDDEN, "접근할 수 없는 폴더", ErrorLevel.SHOULD_NOT_HAPPEN()), + BASIC_FOLDER_CANNOT_CHANGED + ("FO-003", HttpStatus.BAD_REQUEST, "기본폴더는 변경(수정/삭제/이동)할 수 없음", ErrorLevel.MUST_NEVER_HAPPEN()), + CANNOT_DELETE_FOLDER_NOT_IN_RECYCLE_BIN + ("FO-004", HttpStatus.BAD_REQUEST, "휴지통 안에 있는 폴더만 삭제할 수 있음", ErrorLevel.MUST_NEVER_HAPPEN()), + INVALID_FOLDER_TYPE + ("FO-005", HttpStatus.NOT_IMPLEMENTED, "미구현 폴더 타입에 대한 서비스 요청", ErrorLevel.MUST_NEVER_HAPPEN()), + BASIC_FOLDER_ALREADY_EXISTS + ("FO-006", HttpStatus.NOT_ACCEPTABLE, "기본 폴더는 1개만 존재할 수 있음", ErrorLevel.MUST_NEVER_HAPPEN()), + INVALID_TARGET + ("FO-007", HttpStatus.NOT_ACCEPTABLE, "휴지통 또는 미분류 폴더에 폴더를 (생성/이동)할 수 없음", ErrorLevel.SHOULD_NOT_HAPPEN()), + INVALID_PARENT_FOLDER + ("FO-008", HttpStatus.NOT_ACCEPTABLE, "부모 폴더가 올바르지 않음", ErrorLevel.SHOULD_NOT_HAPPEN()), + // TODO: folder depth 추가 시 예외 삭제 예정 + ROOT_FOLDER_SEARCH_NOT_ALLOWED + ("FO-009", HttpStatus.NOT_ACCEPTABLE, "루트 폴더에 대한 검색은 허용되지 않음.", ErrorLevel.SHOULD_NOT_HAPPEN()), + ; + + private final String code; + + private final HttpStatus httpStatus; + + private final String errorMessage; + + private final ErrorLevel errorLevel; + + ApiFolderErrorCode(String code, HttpStatus status, String message, ErrorLevel errorLevel) { + this.code = code; + this.httpStatus = status; + this.errorMessage = message; + this.errorLevel = errorLevel; + } + + @Override + public String getCode() { + return this.code; + } + + @Override + public String getMessage() { + return this.errorMessage; + } + + @Override + public HttpStatus getHttpStatus() { + return this.httpStatus; + } + + @Override + public ErrorLevel getErrorLevel() { + return this.errorLevel; + } +} diff --git a/backend/baguni-domain/src/main/java/baguni/domain/exception/folder/ApiFolderException.java b/backend/baguni-domain/src/main/java/baguni/domain/exception/folder/ApiFolderException.java new file mode 100644 index 000000000..d66e9cfbf --- /dev/null +++ b/backend/baguni-domain/src/main/java/baguni/domain/exception/folder/ApiFolderException.java @@ -0,0 +1,55 @@ +package baguni.domain.exception.folder; + +import baguni.common.exception.base.ApiErrorCode; +import baguni.common.exception.base.ApiException; + +public class ApiFolderException extends ApiException { + + private ApiFolderException(ApiErrorCode errorCode) { + super(errorCode); + } + + /** + * TODO: Implement static factory method + */ + public static ApiFolderException FOLDER_NOT_FOUND() { + return new ApiFolderException(ApiFolderErrorCode.FOLDER_NOT_FOUND); + } + + public static ApiFolderException FOLDER_ALREADY_EXIST() { + return new ApiFolderException(ApiFolderErrorCode.FOLDER_ALREADY_EXIST); + } + + public static ApiFolderException FOLDER_ACCESS_DENIED() { + return new ApiFolderException(ApiFolderErrorCode.FOLDER_ACCESS_DENIED); + } + + public static ApiFolderException BASIC_FOLDER_CANNOT_CHANGED() { + return new ApiFolderException(ApiFolderErrorCode.BASIC_FOLDER_CANNOT_CHANGED); + } + + public static ApiFolderException CANNOT_DELETE_FOLDER_NOT_IN_RECYCLE_BIN() { + return new ApiFolderException(ApiFolderErrorCode.CANNOT_DELETE_FOLDER_NOT_IN_RECYCLE_BIN); + } + + public static ApiFolderException INVALID_FOLDER_TYPE() { + return new ApiFolderException(ApiFolderErrorCode.INVALID_FOLDER_TYPE); + } + + public static ApiFolderException BASIC_FOLDER_ALREADY_EXISTS() { + return new ApiFolderException(ApiFolderErrorCode.BASIC_FOLDER_ALREADY_EXISTS); + } + + public static ApiFolderException INVALID_TARGET() { + return new ApiFolderException(ApiFolderErrorCode.INVALID_TARGET); + } + + public static ApiFolderException INVALID_PARENT_FOLDER() { + return new ApiFolderException(ApiFolderErrorCode.INVALID_PARENT_FOLDER); + } + + public static ApiFolderException ROOT_FOLDER_SEARCH_NOT_ALLOWED() { + return new ApiFolderException(ApiFolderErrorCode.ROOT_FOLDER_SEARCH_NOT_ALLOWED); + } + +} diff --git a/backend/baguni-domain/src/main/java/baguni/domain/exception/link/ApiLinkErrorCode.java b/backend/baguni-domain/src/main/java/baguni/domain/exception/link/ApiLinkErrorCode.java new file mode 100644 index 000000000..c57b2181b --- /dev/null +++ b/backend/baguni-domain/src/main/java/baguni/domain/exception/link/ApiLinkErrorCode.java @@ -0,0 +1,70 @@ +package baguni.domain.exception.link; + +import org.springframework.http.HttpStatus; + +import baguni.common.exception.base.ApiErrorCode; +import baguni.common.exception.level.ErrorLevel; + +public enum ApiLinkErrorCode implements ApiErrorCode { + + /** + * Link Error Code (LI) + */ + LINK_NOT_FOUND + ("LI-000", HttpStatus.NOT_FOUND, "존재하지 않는 링크", ErrorLevel.SHOULD_NOT_HAPPEN()), + + LINK_HAS_PICKS + ("LI-001", HttpStatus.BAD_REQUEST, "링크를 픽한 사람이 존재", ErrorLevel.SHOULD_NOT_HAPPEN()), + + LINK_ALREADY_EXIST + ("LI-002", HttpStatus.BAD_REQUEST, "이미 존재하는 링크(URL)", ErrorLevel.CAN_HAPPEN()), + + LINK_OG_TAG_UPDATE_FAILURE + ("LI-003", HttpStatus.NOT_FOUND, "OG 태그 업데이트를 위한 크롤링 요청 실패", ErrorLevel.CAN_HAPPEN()), + + ; + + // ------------------------------------------------------------ + // 하단 코드는 모든 ApiErrorCode 들에 반드시 포함되야 합니다. + // 새로운 ErrorCode 구현시 복사 붙여넣기 해주세요. + + private final String code; + + private final HttpStatus httpStatus; + + private final String errorMessage; + + private final ErrorLevel errorLevel; + + ApiLinkErrorCode(String code, HttpStatus status, String message, ErrorLevel errorLevel) { + this.code = code; + this.httpStatus = status; + this.errorMessage = message; + this.errorLevel = errorLevel; + } + + @Override + public String getCode() { + return this.code; + } + + @Override + public String getMessage() { + return this.errorMessage; + } + + @Override + public HttpStatus getHttpStatus() { + return this.httpStatus; + } + + @Override + public ErrorLevel getErrorLevel() { + return this.errorLevel; + } + + @Override + public String toString() { + return convertCodeToString(this); + } +} diff --git a/backend/baguni-domain/src/main/java/baguni/domain/exception/link/ApiLinkException.java b/backend/baguni-domain/src/main/java/baguni/domain/exception/link/ApiLinkException.java new file mode 100644 index 000000000..b9f0367f4 --- /dev/null +++ b/backend/baguni-domain/src/main/java/baguni/domain/exception/link/ApiLinkException.java @@ -0,0 +1,30 @@ +package baguni.domain.exception.link; + +import baguni.common.exception.base.ApiErrorCode; +import baguni.common.exception.base.ApiException; + +public class ApiLinkException extends ApiException { + + private ApiLinkException(ApiErrorCode errorCode) { + super(errorCode); + } + + /** + * TODO: Implement static factory method + */ + public static ApiLinkException LINK_NOT_FOUND() { + return new ApiLinkException(ApiLinkErrorCode.LINK_NOT_FOUND); + } + + public static ApiLinkException LINK_HAS_PICKS() { + return new ApiLinkException(ApiLinkErrorCode.LINK_HAS_PICKS); + } + + public static ApiLinkException LINK_ALREADY_EXISTS() { + return new ApiLinkException(ApiLinkErrorCode.LINK_ALREADY_EXIST); + } + + public static ApiLinkException LINK_OG_TAG_UPDATE_FAILURE() { + return new ApiLinkException(ApiLinkErrorCode.LINK_OG_TAG_UPDATE_FAILURE); + } +} diff --git a/backend/baguni-domain/src/main/java/baguni/domain/exception/pick/ApiPickErrorCode.java b/backend/baguni-domain/src/main/java/baguni/domain/exception/pick/ApiPickErrorCode.java new file mode 100644 index 000000000..1bef65e39 --- /dev/null +++ b/backend/baguni-domain/src/main/java/baguni/domain/exception/pick/ApiPickErrorCode.java @@ -0,0 +1,66 @@ +package baguni.domain.exception.pick; + +import org.springframework.http.HttpStatus; + +import baguni.common.exception.base.ApiErrorCode; +import baguni.common.exception.level.ErrorLevel; + +public enum ApiPickErrorCode implements ApiErrorCode { + + /** + * Pick Error Code (PK) + */ + PICK_NOT_FOUND + ("PK-000", HttpStatus.NOT_FOUND, "존재하지 않는 Pick", ErrorLevel.CAN_HAPPEN()), + PICK_ALREADY_EXIST + ("PK-001", HttpStatus.BAD_REQUEST, "이미 존재하는 Pick", ErrorLevel.CAN_HAPPEN()), + PICK_UNAUTHORIZED_USER_ACCESS + ("PK-002", HttpStatus.UNAUTHORIZED, "잘못된 Pick 접근, 다른 사용자의 Pick에 접근", ErrorLevel.SHOULD_NOT_HAPPEN()), + PICK_UNAUTHORIZED_ROOT_ACCESS + ("PK-003", HttpStatus.UNAUTHORIZED, "잘못된 Pick 접근, 폴더가 아닌 Root에 접근", ErrorLevel.SHOULD_NOT_HAPPEN()), + PICK_DELETE_NOT_ALLOWED + ("PK-004", HttpStatus.NOT_ACCEPTABLE, "휴지통이 아닌 폴더에서 픽 삭제는 허용되지 않음", ErrorLevel.SHOULD_NOT_HAPPEN()), + PICK_TITLE_TOO_LONG + ("PK-005", HttpStatus.BAD_REQUEST, "픽 제목이 허용된 최대 길이를 초과했습니다", ErrorLevel.CAN_HAPPEN()), + ; + + private final String code; + + private final HttpStatus httpStatus; + + private final String errorMessage; + + private final ErrorLevel logLevel; + + ApiPickErrorCode(String code, HttpStatus status, String message, ErrorLevel errorLevel) { + this.code = code; + this.httpStatus = status; + this.errorMessage = message; + this.logLevel = errorLevel; + } + + @Override + public String getCode() { + return this.code; + } + + @Override + public String getMessage() { + return this.errorMessage; + } + + @Override + public HttpStatus getHttpStatus() { + return this.httpStatus; + } + + @Override + public ErrorLevel getErrorLevel() { + return this.logLevel; + } + + @Override + public String toString() { + return convertCodeToString(this); + } +} diff --git a/backend/baguni-domain/src/main/java/baguni/domain/exception/pick/ApiPickException.java b/backend/baguni-domain/src/main/java/baguni/domain/exception/pick/ApiPickException.java new file mode 100644 index 000000000..affeb10b9 --- /dev/null +++ b/backend/baguni-domain/src/main/java/baguni/domain/exception/pick/ApiPickException.java @@ -0,0 +1,35 @@ +package baguni.domain.exception.pick; + +import baguni.common.exception.base.ApiErrorCode; +import baguni.common.exception.base.ApiException; + +public class ApiPickException extends ApiException { + + private ApiPickException(ApiErrorCode errorCode) { + super(errorCode); + } + + public static ApiPickException PICK_NOT_FOUND() { + return new ApiPickException(ApiPickErrorCode.PICK_NOT_FOUND); + } + + public static ApiPickException PICK_MUST_BE_UNIQUE_FOR_A_URL() { + return new ApiPickException(ApiPickErrorCode.PICK_ALREADY_EXIST); + } + + public static ApiPickException PICK_UNAUTHORIZED_USER_ACCESS() { + return new ApiPickException(ApiPickErrorCode.PICK_UNAUTHORIZED_USER_ACCESS); + } + + public static ApiPickException PICK_UNAUTHORIZED_ROOT_ACCESS() { + return new ApiPickException(ApiPickErrorCode.PICK_UNAUTHORIZED_ROOT_ACCESS); + } + + public static ApiPickException PICK_DELETE_NOT_ALLOWED() { + return new ApiPickException(ApiPickErrorCode.PICK_DELETE_NOT_ALLOWED); + } + + public static ApiPickException PICK_TITLE_TOO_LONG() { + return new ApiPickException(ApiPickErrorCode.PICK_TITLE_TOO_LONG); + } +} diff --git a/backend/baguni-domain/src/main/java/baguni/domain/exception/sharedFolder/ApiSharedFolderErrorCode.java b/backend/baguni-domain/src/main/java/baguni/domain/exception/sharedFolder/ApiSharedFolderErrorCode.java new file mode 100644 index 000000000..cca6b77e0 --- /dev/null +++ b/backend/baguni-domain/src/main/java/baguni/domain/exception/sharedFolder/ApiSharedFolderErrorCode.java @@ -0,0 +1,65 @@ +package baguni.domain.exception.sharedFolder; + +import org.springframework.http.HttpStatus; + +import baguni.common.exception.base.ApiErrorCode; +import baguni.common.exception.level.ErrorLevel; + +public enum ApiSharedFolderErrorCode implements ApiErrorCode { + + /** + * Pick Error Code (PK) + */ + SHARED_FOLDER_NOT_FOUND + ("SF-000", HttpStatus.NOT_FOUND, "존재하지 않는 SharedFolder", ErrorLevel.CAN_HAPPEN()), + SHARED_FOLDER_UNAUTHORIZED + ("SF-001", HttpStatus.UNAUTHORIZED, "잘못된 SharedFolder 접근, 다른 사용자의 SharedFolder에 접근", + ErrorLevel.SHOULD_NOT_HAPPEN()), + FOLDER_CANT_BE_SHARED + ("SF-002", HttpStatus.UNAUTHORIZED, "해당 폴더는 공유될 수 없는 폴더입니다!", + ErrorLevel.MUST_NEVER_HAPPEN()), + FOLDER_ALREADY_SHARED + ("SF-003", HttpStatus.CONFLICT, "이미 공유된 폴더는 다시 공유 상태가 될 수 없습니다.", + ErrorLevel.SHOULD_NOT_HAPPEN()), + ; + + private final String code; + + private final HttpStatus httpStatus; + + private final String errorMessage; + + private final ErrorLevel logLevel; + + ApiSharedFolderErrorCode(String code, HttpStatus status, String message, ErrorLevel errorLevel) { + this.code = code; + this.httpStatus = status; + this.errorMessage = message; + this.logLevel = errorLevel; + } + + @Override + public String getCode() { + return this.code; + } + + @Override + public String getMessage() { + return this.errorMessage; + } + + @Override + public HttpStatus getHttpStatus() { + return this.httpStatus; + } + + @Override + public ErrorLevel getErrorLevel() { + return this.logLevel; + } + + @Override + public String toString() { + return convertCodeToString(this); + } +} diff --git a/backend/baguni-domain/src/main/java/baguni/domain/exception/sharedFolder/ApiSharedFolderException.java b/backend/baguni-domain/src/main/java/baguni/domain/exception/sharedFolder/ApiSharedFolderException.java new file mode 100644 index 000000000..0d0616ee1 --- /dev/null +++ b/backend/baguni-domain/src/main/java/baguni/domain/exception/sharedFolder/ApiSharedFolderException.java @@ -0,0 +1,27 @@ +package baguni.domain.exception.sharedFolder; + +import baguni.common.exception.base.ApiErrorCode; +import baguni.common.exception.base.ApiException; + +public class ApiSharedFolderException extends ApiException { + + private ApiSharedFolderException(ApiErrorCode errorCode) { + super(errorCode); + } + + public static ApiSharedFolderException SHARED_FOLDER_NOT_FOUND() { + return new ApiSharedFolderException(ApiSharedFolderErrorCode.SHARED_FOLDER_NOT_FOUND); + } + + public static ApiSharedFolderException SHARED_FOLDER_UNAUTHORIZED() { + return new ApiSharedFolderException(ApiSharedFolderErrorCode.SHARED_FOLDER_UNAUTHORIZED); + } + + public static ApiSharedFolderException FOLDER_CANNOT_BE_SHARED() { + return new ApiSharedFolderException(ApiSharedFolderErrorCode.FOLDER_CANT_BE_SHARED); + } + + public static ApiSharedFolderException FOLDER_ALREADY_SHARED() { + return new ApiSharedFolderException(ApiSharedFolderErrorCode.FOLDER_ALREADY_SHARED); + } +} diff --git a/backend/baguni-domain/src/main/java/baguni/domain/exception/tag/ApiTagErrorCode.java b/backend/baguni-domain/src/main/java/baguni/domain/exception/tag/ApiTagErrorCode.java new file mode 100644 index 000000000..4346424df --- /dev/null +++ b/backend/baguni-domain/src/main/java/baguni/domain/exception/tag/ApiTagErrorCode.java @@ -0,0 +1,70 @@ +package baguni.domain.exception.tag; + +import org.springframework.http.HttpStatus; + +import baguni.common.exception.base.ApiErrorCode; +import baguni.common.exception.level.ErrorLevel; + +public enum ApiTagErrorCode implements ApiErrorCode { + + /** + * Tag Error Code (TG) + */ + TAG_NOT_FOUND + ("TG-000", HttpStatus.BAD_REQUEST, "존재하지 않는 태그", ErrorLevel.CAN_HAPPEN()), + TAG_ALREADY_EXIST + ("TG-001", HttpStatus.BAD_REQUEST, "이미 존재하는 태그", ErrorLevel.CAN_HAPPEN()), + TAG_INVALID_NAME + ("TG-002", HttpStatus.BAD_REQUEST, "유효하지 않은 태그 이름", ErrorLevel.CAN_HAPPEN()), + UNAUTHORIZED_TAG_ACCESS + ("TG-003", HttpStatus.UNAUTHORIZED, "잘못된 태그 접근", ErrorLevel.SHOULD_NOT_HAPPEN()), + TAG_INVALID_ORDER + ("TG-004", HttpStatus.BAD_REQUEST, "유효하지 않은 태그 순서", ErrorLevel.SHOULD_NOT_HAPPEN()), + TAG_NAME_TOO_LONG + ("TG-005", HttpStatus.BAD_REQUEST, "태그 이름이 허용된 최대 길이를 초과했습니다", ErrorLevel.CAN_HAPPEN()), + ; + + // ------------------------------------------------------------ + // 하단 코드는 모든 ApiErrorCode 들에 반드시 포함되야 합니다. + // 새로운 ErrorCode 구현시 복사 붙여넣기 해주세요. + + private final String code; + + private final HttpStatus httpStatus; + + private final String errorMessage; + + private final ErrorLevel logLevel; + + ApiTagErrorCode(String code, HttpStatus status, String message, ErrorLevel errorLevel) { + this.code = code; + this.httpStatus = status; + this.errorMessage = message; + this.logLevel = errorLevel; + } + + @Override + public String getCode() { + return this.code; + } + + @Override + public String getMessage() { + return this.errorMessage; + } + + @Override + public HttpStatus getHttpStatus() { + return this.httpStatus; + } + + @Override + public ErrorLevel getErrorLevel() { + return this.logLevel; + } + + @Override + public String toString() { + return convertCodeToString(this); + } +} diff --git a/backend/baguni-domain/src/main/java/baguni/domain/exception/tag/ApiTagException.java b/backend/baguni-domain/src/main/java/baguni/domain/exception/tag/ApiTagException.java new file mode 100644 index 000000000..36fc3a588 --- /dev/null +++ b/backend/baguni-domain/src/main/java/baguni/domain/exception/tag/ApiTagException.java @@ -0,0 +1,40 @@ +package baguni.domain.exception.tag; + +import baguni.common.exception.base.ApiErrorCode; +import baguni.common.exception.base.ApiException; + +public class ApiTagException extends ApiException { + + private ApiTagException(ApiErrorCode errorCode) { + super(errorCode); + } + + /** + * TODO: Implement static factory method + */ + + public static ApiTagException TAG_NOT_FOUND() { + return new ApiTagException(ApiTagErrorCode.TAG_NOT_FOUND); + } + + public static ApiTagException TAG_ALREADY_EXIST() { + return new ApiTagException(ApiTagErrorCode.TAG_ALREADY_EXIST); + } + + public static ApiTagException TAG_INVALID_NAME() { + return new ApiTagException(ApiTagErrorCode.TAG_INVALID_NAME); + } + + public static ApiTagException UNAUTHORIZED_TAG_ACCESS() { + return new ApiTagException(ApiTagErrorCode.UNAUTHORIZED_TAG_ACCESS); + } + + public static ApiTagException TAG_INVALID_ORDER() { + return new ApiTagException(ApiTagErrorCode.TAG_INVALID_ORDER); + } + + public static ApiTagException TAG_NAME_TOO_LONG() { + return new ApiTagException(ApiTagErrorCode.TAG_NAME_TOO_LONG); + } + +} diff --git a/backend/baguni-domain/src/main/java/baguni/domain/exception/user/ApiUserErrorCode.java b/backend/baguni-domain/src/main/java/baguni/domain/exception/user/ApiUserErrorCode.java new file mode 100644 index 000000000..d994e6f25 --- /dev/null +++ b/backend/baguni-domain/src/main/java/baguni/domain/exception/user/ApiUserErrorCode.java @@ -0,0 +1,64 @@ +package baguni.domain.exception.user; + +import org.springframework.http.HttpStatus; + +import baguni.common.exception.base.ApiErrorCode; +import baguni.common.exception.level.ErrorLevel; + +public enum ApiUserErrorCode implements ApiErrorCode { + + /** + * User Error Code (U) + * */ + USER_NOT_FOUND + ("U-000", HttpStatus.BAD_REQUEST, "사용자 없음", ErrorLevel.CAN_HAPPEN()), + + USER_CREATE_FAILURE + ("U-001", HttpStatus.BAD_REQUEST, "사용자 생성 실패", ErrorLevel.MUST_NEVER_HAPPEN()), + + ; + + // ------------------------------------------------------------ + // 하단 코드는 모든 ApiErrorCode 들에 반드시 포함되야 합니다. + // 새로운 ErrorCode 구현시 복사 붙여넣기 해주세요. + + private final String code; + + private final HttpStatus httpStatus; + + private final String errorMessage; + + private final ErrorLevel logLevel; + + ApiUserErrorCode(String code, HttpStatus status, String message, ErrorLevel errorLevel) { + this.code = code; + this.httpStatus = status; + this.errorMessage = message; + this.logLevel = errorLevel; + } + + @Override + public String getCode() { + return this.code; + } + + @Override + public String getMessage() { + return this.errorMessage; + } + + @Override + public HttpStatus getHttpStatus() { + return this.httpStatus; + } + + @Override + public ErrorLevel getErrorLevel() { + return this.logLevel; + } + + @Override + public String toString() { + return convertCodeToString(this); + } +} diff --git a/backend/baguni-domain/src/main/java/baguni/domain/exception/user/ApiUserException.java b/backend/baguni-domain/src/main/java/baguni/domain/exception/user/ApiUserException.java new file mode 100644 index 000000000..6ced08b97 --- /dev/null +++ b/backend/baguni-domain/src/main/java/baguni/domain/exception/user/ApiUserException.java @@ -0,0 +1,22 @@ +package baguni.domain.exception.user; + +import baguni.common.exception.base.ApiErrorCode; +import baguni.common.exception.base.ApiException; + +public class ApiUserException extends ApiException { + + private ApiUserException(ApiErrorCode errorCode) { + super(errorCode); + } + + /** + * TODO: Implement static factory method + * */ + public static ApiUserException USER_NOT_FOUND() { + return new ApiUserException(ApiUserErrorCode.USER_NOT_FOUND); + } + + public static ApiUserException USER_CREATE_FAILURE() { + return new ApiUserException(ApiUserErrorCode.USER_CREATE_FAILURE); + } +} diff --git a/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/folder/FolderDataHandler.java b/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/folder/FolderDataHandler.java new file mode 100644 index 000000000..554e0c89f --- /dev/null +++ b/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/folder/FolderDataHandler.java @@ -0,0 +1,146 @@ +package baguni.domain.infrastructure.folder; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import baguni.domain.model.folder.Folder; +import baguni.domain.model.user.User; +import baguni.domain.infrastructure.user.UserRepository; +import lombok.RequiredArgsConstructor; +import baguni.domain.infrastructure.folder.dto.FolderCommand; +import baguni.domain.exception.folder.ApiFolderException; +import baguni.domain.exception.user.ApiUserException; + +@Component +@RequiredArgsConstructor +public class FolderDataHandler { + + private final FolderRepository folderRepository; + private final UserRepository userRepository; + + @Transactional + public void createMandatoryFolder(User user) { + folderRepository.save(Folder.createEmptyUnclassifiedFolder(user)); + folderRepository.save(Folder.createEmptyRecycleBinFolder(user)); + folderRepository.save(Folder.createEmptyRootFolder(user)); + } + + @Transactional(readOnly = true) + public Folder getFolder(Long folderId) { + return folderRepository.findById(folderId).orElseThrow(ApiFolderException::FOLDER_NOT_FOUND); + } + + // idList에 포함된 모든 ID에 해당하는 폴더 리스트 조회, 순서를 보장하지 않음 + @Transactional(readOnly = true) + public List getFolderList(List folderIdList) { + List folderList = folderRepository.findAllById(folderIdList); + // 조회리스트에 존재하지 않는 태그id가 존재하면 예외 발생 + if (folderList.size() != folderIdList.size()) { + throw ApiFolderException.FOLDER_NOT_FOUND(); + } + return folderList; + } + + // idList에 포함된 모든 ID에 해당하는 폴더 리스트 조회, 순서는 idList의 순서를 따름 + @Transactional(readOnly = true) + public List getFolderListPreservingOrder(List folderIdList) { + List folderList = folderRepository.findAllById(folderIdList); + // 조회리스트에 존재하지 않는 태그id가 존재하면 예외 발생 + if (folderList.size() != folderIdList.size()) { + throw ApiFolderException.FOLDER_NOT_FOUND(); + } + folderList.sort(Comparator.comparing(folder -> folderIdList.indexOf(folder.getId()))); + return folderList; + } + + @Transactional(readOnly = true) + public List getFolderListByUserId(Long userId) { + return folderRepository.findByUserId(userId); + } + + @Transactional(readOnly = true) + public Folder getRootFolder(Long userId) { + return folderRepository.findRootByUserId(userId); + } + + @Transactional(readOnly = true) + public Folder getRecycleBin(Long userId) { + return folderRepository.findRecycleBinByUserId(userId); + } + + @Transactional(readOnly = true) + public Folder getUnclassifiedFolder(Long userId) { + return folderRepository.findUnclassifiedByUserId(userId); + } + + @Transactional + public Folder saveFolder(FolderCommand.Create command) { + User user = userRepository.findById(command.userId()).orElseThrow(ApiUserException::USER_NOT_FOUND); + Folder parentFolder = folderRepository.findById(command.parentFolderId()) + .orElseThrow(ApiFolderException::FOLDER_NOT_FOUND); + + Folder folder = folderRepository.save(Folder.createEmptyGeneralFolder(user, parentFolder, command.name())); + folder.getParentFolder().addChildFolderIdOrdered(folder.getId()); + return folder; + } + + @Transactional + public Folder updateFolder(FolderCommand.Update command) { + Folder folder = folderRepository.findById(command.id()) + .orElseThrow(ApiFolderException::FOLDER_NOT_FOUND); + folder.updateFolderName(command.name()); + + return folder; + } + + @Transactional + public List moveFolderWithinParent(FolderCommand.Move command) { + Folder parentFolder = folderRepository.findById(command.parentFolderId()) + .orElseThrow(ApiFolderException::FOLDER_NOT_FOUND); + + parentFolder.updateChildFolderIdOrderedList(command.idList(), command.orderIdx()); + return parentFolder.getChildFolderIdOrderedList(); + } + + @Transactional + public List moveFolderToDifferentParent(FolderCommand.Move command) { + Folder folder = folderRepository.findById(command.idList().get(0)) + .orElseThrow(ApiFolderException::FOLDER_NOT_FOUND); + + Folder oldParent = folder.getParentFolder(); + oldParent.getChildFolderIdOrderedList().removeAll(command.idList()); + + Folder newParent = folderRepository.findById(command.destinationFolderId()) + .orElseThrow(ApiFolderException::FOLDER_NOT_FOUND); + newParent.addChildFolderIdOrderedList(command.idList(), command.orderIdx()); + + List folderList = getFolderList(command.idList()); + for (Folder moveFolder : folderList) { + moveFolder.updateParentFolder(newParent); + } + + return newParent.getChildFolderIdOrderedList(); + } + + @Transactional + public void deleteFolderList(FolderCommand.Delete command) { + + List deleteList = new ArrayList<>(); + + for (Long id : command.idList()) { + Folder folder = folderRepository.findById(id) + .orElseThrow(ApiFolderException::FOLDER_NOT_FOUND); + + Folder parentFolder = folder.getParentFolder(); + parentFolder.removeChildFolderIdOrdered(folder.getId()); + + deleteList.add(folder); + } + + folderRepository.deleteAllInBatch(deleteList); + } +} diff --git a/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/folder/FolderRepository.java b/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/folder/FolderRepository.java new file mode 100644 index 000000000..476196c14 --- /dev/null +++ b/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/folder/FolderRepository.java @@ -0,0 +1,45 @@ +package baguni.domain.infrastructure.folder; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.jpa.repository.QueryHints; +import org.springframework.data.repository.query.Param; + +import baguni.domain.model.folder.Folder; +import jakarta.persistence.LockModeType; +import jakarta.persistence.QueryHint; + +public interface FolderRepository extends JpaRepository { + + @Lock(value = LockModeType.PESSIMISTIC_WRITE) + @QueryHints({ + @QueryHint(name = "javax.persistence.lock.timeout", value = "3000") + }) + @Query("SELECT f FROM Folder f WHERE f.id=:id") + Optional findByIdForUpdate(Long id); + + List findByUserId(Long userId); + + List findByParentFolderId(Long parentFolderId); + + // TODO: QueryDSL 도입 후 리팩토링 필요 + @Query("SELECT f FROM Folder f WHERE f.user.id = :userId AND f.folderType = baguni.domain.model.folder.FolderType" + + ".UNCLASSIFIED") + Folder findUnclassifiedByUserId(@Param("userId") Long userId); + + // TODO: QueryDSL 도입 후 리팩토링 필요 + @Query("SELECT f FROM Folder f WHERE f.user.id = :userId AND f.folderType = baguni.domain.model.folder.FolderType" + + ".RECYCLE_BIN") + Folder findRecycleBinByUserId(@Param("userId") Long userId); + + // TODO: QueryDSL 도입 후 리팩토링 필요 + @Query("SELECT f FROM Folder f WHERE f.user.id = :userId AND f.folderType = baguni.domain.model.folder.FolderType" + + ".ROOT") + Folder findRootByUserId(@Param("userId") Long userId); + + void deleteByUserId(Long userId); +} diff --git a/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/folder/dto/FolderCommand.java b/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/folder/dto/FolderCommand.java new file mode 100644 index 000000000..6b58c3d99 --- /dev/null +++ b/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/folder/dto/FolderCommand.java @@ -0,0 +1,53 @@ +package baguni.domain.infrastructure.folder.dto; + +import java.util.List; + +public class FolderCommand { + + public record Create( + Long userId, + String name, + Long parentFolderId) { + } + + public record Read( + Long userId, + Long id) { + } + + public record Update( + Long userId, + Long id, + String name + ) { + } + + public record Move( + Long userId, + List idList, + Long parentFolderId, + Long destinationFolderId, + int orderIdx + ) { + } + + public record Order( + Long userId, + List idList, + Long parentFolderId, + int orderIdx + ) { + } + + public record Delete( + Long userId, + List idList + ) { + } + + public record Export( + Long userId, + Long folderId + ) { + } +} diff --git a/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/folder/dto/FolderMapper.java b/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/folder/dto/FolderMapper.java new file mode 100644 index 000000000..6f0231d26 --- /dev/null +++ b/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/folder/dto/FolderMapper.java @@ -0,0 +1,19 @@ +package baguni.domain.infrastructure.folder.dto; + +import org.mapstruct.InjectionStrategy; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.ReportingPolicy; + +import baguni.domain.model.folder.Folder; + +@Mapper( + componentModel = "spring", + injectionStrategy = InjectionStrategy.CONSTRUCTOR, + unmappedTargetPolicy = ReportingPolicy.ERROR +) +public interface FolderMapper { + + @Mapping(source = "folder.parentFolder.id", target = "parentFolderId") + FolderResult toResult(Folder folder); +} diff --git a/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/folder/dto/FolderResult.java b/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/folder/dto/FolderResult.java new file mode 100644 index 000000000..3ded643bf --- /dev/null +++ b/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/folder/dto/FolderResult.java @@ -0,0 +1,18 @@ +package baguni.domain.infrastructure.folder.dto; + +import java.time.LocalDateTime; +import java.util.List; + +import baguni.domain.model.folder.FolderType; + +public record FolderResult( + Long id, + String name, + FolderType folderType, + Long parentFolderId, + List childFolderIdOrderedList, + List childPickIdOrderedList, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { +} diff --git a/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/link/LinkDataHandler.java b/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/link/LinkDataHandler.java new file mode 100644 index 000000000..652675855 --- /dev/null +++ b/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/link/LinkDataHandler.java @@ -0,0 +1,41 @@ +package baguni.domain.infrastructure.link; + +import java.util.Optional; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import baguni.domain.exception.link.ApiLinkException; +import baguni.domain.model.link.Link; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class LinkDataHandler { + private final LinkRepository linkRepository; + + @Transactional(readOnly = true) + public Link getLink(String url) { + return linkRepository.findByUrl(url).orElseThrow(ApiLinkException::LINK_NOT_FOUND); + } + + @Transactional(readOnly = true) + public Optional getOptionalLink(String url) { + return linkRepository.findByUrl(url); + } + + @Transactional(readOnly = true) + public Optional getOptionalLinkById(Long id) { + return linkRepository.findById(id); + } + + @Transactional + public Link saveLink(Link link) { + return linkRepository.save(link); + } + + @Transactional(readOnly = true) + public boolean existsByUrl(String url) { + return linkRepository.existsByUrl(url); + } +} diff --git a/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/link/LinkRepository.java b/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/link/LinkRepository.java new file mode 100644 index 000000000..a7b07901d --- /dev/null +++ b/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/link/LinkRepository.java @@ -0,0 +1,14 @@ +package baguni.domain.infrastructure.link; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import baguni.domain.model.link.Link; + +public interface LinkRepository extends JpaRepository { + + Optional findByUrl(String url); + + boolean existsByUrl(String url); +} diff --git a/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/link/dto/LinkCommand.java b/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/link/dto/LinkCommand.java new file mode 100644 index 000000000..9ec7e53d8 --- /dev/null +++ b/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/link/dto/LinkCommand.java @@ -0,0 +1,16 @@ +package baguni.domain.infrastructure.link.dto; + +public class LinkCommand { + + public record Create() { + } + + public record Read() { + } + + public record Update() { + } + + public record Delete() { + } +} diff --git a/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/link/dto/LinkInfo.java b/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/link/dto/LinkInfo.java new file mode 100644 index 000000000..444733166 --- /dev/null +++ b/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/link/dto/LinkInfo.java @@ -0,0 +1,17 @@ +package baguni.domain.infrastructure.link.dto; + +import java.time.LocalDateTime; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +public record LinkInfo( + @Schema(example = "https://velog.io/@hyeok_1212/Java-Record-%EC%82%AC%EC%9A%A9%ED%95%98%EC%8B%9C%EB%82%98%EC%9A%94" + ) @NotNull String url, + @Schema(example = "[Java] Record 사용하시나요?") String title, + @Schema(example = "IntelliJ : 레코드 써봐") String description, + @Schema(example = "https://velog.velcdn.com/images/hyeok_1212/post/5ea148fb-1490-4b03-811e-222b4d26b65e/image.png") String imageUrl, + @Schema(example = "2024-10-19T10:46:30.035Z") LocalDateTime invalidatedAt +) { +} + diff --git a/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/link/dto/LinkMapper.java b/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/link/dto/LinkMapper.java new file mode 100644 index 000000000..e1abd634d --- /dev/null +++ b/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/link/dto/LinkMapper.java @@ -0,0 +1,36 @@ +package baguni.domain.infrastructure.link.dto; + +import java.util.List; + +import org.mapstruct.InjectionStrategy; +import org.mapstruct.IterableMapping; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.Named; +import org.mapstruct.ReportingPolicy; + +import baguni.domain.model.link.Link; + +@Mapper( + componentModel = "spring", + injectionStrategy = InjectionStrategy.CONSTRUCTOR, + unmappedTargetPolicy = ReportingPolicy.ERROR +) +public interface LinkMapper { + + @Mapping(target = "invalidatedAt", ignore = true) + @Mapping(target = "title", source = "title", defaultValue = "") + @Mapping(target = "description", source = "description", defaultValue = "") + @Mapping(target = "imageUrl", source = "imageUrl", defaultValue = "") + Link of(LinkInfo linkInfo); + + LinkInfo of(Link link); + + LinkResult toLinkResult(Link link); + + @Named("toLinkInfoList") + LinkInfo toLinkInfo(Link link); + + @IterableMapping(qualifiedByName = "toLinkInfoList") + List toLinkInfoList(List links); +} diff --git a/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/link/dto/LinkResult.java b/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/link/dto/LinkResult.java new file mode 100644 index 000000000..6f13ef201 --- /dev/null +++ b/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/link/dto/LinkResult.java @@ -0,0 +1,13 @@ +package baguni.domain.infrastructure.link.dto; + +import java.time.LocalDateTime; + +public record LinkResult( + Long id, + String url, + String title, + String description, + String imageUrl, + LocalDateTime invalidatedAt +) { +} diff --git a/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/pick/PickBulkDataHandler.java b/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/pick/PickBulkDataHandler.java new file mode 100644 index 000000000..f853812e0 --- /dev/null +++ b/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/pick/PickBulkDataHandler.java @@ -0,0 +1,68 @@ +package baguni.domain.infrastructure.pick; + +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.jdbc.core.BatchPreparedStatementSetter; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; +import baguni.domain.infrastructure.link.dto.LinkInfo; +import baguni.domain.infrastructure.pick.dto.PickCommand; +import baguni.domain.model.link.Link; +import baguni.domain.infrastructure.link.LinkRepository; + +@Repository +@RequiredArgsConstructor +public class PickBulkDataHandler { + + private final JdbcTemplate jdbcTemplate; + private final LinkRepository linkRepository; + + @Transactional + public Link getOrCreateLink(LinkInfo linkInfo) { + return linkRepository.findByUrl(linkInfo.url()) + .orElseGet(() -> { + Link link = Link + .builder() + .url(linkInfo.url()) + .title(linkInfo.title()) + .description(linkInfo.description()) + .imageUrl(linkInfo.imageUrl()) + .invalidatedAt(linkInfo.invalidatedAt()) + .build(); + return linkRepository.save(link); + }); + } + + @Transactional + public void bulkInsertPick(List pickList) { + String sql = "INSERT INTO pick (user_id, link_id, parent_folder_id, title, tag_order, created_at, updated_at) " + + "VALUES (?, ?, ?, ?, ?, ?, ?)"; + + jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() { + @Override + public void setValues(PreparedStatement ps, int i) throws SQLException { + PickCommand.Create pick = pickList.get(i); + Link link = getOrCreateLink(pick.linkInfo()); + ps.setLong(1, pick.userId()); + ps.setLong(2, link.getId()); + ps.setLong(3, pick.parentFolderId()); + ps.setString(4, pick.title()); + ps.setString(5, + String.join(" ", pick.tagIdOrderedList().stream().map(String::valueOf).toArray(String[]::new))); + ps.setString(6, String.valueOf(LocalDateTime.now())); + ps.setString(7, String.valueOf(LocalDateTime.now())); + } + + @Override + public int getBatchSize() { + return pickList.size(); + } + }); + } +} diff --git a/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/pick/PickDataHandler.java b/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/pick/PickDataHandler.java new file mode 100644 index 000000000..4922a4354 --- /dev/null +++ b/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/pick/PickDataHandler.java @@ -0,0 +1,274 @@ +package baguni.domain.infrastructure.pick; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import baguni.domain.exception.folder.ApiFolderException; +import baguni.domain.infrastructure.link.dto.LinkMapper; +import baguni.domain.infrastructure.pick.dto.PickCommand; +import baguni.domain.infrastructure.pick.dto.PickMapper; +import baguni.domain.exception.pick.ApiPickException; +import baguni.domain.exception.tag.ApiTagException; +import baguni.domain.exception.user.ApiUserException; +import baguni.domain.model.folder.Folder; +import baguni.domain.infrastructure.folder.FolderRepository; +import baguni.domain.model.link.Link; +import baguni.domain.infrastructure.link.LinkRepository; +import baguni.domain.model.pick.Pick; +import baguni.domain.model.pick.PickTag; +import baguni.domain.model.tag.Tag; +import baguni.domain.infrastructure.tag.TagRepository; +import baguni.domain.model.user.User; +import baguni.domain.infrastructure.user.UserRepository; + +@Slf4j +@Component +@RequiredArgsConstructor +public class PickDataHandler { + + private final PickMapper pickMapper; + private final PickRepository pickRepository; + private final PickTagRepository pickTagRepository; + private final UserRepository userRepository; + private final FolderRepository folderRepository; + private final LinkRepository linkRepository; + private final LinkMapper linkMapper; + private final TagRepository tagRepository; + private final PickQuery pickQuery; + + @Transactional(readOnly = true) + public Pick getPick(Long pickId) { + return pickRepository.findById(pickId).orElseThrow(ApiPickException::PICK_NOT_FOUND); + } + + @Transactional(readOnly = true) + public Pick getPickUrl(Long userId, String url) { + return pickRepository.findByUserIdAndLinkUrl(userId, url) + .orElseThrow(ApiPickException::PICK_NOT_FOUND); + } + + @Transactional(readOnly = true) + public Optional findPickUrl(Long userId, String url) { + return pickRepository.findByUserIdAndLinkUrl(userId, url); + } + + @Transactional(readOnly = true) + public List getPickList(List pickIdList) { + List pickList = pickRepository.findAllById_JoinLink(pickIdList); + // 조회 리스트에 존재하지 않는 픽이 있으면 예외 발생 + if (pickList.size() != pickIdList.size()) { + throw ApiPickException.PICK_NOT_FOUND(); + } + return pickList; + } + + @Transactional(readOnly = true) + public List getPickListPreservingOrder(List pickIdList) { + List pickList = pickRepository.findAllById(pickIdList); + // 조회리스트에 존재하지 않는 픽이 있으면 예외 발생 + if (pickList.size() != pickIdList.size()) { + throw ApiPickException.PICK_NOT_FOUND(); + } + pickList.sort(Comparator.comparing(pick -> pickIdList.indexOf(pick.getId()))); + return pickList; + } + + @Transactional(readOnly = true) + public List getPickTagList(Long pickId) { + return pickTagRepository.findAllByPickId(pickId); + } + + @Transactional(readOnly = true) + public boolean existsByUserIdAndLink(Long userId, Link link) { + return pickRepository.existsByUserIdAndLink(userId, link); + } + + @Transactional + public Pick savePick(PickCommand.Create command) throws ApiPickException { + User user = userRepository.findById(command.userId()).orElseThrow(ApiUserException::USER_NOT_FOUND); + Folder folder = folderRepository.findById(command.parentFolderId()) + .orElseThrow(ApiFolderException::FOLDER_NOT_FOUND); + Link link = linkRepository.findByUrl(command.linkInfo().url()) + .map(existLink -> { + existLink.updateMetadata(command.linkInfo().title(), + command.linkInfo().description(), + command.linkInfo().imageUrl()); + return existLink; + }) + .orElseGet(() -> linkRepository.save(linkMapper.of(command.linkInfo()))); + + // 픽 존재 여부 검증 + pickRepository.findByUserAndLink(user, link) + .ifPresent((__) -> { + throw ApiPickException.PICK_MUST_BE_UNIQUE_FOR_A_URL(); + }); + + Pick savedPick = pickRepository.save(pickMapper.toEntity(command, user, folder, link)); + Folder parentFolder = savedPick.getParentFolder(); + attachPickToParentFolder(savedPick, parentFolder); + + List pickTagList = tagRepository.findAllById(command.tagIdOrderedList()) + .stream() + .map(tag -> PickTag.of(savedPick, tag)) + .toList(); + pickTagRepository.saveAll(pickTagList); + + return savedPick; + } + + /** + * @author sangwon + * 픽 생성 시 Link 데이터 수정하지 않음. + */ + @Transactional + public Pick savePickToUnclassified(PickCommand.Unclassified command) { + User user = userRepository.findById(command.userId()).orElseThrow(ApiUserException::USER_NOT_FOUND); + Folder unclassified = folderRepository.findUnclassifiedByUserId(user.getId()); + Link link = linkRepository.findByUrl(command.url()) + .orElseGet(() -> linkRepository.save(Link.createLinkByUrlAndTitle(command.url(), + command.title()))); + + Pick pick = pickMapper.toEntityByExtension(command.title(), new ArrayList<>(), user, unclassified, + link); + + Pick savedPick = pickRepository.save(pick); + + attachPickToParentFolder(savedPick, unclassified); + return savedPick; + } + + /** + * 부모 폴더 픽 리스트에서 pick 제거 후 + * 이동하는 폴더 픽 리스트에 pick 추가 + */ + @Transactional + public Pick updatePick(PickCommand.Update command) { + Pick pick = pickRepository.findById(command.id()).orElseThrow(ApiPickException::PICK_NOT_FOUND); + pick.updateTitle(command.title()); + + Folder parentFolder = pick.getParentFolder(); + + if (Objects.nonNull(command.parentFolderId()) && + isDifferentFolder(parentFolder, command) + ) { + Folder destinationFolder = folderRepository.findById(command.parentFolderId()) + .orElseThrow(ApiFolderException::FOLDER_NOT_FOUND); + detachPickFromParentFolder(pick, parentFolder); + attachPickToParentFolder(pick, destinationFolder); + updatePickParentFolder(pick, destinationFolder); + } + + if (command.tagIdOrderedList() != null) { + updateNewTagIdList(pick, command.tagIdOrderedList()); + } + return pick; + } + + @Transactional + public void movePickToCurrentFolder(PickCommand.Move command) { + List pickIdList = command.idList(); + Folder folder = folderRepository.findById(command.destinationFolderId()) + .orElseThrow(ApiFolderException::FOLDER_NOT_FOUND); + movePickListToDestinationFolder(pickIdList, folder, command.orderIdx()); + } + + @Transactional + public void movePickToOtherFolder(PickCommand.Move command) { + List pickIdList = command.idList(); + Folder destinationFolder = folderRepository.findById(command.destinationFolderId()) + .orElseThrow(ApiFolderException::FOLDER_NOT_FOUND); + + List pickList = pickRepository.findAllById(pickIdList); + pickList.forEach(pick -> { + detachPickFromParentFolder(pick, pick.getParentFolder()); + updatePickParentFolder(pick, destinationFolder); + }); + + movePickListToDestinationFolder(pickIdList, destinationFolder, command.orderIdx()); + } + + @Transactional + public void movePickListToRecycleBin(Long userId, List pickIdList) { + Folder recycleBin = folderRepository.findRecycleBinByUserId(userId); + + // 픽들의 부모를 휴지통으로 변경 + List pickList = pickRepository.findAllById(pickIdList); + pickList.forEach(pick -> { + attachPickToParentFolder(pick, recycleBin); + updatePickParentFolder(pick, recycleBin); + }); + } + + @Transactional + public void deletePickList(PickCommand.Delete command) { + List pickIdList = command.idList(); + List pickList = pickRepository.findAllById(pickIdList); + + pickList.forEach(pick -> { + detachPickFromParentFolder(pick, pick.getParentFolder()); + pickTagRepository.deleteByPick(pick); + pickRepository.delete(pick); + }); + } + + @Transactional + public void attachTagToPickTag(Pick pick, Long tagId) { + Tag tag = tagRepository.findById(tagId) + .orElseThrow(ApiTagException::TAG_NOT_FOUND); + PickTag pickTag = PickTag.of(pick, tag); + pickTagRepository.save(pickTag); + } + + @Transactional + public void detachTagFromPickTag(Pick pick, Long tagId) { + pickTagRepository.findByPickAndTagId(pick, tagId) + .ifPresent(pickTagRepository::delete); + } + + // 부모 폴더의 픽 리스트에 추가 + private void attachPickToParentFolder(Pick pick, Folder folder) { + folder.addChildPickIdOrdered(pick.getId()); + } + + // 부모 폴더의 픽 리스트에서 제거 + private void detachPickFromParentFolder(Pick pick, Folder folder) { + folder.removeChildPickIdOrdered(pick.getId()); + } + + // 픽의 부모 폴더 변경 + private void updatePickParentFolder(Pick pick, Folder folder) { + pick.updateParentFolder(folder); + } + + // 픽 리스트 순서를 유지한 채 목적지 폴더로 이동 + private void movePickListToDestinationFolder(List pickIdList, Folder folder, int orderIdx) { + folder.updateChildPickIdOrderedList(pickIdList, orderIdx); + } + + private void updateNewTagIdList(Pick pick, List newTagOrderList) { + // 1. 기존 태그와 새로운 태그를 비교하여 없어진 태그를 PickTag 테이블에서 제거 + pick.getTagIdOrderedList().stream() + .filter(tagId -> !newTagOrderList.contains(tagId)) + .forEach(tagId -> detachTagFromPickTag(pick, tagId)); + + // 2. 새로운 태그 중 기존에 없는 태그를 PickTag 테이블에 추가 + newTagOrderList.stream() + .filter(tagId -> !pick.getTagIdOrderedList().contains(tagId)) + .forEach(tagId -> attachTagToPickTag(pick, tagId)); + + pick.updateTagOrderList(newTagOrderList); + } + + private boolean isDifferentFolder(Folder parentFolder, PickCommand.Update command) { + return !Objects.equals(parentFolder.getId(), command.parentFolderId()); + } + +} \ No newline at end of file diff --git a/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/pick/PickQuery.java b/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/pick/PickQuery.java new file mode 100644 index 000000000..3c6e8a944 --- /dev/null +++ b/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/pick/PickQuery.java @@ -0,0 +1,183 @@ +package baguni.domain.infrastructure.pick; + +import static baguni.domain.model.folder.QFolder.*; +import static baguni.domain.model.pick.QPick.*; +import static baguni.domain.model.pick.QPickTag.*; + +import java.util.List; +import java.util.StringTokenizer; +import java.util.stream.Collectors; + +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; +import org.springframework.stereotype.Repository; + +import com.querydsl.core.types.ConstructorExpression; +import com.querydsl.core.types.Expression; +import com.querydsl.core.types.Order; +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.Projections; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.Expressions; +import com.querydsl.jpa.impl.JPAQueryFactory; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import baguni.domain.infrastructure.link.dto.LinkInfo; +import baguni.domain.infrastructure.pick.dto.PickResult; + +@Slf4j +@Repository +@RequiredArgsConstructor +public class PickQuery { + + private final JPAQueryFactory jpaQueryFactory; + + // TODO: 폴더 리스트 내 픽 조회 시 java sort vs querydsl 시간 측정 후 빠르면 사용 예정 + // Java sort에 비해 속도가 느린 것을 확인. 참고를 위해 코드 유지 + public List getPickList(Long userId, List folderIdList) { + if (folderIdList == null || folderIdList.isEmpty()) { + return List.of(); + } + + List pickIdList = folderIdList.stream() + .flatMap(folderId -> getChildPickIdOrderedList(folderId).stream()) + .toList(); + + if (pickIdList.isEmpty()) { + return List.of(); + } + + String orderListStr = pickIdList.stream() + .map(String::valueOf) + .collect(Collectors.joining(", ")); + + Expression orderByField = Expressions.template(Integer.class, + "FIELD({0}, " + orderListStr + ")", pick.id); + + OrderSpecifier orderSpecifier = new OrderSpecifier<>(Order.ASC, orderByField); + + return jpaQueryFactory + .select(pickResultFields()) + .from(pick) + .where( + userEqCondition(userId) + ) + .orderBy(orderSpecifier) + .fetch(); + } + + // TODO: 본인 픽이 아닌 다른 사람의 픽도 검색하고 싶다면 userId 부분 제거 + public Slice searchPickPagination( + Long userId, List folderIdList, List searchTokenList, + List tagIdList, Long cursor, int size + ) { + + List pickList = jpaQueryFactory + .select(pickResultFields()) // dto로 반환 + .from(pick) + .leftJoin(pickTag).on(pick.id.eq(pickTag.pick.id)) + .where( + userEqCondition(userId), // 본인 pick 조회 + folderIdCondition(folderIdList), // 폴더에 해당 하는 pick 조회 + searchTokenListCondition(searchTokenList), // 제목 검색 조건 + tagIdListCondition(tagIdList), // 태그 검색 조건 + cursorIdCondition(cursor) // 페이지네이션 조건 + ) + .distinct() + .limit(size + 1) + .fetch(); + + /** + * 다음 페이지 존재 여부 확인 (true: 있음, false: 없음) + * 다음 페이지가 있는지 확인하기 위해 limit에 size + 1 + * 다음 페이지가 존재한다면, 초과된 데이터 1개 제거 + */ + boolean hasNext = false; + if (pickList.size() > size) { + pickList.remove(size); + hasNext = true; + } + + return new SliceImpl<>(pickList, PageRequest.ofSize(size), hasNext); + } + + private ConstructorExpression pickResultFields() { + return Projections.constructor( + PickResult.Pick.class, + pick.id, + pick.title, + Projections.constructor( + LinkInfo.class, + pick.link.url, + pick.link.title, + pick.link.description, + pick.link.imageUrl, + pick.link.invalidatedAt), + pick.parentFolder.id, + pick.tagIdOrderedList, + pick.createdAt, + pick.updatedAt + ); + } + + private BooleanExpression userEqCondition(Long userId) { + return pick.user.id.eq(userId); + } + + private BooleanExpression cursorIdCondition(Long cursorId) { + return cursorId == null ? null : pick.id.gt(cursorId); + } + + private List getChildPickIdOrderedList(Long folderId) { + return jpaQueryFactory + .select(folder.childPickIdOrderedList) + .from(folder) + .where(folder.id.eq(folderId)) + .fetchOne(); + } + + private BooleanExpression folderIdCondition(List folderIdList) { + if (folderIdList == null || folderIdList.isEmpty()) { + return null; + } + return pick.parentFolder.id.in(folderIdList); + } + + private BooleanExpression searchTokenListCondition(List searchTokenList) { + if (searchTokenList == null || searchTokenList.isEmpty()) { + return null; + } + + return searchTokenList.stream() + .map(token -> { + StringTokenizer stringTokenizer = new StringTokenizer(token); + BooleanExpression combinedCondition = null; + while (stringTokenizer.hasMoreTokens()) { + String part = stringTokenizer.nextToken().toLowerCase(); + BooleanExpression condition = pick.title.lower().like("%" + part + "%"); + combinedCondition = + (combinedCondition == null) ? condition : combinedCondition.and(condition); + } + return combinedCondition; + }) + .reduce(BooleanExpression::and) + .orElse(null); + } + + private BooleanExpression tagIdListCondition(List tagIdList) { + if (tagIdList == null || tagIdList.isEmpty()) { + return null; + } + + return jpaQueryFactory + .selectFrom(pickTag) + .where( + pickTag.pick.id.eq(pick.id) + .and(pickTag.tag.id.in(tagIdList))) + .groupBy(pickTag.pick.id) + .having(pickTag.tag.id.count().eq((long)tagIdList.size())) + .exists(); + } +} diff --git a/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/pick/PickRepository.java b/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/pick/PickRepository.java new file mode 100644 index 000000000..49c2fc782 --- /dev/null +++ b/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/pick/PickRepository.java @@ -0,0 +1,39 @@ +package baguni.domain.infrastructure.pick; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.jpa.repository.QueryHints; + +import baguni.domain.model.pick.Pick; +import jakarta.persistence.LockModeType; +import jakarta.persistence.QueryHint; +import baguni.domain.model.link.Link; +import baguni.domain.model.user.User; + +public interface PickRepository extends JpaRepository { + + Optional findByUserIdAndLinkUrl(Long userId, String url); + + @Lock(value = LockModeType.PESSIMISTIC_WRITE) + @QueryHints({ + @QueryHint(name = "javax.persistence.lock.timeout", value = "3000") + }) + @Query("SELECT p FROM Pick p WHERE p.id = :id") + Optional findByIdForUpdate(Long id); + + Optional findByUserAndLink(User user, Link link); + + boolean existsByUserIdAndLink(Long userId, Link link); + + List findAllByUserId(Long userId); + + @Query("SELECT p from Pick p JOIN FETCH p.link WHERE p.id IN (:pickIdList)") + List findAllById_JoinLink(List pickIdList); + + @Query("SELECT p.id FROM Pick p WHERE p.user.id = :userId") + List findIdAllByUserId(Long userId); +} diff --git a/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/pick/PickTagRepository.java b/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/pick/PickTagRepository.java new file mode 100644 index 000000000..a42a3eeda --- /dev/null +++ b/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/pick/PickTagRepository.java @@ -0,0 +1,30 @@ +package baguni.domain.infrastructure.pick; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; + +import baguni.domain.model.pick.Pick; +import baguni.domain.model.pick.PickTag; + +public interface PickTagRepository extends JpaRepository { + + List findAllByPickId(Long pickId); + + List findAllByTagId(Long tagId); + + Optional findByPickAndTagId(Pick pick, Long tagId); + + void deleteByPick(Pick pick); + + void deleteByTagId(Long tagId); + + void deleteByPickAndTagId(Pick pick, Long tagId); + + @Modifying + @Query("DELETE FROM PickTag pt WHERE pt.pick.id IN :pickIdList") + void deleteAllByPickList(List pickIdList); +} diff --git a/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/pick/dto/PickCommand.java b/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/pick/dto/PickCommand.java new file mode 100644 index 000000000..c8961fa14 --- /dev/null +++ b/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/pick/dto/PickCommand.java @@ -0,0 +1,34 @@ +package baguni.domain.infrastructure.pick.dto; + +import java.util.List; + +import baguni.domain.infrastructure.link.dto.LinkInfo; + +public class PickCommand { + + public record Read(Long userId, Long id) { + } + + public record ReadList(Long userId, List folderIdList) { + } + + public record SearchPagination(Long userId, List folderIdList, List searchTokenList, + List tagIdList, Long cursor, int size) { + } + + public record Create(Long userId, String title, List tagIdOrderedList, Long parentFolderId, + LinkInfo linkInfo) { + } + + public record Unclassified(Long userId, String title, String url) { + } + + public record Update(Long userId, Long id, String title, Long parentFolderId, List tagIdOrderedList) { + } + + public record Move(Long userId, List idList, Long destinationFolderId, int orderIdx) { + } + + public record Delete(Long userId, List idList) { + } +} diff --git a/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/pick/dto/PickMapper.java b/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/pick/dto/PickMapper.java new file mode 100644 index 000000000..4dae37864 --- /dev/null +++ b/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/pick/dto/PickMapper.java @@ -0,0 +1,47 @@ +package baguni.domain.infrastructure.pick.dto; + +import java.util.List; + +import org.mapstruct.InjectionStrategy; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.ReportingPolicy; + +import baguni.domain.model.folder.Folder; +import baguni.domain.model.link.Link; +import baguni.domain.model.pick.Pick; +import baguni.domain.model.user.User; + +@Mapper( + componentModel = "spring", + injectionStrategy = InjectionStrategy.CONSTRUCTOR, + unmappedTargetPolicy = ReportingPolicy.ERROR +) +public interface PickMapper { + + @Mapping(source = "pick.link", target = "linkInfo") + @Mapping(source = "pick.parentFolder.id", target = "parentFolderId") + PickResult.Pick toPickResult(Pick pick); + + @Mapping(source = "pick.link.id", target = "linkId") + @Mapping(source = "pick.link.url", target = "url") + @Mapping(source = "pick.parentFolder.id", target = "parentFolderId") + PickResult.Extension toExtensionResult(Pick pick); + + PickResult.PickWithViewCount toPickResultWithViewCount(PickResult.Pick pickResult, Boolean isHot, + Long weeklyViewCount); + + @Mapping(source = "folderId", target = "folderId") + @Mapping(source = "pick", target = "pickList") + PickResult.FolderPickWithViewCountList toPickResultList(Long folderId, List pick); + + @Mapping(source = "command.title", target = "title") + @Mapping(source = "command.tagIdOrderedList", target = "tagIdOrderedList") + @Mapping(source = "parentFolder", target = "parentFolder") + @Mapping(source = "user", target = "user") + Pick toEntity(PickCommand.Create command, User user, Folder parentFolder, Link link); + + @Mapping(source = "parentFolder", target = "parentFolder") + @Mapping(source = "link", target = "link") + Pick toEntityByExtension(String title, List tagIdOrderedList, User user, Folder parentFolder, Link link); +} diff --git a/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/pick/dto/PickResult.java b/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/pick/dto/PickResult.java new file mode 100644 index 000000000..994449de2 --- /dev/null +++ b/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/pick/dto/PickResult.java @@ -0,0 +1,59 @@ +package baguni.domain.infrastructure.pick.dto; + +import java.time.LocalDateTime; +import java.util.List; + +import baguni.domain.infrastructure.link.dto.LinkInfo; + +public class PickResult { + + public record Pick( + Long id, + String title, + LinkInfo linkInfo, + Long parentFolderId, + List tagIdOrderedList, + LocalDateTime createdAt, + LocalDateTime updatedAt + ) { + } + + public record Extension( + Long id, + String title, + Long linkId, + String url, + Long parentFolderId, + List tagIdOrderedList, + LocalDateTime createdAt, + LocalDateTime updatedAt + ) { + } + + public record PickWithViewCount( + Long id, + String title, + LinkInfo linkInfo, + Long parentFolderId, + List tagIdOrderedList, + LocalDateTime createdAt, + LocalDateTime updatedAt, + // 프론트엔드에서 깔끔하게 처리하기 위한 힌트 + Boolean isHot, + // 랭킹 정보에 표시된 최근 7일간 조회수 + Long weeklyViewCount + ) { + } + + public record FolderPickList( + Long folderId, + List pickList + ) { + } + + public record FolderPickWithViewCountList( + Long folderId, + List pickList + ) { + } +} \ No newline at end of file diff --git a/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/ranking/RankingApi.java b/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/ranking/RankingApi.java new file mode 100644 index 000000000..8b46d7cc9 --- /dev/null +++ b/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/ranking/RankingApi.java @@ -0,0 +1,39 @@ +package baguni.domain.infrastructure.ranking; + +import java.time.LocalDate; +import java.util.List; + +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.service.annotation.GetExchange; + +import baguni.common.dto.UrlWithCount; + +/** + * @author minkyeu kim + * 랭킹 서버와 통신하기 위한 Http Interface.
+ * 형식은 baguni-api 모듈의 컨트롤러와 일치합니다. + */ +public interface RankingApi { + + /** + * 조회수 기반 링크 랭킹 + */ + @GetExchange("/ranking/link/view") + ResponseEntity> getUrlRankingByViewCount( + @RequestParam("date_begin") @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate dateBegin, + @RequestParam("date_end") @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate dateEnd, + @RequestParam(required = false, defaultValue = "5") Integer limit + ); + + /** + * 픽된 횟수 기반 링크 랭킹 + */ + @GetExchange("/ranking/link/picked") + ResponseEntity> getUrlRankingByPickedCount( + @RequestParam("date_begin") @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate dateBegin, + @RequestParam("date_end") @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate dateEnd, + @RequestParam(required = false, defaultValue = "5") Integer limit + ); +} diff --git a/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/rss/RssBlogRepository.java b/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/rss/RssBlogRepository.java new file mode 100644 index 000000000..c08c2e9ed --- /dev/null +++ b/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/rss/RssBlogRepository.java @@ -0,0 +1,8 @@ +package baguni.domain.infrastructure.rss; + +import org.springframework.data.jpa.repository.JpaRepository; + +import baguni.domain.model.rss.RssBlog; + +public interface RssBlogRepository extends JpaRepository { +} diff --git a/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/rss/RssFeedRepository.java b/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/rss/RssFeedRepository.java new file mode 100644 index 000000000..bb8a9294a --- /dev/null +++ b/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/rss/RssFeedRepository.java @@ -0,0 +1,20 @@ +package baguni.domain.infrastructure.rss; + +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import baguni.domain.model.rss.RssFeed; + +public interface RssFeedRepository extends JpaRepository { + + List findByRssBlogId(Long blogId); + + @Query(value = "select f.url from RssFeed f where f.rssBlogId = :blogId") + List findAllUrlByBlogId(@Param("blogId") Long blogId); + + List findByCreatedAtGreaterThanEqualAndCreatedAtLessThan(LocalDateTime from, LocalDateTime to); +} diff --git a/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/sharedFolder/SharedFolderDataHandler.java b/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/sharedFolder/SharedFolderDataHandler.java new file mode 100644 index 000000000..0ba94b439 --- /dev/null +++ b/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/sharedFolder/SharedFolderDataHandler.java @@ -0,0 +1,58 @@ +package baguni.domain.infrastructure.sharedFolder; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import org.springframework.stereotype.Component; + +import baguni.domain.model.folder.Folder; +import baguni.domain.infrastructure.folder.FolderRepository; +import baguni.domain.model.sharedFolder.SharedFolder; +import baguni.domain.model.user.User; +import baguni.domain.infrastructure.user.UserRepository; +import lombok.RequiredArgsConstructor; +import baguni.domain.exception.folder.ApiFolderException; +import baguni.domain.exception.sharedFolder.ApiSharedFolderException; +import baguni.domain.exception.user.ApiUserException; + +@Component +@RequiredArgsConstructor +public class SharedFolderDataHandler { + + private final SharedFolderRepository sharedFolderRepository; + private final UserRepository userRepository; + private final FolderRepository folderRepository; + + public SharedFolder save(Long userId, Long folderId) { + User user = userRepository.findById(userId).orElseThrow(ApiUserException::USER_NOT_FOUND); + Folder folder = folderRepository.findById(folderId).orElseThrow(ApiFolderException::FOLDER_NOT_FOUND); + return sharedFolderRepository.save(SharedFolder.createSharedFolder(user, folder)); + } + + public SharedFolder getByUUID(UUID uuid) { + return sharedFolderRepository.findById(uuid).orElseThrow(ApiSharedFolderException::SHARED_FOLDER_NOT_FOUND); + } + + public SharedFolder getByFolderId(Long folderId) { + return sharedFolderRepository + .findByFolderId(folderId) + .orElseThrow(ApiSharedFolderException::SHARED_FOLDER_NOT_FOUND); + } + + public boolean isSharedFolder(Long folderId) { + return sharedFolderRepository.findByFolderId(folderId).isPresent(); + } + + public List getByUserId(Long userId) { + return sharedFolderRepository.findByUserId(userId); + } + + public void deleteBySourceFolderId(Long sourceFolderId) { + sharedFolderRepository.deleteByFolderId(sourceFolderId); + } + + public Optional findUUIDBySourceFolderId(Long sourceFolderId) { + return sharedFolderRepository.findByFolderId(sourceFolderId).map(SharedFolder::getId); + } +} diff --git a/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/sharedFolder/SharedFolderRepository.java b/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/sharedFolder/SharedFolderRepository.java new file mode 100644 index 000000000..dfa1ed7a9 --- /dev/null +++ b/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/sharedFolder/SharedFolderRepository.java @@ -0,0 +1,22 @@ +package baguni.domain.infrastructure.sharedFolder; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.web.bind.annotation.RestController; + +import baguni.domain.model.sharedFolder.SharedFolder; + +@RestController +public interface SharedFolderRepository extends JpaRepository { + + List findByUserId(Long userId); + + Optional findByFolderId(Long folderId); + + void deleteByFolderId(Long folderId); + + void deleteByUserId(Long userId); +} diff --git a/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/sharedFolder/dto/SharedFolderMapper.java b/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/sharedFolder/dto/SharedFolderMapper.java new file mode 100644 index 000000000..62cf8351d --- /dev/null +++ b/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/sharedFolder/dto/SharedFolderMapper.java @@ -0,0 +1,28 @@ +package baguni.domain.infrastructure.sharedFolder.dto; + +import org.mapstruct.InjectionStrategy; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.ReportingPolicy; + +import baguni.domain.infrastructure.folder.dto.FolderMapper; +import baguni.domain.model.sharedFolder.SharedFolder; +import baguni.domain.model.tag.Tag; + +@Mapper( + componentModel = "spring", + injectionStrategy = InjectionStrategy.CONSTRUCTOR, + unmappedTargetPolicy = ReportingPolicy.ERROR, + uses = FolderMapper.class +) +public interface SharedFolderMapper { + + @Mapping(expression = "java(sharedFolder.getId().toString())", target = "folderAccessToken") + SharedFolderResult.Create toCreateResult(SharedFolder sharedFolder); + + @Mapping(source = "folder", target = "sourceFolder") + @Mapping(expression = "java(sharedFolder.getId().toString())", target = "folderAccessToken") + SharedFolderResult.Read toReadResult(SharedFolder sharedFolder); + + SharedFolderResult.SharedTagInfo toSharedTagInfo(Tag tag); +} diff --git a/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/sharedFolder/dto/SharedFolderResult.java b/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/sharedFolder/dto/SharedFolderResult.java new file mode 100644 index 000000000..39ff5fc40 --- /dev/null +++ b/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/sharedFolder/dto/SharedFolderResult.java @@ -0,0 +1,83 @@ +package baguni.domain.infrastructure.sharedFolder.dto; + +import java.time.LocalDateTime; +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Builder; +import baguni.domain.infrastructure.folder.dto.FolderResult; +import baguni.domain.infrastructure.link.dto.LinkInfo; + +public class SharedFolderResult { + + public record Create( + String folderAccessToken + ) { + } + + // TODO: 추후 공유자의 정보까지 DTO에 담아주는게 좋겠다. + public record Read( + FolderResult sourceFolder, + String folderAccessToken + ) { + } + + // 폴더, 태그, 픽이 모두 포함되서 반환 + @Builder + public record SharedFolderInfo( + + @NotEmpty + String folderName, + + @NotNull + LocalDateTime createdAt, + + @NotNull + LocalDateTime updatedAt, + + @NotNull + List pickList, + + @Schema(description = "해당 폴더 내에서 사용된 모든 태그들 정보. tagIdxList의 각 값을 index로 사용하세요.", example = "[0, 5, 2, 3]") + @NotNull + List tagList + ) { + } + + // id 같은 예민한 값을 모두 제외한 DTO + @Builder + public record SharedPickInfo( + + @NotEmpty + @Schema(example = "자바 레코드 참고 블로그 1") + String title, + + @NotNull + LinkInfo linkInfo, + + @NotNull + @Schema(description = "tagList.get(idx) 로 태그 정보를 획득할 수 있습니다.", example = "[0, 5, 2, 3]") + List tagIdxList, + + @NotNull + LocalDateTime createdAt, + + @NotNull + LocalDateTime updatedAt + ) { + } + + // id 같은 예민한 값을 모두 제외한 DTO + @Builder + public record SharedTagInfo( + + @NotEmpty + String name, + + @NotNull + Integer colorNumber + ) { + } +} \ No newline at end of file diff --git a/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/tag/TagDataHandler.java b/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/tag/TagDataHandler.java new file mode 100644 index 000000000..492417cb5 --- /dev/null +++ b/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/tag/TagDataHandler.java @@ -0,0 +1,110 @@ +package baguni.domain.infrastructure.tag; + +import java.util.Comparator; +import java.util.List; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import baguni.domain.infrastructure.pick.PickRepository; +import baguni.domain.infrastructure.pick.PickTagRepository; +import baguni.domain.model.tag.Tag; +import baguni.domain.model.user.User; +import baguni.domain.infrastructure.user.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import baguni.domain.infrastructure.tag.dto.TagCommand; +import baguni.domain.infrastructure.tag.dto.TagMapper; +import baguni.domain.exception.tag.ApiTagException; +import baguni.domain.exception.user.ApiUserException; + +@Slf4j +@Component +@RequiredArgsConstructor +public class TagDataHandler { + + private final TagRepository tagRepository; + private final PickRepository pickRepository; + private final PickTagRepository pickTagRepository; + private final UserRepository userRepository; + private final TagMapper tagMapper; + + @Transactional(readOnly = true) + public Tag getTag(Long tagId) { + return tagRepository.findById(tagId).orElseThrow(ApiTagException::TAG_NOT_FOUND); + } + + @Transactional(readOnly = true) + public List getTagList(Long userId) { + User user = userRepository.findById(userId).orElseThrow(ApiUserException::USER_NOT_FOUND); + List tagOrderList = user.getTagOrderList(); + List tagList = tagRepository.findAllByUserId(userId); + tagList.sort(Comparator.comparing(tag -> tagOrderList.indexOf(tag.getId()))); + + return tagList; + } + + @Transactional(readOnly = true) + public List getTagList(List tagOrderList) { + List tagList = tagRepository.findAllById(tagOrderList); + // 조회리스트에 존재하지 않는 태그id가 존재하면 예외 발생 + if (tagList.size() != tagOrderList.size()) { + throw ApiTagException.TAG_NOT_FOUND(); + } + return tagList; + } + + @Transactional(readOnly = true) + public List getTagListPreservingOrder(List tagOrderList) { + List tagList = tagRepository.findAllById(tagOrderList); + // 조회리스트에 존재하지 않는 태그id가 존재하면 예외 발생 + if (tagList.size() != tagOrderList.size()) { + throw ApiTagException.TAG_NOT_FOUND(); + } + tagList.sort(Comparator.comparing(tag -> tagOrderList.indexOf(tag.getId()))); + return tagList; + } + + @Transactional + public Tag saveTag(Long userId, TagCommand.Create command) { + User user = userRepository.findById(userId).orElseThrow(ApiUserException::USER_NOT_FOUND); + Tag tag = tagRepository.save(tagMapper.toEntity(command, user)); + user.getTagOrderList().add(tag.getId()); + return tag; + } + + @Transactional + public Tag updateTag(TagCommand.Update command) { + log.info("TagDataHandler: tag id={}", command.id()); // for debug + Tag tag = tagRepository.findById(command.id()).orElseThrow(ApiTagException::TAG_NOT_FOUND); + tag.updateTagName(command.name()); + tag.updateColorNumber(command.colorNumber()); + return tag; + } + + @Transactional + public void moveTag(Long userId, TagCommand.Move command) { + User user = userRepository.findById(userId).orElseThrow(ApiUserException::USER_NOT_FOUND); + user.updateTagOrderList(command.id(), command.orderIdx()); + } + + @Transactional + public void deleteTag(Long userId, TagCommand.Delete command) { + User user = userRepository.findById(userId).orElseThrow(ApiUserException::USER_NOT_FOUND); + Long tagId = command.id(); + user.getTagOrderList().remove(tagId); + pickTagRepository.findAllByTagId(tagId).stream() + .map(pickTag -> pickRepository.findById(pickTag.getPick().getId()) + .orElseThrow(ApiTagException::TAG_NOT_FOUND)) + .forEach(pick -> { + pick.getTagIdOrderedList().remove(tagId); + }); + pickTagRepository.deleteByTagId(tagId); + tagRepository.deleteById(tagId); + } + + @Transactional(readOnly = true) + public boolean checkDuplicateTagName(Long userId, String name) { + return tagRepository.existsByUserIdAndName(userId, name); + } +} diff --git a/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/tag/TagRepository.java b/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/tag/TagRepository.java new file mode 100644 index 000000000..6056f2c9e --- /dev/null +++ b/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/tag/TagRepository.java @@ -0,0 +1,18 @@ +package baguni.domain.infrastructure.tag; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; + +import baguni.domain.model.tag.Tag; + +public interface TagRepository extends JpaRepository { + + boolean existsByUserIdAndName(Long userId, String name); + + List findAllByUserId(Long userId); + + void deleteByIdAndUserId(Long id, Long userId); + + void deleteByUserId(Long userId); +} diff --git a/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/tag/dto/TagCommand.java b/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/tag/dto/TagCommand.java new file mode 100644 index 000000000..88ea0a8a6 --- /dev/null +++ b/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/tag/dto/TagCommand.java @@ -0,0 +1,35 @@ +package baguni.domain.infrastructure.tag.dto; + +public class TagCommand { + + public record Create( + Long userId, + String name, + Integer colorNumber) { + } + + public record Read( + Long userId, + Long id) { + } + + public record Update( + Long userId, + Long id, + String name, + Integer colorNumber) { + } + + public record Move( + Long userId, + Long id, + int orderIdx + ) { + } + + public record Delete( + Long userId, + Long id + ) { + } +} diff --git a/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/tag/dto/TagMapper.java b/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/tag/dto/TagMapper.java new file mode 100644 index 000000000..2a8c49b8e --- /dev/null +++ b/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/tag/dto/TagMapper.java @@ -0,0 +1,22 @@ +package baguni.domain.infrastructure.tag.dto; + +import org.mapstruct.InjectionStrategy; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.ReportingPolicy; + +import baguni.domain.model.tag.Tag; +import baguni.domain.model.user.User; + +@Mapper( + componentModel = "spring", + injectionStrategy = InjectionStrategy.CONSTRUCTOR, + unmappedTargetPolicy = ReportingPolicy.ERROR +) +public interface TagMapper { + + @Mapping(source = "tag.user.id", target = "userId") + TagResult toResult(Tag tag); + + Tag toEntity(TagCommand.Create create, User user); +} diff --git a/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/tag/dto/TagResult.java b/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/tag/dto/TagResult.java new file mode 100644 index 000000000..dfc8ccba3 --- /dev/null +++ b/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/tag/dto/TagResult.java @@ -0,0 +1,9 @@ +package baguni.domain.infrastructure.tag.dto; + +public record TagResult( + Long id, + String name, + Integer colorNumber, + Long userId +) { +} diff --git a/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/user/UserRepository.java b/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/user/UserRepository.java new file mode 100644 index 000000000..3d6df400b --- /dev/null +++ b/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/user/UserRepository.java @@ -0,0 +1,16 @@ +package baguni.domain.infrastructure.user; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import baguni.domain.model.user.SocialProvider; +import baguni.domain.model.user.User; +import baguni.domain.model.util.IDToken; + +public interface UserRepository extends JpaRepository { + + Optional findBySocialProviderAndSocialProviderId(SocialProvider socialProvider, String socialProviderId); + + Optional findByIdToken(IDToken idToken); +} diff --git a/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/user/dto/UserInfo.java b/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/user/dto/UserInfo.java new file mode 100644 index 000000000..55a6dd587 --- /dev/null +++ b/backend/baguni-domain/src/main/java/baguni/domain/infrastructure/user/dto/UserInfo.java @@ -0,0 +1,15 @@ +package baguni.domain.infrastructure.user.dto; + +import baguni.domain.model.user.User; +import baguni.domain.model.util.IDToken; + +public record UserInfo( + Long id, + String name, + IDToken idToken, + String email +) { + public static UserInfo from(User user) { + return new UserInfo(user.getId(), user.getNickname(), user.getIdToken(), user.getEmail()); + } +} diff --git a/backend/baguni-domain/src/main/java/baguni/domain/lock/LockProvider.java b/backend/baguni-domain/src/main/java/baguni/domain/lock/LockProvider.java new file mode 100644 index 000000000..1f1d2c1f1 --- /dev/null +++ b/backend/baguni-domain/src/main/java/baguni/domain/lock/LockProvider.java @@ -0,0 +1,13 @@ +package baguni.domain.lock; + +/** + * @author sangwon + * MySQL, Redis를 이용하여 분산 락을 구현하기 위한 인터페이스 + * MySQL, Redis 구현체를 쉽게 갈아끼우기 위해 인터페이스 선언 + */ +public interface LockProvider { + + boolean acquireLock(String key, long timeout, Long userId); + + void releaseLock(String key, Long userId); +} diff --git a/backend/baguni-domain/src/main/java/baguni/domain/lock/MysqlLockProvider.java b/backend/baguni-domain/src/main/java/baguni/domain/lock/MysqlLockProvider.java new file mode 100644 index 000000000..aa3a41f76 --- /dev/null +++ b/backend/baguni-domain/src/main/java/baguni/domain/lock/MysqlLockProvider.java @@ -0,0 +1,41 @@ +package baguni.domain.lock; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import baguni.domain.lock.util.LockException; + +@Slf4j +@Component +@RequiredArgsConstructor +public class MysqlLockProvider implements LockProvider { + + private final JdbcTemplate jdbcTemplate; + + /** + * 락을 획득하는 메서드 + */ + @Override + public boolean acquireLock(String key, long timeout, Long userId) { + String sql = "SELECT GET_LOCK(?, ?)"; + String lockKey = userId + ""; + log.debug("lockKey : {}", lockKey); + Boolean result = jdbcTemplate.queryForObject(sql, Boolean.class, lockKey, timeout / 1000); + return Boolean.TRUE.equals(result); // null인 경우 false 반환 + } + + /** + * 락을 해제하는 메서드 + */ + @Override + public void releaseLock(String key, Long userId) { + String sql = "SELECT RELEASE_LOCK(?)"; + String lockKey = userId + ""; + Boolean result = jdbcTemplate.queryForObject(sql, Boolean.class, lockKey); + if (!Boolean.TRUE.equals(result)) { + throw new LockException("락 해제 실패 : " + lockKey); + } + } +} diff --git a/backend/baguni-domain/src/main/java/baguni/domain/lock/util/LockException.java b/backend/baguni-domain/src/main/java/baguni/domain/lock/util/LockException.java new file mode 100644 index 000000000..6c50264e7 --- /dev/null +++ b/backend/baguni-domain/src/main/java/baguni/domain/lock/util/LockException.java @@ -0,0 +1,8 @@ +package baguni.domain.lock.util; + +public class LockException extends RuntimeException { + + public LockException(String message) { + super(message); + } +} diff --git a/backend/baguni-domain/src/main/java/baguni/domain/lock/util/LoginUserIdDistributedLockAspect.java b/backend/baguni-domain/src/main/java/baguni/domain/lock/util/LoginUserIdDistributedLockAspect.java new file mode 100644 index 000000000..dddda49a4 --- /dev/null +++ b/backend/baguni-domain/src/main/java/baguni/domain/lock/util/LoginUserIdDistributedLockAspect.java @@ -0,0 +1,88 @@ +package baguni.domain.lock.util; + +import java.lang.reflect.Field; + +import org.aspectj.lang.JoinPoint; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.reflect.MethodSignature; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; +import baguni.domain.annotation.LoginUserIdDistributedLock; +import baguni.domain.lock.LockProvider; + +@Order(1) +@Aspect +@Component +@RequiredArgsConstructor +public class LoginUserIdDistributedLockAspect { + + private final LockProvider lockProvider; + + @Around("@annotation(loginUserIdDistributedLock)") + public Object handleDistributedLock( + ProceedingJoinPoint joinPoint, + LoginUserIdDistributedLock loginUserIdDistributedLock + ) throws Throwable { + String key = getMethodName(joinPoint); + long timeout = loginUserIdDistributedLock.timeout(); + Long userId = getUserIdFromArgs(joinPoint); + + boolean lockCheck = lockProvider.acquireLock(key, timeout, userId); + if (!lockCheck) { + throw new LockException("락 설정 실패 : " + userId); + } + + try { + return joinPoint.proceed(); + } finally { + lockProvider.releaseLock(key, userId); + } + } + + /** + * @author sangwon + * 리플렉션을 통해 메서드 이름을 가져오는 메서드 + */ + private String getMethodName(JoinPoint joinPoint) { + MethodSignature signature = (MethodSignature)joinPoint.getSignature(); + return signature.getMethod().getName(); + } + + /** + * @author sangwon + * 리플렉션을 통해 메서드 파라미터에 있는 userId를 가져온다. + */ + private Long getUserIdFromArgs(JoinPoint joinPoint) { + Object[] args = joinPoint.getArgs(); + MethodSignature methodSignature = (MethodSignature)joinPoint.getSignature(); + String[] parameterNames = methodSignature.getParameterNames(); + + for (int i = 0; i < parameterNames.length; i++) { + Object arg = args[i]; + + if ("userId".equals(parameterNames[i]) && arg instanceof Long) { + return (Long)arg; + } + + if (arg != null) { + try { + // Reflection으로 "userId" 필드를 추출 + Field field = arg.getClass().getDeclaredField("userId"); + field.setAccessible(true); // private 필드 접근 허용 + Object userId = field.get(arg); + if (userId instanceof Long) { + return (Long)userId; + } + } catch (NoSuchFieldException | IllegalAccessException ignored) { + // 해당 파라미터에 "userId"가 없으면 무시 + } + } + } + + throw new IllegalArgumentException("userId 파라미터를 찾을 수 없습니다."); + } +} diff --git a/backend/baguni-domain/src/main/java/baguni/domain/model/common/BaseEntity.java b/backend/baguni-domain/src/main/java/baguni/domain/model/common/BaseEntity.java new file mode 100644 index 000000000..444dec04b --- /dev/null +++ b/backend/baguni-domain/src/main/java/baguni/domain/model/common/BaseEntity.java @@ -0,0 +1,28 @@ +package baguni.domain.model.common; + +import java.time.LocalDateTime; + +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; + +@Getter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class BaseEntity { + + // 생성 시간 자동 부여 + @CreatedDate + @Column(name = "created_at", updatable = false, nullable = false) + protected LocalDateTime createdAt; + + // 수정 시간 자동 부여 + @LastModifiedDate + @Column(name = "updated_at", nullable = false) + protected LocalDateTime updatedAt; +} diff --git a/backend/baguni-domain/src/main/java/baguni/domain/model/folder/Folder.java b/backend/baguni-domain/src/main/java/baguni/domain/model/folder/Folder.java new file mode 100644 index 000000000..da2d167e8 --- /dev/null +++ b/backend/baguni-domain/src/main/java/baguni/domain/model/folder/Folder.java @@ -0,0 +1,180 @@ +package baguni.domain.model.folder; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import org.hibernate.annotations.OnDelete; +import org.hibernate.annotations.OnDeleteAction; + +import baguni.domain.model.util.OrderConverter; +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import baguni.domain.model.common.BaseEntity; +import baguni.domain.model.user.User; + +@Table(name = "folder") +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Folder extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @Column(name = "name", nullable = false) + private String name; + + @Enumerated(EnumType.STRING) + @Column(name = "folder_type", nullable = false) + private FolderType folderType; + + @ManyToOne(fetch = FetchType.LAZY) + @OnDelete(action = OnDeleteAction.CASCADE) // 부모 폴더가 삭제 되면 자식 폴더 또한 삭제 + @JoinColumn(name = "parent_folder_id") + private Folder parentFolder; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + // 폴더에 속한 자식 folder id들을 공백으로 분리된 String으로 변환하여 db에 저장 + // ex) [6,3,2,23,1] -> "6 3 2 23 1" + @Convert(converter = OrderConverter.class) + @Column(name = "child_folder_order", columnDefinition = "longblob", nullable = false) + private List childFolderIdOrderedList = new ArrayList<>(); + + // 폴더에 속한 pick id들을 공백으로 분리된 String으로 변환하여 db에 저장 + // ex) [6,3,2,23,1] -> "6 3 2 23 1" + @Convert(converter = OrderConverter.class) + @Column(name = "pick_order", columnDefinition = "longblob", nullable = false) + private List childPickIdOrderedList = new ArrayList<>(); + + public static Folder createEmptyRootFolder(User user) { + return Folder + .builder() + .folderType(FolderType.ROOT) + .user(user) + .name(FolderType.ROOT.getLabel()) + .build(); + } + + public static Folder createEmptyRecycleBinFolder(User user) { + return Folder + .builder() + .folderType(FolderType.RECYCLE_BIN) + .user(user) + .name(FolderType.RECYCLE_BIN.getLabel()) + .build(); + } + + public static Folder createEmptyUnclassifiedFolder(User user) { + return Folder + .builder() + .folderType(FolderType.UNCLASSIFIED) + .user(user) + .name(FolderType.UNCLASSIFIED.getLabel()) + .build(); + } + + public static Folder createEmptyGeneralFolder(User user, Folder parentFolder, String name) { + return Folder + .builder() + .folderType(FolderType.GENERAL) + .parentFolder(parentFolder) + .user(user) + .name(name) + .build(); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof Folder folder)) { + return false; + } + return Objects.equals(id, folder.id); + } + + @Override + public int hashCode() { + return Objects.hashCode(id); + } + + public void updateFolderName(String name) { + this.name = name; + } + + public void updateParentFolder(Folder parentFolder) { + this.parentFolder = parentFolder; + } + + public void updateChildPickIdOrderedList(List pickIdList, int destination) { + childPickIdOrderedList.removeAll(pickIdList); + int calculatedDestination = Math.min(destination, childPickIdOrderedList.size()); + childPickIdOrderedList.addAll(calculatedDestination, pickIdList); + } + + public void updateChildFolderIdOrderedList(List folderIdList, int destination) { + childFolderIdOrderedList.removeAll(folderIdList); + int calculatedDestination = Math.min(destination, childFolderIdOrderedList.size()); + childFolderIdOrderedList.addAll(calculatedDestination, folderIdList); + } + + public void addChildPickIdOrdered(Long pickId) { + childPickIdOrderedList.add(0, pickId); + } + + public void addChildFolderIdOrdered(Long folderId) { + childFolderIdOrderedList.add(0, folderId); + } + + public void addChildFolderIdOrderedList(List folderIdList, int destination) { + int calculatedDestination = Math.min(destination, childFolderIdOrderedList.size()); + childFolderIdOrderedList.addAll(calculatedDestination, folderIdList); + } + + public void removeChildPickIdOrdered(Long pickId) { + childPickIdOrderedList.remove(pickId); + } + + public void removeChildFolderIdOrdered(Long folderId) { + childFolderIdOrderedList.remove(folderId); + } + + @Builder + private Folder( + String name, + FolderType folderType, + Folder parentFolder, + User user, + List childFolderIdOrderedList, + List childPickIdOrderedList + ) { + this.name = name; + this.folderType = folderType; + this.parentFolder = parentFolder; + this.user = user; + this.childFolderIdOrderedList = childFolderIdOrderedList != null ? childFolderIdOrderedList : + new ArrayList<>(); + this.childPickIdOrderedList = childPickIdOrderedList != null ? childPickIdOrderedList : new ArrayList<>(); + } +} diff --git a/backend/baguni-domain/src/main/java/baguni/domain/model/folder/FolderType.java b/backend/baguni-domain/src/main/java/baguni/domain/model/folder/FolderType.java new file mode 100644 index 000000000..1df338886 --- /dev/null +++ b/backend/baguni-domain/src/main/java/baguni/domain/model/folder/FolderType.java @@ -0,0 +1,30 @@ +package baguni.domain.model.folder; + +import java.util.EnumSet; + +public enum FolderType { + + UNCLASSIFIED("미분류"), + RECYCLE_BIN("휴지통"), + ROOT("루트"), + GENERAL("일반"), + ; + + private final String label; + + FolderType(String label) { + this.label = label; + } + + public String getLabel() { + return label; + } + + public static EnumSet getBasicFolderTypes() { + return EnumSet.of(UNCLASSIFIED, RECYCLE_BIN, ROOT); + } + + public static EnumSet getUnclassifiedFolderTypes() { + return EnumSet.of(UNCLASSIFIED); + } +} diff --git a/backend/baguni-domain/src/main/java/baguni/domain/model/link/Link.java b/backend/baguni-domain/src/main/java/baguni/domain/model/link/Link.java new file mode 100644 index 000000000..69668ba14 --- /dev/null +++ b/backend/baguni-domain/src/main/java/baguni/domain/model/link/Link.java @@ -0,0 +1,95 @@ +package baguni.domain.model.link; + +import java.time.LocalDateTime; + +import org.apache.commons.lang3.StringUtils; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Table(name = "link") +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Link { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + // URL + // TODO: VARCHAR 최대 크기를 몇으로 할지 토의 필요합니다. + // The index key prefix length limit is 3072 bytes for InnoDB -> VARCHAR(1000) + utf8 = 4000byte + // 일단 medium 기준 가장 길었던 url 320 글자의 약 2배인 VARCHAR(600)으로 변경 + // Baguni 노션 기술 부채에 VARCHAR, TEXT 부분 참고. + @Column(name = "url", nullable = false, columnDefinition = "VARCHAR(600)", unique = true) + private String url; + + // title이 한글 200자 이상인 경우가 있어 text타입으로 변경 + @Column(name = "title", columnDefinition = "TEXT") + private String title; + + // description이 한글 300자 이상인 경우가 있어 text타입으로 변경 + @Column(name = "description", columnDefinition = "TEXT") + private String description; + + // image가 base64 로 인코딩되서 url에 담기는 경우가 있기 때문에 text로 변경 + @Column(name = "image_url", columnDefinition = "TEXT") + private String imageUrl; + + @Column(name = "invalidated_at") + private LocalDateTime invalidatedAt; + + public static Link createLinkByUrl(String url) { + return new Link(url, "", "", "", null); + } + + /** + * 익스텐션으로 픽 생성 시 항상 title을 받기 때문에 생성 메서드 추가 + */ + public static Link createLinkByUrlAndTitle(String url, String title) { + return new Link(url, title, "", "", null); + } + + // null 값이 아닌 필드만 업데이트 + // 이미 값이 있으면 업데이트하지 않음. + public Link updateMetadata(String title, String description, String imageUrl) { + if (StringUtils.isEmpty(this.title)) { + this.title = title; + } + if (StringUtils.isEmpty(this.description)) { + this.description = description; + } + if (StringUtils.isEmpty(this.imageUrl)) { + this.imageUrl = imageUrl; + } + return this; + } + + public Link markAsInvalid() { + this.invalidatedAt = LocalDateTime.now(); + return this; + } + + public boolean isValidLink() { + return (this.invalidatedAt == null); + } + + @Builder + private Link(String url, String title, String description, String imageUrl, LocalDateTime invalidatedAt) { + this.url = url; + this.title = title; + this.description = description; + this.imageUrl = imageUrl; + this.invalidatedAt = invalidatedAt; + } +} diff --git a/backend/baguni-domain/src/main/java/baguni/domain/model/pick/Pick.java b/backend/baguni-domain/src/main/java/baguni/domain/model/pick/Pick.java new file mode 100644 index 000000000..2aaf6b378 --- /dev/null +++ b/backend/baguni-domain/src/main/java/baguni/domain/model/pick/Pick.java @@ -0,0 +1,87 @@ +package baguni.domain.model.pick; + +import java.util.ArrayList; +import java.util.List; + +import baguni.domain.model.util.OrderConverter; +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import baguni.domain.model.common.BaseEntity; +import baguni.domain.model.folder.Folder; +import baguni.domain.model.link.Link; +import baguni.domain.model.user.User; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Pick extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + // 사용자 + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + // 북마크 대상 링크 + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "link_id", nullable = false) + private Link link; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "parent_folder_id", nullable = false) + private Folder parentFolder; + + // 사용자가 수정 가능한 Pick 제목. 기본값은 원문 제목과 동일 + @Column(name = "title", nullable = false) + private String title = ""; + + // 픽에 속한 tag id들을 공백으로 분리된 String으로 변환하여 db에 저장. Ex) [6,3,2,23,1] -> "6 3 2 23 1" + @Convert(converter = OrderConverter.class) + @Column(name = "tag_order", columnDefinition = "longblob", nullable = false) + private List tagIdOrderedList = new ArrayList<>(); + + @Builder + private Pick(User user, Link link, Folder parentFolder, String title, List tagIdOrderedList) { + this.user = user; + this.link = link; + this.parentFolder = parentFolder; + this.title = title; + this.tagIdOrderedList = tagIdOrderedList; + } + + public Pick updateTagOrderList(List tagOrderList) { + if (tagOrderList == null) + return this; + this.tagIdOrderedList = tagOrderList; + return this; + } + + public Pick updateParentFolder(Folder parentFolder) { + if (parentFolder == null) + return this; + this.parentFolder = parentFolder; + return this; + } + + public Pick updateTitle(String title) { + if (title == null) + return this; + this.title = title; + return this; + } +} diff --git a/backend/baguni-domain/src/main/java/baguni/domain/model/pick/PickTag.java b/backend/baguni-domain/src/main/java/baguni/domain/model/pick/PickTag.java new file mode 100644 index 000000000..613f4995e --- /dev/null +++ b/backend/baguni-domain/src/main/java/baguni/domain/model/pick/PickTag.java @@ -0,0 +1,56 @@ +package baguni.domain.model.pick; + +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.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import baguni.domain.model.tag.Tag; + +@Table( + name = "pick_tag", + uniqueConstraints = { + @UniqueConstraint( + name = "UC_PICK_TAG", + columnNames = {"pick_id", "tag_id"} + ) + } +) +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PickTag { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + // 사용자 FK + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "pick_id", nullable = false) + private Pick pick; + + // 사용자 정의 태그 FK + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "tag_id", nullable = false) + private Tag tag; + + // TODO: 엔티티 사용자가 정적 팩토리 메소드로 필요한 함수를 구현 하세요 + private PickTag(Pick pick, Tag tag) { + this.pick = pick; + this.tag = tag; + } + + public static PickTag of(Pick pick, Tag tag) { + return new PickTag(pick, tag); + } +} diff --git a/backend/baguni-domain/src/main/java/baguni/domain/model/rss/RssBlog.java b/backend/baguni-domain/src/main/java/baguni/domain/model/rss/RssBlog.java new file mode 100644 index 000000000..2f3310372 --- /dev/null +++ b/backend/baguni-domain/src/main/java/baguni/domain/model/rss/RssBlog.java @@ -0,0 +1,43 @@ +package baguni.domain.model.rss; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import baguni.domain.model.common.BaseEntity; + +@Table(name = "rss_blog") +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class RssBlog extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @Column(name = "blog_name", nullable = false, unique = true) + private String blogName; + + // Rss 피드 주소 + @Column(name = "url", nullable = false, unique = true) + private String url; + + // TODO: 엔티티 사용자가 정적 팩토리 메소드로 필요한 함수를 구현 하세요 + @Builder + private RssBlog(String blogName, String url) { + this.blogName = blogName; + this.url = url; + } + + public static RssBlog create(String blogName, String url) { + return new RssBlog(blogName, url); + } +} diff --git a/backend/baguni-domain/src/main/java/baguni/domain/model/rss/RssFeed.java b/backend/baguni-domain/src/main/java/baguni/domain/model/rss/RssFeed.java new file mode 100644 index 000000000..e803bbf1f --- /dev/null +++ b/backend/baguni-domain/src/main/java/baguni/domain/model/rss/RssFeed.java @@ -0,0 +1,80 @@ +package baguni.domain.model.rss; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import baguni.domain.model.common.BaseEntity; + +@Table(name = "rss_raw_data") +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class RssFeed extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + // 제목 + @Column(name = "title") + private String title; + + // 원문 주소 + // TODO: VARCHAR 최대 크기를 몇으로 할지 토의 필요합니다. + // medium 기준 가장 길었던 url 320 글자의 약 2배인 VARCHAR(600)으로 변경 + @Column(name = "url", columnDefinition = "VARCHAR(600)") + private String url; + + @Column(name = "guid") + private String guid; + + // 작성 일자 + @Column(name = "published_at") + private String publishedAt; + + // 요약 설명 + @Column(name = "description", columnDefinition = "longblob") // nullable + private String description; + + // 작성자 + @Column(name = "creator") // nullable + private String creator; + + // 복수 카테고리를 쉼표(,)로 구분 (ex. "Foo,Bar,Baz") + @Column(name = "joined_category") // nullable + private String joinedCategory; + + @Column(name = "rssBlogId") + private Long rssBlogId; + + // TODO: 엔티티 사용자가 정적 팩토리 메소드로 필요한 함수를 구현 하세요 + + @Builder + private RssFeed( + String title, + String url, + String guid, + String publishedAt, + String description, + String creator, + String joinedCategory, + Long rssBlogId + ) { + this.title = title; + this.url = url; + this.guid = guid; + this.publishedAt = publishedAt; + this.description = description; + this.creator = creator; + this.joinedCategory = joinedCategory; + this.rssBlogId = rssBlogId; + } +} diff --git a/backend/baguni-domain/src/main/java/baguni/domain/model/sharedFolder/SharedFolder.java b/backend/baguni-domain/src/main/java/baguni/domain/model/sharedFolder/SharedFolder.java new file mode 100644 index 000000000..9eddbcc98 --- /dev/null +++ b/backend/baguni-domain/src/main/java/baguni/domain/model/sharedFolder/SharedFolder.java @@ -0,0 +1,46 @@ +package baguni.domain.model.sharedFolder; + +import java.util.UUID; + +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import baguni.domain.model.common.BaseEntity; +import baguni.domain.model.folder.Folder; +import baguni.domain.model.user.User; + +@Table(name = "sharedFolder") +@Entity +@Getter +@NoArgsConstructor +public class SharedFolder extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "folder_id", nullable = false, unique = true) + private Folder folder; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + private SharedFolder(User user, Folder folder) { + this.user = user; + this.folder = folder; + } + + public static SharedFolder createSharedFolder(User user, Folder folder) { + return new SharedFolder(user, folder); + } +} diff --git a/backend/baguni-domain/src/main/java/baguni/domain/model/tag/Tag.java b/backend/baguni-domain/src/main/java/baguni/domain/model/tag/Tag.java new file mode 100644 index 000000000..88275d428 --- /dev/null +++ b/backend/baguni-domain/src/main/java/baguni/domain/model/tag/Tag.java @@ -0,0 +1,71 @@ +package baguni.domain.model.tag; + +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.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import baguni.domain.model.user.User; + +@Table( + name = "tag", + uniqueConstraints = { + @UniqueConstraint( + name = "UC_TAG_NAME_PER_USER", + columnNames = {"user_id", "name"} + ) + } +) +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Tag { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + // 태그명 + @Column(name = "name", nullable = false) + private String name; + + // 프론트가 쓸 컬러 넘버 (숫자 - 색상 매핑은 프론트에서 처리, 무조건 처리) + @Column(name = "color_number", nullable = false) + private Integer colorNumber; + + // 사용자 FK + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Builder + private Tag(String name, Integer colorNumber, User user) { + this.name = name; + this.colorNumber = colorNumber; + this.user = user; + } + + // TODO: 엔티티 사용자가 정적 팩토리 메소드로 필요한 함수를 구현 하세요 + + public void updateTagName(String name) { + if (name != null) { + this.name = name; + } + } + + public void updateColorNumber(Integer colorNumber) { + if (colorNumber != null) { + this.colorNumber = colorNumber; + } + } +} diff --git a/backend/baguni-domain/src/main/java/baguni/domain/model/user/Role.java b/backend/baguni-domain/src/main/java/baguni/domain/model/user/Role.java new file mode 100644 index 000000000..5c614e2e2 --- /dev/null +++ b/backend/baguni-domain/src/main/java/baguni/domain/model/user/Role.java @@ -0,0 +1,6 @@ +package baguni.domain.model.user; + +public enum Role { + ROLE_USER, + ROLE_ADMIN, +} diff --git a/backend/baguni-domain/src/main/java/baguni/domain/model/user/SocialProvider.java b/backend/baguni-domain/src/main/java/baguni/domain/model/user/SocialProvider.java new file mode 100644 index 000000000..aa8e99c2a --- /dev/null +++ b/backend/baguni-domain/src/main/java/baguni/domain/model/user/SocialProvider.java @@ -0,0 +1,27 @@ +package baguni.domain.model.user; + +// 로그인 타입 +public enum SocialProvider { + GOOGLE("google"), + KAKAO("kakao"), + ; + + private final String provider; + + SocialProvider(String provider) { + this.provider = provider; + } + + public static SocialProvider of(String provider) throws IllegalArgumentException { + for (SocialProvider socialProvider : SocialProvider.values()) { + if (socialProvider.provider.equals(provider)) { + return socialProvider; + } + } + throw new IllegalArgumentException(); + } + + public String getName() { + return this.provider; + } +} diff --git a/backend/baguni-domain/src/main/java/baguni/domain/model/user/User.java b/backend/baguni-domain/src/main/java/baguni/domain/model/user/User.java new file mode 100644 index 000000000..de78617c0 --- /dev/null +++ b/backend/baguni-domain/src/main/java/baguni/domain/model/user/User.java @@ -0,0 +1,129 @@ +package baguni.domain.model.user; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import baguni.domain.model.util.IDToken; +import baguni.domain.model.util.IDTokenConverter; +import baguni.domain.model.util.OrderConverter; +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import baguni.domain.model.common.BaseEntity; + +@Table(name = "user") +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class User extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + // 이메일 (WARN: 두 소셜 로그인이 같은 이메일을 가질 수 있음) + @Column(name = "email", nullable = false) + private String email; + + // 유저 권한 + @Column(name = "role", nullable = false) + @Enumerated(EnumType.STRING) + private Role role; + + // 유저 식별 토큰 ( Unique ) + @Convert(converter = IDTokenConverter.class) + @Column(name = "id_token", nullable = false, columnDefinition = "char(36)", unique = true) + private IDToken idToken; + + // 닉네임 (없으면 랜덤 생성 - Ex. "노래하는피치#145") + @Column(name = "nickname") + private String nickname; + + // 일반 로그인만 해당 ---------------------------------- + // 비밀번호 (소셜 로그인 사용자는 null) + @Column(name = "password") // nullable + private String password; + + // 소셜 로그인만 해당 ---------------------------------- + // 소셜 제공자 (null일 경우 자체 가입 회원) + @Enumerated(EnumType.STRING) + @Column(name = "social_provider") // nullable + private SocialProvider socialProvider; + + // 소셜 제공자 Id (null일 경우 자체 가입 회원) + @Column(name = "social_provider_id") // nullable + private String socialProviderId; + // ------------------------------------------------- + + // 유저의 tag id들을 공백으로 분리된 String으로 변환하여 db에 저장 + // ex) [6,3,2,23,1] -> "6 3 2 23 1" + @Convert(converter = OrderConverter.class) + @Column(name = "tag_order", columnDefinition = "longblob", nullable = false) + private List tagOrderList = new ArrayList<>(); + + public void updateTagOrderList(Long id, int destination) { + tagOrderList.remove(id); + int calculatedDestination = Math.min(destination, tagOrderList.size()); + tagOrderList.add(calculatedDestination, id); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof User user)) { + return false; + } + return Objects.equals(id, user.id); + } + + @Override + public int hashCode() { + return Objects.hashCode(id); + } + + @Builder + private User( + SocialProvider socialProvider, + String socialProviderId, + String nickname, + String password, + String email, + Role role, + IDToken idToken, + List tagOrderList + ) { + this.socialProviderId = socialProviderId; + this.socialProvider = socialProvider; + this.nickname = nickname; + this.password = password; + this.email = email; + this.role = role; + this.idToken = idToken; + this.tagOrderList = tagOrderList; + } + + public static User SocialUser(SocialProvider socialProvider, String socialProviderId, String email) { + return User + .builder() + .socialProvider(socialProvider) + .socialProviderId(socialProviderId) + .email(email) + .idToken(IDToken.makeNew()) + .role(Role.ROLE_USER) + .build(); + } +} diff --git a/backend/baguni-domain/src/main/java/baguni/domain/model/util/IDToken.java b/backend/baguni-domain/src/main/java/baguni/domain/model/util/IDToken.java new file mode 100644 index 000000000..fbda4126f --- /dev/null +++ b/backend/baguni-domain/src/main/java/baguni/domain/model/util/IDToken.java @@ -0,0 +1,44 @@ +package baguni.domain.model.util; + +import java.util.UUID; + +/** + * VO for ID Token + */ +public class IDToken { + + private final UUID uuid; + + public static IDToken fromString(String raw) throws IdTokenConversionException { + try { + var uuid = UUID.fromString(raw); + return new IDToken(uuid); + } catch (Exception e) { + throw new IdTokenConversionException("ID 토큰의 값이 UUID 가 아닙니다!"); + } + } + + public static IDToken makeNew() { + return new IDToken(UUID.randomUUID()); + } + + @Override + public String toString() { + return this.uuid.toString(); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof IDToken token)) { + return false; + } + return this.uuid.compareTo(token.uuid) == 0; + } + + private IDToken(UUID uuid) { + this.uuid = uuid; + } +} diff --git a/backend/baguni-domain/src/main/java/baguni/domain/model/util/IDTokenConverter.java b/backend/baguni-domain/src/main/java/baguni/domain/model/util/IDTokenConverter.java new file mode 100644 index 000000000..445f47312 --- /dev/null +++ b/backend/baguni-domain/src/main/java/baguni/domain/model/util/IDTokenConverter.java @@ -0,0 +1,18 @@ +package baguni.domain.model.util; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +@Converter +public class IDTokenConverter implements AttributeConverter { + + @Override + public String convertToDatabaseColumn(IDToken attribute) { + return attribute != null ? attribute.toString() : null; + } + + @Override + public IDToken convertToEntityAttribute(String dbData) { + return IDToken.fromString(dbData); + } +} diff --git a/backend/baguni-domain/src/main/java/baguni/domain/model/util/IdTokenConversionException.java b/backend/baguni-domain/src/main/java/baguni/domain/model/util/IdTokenConversionException.java new file mode 100644 index 000000000..34a12865f --- /dev/null +++ b/backend/baguni-domain/src/main/java/baguni/domain/model/util/IdTokenConversionException.java @@ -0,0 +1,8 @@ +package baguni.domain.model.util; + +public class IdTokenConversionException extends RuntimeException { + + public IdTokenConversionException(String message) { + super(message); + } +} diff --git a/backend/baguni-domain/src/main/java/baguni/domain/model/util/OrderConverter.java b/backend/baguni-domain/src/main/java/baguni/domain/model/util/OrderConverter.java new file mode 100644 index 000000000..e1b2f0ab0 --- /dev/null +++ b/backend/baguni-domain/src/main/java/baguni/domain/model/util/OrderConverter.java @@ -0,0 +1,34 @@ +package baguni.domain.model.util; + +import java.util.ArrayList; +import java.util.List; +import java.util.StringTokenizer; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +@Converter +public class OrderConverter implements AttributeConverter, String> { + + @Override + public String convertToDatabaseColumn(List idList) { + if (idList == null) { + return ""; + } + StringBuilder sb = new StringBuilder(); + for (Long id : idList) { + sb.append(id).append(" "); + } + return sb.toString().trim(); + } + + @Override + public List convertToEntityAttribute(String s) { + List idList = new ArrayList<>(); + StringTokenizer st = new StringTokenizer(s); + while (st.hasMoreTokens()) { + idList.add(Long.parseLong(st.nextToken())); + } + return idList; + } +} diff --git a/backend/baguni-domain/src/main/resources/application-domain.yaml b/backend/baguni-domain/src/main/resources/application-domain.yaml new file mode 100644 index 000000000..262222ef6 --- /dev/null +++ b/backend/baguni-domain/src/main/resources/application-domain.yaml @@ -0,0 +1,83 @@ +# ----------------------------- +# COMMON SETTINGS +# ----------------------------- +spring: + output: + ansi: + enabled: always + sql: + init: + mode: never # schema.sql 실행시 always 키고 실행하시면 됩니다. option: never, always + jpa: + open-in-view: false + properties: + hibernate: + format_sql: false + show_sql: false + dialect: org.hibernate.dialect.MySQL8Dialect + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + username: ${DOCKER_MYSQL_USERNAME} + password: ${DOCKER_MYSQL_PASSWORD} + flyway: + user: ${DOCKER_MYSQL_USERNAME} + password: ${DOCKER_MYSQL_PASSWORD} + schemas: ${DOCKER_MYSQL_DATABASE} + +--- +# ----------------------------- +# LOCAL SETTINGS +# ----------------------------- +spring: + config: + activate: + on-profile: local + jpa: + hibernate: + ddl-auto: validate + datasource: + url: ${DOCKER_LOCAL_MYSQL_URL} + flyway: + url: ${DOCKER_LOCAL_MYSQL_URL} + baseline-on-migrate: true + enabled: true + +--- +# ----------------------------- +# DEVELOPMENT SETTINGS +# ----------------------------- +spring: + config: + activate: + on-profile: dev + jpa: + hibernate: + ddl-auto: validate + datasource: + url: ${DOCKER_DEV_MYSQL_URL} + flyway: + url: ${DOCKER_DEV_MYSQL_URL} + baseline-on-migrate: true + enabled: true + +--- +# ----------------------------- +# PRODUCTION SETTINGS +# ----------------------------- +spring: + config: + activate: + on-profile: prod + jpa: + hibernate: + ddl-auto: none + datasource: + url: ${DOCKER_PROD_MYSQL_URL} + flyway: + url: ${DOCKER_PROD_MYSQL_URL} + baseline-on-migrate: true + enabled: true +decorator: + datasource: + p6spy: # 운영 서버에서는 p6spy 로깅을 사용하지 않음 + enable-logging: false \ No newline at end of file diff --git a/backend/baguni-domain/src/main/resources/db/migration/V1__init_schema.sql b/backend/baguni-domain/src/main/resources/db/migration/V1__init_schema.sql new file mode 100644 index 000000000..26baef072 --- /dev/null +++ b/backend/baguni-domain/src/main/resources/db/migration/V1__init_schema.sql @@ -0,0 +1,139 @@ +create table baguni_db.link +( + id bigint auto_increment + primary key, + invalidated_at_at datetime(6) null, + description text null, + image_url text null, + title text null, + url varchar(600) not null, + constraint UK4dycbe6q8trcendnql3b13cuf + unique (url) +); + +create table baguni_db.rss_blog +( + created_at datetime(6) not null, + id bigint auto_increment + primary key, + updated_at datetime(6) not null, + blog_name varchar(255) not null, + url varchar(255) not null, + constraint UKhkgcu0q1xs43reu4pa6xhpt24 + unique (blog_name), + constraint UKs6orlq8fncv7ps4wpp05o1pv + unique (url) +); + +create table baguni_db.rss_raw_data +( + created_at datetime(6) not null, + id bigint auto_increment + primary key, + rss_blog_id bigint null, + updated_at datetime(6) not null, + creator varchar(255) null, + description longblob null, + guid varchar(255) null, + joined_category varchar(255) null, + published_at varchar(255) null, + title varchar(255) null, + url varchar(600) null +); + +create table baguni_db.user +( + created_at datetime(6) not null, + id bigint auto_increment + primary key, + updated_at datetime(6) not null, + email varchar(255) not null, + nickname varchar(255) null, + password varchar(255) null, + social_provider_id varchar(255) null, + tag_order longblob not null, + role enum ('ROLE_ADMIN', 'ROLE_GUEST', 'ROLE_USER') not null, + social_provider enum ('GOOGLE', 'KAKAO') null +); + +create table baguni_db.folder +( + created_at datetime(6) not null, + id bigint auto_increment + primary key, + parent_folder_id bigint null, + updated_at datetime(6) not null, + user_id bigint not null, + child_folder_order longblob not null, + name varchar(255) not null, + pick_order longblob not null, + folder_type enum ('GENERAL', 'RECYCLE_BIN', 'ROOT', 'UNCLASSIFIED') not null, + constraint FK57g7veis1gp5wn3g0mp0x57pl + foreign key (parent_folder_id) references baguni_db.folder (id) + on delete cascade, + constraint FK5fd2civdi8s832iyrufpk400k + foreign key (user_id) references baguni_db.user (id) +); + +create table baguni_db.pick +( + created_at datetime(6) not null, + id bigint auto_increment + primary key, + link_id bigint not null, + parent_folder_id bigint not null, + updated_at datetime(6) not null, + user_id bigint not null, + tag_order longblob not null, + title varchar(255) not null, + constraint FKbilrp2m7mc9ssut5d85loj5d7 + foreign key (user_id) references baguni_db.user (id), + constraint FKf3o2jbamw9l96i1lwvaytuik7 + foreign key (link_id) references baguni_db.link (id), + constraint FKhfrafg7f40ym7wgrtp9j45pha + foreign key (parent_folder_id) references baguni_db.folder (id) +); + +create table baguni_db.shared_folder +( + created_at datetime(6) not null, + folder_id bigint not null, + updated_at datetime(6) not null, + user_id bigint not null, + id binary(16) not null + primary key, + constraint UK5p0agkwypm465pveqn7na9tig + unique (folder_id), + constraint FK34v8mqhr9a6rwep0hi9aegr79 + foreign key (user_id) references baguni_db.user (id), + constraint FK8xepmn10i8pgw3w1rwuffwynp + foreign key (folder_id) references baguni_db.folder (id) +); + +create table baguni_db.tag +( + color_number int not null, + id bigint auto_increment + primary key, + user_id bigint not null, + name varchar(255) not null, + constraint UC_TAG_NAME_PER_USER + unique (user_id, name), + constraint FKld85w5kr7ky5w4wda3nrdo0p8 + foreign key (user_id) references baguni_db.user (id) +); + +create table baguni_db.pick_tag +( + id bigint auto_increment + primary key, + pick_id bigint not null, + tag_id bigint not null, + constraint UC_PICK_TAG + unique (pick_id, tag_id), + constraint FK9e42g0lyb0ss1pjhvdrqqh0a8 + foreign key (tag_id) references baguni_db.tag (id), + constraint FKcbtnw1dxhgh641h8yjp9nwnav + foreign key (pick_id) references baguni_db.pick (id) +); + diff --git a/backend/baguni-domain/src/main/resources/db/migration/V2.1__change_wrong_link_field_name.sql b/backend/baguni-domain/src/main/resources/db/migration/V2.1__change_wrong_link_field_name.sql new file mode 100644 index 000000000..7e2745ffd --- /dev/null +++ b/backend/baguni-domain/src/main/resources/db/migration/V2.1__change_wrong_link_field_name.sql @@ -0,0 +1 @@ +ALTER TABLE baguni_db.link RENAME COLUMN invalidated_at_at TO invalidated_at; \ No newline at end of file diff --git a/backend/baguni-domain/src/main/resources/db/migration/V2__add_user_idToken.sql b/backend/baguni-domain/src/main/resources/db/migration/V2__add_user_idToken.sql new file mode 100644 index 000000000..5ee965e20 --- /dev/null +++ b/backend/baguni-domain/src/main/resources/db/migration/V2__add_user_idToken.sql @@ -0,0 +1,11 @@ +ALTER TABLE baguni_db.user + ADD id_token char(36); + +UPDATE baguni_db.user +SET id_token = uuid(); + +ALTER TABLE baguni_db.user + MODIFY id_token char(36) NOT NULL; + +ALTER TABLE baguni_db.user + ADD CONSTRAINT UKhsxhy38v8pbjsyvvql9y962ie UNIQUE (id_token); \ No newline at end of file diff --git a/backend/baguni-ranking/src/main/java/baguni/ranking/infra/RankingEventListener.java b/backend/baguni-ranking/src/main/java/baguni/ranking/infra/RankingEventListener.java new file mode 100644 index 000000000..a90777dcc --- /dev/null +++ b/backend/baguni-ranking/src/main/java/baguni/ranking/infra/RankingEventListener.java @@ -0,0 +1,126 @@ +package baguni.ranking.infra; + +import java.time.LocalDate; + +import org.springframework.amqp.rabbit.annotation.RabbitHandler; +import org.springframework.amqp.rabbit.annotation.RabbitListener; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import baguni.common.config.RabbitmqConfig; +import baguni.common.event.events.PickViewEvent; +import baguni.common.event.events.PickCreateEvent; +import baguni.common.event.events.SharedFolderLinkViewEvent; +import baguni.common.event.events.SuggestionViewEvent; +import baguni.ranking.infra.pick.LinkPickedCount; +import baguni.ranking.infra.pick.LinkPickedCountRepository; +import baguni.ranking.infra.pick.LinkViewCount; +import baguni.ranking.infra.pick.LinkViewCountRepository; +import baguni.ranking.infra.sharedFolder.SharedFolderPickViewCount; +import baguni.ranking.infra.sharedFolder.SharedFolderPickViewCountRepository; + +/** + * @author minkyeu kim + * RabbitMq Queue(PICK_RANKING) 에 들어오는 대로 + * 즉시 꺼내 집계에 반영하는 소비자 컴포넌트 + * */ +@Slf4j +@Component +@RequiredArgsConstructor +@RabbitListener(queues = {RabbitmqConfig.QUEUE.PICK_RANKING}) +public class RankingEventListener { + + private final LinkViewCountRepository linkViewCountRepository; + private final LinkPickedCountRepository linkPickedCountRepository; + private final SharedFolderPickViewCountRepository sharedFolderPickViewCountRepository; + + /** + * 사용자의 북마크 생성 이벤트 집계 + */ + @RabbitHandler + public void pickCreateEvent(PickCreateEvent event) { + log.info("픽 생성 이벤트 수신 {}", event); + var date = event.getTime().toLocalDate(); + var url = event.getUrl(); + var pickId = event.getPickId(); // unused + var userId = event.getUserId(); // unused + updateLinkPickedCount(date, url); + } + + /** + * 사용자의 북마크 조회 이벤트 집계 + */ + @RabbitHandler + public void pickViewEvent(PickViewEvent event) { + log.info("픽 조회 이벤트 수신 {}", event); + var date = event.getTime().toLocalDate(); + var url = event.getUrl(); + var pickId = event.getPickId(); // unused + var userId = event.getUserId(); // unused + updateLinkViewCount(date, url); + } + + /** + * 공개 폴더 이벤트 집계 + * 1. 공유 폴더별 링크(url)에 대한 날별 집계 - 마이페이지에서 폴더 공유자가 확인 + * 2. 공유 폴더의 픽 조회도 전체 링크 조회에 포함시켜 집계. + */ + @RabbitHandler + public void sharedFolderViewEvent(SharedFolderLinkViewEvent event) { + log.info("공유 폴더 내 픽 조회 이벤트 수신 {}", event); + var date = event.getTime().toLocalDate(); + var url = event.getUrl(); + var folderAccessToken = event.getFolderAccessToken(); + updateSharedFolderViewCount(date, url, folderAccessToken); + updateLinkViewCount(date, url); + } + + /** + * 추천 페이지 에서 추천된 링크 클릭 이벤트 집계 + */ + @RabbitHandler + public void suggestionViewEvent(SuggestionViewEvent event) { + log.info("추천 페이지 내 픽 조회 이벤트 수신 {}", event); + var date = event.getTime().toLocalDate(); + var url = event.getUrl(); + var userId = event.getUserId(); // unused + updateLinkViewCount(date, url); + } + + /** + * 매핑되지 않은 이벤트 + */ + @RabbitHandler(isDefault = true) + public void defaultMethod(Object object) { + log.error("일치하는 이벤트 타입이 없습니다! {}", object.toString()); + } + + /** + * 헬퍼 함수 + * TODO: 다른 클래스나 서비스로 분리 리팩토링 진행 + */ + private void updateLinkViewCount(LocalDate date, String url) { + var linkViewCount = linkViewCountRepository + .findLinkViewCountByDateAndUrl(date, url) + .orElseGet(() -> new LinkViewCount(date, url)); + linkViewCount.incrementCount(); + linkViewCountRepository.save(linkViewCount); + } + + private void updateLinkPickedCount(LocalDate date, String url) { + var linkPickedCount = linkPickedCountRepository + .findLinkPickedCountByDateAndUrl(date, url) + .orElseGet(() -> new LinkPickedCount(date, url)); + linkPickedCount.incrementCount(); + linkPickedCountRepository.save(linkPickedCount); + } + + private void updateSharedFolderViewCount(LocalDate date, String url, String folderAccessToken) { + var sharedFolderLinkViewCount = sharedFolderPickViewCountRepository + .findSharedFolderPickViewCountByDateAndUrl(date, url) + .orElseGet(() -> new SharedFolderPickViewCount(date, url, folderAccessToken)); + sharedFolderLinkViewCount.incrementCount(); + sharedFolderPickViewCountRepository.save(sharedFolderLinkViewCount); + } +} diff --git a/backend/docker-compose.yaml b/backend/docker-compose.yaml index f9b0fe73f..3d2ba9d13 100644 --- a/backend/docker-compose.yaml +++ b/backend/docker-compose.yaml @@ -135,4 +135,3 @@ services: command: [ "--bind_ip", "0.0.0.0" ] networks: - baguni-dev-network - diff --git a/backend/project-setup/README.md b/backend/project-setup/README.md index 0f83d8685..a4aec2b6a 100644 --- a/backend/project-setup/README.md +++ b/backend/project-setup/README.md @@ -7,15 +7,15 @@ ##### (1) 포매터 설정 - IntelliJ 설정 - - Code Formatter 설정 - - ./formatter 에 위치한 `naver-intellij-formatter.xml` 파일을 이용하여 설정 - - Settings -> CodeStyle -> Java -> Import Scheme - - CheckStyle 설정 - - Plugin CheckStyle 설치 - - ./formatter 에 위치한 `naver-checkstyle-rules.xml` 파일을 이용하여 설정 - - suppression file 의 경우 `./.idea/naver-checkstyle-suppresssions.xml` 지정 - - Format-on-save 기능 활성화 - - Settings -> Tools -> Actions on Save -> Reformat code 체크 + - Code Formatter 설정 + - ./formatter 에 위치한 `naver-intellij-formatter.xml` 파일을 이용하여 설정 + - Settings -> CodeStyle -> Java -> Import Scheme + - CheckStyle 설정 + - Plugin CheckStyle 설치 + - ./formatter 에 위치한 `naver-checkstyle-rules.xml` 파일을 이용하여 설정 + - suppression file 의 경우 `./.idea/naver-checkstyle-suppresssions.xml` 지정 + - Format-on-save 기능 활성화 + - Settings -> Tools -> Actions on Save -> Reformat code 체크 ##### (2) Env 설정 @@ -27,13 +27,3 @@ 아래 설정을 꼭 해줘야 테스트가 실행 됩니다. ![how-to-run-test.png](how-to-run-test.png) - - - - - - - - - - diff --git a/backend/settings.gradle b/backend/settings.gradle index a2e4dd1a5..2fa488a21 100644 --- a/backend/settings.gradle +++ b/backend/settings.gradle @@ -1,6 +1,6 @@ rootProject.name = 'backend' include 'baguni-common' -include 'baguni-entity' +include 'baguni-domain' include 'baguni-api' include 'baguni-batch'