diff --git a/build.gradle b/build.gradle index 2666f39..bedc149 100644 --- a/build.gradle +++ b/build.gradle @@ -43,6 +43,12 @@ dependencies { runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.2' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.2' + // Query Dsl + implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' + annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta" + annotationProcessor "jakarta.annotation:jakarta.annotation-api" + annotationProcessor "jakarta.persistence:jakarta.persistence-api" + // MongoDB implementation 'org.springframework.boot:spring-boot-starter-data-mongodb' diff --git a/src/main/java/com/olive/pribee/global/config/QueryDslConfig.java b/src/main/java/com/olive/pribee/global/config/QueryDslConfig.java new file mode 100644 index 0000000..72d90ae --- /dev/null +++ b/src/main/java/com/olive/pribee/global/config/QueryDslConfig.java @@ -0,0 +1,21 @@ +package com.olive.pribee.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import com.querydsl.jpa.impl.JPAQueryFactory; + + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; + +@Configuration +public class QueryDslConfig { + + @PersistenceContext + private EntityManager entityManager; + + @Bean + public JPAQueryFactory jpaQueryFactory() { + return new JPAQueryFactory(entityManager); + } +} \ No newline at end of file diff --git a/src/main/java/com/olive/pribee/global/enums/DetectKeyword.java b/src/main/java/com/olive/pribee/global/enums/DetectKeyword.java new file mode 100644 index 0000000..be13fe7 --- /dev/null +++ b/src/main/java/com/olive/pribee/global/enums/DetectKeyword.java @@ -0,0 +1,49 @@ +package com.olive.pribee.global.enums; + +import java.util.Arrays; +import java.util.List; + +import lombok.Getter; + +@Getter +public enum DetectKeyword { + PERSON_NAME("PERSON_NAME", "이름", 10), + GENDER("GENDER", "성별", 10), + AGE("AGE", "나이", 10), + LOCATION("LOCATION", "주소", 10), + EMAIL_ADDRESS("EMAIL_ADDRESS", "이메일", 10), + PHONE_NUMBER("PHONE_NUMBER", "전화번호", 10), + IP_ADDRESS("IP_ADDRESS", "IP 주소", 5), + MAC_ADDRESS("MAC_ADDRESS", "MAC 주소", 5), + FINANCIAL_ACCOUNT_NUMBER("FINANCIAL_ACCOUNT_NUMBER", "계좌번호", 20), + CREDIT_CARD_NUMBER("CREDIT_CARD_NUMBER", "카드번호", 20), + IBAN_CODE("IBAN_CODE", "국제 계좌번호", 20), + MEDICAL_RECORD_NUMBER("MEDICAL_RECORD_NUMBER", "의료 기록 번호", 20), + KOREA_RRN("KOREA_RRN", "주민등록번호", 30), + KOREA_DRIVERS_LICENSE_NUMBER("KOREA_DRIVERS_LICENSE_NUMBER", "운전면허번호", 30), + KOREA_PASSPORT("KOREA_PASSPORT", "여권번호", 30); + + private final String code; + private final String name; + private final int score; + + private DetectKeyword(String code, String name, int score) { + this.code = code; + this.name = name; + this.score = score; + } + + public static DetectKeyword of(String code) { + return Arrays.stream(DetectKeyword.values()) + .filter(r -> r.getCode().equals(code)) + .findAny() + .orElse(null); + } + + public static List getInfoTypes() { + return Arrays.stream(values()) + .map(keyword -> keyword.code) + .toList(); + } + +} diff --git a/src/main/java/com/olive/pribee/global/enums/DetectLikelihood.java b/src/main/java/com/olive/pribee/global/enums/DetectLikelihood.java new file mode 100644 index 0000000..6f5d111 --- /dev/null +++ b/src/main/java/com/olive/pribee/global/enums/DetectLikelihood.java @@ -0,0 +1,29 @@ +package com.olive.pribee.global.enums; + +import java.util.Arrays; + +import lombok.Getter; + +@Getter +public enum DetectLikelihood { + VERY_UNLIKELY("VERY_UNLIKELY", "가능성 매우 낮음"), + UNLIKELY("UNLIKELY", "가능성 낮음"), + POSSIBLE("POSSIBLE", "가능성 있음"), + LIKELY("LIKELY", "가능성 높음"), + VERY_LIKELY("VERY_LIKELY", "가능성 매우 높음"); + + private final String code; + private final String name; + + private DetectLikelihood(String code, String name) { + this.code = code; + this.name = name; + } + + public static DetectLikelihood of(String code) { + return Arrays.stream(DetectLikelihood.values()) + .filter(r -> r.getCode().equals(code)) + .findAny() + .orElse(null); + } +} diff --git a/src/main/java/com/olive/pribee/global/util/fileUtil.java b/src/main/java/com/olive/pribee/global/util/fileUtil.java new file mode 100644 index 0000000..53d60d0 --- /dev/null +++ b/src/main/java/com/olive/pribee/global/util/fileUtil.java @@ -0,0 +1,26 @@ +package com.olive.pribee.global.util; + +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.net.URL; +import java.util.Base64; + +public class fileUtil { + + public static String encodeImageToBase64(String imageUrl) { + try { + URL url = new URL(imageUrl); + InputStream inputStream = url.openStream(); + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + byte[] buffer = new byte[8192]; + int bytesRead; + while ((bytesRead = inputStream.read(buffer)) != -1) { + outputStream.write(buffer, 0, bytesRead); + } + inputStream.close(); + return Base64.getEncoder().encodeToString(outputStream.toByteArray()); + } catch (Exception e) { + throw new RuntimeException("Failed to encode image", e); + } + } +} diff --git a/src/main/java/com/olive/pribee/module/auth/service/FacebookAuthService.java b/src/main/java/com/olive/pribee/infra/api/facebook/FacebookApiService.java similarity index 65% rename from src/main/java/com/olive/pribee/module/auth/service/FacebookAuthService.java rename to src/main/java/com/olive/pribee/infra/api/facebook/FacebookApiService.java index ce5678b..e5742de 100644 --- a/src/main/java/com/olive/pribee/module/auth/service/FacebookAuthService.java +++ b/src/main/java/com/olive/pribee/infra/api/facebook/FacebookApiService.java @@ -1,4 +1,8 @@ -package com.olive.pribee.module.auth.service; +package com.olive.pribee.infra.api.facebook; + +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.List; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; @@ -9,19 +13,22 @@ import com.fasterxml.jackson.databind.JsonNode; import com.olive.pribee.global.error.GlobalErrorCode; import com.olive.pribee.global.error.exception.AppException; -import com.olive.pribee.module.auth.dto.res.FacebookAuthRes; -import com.olive.pribee.module.auth.dto.res.FacebookTokenRes; -import com.olive.pribee.module.auth.dto.res.FacebookUserInfoRes; +import com.olive.pribee.infra.api.facebook.dto.res.auth.FacebookAuthRes; +import com.olive.pribee.infra.api.facebook.dto.res.auth.FacebookTokenRes; +import com.olive.pribee.infra.api.facebook.dto.res.auth.FacebookUserInfoRes; +import com.olive.pribee.infra.api.facebook.dto.res.post.FacebookPostListRes; +import com.olive.pribee.infra.api.facebook.dto.res.post.FacebookPostRes; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @Service @Transactional(readOnly = true) @RequiredArgsConstructor @Slf4j -public class FacebookAuthService { +public class FacebookApiService { private final String FB_EXCHANGE_TOKEN = "fb_exchange_token"; @@ -75,7 +82,7 @@ private Mono exchangeCodeForAccessToken(String code) { .build()) .retrieve() .onStatus(status -> - status == HttpStatus.UNAUTHORIZED || status == HttpStatus.BAD_REQUEST, response ->{ + status == HttpStatus.UNAUTHORIZED || status == HttpStatus.BAD_REQUEST, response -> { log.error("[Facebook] Invalid Facebook Code: {}", code); return Mono.error(new AppException(GlobalErrorCode.INVALID_FACEBOOK_CODE)); }) @@ -127,12 +134,12 @@ private Mono fetchFacebookId(String accessToken) { }); } - // (4-1) Facebook 사용자 정보 조회 + // (4) Facebook 사용자 정보 조회 public Mono fetchFacebookUserInfo(String accessToken) { return getFacebookWebClient().get() .uri(uriBuilder -> uriBuilder .path("/me") - .queryParam("fields", "id,name,email,picture.width(1000).height(1000)") + .queryParam("fields", "id,name,email,picture.width(500).height(500){url}") .queryParam("access_token", accessToken) .build()) .retrieve() @@ -142,4 +149,46 @@ public Mono fetchFacebookUserInfo(String accessToken) { return Mono.error(new AppException(GlobalErrorCode.INTERNAL_SERVER_ERROR)); }); } + + // (5) Facebook 게시물 조회 + public Mono> fetchFacebookUserPosts(String accessToken, LocalDateTime sinceTime) { + return getFacebookWebClient().get() + .uri(uriBuilder -> uriBuilder + .path("/me") + .queryParam("fields", + "feed.limit(100){id,updated_time,created_time,message,permalink_url,full_picture,place{name},attachments{subattachments{media{image{src}}},type}}") + .queryParam("access_token", accessToken) + .build()) + .retrieve() + .bodyToMono(FacebookPostListRes.class) // 전체 피드를 받아옴 + .flatMapMany(feed -> Flux.fromIterable(feed.getPosts().getPosts())) // 리스트로 변환하여 Flux로 반환 + .filter(facebookPostRes -> isAfterSinceTime(facebookPostRes, sinceTime)) // 시간 필터링 + .filter(this::isValidAttachment) // 유효한 첨부 필터링 + .collectList() + .onErrorResume(Exception.class, ex -> { + log.error("[Facebook] 게시물 조회 실패: {}", ex.getMessage(), ex); + return Mono.error(new AppException(GlobalErrorCode.INTERNAL_SERVER_ERROR)); + }); + } + + // 지정한 시간 이후의 게시물인지 확인 + private boolean isAfterSinceTime(FacebookPostRes facebookPostRes, LocalDateTime sinceTime) { + if (sinceTime == null) { + return true; + } + LocalDateTime postCreatedTime = facebookPostRes.getCreatedTime().atZone(ZoneOffset.UTC).toLocalDateTime(); + return postCreatedTime.isAfter(sinceTime); + } + + // 유효한 첨부파일인지 확인 (null이거나, album/photo 타입만 허용) + private boolean isValidAttachment(FacebookPostRes facebookPostRes) { + if (facebookPostRes.getAttachments() == null) { + return true; + } + return facebookPostRes.getAttachments().getData().stream() + .findFirst() + .map(attachment -> "album".equals(attachment.getType()) || "photo".equals(attachment.getType())) + .orElse(false); + } + } diff --git a/src/main/java/com/olive/pribee/module/auth/dto/res/FacebookAuthRes.java b/src/main/java/com/olive/pribee/infra/api/facebook/dto/res/auth/FacebookAuthRes.java similarity index 90% rename from src/main/java/com/olive/pribee/module/auth/dto/res/FacebookAuthRes.java rename to src/main/java/com/olive/pribee/infra/api/facebook/dto/res/auth/FacebookAuthRes.java index 5ed285c..db55201 100644 --- a/src/main/java/com/olive/pribee/module/auth/dto/res/FacebookAuthRes.java +++ b/src/main/java/com/olive/pribee/infra/api/facebook/dto/res/auth/FacebookAuthRes.java @@ -1,4 +1,4 @@ -package com.olive.pribee.module.auth.dto.res; +package com.olive.pribee.infra.api.facebook.dto.res.auth; import com.fasterxml.jackson.annotation.JsonProperty; diff --git a/src/main/java/com/olive/pribee/module/auth/dto/res/FacebookTokenRes.java b/src/main/java/com/olive/pribee/infra/api/facebook/dto/res/auth/FacebookTokenRes.java similarity index 91% rename from src/main/java/com/olive/pribee/module/auth/dto/res/FacebookTokenRes.java rename to src/main/java/com/olive/pribee/infra/api/facebook/dto/res/auth/FacebookTokenRes.java index 9436447..ff2f7f4 100644 --- a/src/main/java/com/olive/pribee/module/auth/dto/res/FacebookTokenRes.java +++ b/src/main/java/com/olive/pribee/infra/api/facebook/dto/res/auth/FacebookTokenRes.java @@ -1,4 +1,4 @@ -package com.olive.pribee.module.auth.dto.res; +package com.olive.pribee.infra.api.facebook.dto.res.auth; import com.fasterxml.jackson.annotation.JsonProperty; diff --git a/src/main/java/com/olive/pribee/module/auth/dto/res/FacebookUserInfoPictureRes.java b/src/main/java/com/olive/pribee/infra/api/facebook/dto/res/auth/FacebookUserInfoPictureRes.java similarity index 93% rename from src/main/java/com/olive/pribee/module/auth/dto/res/FacebookUserInfoPictureRes.java rename to src/main/java/com/olive/pribee/infra/api/facebook/dto/res/auth/FacebookUserInfoPictureRes.java index a230398..8d79fa1 100644 --- a/src/main/java/com/olive/pribee/module/auth/dto/res/FacebookUserInfoPictureRes.java +++ b/src/main/java/com/olive/pribee/infra/api/facebook/dto/res/auth/FacebookUserInfoPictureRes.java @@ -1,4 +1,4 @@ -package com.olive.pribee.module.auth.dto.res; +package com.olive.pribee.infra.api.facebook.dto.res.auth; import com.fasterxml.jackson.annotation.JsonProperty; diff --git a/src/main/java/com/olive/pribee/module/auth/dto/res/FacebookUserInfoRes.java b/src/main/java/com/olive/pribee/infra/api/facebook/dto/res/auth/FacebookUserInfoRes.java similarity index 92% rename from src/main/java/com/olive/pribee/module/auth/dto/res/FacebookUserInfoRes.java rename to src/main/java/com/olive/pribee/infra/api/facebook/dto/res/auth/FacebookUserInfoRes.java index ab756a8..98d7073 100644 --- a/src/main/java/com/olive/pribee/module/auth/dto/res/FacebookUserInfoRes.java +++ b/src/main/java/com/olive/pribee/infra/api/facebook/dto/res/auth/FacebookUserInfoRes.java @@ -1,4 +1,4 @@ -package com.olive.pribee.module.auth.dto.res; +package com.olive.pribee.infra.api.facebook.dto.res.auth; import com.fasterxml.jackson.annotation.JsonProperty; diff --git a/src/main/java/com/olive/pribee/infra/api/facebook/dto/res/post/FacebookPostAttachmentDataRes.java b/src/main/java/com/olive/pribee/infra/api/facebook/dto/res/post/FacebookPostAttachmentDataRes.java new file mode 100644 index 0000000..7b083bf --- /dev/null +++ b/src/main/java/com/olive/pribee/infra/api/facebook/dto/res/post/FacebookPostAttachmentDataRes.java @@ -0,0 +1,24 @@ +package com.olive.pribee.infra.api.facebook.dto.res.post; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class FacebookPostAttachmentDataRes { + private String type; + private FacebookPostSubattachmentsRes subattachments; + + @Builder + public FacebookPostAttachmentDataRes( + @JsonProperty("type") String type, + @JsonProperty("subattachments") FacebookPostSubattachmentsRes subattachments + ) { + this.type = type; + this.subattachments = subattachments; + } +} \ No newline at end of file diff --git a/src/main/java/com/olive/pribee/infra/api/facebook/dto/res/post/FacebookPostAttachmentsRes.java b/src/main/java/com/olive/pribee/infra/api/facebook/dto/res/post/FacebookPostAttachmentsRes.java new file mode 100644 index 0000000..6a09c43 --- /dev/null +++ b/src/main/java/com/olive/pribee/infra/api/facebook/dto/res/post/FacebookPostAttachmentsRes.java @@ -0,0 +1,22 @@ +package com.olive.pribee.infra.api.facebook.dto.res.post; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class FacebookPostAttachmentsRes { + @JsonProperty("data") + private List data; + + @Builder + public FacebookPostAttachmentsRes(List data) { + this.data = data; + } +} \ No newline at end of file diff --git a/src/main/java/com/olive/pribee/infra/api/facebook/dto/res/post/FacebookPostDataRes.java b/src/main/java/com/olive/pribee/infra/api/facebook/dto/res/post/FacebookPostDataRes.java new file mode 100644 index 0000000..4bf337b --- /dev/null +++ b/src/main/java/com/olive/pribee/infra/api/facebook/dto/res/post/FacebookPostDataRes.java @@ -0,0 +1,16 @@ +package com.olive.pribee.infra.api.facebook.dto.res.post; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class FacebookPostDataRes { + @JsonProperty("data") + private List posts; +} \ No newline at end of file diff --git a/src/main/java/com/olive/pribee/infra/api/facebook/dto/res/post/FacebookPostImageDataRes.java b/src/main/java/com/olive/pribee/infra/api/facebook/dto/res/post/FacebookPostImageDataRes.java new file mode 100644 index 0000000..b2bb062 --- /dev/null +++ b/src/main/java/com/olive/pribee/infra/api/facebook/dto/res/post/FacebookPostImageDataRes.java @@ -0,0 +1,16 @@ +package com.olive.pribee.infra.api.facebook.dto.res.post; + + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.*; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class FacebookPostImageDataRes { + private String src; + + @Builder + public FacebookPostImageDataRes(@JsonProperty("src") String src) { + this.src = src; + } +} \ No newline at end of file diff --git a/src/main/java/com/olive/pribee/infra/api/facebook/dto/res/post/FacebookPostImageRes.java b/src/main/java/com/olive/pribee/infra/api/facebook/dto/res/post/FacebookPostImageRes.java new file mode 100644 index 0000000..2b422a2 --- /dev/null +++ b/src/main/java/com/olive/pribee/infra/api/facebook/dto/res/post/FacebookPostImageRes.java @@ -0,0 +1,16 @@ +package com.olive.pribee.infra.api.facebook.dto.res.post; + + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.*; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class FacebookPostImageRes { + private FacebookPostImageDataRes image; + + @Builder + public FacebookPostImageRes(@JsonProperty("image") FacebookPostImageDataRes image) { + this.image = image; + } +} \ No newline at end of file diff --git a/src/main/java/com/olive/pribee/infra/api/facebook/dto/res/post/FacebookPostListRes.java b/src/main/java/com/olive/pribee/infra/api/facebook/dto/res/post/FacebookPostListRes.java new file mode 100644 index 0000000..3ca0135 --- /dev/null +++ b/src/main/java/com/olive/pribee/infra/api/facebook/dto/res/post/FacebookPostListRes.java @@ -0,0 +1,22 @@ +package com.olive.pribee.infra.api.facebook.dto.res.post; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class FacebookPostListRes { + @JsonProperty("feed") + private FacebookPostDataRes posts; // 게시물 리스트 + private FacebookPostPaging paging; + + @Builder + public FacebookPostListRes(FacebookPostDataRes posts, FacebookPostPaging paging) { + this.posts = posts; + this.paging = paging; + } +} \ No newline at end of file diff --git a/src/main/java/com/olive/pribee/infra/api/facebook/dto/res/post/FacebookPostMediaRes.java b/src/main/java/com/olive/pribee/infra/api/facebook/dto/res/post/FacebookPostMediaRes.java new file mode 100644 index 0000000..99f4f3e --- /dev/null +++ b/src/main/java/com/olive/pribee/infra/api/facebook/dto/res/post/FacebookPostMediaRes.java @@ -0,0 +1,21 @@ +package com.olive.pribee.infra.api.facebook.dto.res.post; + + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.*; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class FacebookPostMediaRes { + private FacebookPostImageRes media; + private String type; + + @Builder + public FacebookPostMediaRes( + @JsonProperty("media") FacebookPostImageRes media, + @JsonProperty("type") String type + ) { + this.media = media; + this.type = type; + } +} diff --git a/src/main/java/com/olive/pribee/infra/api/facebook/dto/res/post/FacebookPostPaging.java b/src/main/java/com/olive/pribee/infra/api/facebook/dto/res/post/FacebookPostPaging.java new file mode 100644 index 0000000..267cb61 --- /dev/null +++ b/src/main/java/com/olive/pribee/infra/api/facebook/dto/res/post/FacebookPostPaging.java @@ -0,0 +1,24 @@ +package com.olive.pribee.infra.api.facebook.dto.res.post; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class FacebookPostPaging { + private String previous; + private String next; + + @Builder + public FacebookPostPaging( + @JsonProperty("previous") String previous, + @JsonProperty("next") String next + ) { + this.previous = previous; + this.next = next; + } +} \ No newline at end of file diff --git a/src/main/java/com/olive/pribee/infra/api/facebook/dto/res/post/FacebookPostPlaceRes.java b/src/main/java/com/olive/pribee/infra/api/facebook/dto/res/post/FacebookPostPlaceRes.java new file mode 100644 index 0000000..a99310b --- /dev/null +++ b/src/main/java/com/olive/pribee/infra/api/facebook/dto/res/post/FacebookPostPlaceRes.java @@ -0,0 +1,19 @@ +package com.olive.pribee.infra.api.facebook.dto.res.post; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class FacebookPostPlaceRes { + private String name; + + @Builder + public FacebookPostPlaceRes(@JsonProperty("name") String name) { + this.name = name; + } +} \ No newline at end of file diff --git a/src/main/java/com/olive/pribee/infra/api/facebook/dto/res/post/FacebookPostRes.java b/src/main/java/com/olive/pribee/infra/api/facebook/dto/res/post/FacebookPostRes.java new file mode 100644 index 0000000..253f907 --- /dev/null +++ b/src/main/java/com/olive/pribee/infra/api/facebook/dto/res/post/FacebookPostRes.java @@ -0,0 +1,54 @@ +package com.olive.pribee.infra.api.facebook.dto.res.post; + +import java.time.LocalDateTime; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonProperty; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class FacebookPostRes { + private String id; + + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ssZ", timezone = "UTC") + private LocalDateTime updatedTime; + + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ssZ", timezone = "UTC") + private LocalDateTime createdTime; + + private String message; + + private String permalinkUrl; + + private String fullPicture; + + private FacebookPostPlaceRes place; + + private FacebookPostAttachmentsRes attachments; + + @Builder + public FacebookPostRes( + @JsonProperty("id") String id, + @JsonProperty("updated_time") LocalDateTime updatedTime, + @JsonProperty("created_time") LocalDateTime createdTime, + @JsonProperty("message") String message, + @JsonProperty("permalink_url") String permalinkUrl, + @JsonProperty("full_picture") String fullPicture, + @JsonProperty("place") FacebookPostPlaceRes place, + @JsonProperty("attachments") FacebookPostAttachmentsRes attachments + ) { + this.id = id; + this.updatedTime = updatedTime; + this.createdTime = createdTime; + this.message = message; + this.permalinkUrl = permalinkUrl; + this.fullPicture = fullPicture; + this.place = place; + this.attachments = attachments; + } +} \ No newline at end of file diff --git a/src/main/java/com/olive/pribee/infra/api/facebook/dto/res/post/FacebookPostSubattachmentsRes.java b/src/main/java/com/olive/pribee/infra/api/facebook/dto/res/post/FacebookPostSubattachmentsRes.java new file mode 100644 index 0000000..9b10feb --- /dev/null +++ b/src/main/java/com/olive/pribee/infra/api/facebook/dto/res/post/FacebookPostSubattachmentsRes.java @@ -0,0 +1,19 @@ +package com.olive.pribee.infra.api.facebook.dto.res.post; + + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.*; + +import java.util.List; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class FacebookPostSubattachmentsRes { + @JsonProperty("data") + private List data; + + @Builder + public FacebookPostSubattachmentsRes(@JsonProperty("data") List data) { + this.data = data; + } +} \ No newline at end of file diff --git a/src/main/java/com/olive/pribee/infra/api/google/dlp/GoogleDlpApiService.java b/src/main/java/com/olive/pribee/infra/api/google/dlp/GoogleDlpApiService.java new file mode 100644 index 0000000..c29ca31 --- /dev/null +++ b/src/main/java/com/olive/pribee/infra/api/google/dlp/GoogleDlpApiService.java @@ -0,0 +1,67 @@ +package com.olive.pribee.infra.api.google.dlp; + +import java.util.List; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.reactive.function.client.WebClient; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.olive.pribee.infra.api.google.dlp.dto.req.DlpReq; +import com.olive.pribee.infra.api.google.dlp.dto.res.DlpFinding; +import com.olive.pribee.infra.api.google.dlp.dto.res.DlpRes; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import reactor.core.publisher.Mono; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +@Slf4j +public class GoogleDlpApiService { + + @Value("${url.dlp}") + private String GOOGLE_DLP_BASE_URL; + + private final WebClient.Builder webClientBuilder; + + private WebClient getDlpWebClient() { + return webClientBuilder.baseUrl(GOOGLE_DLP_BASE_URL).build(); + } + + public Mono> analyzeText(String text) { + return getDlpWebClient().post() + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(DlpReq.ofText(text)) + .retrieve() + .bodyToMono(DlpRes.class) + .doOnNext(res -> log.info("DLP API Response: {}", res)) + .map(DlpRes::getFindings); + } + + public Mono> analyzeImage(String imageUrl) { + return getDlpWebClient().post() + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(DlpReq.ofImage(imageUrl)) + .retrieve() + .bodyToMono(JsonNode.class) // JSON 응답 로그 찍기 + .doOnNext(res -> log.info("DLP API Raw Response: {}", res)) + .flatMap(jsonNode -> { + try { + DlpRes dlpRes = new ObjectMapper().treeToValue(jsonNode, DlpRes.class); + return Mono.just(dlpRes); + } catch (JsonProcessingException e) { + log.error("Failed to parse DLP API response: {}", e.getMessage()); + return Mono.empty(); // 변환 실패 시 빈 Mono 반환 + } + }) + .doOnNext(res -> log.info("DLP API Parsed Response: {}", res)) + .map(DlpRes::getFindings); + } + +} diff --git a/src/main/java/com/olive/pribee/infra/api/google/dlp/dto/req/DlpReq.java b/src/main/java/com/olive/pribee/infra/api/google/dlp/dto/req/DlpReq.java new file mode 100644 index 0000000..acc1ecd --- /dev/null +++ b/src/main/java/com/olive/pribee/infra/api/google/dlp/dto/req/DlpReq.java @@ -0,0 +1,61 @@ +package com.olive.pribee.infra.api.google.dlp.dto.req; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.olive.pribee.global.enums.DetectKeyword; + +import java.util.List; +import java.util.Map; + +import static com.olive.pribee.global.util.fileUtil.encodeImageToBase64; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public record DlpReq( + @JsonProperty("item") Item item, + @JsonProperty("inspectConfig") InspectConfig inspectConfig +) { + public static DlpReq ofText(String value) { + DlpReq req = new DlpReq( + new Item(value), + new InspectConfig( + DetectKeyword.getInfoTypes().stream().map(InfoType::new).toList(), + "POSSIBLE" + ) + ); + return req; + } + + public static DlpReq ofImage(String imageUrl) { + String base64Image = encodeImageToBase64(imageUrl); + return new DlpReq( + new Item(Map.of("type", "IMAGE", "data", base64Image)), + new InspectConfig( + DetectKeyword.getInfoTypes().stream().map(InfoType::new).toList(), + "POSSIBLE" + ) + ); + } +} + +record Item(@JsonProperty("value") String value, @JsonProperty("byteItem") Map byteItem) { + public Item(String value) { + this(value, null); + } + public Item(Map byteItem) { + this(null, byteItem); + } +} + +record InspectConfig( + @JsonProperty("infoTypes") List infoTypes, + @JsonProperty("minLikelihood") String minLikelihood, + @JsonProperty("limits") Map limits, + @JsonProperty("includeQuote") boolean includeQuote +) { + public InspectConfig(List infoTypes, String minLikelihood) { + this(infoTypes, minLikelihood, Map.of("maxFindingsPerItem", 0), true); + } +} + +record InfoType(@JsonProperty("name") String name) {} \ No newline at end of file diff --git a/src/main/java/com/olive/pribee/infra/api/google/dlp/dto/res/DlpFinding.java b/src/main/java/com/olive/pribee/infra/api/google/dlp/dto/res/DlpFinding.java new file mode 100644 index 0000000..07df23c --- /dev/null +++ b/src/main/java/com/olive/pribee/infra/api/google/dlp/dto/res/DlpFinding.java @@ -0,0 +1,67 @@ +package com.olive.pribee.infra.api.google.dlp.dto.res; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class DlpFinding { + @JsonProperty("quote") + private String quote; + + @JsonProperty("infoType") + private InfoType infoType; + + @JsonProperty("likelihood") + private String likelihood; + + @JsonProperty("location") + private DlpLocation location; + + @JsonProperty("createTime") + private String createTime; + + @JsonProperty("findingId") + private String findingId; + + @Builder + public DlpFinding(String quote, InfoType infoType, String likelihood, DlpLocation location, String createTime, String findingId) { + this.quote = quote; + this.infoType = infoType; + this.likelihood = likelihood; + this.location = location; + this.createTime = createTime; + this.findingId = findingId; + } + + @Getter + @NoArgsConstructor(access = AccessLevel.PROTECTED) + public static class InfoType { + @JsonProperty("name") + private String name; + + @JsonProperty("sensitivityScore") + private SensitivityScore sensitivityScore; + + @Builder + public InfoType(String name, SensitivityScore sensitivityScore) { + this.name = name; + this.sensitivityScore = sensitivityScore; + } + } + + @Getter + @NoArgsConstructor(access = AccessLevel.PROTECTED) + public static class SensitivityScore { + @JsonProperty("score") + private String score; + + @Builder + public SensitivityScore(String score) { + this.score = score; + } + } +} \ No newline at end of file diff --git a/src/main/java/com/olive/pribee/infra/api/google/dlp/dto/res/DlpLocation.java b/src/main/java/com/olive/pribee/infra/api/google/dlp/dto/res/DlpLocation.java new file mode 100644 index 0000000..15ad5fb --- /dev/null +++ b/src/main/java/com/olive/pribee/infra/api/google/dlp/dto/res/DlpLocation.java @@ -0,0 +1,93 @@ +package com.olive.pribee.infra.api.google.dlp.dto.res; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class DlpLocation { + @JsonProperty("byteRange") + private Range byteRange; + + @JsonProperty("codepointRange") + private Range codepointRange; + + @JsonProperty("contentLocations") // 이미지 분석을 위한 추가 필드 + private List contentLocations; + + @Builder + public DlpLocation(Range byteRange, Range codepointRange, List contentLocations) { + this.byteRange = byteRange; + this.codepointRange = codepointRange; + this.contentLocations = contentLocations; + } + + @Getter + @NoArgsConstructor(access = AccessLevel.PROTECTED) + public static class Range { + @JsonProperty("start") + private int start; + + @JsonProperty("end") + private int end; + + @Builder + public Range(int start, int end) { + this.start = start; + this.end = end; + } + } + + @Getter + @NoArgsConstructor(access = AccessLevel.PROTECTED) + public static class ContentLocation { + @JsonProperty("imageLocation") + private ImageLocation imageLocation; + + @Builder + public ContentLocation(ImageLocation imageLocation) { + this.imageLocation = imageLocation; + } + } + + @Getter + @NoArgsConstructor(access = AccessLevel.PROTECTED) + public static class ImageLocation { + @JsonProperty("boundingBoxes") + private List boundingBoxes; + + @Builder + public ImageLocation(List boundingBoxes) { + this.boundingBoxes = boundingBoxes; + } + } + + @Getter + @NoArgsConstructor(access = AccessLevel.PROTECTED) + public static class BoundingBox { + @JsonProperty("top") + private int top; + + @JsonProperty("left") + private int left; + + @JsonProperty("width") + private int width; + + @JsonProperty("height") + private int height; + + @Builder + public BoundingBox(int top, int left, int width, int height) { + this.top = top; + this.left = left; + this.width = width; + this.height = height; + } + } +} diff --git a/src/main/java/com/olive/pribee/infra/api/google/dlp/dto/res/DlpRes.java b/src/main/java/com/olive/pribee/infra/api/google/dlp/dto/res/DlpRes.java new file mode 100644 index 0000000..f01cafb --- /dev/null +++ b/src/main/java/com/olive/pribee/infra/api/google/dlp/dto/res/DlpRes.java @@ -0,0 +1,39 @@ +package com.olive.pribee.infra.api.google.dlp.dto.res; + +import java.util.List; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class DlpRes { + + @JsonProperty("result") + private Result result; + + @Builder + public DlpRes(Result result) { + this.result = result; + } + + public List getFindings() { + return result != null && result.findings != null ? result.findings : List.of(); + } + + + @Getter + @NoArgsConstructor(access = AccessLevel.PROTECTED) + public static class Result { + @JsonProperty("findings") + private List findings; + + @Builder + public Result(List findings) { + this.findings = findings; + } + } + +} diff --git a/src/main/java/com/olive/pribee/module/auth/controller/MemberController.java b/src/main/java/com/olive/pribee/module/auth/controller/MemberController.java index 73f1097..11ea399 100644 --- a/src/main/java/com/olive/pribee/module/auth/controller/MemberController.java +++ b/src/main/java/com/olive/pribee/module/auth/controller/MemberController.java @@ -12,6 +12,7 @@ import com.olive.pribee.global.common.ResponseDto; import com.olive.pribee.module.auth.domain.entity.Member; import com.olive.pribee.module.auth.dto.res.LoginRes; +import com.olive.pribee.module.auth.dto.res.LoginUserInfoRes; import com.olive.pribee.module.auth.service.MemberService; import lombok.RequiredArgsConstructor; @@ -26,7 +27,7 @@ public class MemberController implements MemberControllerDocs { @GetMapping("/login/facebook") public ResponseEntity getLogin(@RequestHeader("facebook-code") String code){ - LoginRes resDto = memberService.getAccessToken(code); + LoginUserInfoRes resDto = memberService.getAccessToken(code); return ResponseEntity.status(201).body(DataResponseDto.of(resDto, 201)); } diff --git a/src/main/java/com/olive/pribee/module/auth/dto/res/LoginUserInfoRes.java b/src/main/java/com/olive/pribee/module/auth/dto/res/LoginUserInfoRes.java new file mode 100644 index 0000000..4292806 --- /dev/null +++ b/src/main/java/com/olive/pribee/module/auth/dto/res/LoginUserInfoRes.java @@ -0,0 +1,31 @@ +package com.olive.pribee.module.auth.dto.res; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder(access = AccessLevel.PRIVATE) +public class LoginUserInfoRes { + @Schema(description = "accessToken", example = "eyJ0eXAiOiJKV1QiLCJhbGc...") + private final String accessToken; + + @Schema(description = "refreshToken", example = "eyJ0eXAiOiJKV1QiLCJhbGc...") + private final String refreshToken; + + @Schema(description = "이름", example = "올리비") + private String name; + + @Schema(description = "사용자 프로필 사진", example = "eyJ0eXAiOiJKV1QiLCJhbGc...") + private String profilePictureUrl; + + public static LoginUserInfoRes of(String accessToken, String refreshToken, String name, String profilePictureUrl) { + return LoginUserInfoRes.builder() + .accessToken(accessToken) + .refreshToken(refreshToken) + .name(name) + .profilePictureUrl(profilePictureUrl) + .build(); + } +} diff --git a/src/main/java/com/olive/pribee/module/auth/service/MemberService.java b/src/main/java/com/olive/pribee/module/auth/service/MemberService.java index 3601890..655cbf2 100644 --- a/src/main/java/com/olive/pribee/module/auth/service/MemberService.java +++ b/src/main/java/com/olive/pribee/module/auth/service/MemberService.java @@ -1,5 +1,7 @@ package com.olive.pribee.module.auth.service; +import java.util.Optional; + import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -8,12 +10,15 @@ import com.olive.pribee.global.error.GlobalErrorCode; import com.olive.pribee.global.error.exception.AppException; import com.olive.pribee.global.util.RedisUtil; +import com.olive.pribee.infra.api.facebook.FacebookApiService; +import com.olive.pribee.infra.api.facebook.dto.res.auth.FacebookAuthRes; +import com.olive.pribee.infra.api.facebook.dto.res.auth.FacebookUserInfoRes; import com.olive.pribee.module.auth.JwtTokenProvider; import com.olive.pribee.module.auth.domain.entity.Member; import com.olive.pribee.module.auth.domain.repository.MemberRepository; -import com.olive.pribee.module.auth.dto.res.FacebookAuthRes; -import com.olive.pribee.module.auth.dto.res.FacebookUserInfoRes; import com.olive.pribee.module.auth.dto.res.LoginRes; +import com.olive.pribee.module.auth.dto.res.LoginUserInfoRes; +import com.olive.pribee.module.feed.service.FbPostService; import io.jsonwebtoken.ExpiredJwtException; import io.jsonwebtoken.JwtException; @@ -25,40 +30,55 @@ @RequiredArgsConstructor @Slf4j public class MemberService { - private final FacebookAuthService facebookAuthService; + private final FacebookApiService facebookApiService; private final JwtTokenProvider jwtTokenProvider; private final RedisUtil redisUtil; private final MemberRepository memberRepository; + private final FbPostService fbPostService; // facebook code 기반 facebook 로그인을 통한 접근 jwt 발급 @Transactional - public LoginRes getAccessToken(String code) { + public LoginUserInfoRes getAccessToken(String code) { // code 기반 facebook ID 조회 - FacebookAuthRes facebookAuthRes = facebookAuthService.getFacebookIdWithToken(code).block(); + FacebookAuthRes facebookAuthRes = facebookApiService.getFacebookIdWithToken(code).block(); if (facebookAuthRes == null) { log.error("[Facebook] facebookAuthRes is null in memberService -- " + code); throw new AppException(GlobalErrorCode.INTERNAL_SERVER_ERROR); } // facebook ID 기반 DB에서 회원 조회 - Member member = memberRepository.findByFacebookId(facebookAuthRes.getFacebookId()) - .orElseGet(() -> { - // 저장된 회원이 없으면 Facebook API에서 회원 정보 조회 - FacebookUserInfoRes userInfo = facebookAuthService.fetchFacebookUserInfo( - facebookAuthRes.getLongTermToken()).block(); - if (userInfo == null) { - log.error( - "[Facebook] facebook userInfo is null in memberService -- " + facebookAuthRes.getFacebookId()); - throw new AppException(GlobalErrorCode.INTERNAL_SERVER_ERROR); - } - - return memberRepository.save(Member.of( - userInfo.getId(), - userInfo.getName(), - userInfo.getEmail(), - userInfo.getPicture().getData().getUrl() - )); - }); + Optional optionalMember = memberRepository.findByFacebookId(facebookAuthRes.getFacebookId()); + + Member member; + if (optionalMember.isEmpty()) { + // 저장된 회원이 없으면 Facebook API에서 회원 정보 조회 + FacebookUserInfoRes userInfo = facebookApiService.fetchFacebookUserInfo( + facebookAuthRes.getLongTermToken()).block(); + if (userInfo == null) { + log.error( + "[Facebook] facebook userInfo is null in memberService -- " + facebookAuthRes.getFacebookId()); + throw new AppException(GlobalErrorCode.INTERNAL_SERVER_ERROR); + } + + member = memberRepository.save(Member.of( + userInfo.getId(), + userInfo.getName(), + userInfo.getEmail(), + userInfo.getPicture().getData().getUrl() + )); + + // 저장된 회원이 없으면 전체 게시물 가져오기 + fbPostService.savePostsAsync(facebookAuthRes.getLongTermToken(), member.getId(), null); + + } else { + member = optionalMember.get(); + + // 가장 최근 게시물의 createTime 이후의 게시물 가져오기 + // TODO 현재는 추가된 게시물만 처리하고 있음 + // 업데이트, 삭제된 게시물 반영에 대한 처리 추가 필요 + fbPostService.savePostsAsync(facebookAuthRes.getLongTermToken(), member.getId(), + fbPostService.getRecentPostsCreateTime()); + } // facebook long live accessToken Redis 에 저장 redisUtil.setOpsForValue(member.getId() + "_fb_access", facebookAuthRes.getLongTermToken(), 5184000); @@ -68,7 +88,12 @@ public LoginRes getAccessToken(String code) { redisUtil.setOpsForValue(member.getId() + "_refresh", jwtVo.getRefreshToken(), jwtTokenProvider.getREFRESH_TOKEN_EXPIRATION()); - return LoginRes.of(jwtVo.getAccessToken(), jwtVo.getRefreshToken()); + return LoginUserInfoRes.of( + jwtVo.getAccessToken(), + jwtVo.getRefreshToken(), + member.getName(), + member.getProfilePictureUrl() + ); } // refresh token 으로 새로운 accessToken 발급 @@ -111,7 +136,7 @@ public void deleteMember(Member member) { memberRepository.delete(member); } - private void deleteMemberRedis(Member member){ + private void deleteMemberRedis(Member member) { redisUtil.delete(member.getId() + "_fb_access"); redisUtil.delete(member.getId() + "_refresh"); } diff --git a/src/main/java/com/olive/pribee/module/feed/controller/FbPostController.java b/src/main/java/com/olive/pribee/module/feed/controller/FbPostController.java new file mode 100644 index 0000000..349ef1e --- /dev/null +++ b/src/main/java/com/olive/pribee/module/feed/controller/FbPostController.java @@ -0,0 +1,47 @@ +package com.olive.pribee.module.feed.controller; + +import org.springframework.data.domain.Page; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.mongodb.lang.Nullable; +import com.olive.pribee.global.common.DataPageResponseDto; +import com.olive.pribee.global.common.ResponseDto; +import com.olive.pribee.global.enums.DetectKeyword; +import com.olive.pribee.module.auth.domain.entity.Member; +import com.olive.pribee.module.feed.dto.res.FbPostRes; +import com.olive.pribee.module.feed.dto.res.FbPostTotalRes; +import com.olive.pribee.module.feed.service.FbPostService; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/api/feed/") +@RequiredArgsConstructor +public class FbPostController implements FbPostControllerDocs { + private final FbPostService fbPostService; + + // 개별 게시물 조회 + + // 전체 게시물 조회 + @GetMapping + public ResponseEntity getExhibitions( + @AuthenticationPrincipal Member member, + @Schema(description = "필터를 의미합니다.") @RequestParam(name = "detectType") @Nullable DetectKeyword detectType, + @Schema(description = "검색어를 의미합니다.") @RequestParam(name = "keyword") @Nullable String keyword, + @Schema(description = "0번부터 시작합니다. 조회할 페이지 번호를 의미합니다.") @RequestParam(name = "page") int page, + @Schema(description = "조회할 페이지 크기를 의미합니다.") @RequestParam(name = "size") int size) { + FbPostTotalRes resPage = fbPostService.getTotalPost(member.getId(), detectType, keyword, page, size); + return ResponseEntity.status(200).body( + DataPageResponseDto.of(resPage.getFbPostResPage().getContent(), 200, resPage.getFbPostResPage().getTotalElements(), + resPage.getFbPostResPage().getTotalPages(), resPage.getFbPostResPage().getSize(), resPage.getFbPostResPage().getNumberOfElements())); + } + + // 게시물 첨부 조회 + +} diff --git a/src/main/java/com/olive/pribee/module/feed/controller/FbPostControllerDocs.java b/src/main/java/com/olive/pribee/module/feed/controller/FbPostControllerDocs.java new file mode 100644 index 0000000..f803476 --- /dev/null +++ b/src/main/java/com/olive/pribee/module/feed/controller/FbPostControllerDocs.java @@ -0,0 +1,126 @@ +package com.olive.pribee.module.feed.controller; + +import org.springframework.http.ResponseEntity; + +import com.olive.pribee.global.common.ResponseDto; +import com.olive.pribee.global.enums.DetectKeyword; +import com.olive.pribee.module.auth.domain.entity.Member; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Feed", description = "게시물 관련 API") +public interface FbPostControllerDocs { + @Operation(summary = "게시물 조회", description = "전체 게시물을 조회합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Ok", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ResponseDto.class), + examples = + @ExampleObject(value = "{\n" + + " \"code\": 200,\n" + + " \"message\": \"OK\",\n" + + " \"data\": [\n" + + " {\n" + + " \"id\": 151,\n" + + " \"createdTime\": \"2025-03-06T20:41:12\",\n" + + " \"firstPhotoUrl\": null,\n" + + " \"detectedKeywords\": [\n" + + " \"PERSON_NAME\",\n" + + " \"PHONE_NUMBER\",\n" + + " \"EMAIL_ADDRESS\"\n" + + " ]\n" + + " },\n" + + " {\n" + + " \"id\": 152,\n" + + " \"createdTime\": \"2025-03-06T15:01:36\",\n" + + " \"firstPhotoUrl\": \"https://scontent-gmp1-1.xx.fbcdn.net/v/t39.30808-6/482005688_122110821902769823_5883437262206830682_n.jpg?stp=cp1_dst-jpegr_s960x960_tt6&_nc_cat=109&ccb=1-7&_nc_sid=127cfc&_nc_ohc=_89orQPXAU4Q7kNvgHHDDfF&_nc_oc=Adis2DKgtqlPMSInKT1rq0EdMJk6HmxfVGCXD0dV3Tjoo34wIBMFr4wRhjgTpLtCcaA&_nc_zt=23&se=-1&_nc_ht=scontent-gmp1-1.xx&edm=AP4hL3IEAAAA&_nc_gid=APgMhPiomivhYaHlQ0N7ogZ&oh=00_AYEhMlsoqOMBiXKi-Zaa3UBaSD3oSBvmqZspg37f3cG94A&oe=67D04B41\",\n" + + " \"detectedKeywords\": [\n" + + " \"LOCATION\"\n" + + " ]\n" + + " },\n" + + " {\n" + + " \"id\": 153,\n" + + " \"createdTime\": \"2025-03-06T14:57:00\",\n" + + " \"firstPhotoUrl\": \"https://scontent-gmp1-1.xx.fbcdn.net/v/t39.30808-6/481820902_122110819862769823_6805450053096602031_n.jpg?stp=dst-jpg_p720x720_tt6&_nc_cat=106&ccb=1-7&_nc_sid=127cfc&_nc_ohc=4QVBrscw0c8Q7kNvgELjzdr&_nc_oc=AdhCgQD2zlsa4Hg825zj-DKr4ap131fSquivJ46rCh7kRs5ggPSEZ52ooD_TfcsR7zU&_nc_zt=23&_nc_ht=scontent-gmp1-1.xx&edm=AP4hL3IEAAAA&_nc_gid=APgMhPiomivhYaHlQ0N7ogZ&oh=00_AYEjnM7gBNxhuTBrvgLmhy3CfgTTnCLhHN1H3EAZDqqBiw&oe=67D0502C\",\n" + + " \"detectedKeywords\": [\n" + + " \"PERSON_NAME\",\n" + + " \"PHONE_NUMBER\",\n" + + " \"LOCATION\"\n" + + " ]\n" + + " },\n" + + " {\n" + + " \"id\": 154,\n" + + " \"createdTime\": \"2025-03-06T14:40:15\",\n" + + " \"firstPhotoUrl\": \"https://scontent-gmp1-1.xx.fbcdn.net/v/t39.30808-6/482006157_122110816010769823_4822934692025284850_n.jpg?stp=dst-jpg_p720x720_tt6&_nc_cat=103&ccb=1-7&_nc_sid=127cfc&_nc_ohc=KlCInOtXz28Q7kNvgEvqHN8&_nc_oc=AdjDKcRdysEHQpSEO879poNXbnmxaC9b6Q76zyGCFaZjI7ZSPOFWRjPTBMK68iTIM7U&_nc_zt=23&_nc_ht=scontent-gmp1-1.xx&edm=AP4hL3IEAAAA&_nc_gid=APgMhPiomivhYaHlQ0N7ogZ&oh=00_AYEX_qhEoL8SnhCEj5rLYYEwsELQyz5jYm3FC_Frf9D9Hw&oe=67D07164\",\n" + + " \"detectedKeywords\": [\n" + + " \"PERSON_NAME\",\n" + + " \"LOCATION\"\n" + + " ]\n" + + " },\n" + + " {\n" + + " \"id\": 155,\n" + + " \"createdTime\": \"2025-03-06T14:35:45\",\n" + + " \"firstPhotoUrl\": \"https://scontent-gmp1-1.xx.fbcdn.net/v/t39.30808-6/481898235_122110815350769823_7830570790572853712_n.jpg?stp=dst-jpg_s960x960_tt6&_nc_cat=103&ccb=1-7&_nc_sid=127cfc&_nc_ohc=pqTxmMiaRmAQ7kNvgF_M2fo&_nc_oc=AdinZRm68WKJbnn4k8rmen-R1dYh-fVdY5Ln9wy9FUZzoYlqG6p3dTwjVynw9mzo0uM&_nc_zt=23&_nc_ht=scontent-gmp1-1.xx&edm=AP4hL3IEAAAA&_nc_gid=APgMhPiomivhYaHlQ0N7ogZ&oh=00_AYEzEWPriAvCi_kxV_i4eY7DSTzniaPtlf7qcPcVrlettQ&oe=67D07B53\",\n" + + " \"detectedKeywords\": [\n" + + " \"LOCATION\",\n" + + " \"KOREA_PASSPORT\",\n" + + " \"PERSON_NAME\"\n" + + " ]\n" + + " },\n" + + " {\n" + + " \"id\": 156,\n" + + " \"createdTime\": \"2025-03-06T14:30:10\",\n" + + " \"firstPhotoUrl\": \"https://scontent-gmp1-1.xx.fbcdn.net/v/t39.30808-6/481784709_122110814570769823_1075445356216340862_n.jpg?stp=dst-jpg_s720x720_tt6&_nc_cat=105&ccb=1-7&_nc_sid=127cfc&_nc_ohc=akejy1pFTa0Q7kNvgE9XOoT&_nc_oc=Adg34RMotqKCf1aJJAMqtuREdnJ9ylGTvIzVdGfHbXZftniJzyMuimRrC_Wfo7HdGl4&_nc_zt=23&_nc_ht=scontent-gmp1-1.xx&edm=AP4hL3IEAAAA&_nc_gid=APgMhPiomivhYaHlQ0N7ogZ&oh=00_AYEZ135NtAgXdENzv8jaUqSTc9b9K4SIOGgrvQQqQdwZyA&oe=67D06263\",\n" + + " \"detectedKeywords\": [\n" + + " \"PHONE_NUMBER\"\n" + + " ]\n" + + " },\n" + + " {\n" + + " \"id\": 157,\n" + + " \"createdTime\": \"2025-03-06T12:04:58\",\n" + + " \"firstPhotoUrl\": \"https://scontent-gmp1-1.xx.fbcdn.net/v/t39.30808-6/482000767_122110765268769823_7669289345773660085_n.jpg?stp=dst-jpg_p720x720_tt6&_nc_cat=102&ccb=1-7&_nc_sid=127cfc&_nc_ohc=uJUeAzILfEAQ7kNvgE7TfMb&_nc_oc=AdgepuB4YVwf55XUeu9-8y-cr_rmliSInY3FAYhpT63pKsjb9eL_N2p-jigE9ZCJpRo&_nc_zt=23&_nc_ht=scontent-gmp1-1.xx&edm=AP4hL3IEAAAA&_nc_gid=APgMhPiomivhYaHlQ0N7ogZ&oh=00_AYEcpbr8H07mfjd6pB9WGwWx66r6ij4Flr19Wdv9dNWR4w&oe=67D06C87\",\n" + + " \"detectedKeywords\": []\n" + + " },\n" + + " {\n" + + " \"id\": 158,\n" + + " \"createdTime\": \"2025-03-06T11:57:26\",\n" + + " \"firstPhotoUrl\": \"https://scontent-gmp1-1.xx.fbcdn.net/v/t39.30808-6/482023063_122110760102769823_4351467489794574342_n.jpg?stp=dst-jpg_p720x720_tt6&_nc_cat=104&ccb=1-7&_nc_sid=127cfc&_nc_ohc=OGF2WaeTx20Q7kNvgEAI-yv&_nc_oc=AdjU9rcdQLeDH_HZcwdgFlhNo5JGFKTFmQi5MKgTzBIDSw5_99ll42yHGYQiwQU3SM4&_nc_zt=23&_nc_ht=scontent-gmp1-1.xx&edm=AP4hL3IEAAAA&_nc_gid=APgMhPiomivhYaHlQ0N7ogZ&oh=00_AYFYX1e3_iuFWGzn-GeWuYgWdDWa4IBU7fMCGRNvhSq4xA&oe=67D05993\",\n" + + " \"detectedKeywords\": [\n" + + " \"PERSON_NAME\",\n" + + " \"LOCATION\"\n" + + " ]\n" + + " },\n" + + " {\n" + + " \"id\": 159,\n" + + " \"createdTime\": \"2025-02-26T06:44:10\",\n" + + " \"firstPhotoUrl\": \"https://scontent-gmp1-1.xx.fbcdn.net/v/t51.75761-15/481183925_17845332873425342_9111973536618245990_n.jpg?stp=dst-jpg_p720x720_tt6&_nc_cat=106&ccb=1-7&_nc_sid=127cfc&_nc_ohc=tGJeHDNelXUQ7kNvgFkn75g&_nc_oc=AdgigxklAKeh_KvLXY-OITdWuG5Skm4ZgxrNyllvej-XbBcqrhZ894tG8M-yrrIGcJM&_nc_zt=23&_nc_ht=scontent-gmp1-1.xx&edm=AP4hL3IEAAAA&_nc_gid=APgMhPiomivhYaHlQ0N7ogZ&oh=00_AYEoZ1MUAsbf-JCOLxGRTH5qKKIGf3ANu4dq1qaP7giNMQ&oe=67D0597B\",\n" + + " \"detectedKeywords\": []\n" + + " },\n" + + " {\n" + + " \"id\": 160,\n" + + " \"createdTime\": \"2025-02-26T05:53:39\",\n" + + " \"firstPhotoUrl\": \"https://scontent-gmp1-1.xx.fbcdn.net/v/t51.75761-15/480981728_17845326564425342_6913190954196226453_n.jpg?stp=dst-jpg_s720x720_tt6&_nc_cat=103&ccb=1-7&_nc_sid=127cfc&_nc_ohc=bOuIQ3JTMHcQ7kNvgEYt7zo&_nc_oc=Adg5hRwzuNW2Xjx6lJBkl_7JXv3pfkTAMTydiGSX7Zvc8e4TWYfBXcx_ugva3lscHZA&_nc_zt=23&_nc_ht=scontent-gmp1-1.xx&edm=AP4hL3IEAAAA&_nc_gid=APgMhPiomivhYaHlQ0N7ogZ&oh=00_AYG0asG6FVLmE_eW2OA6dYFXakMs5tJQx5mMCMbYzo8JxA&oe=67D0711C\",\n" + + " \"detectedKeywords\": [\n" + + " \"LOCATION\"\n" + + " ]\n" + + " }\n" + + " ],\n" + + " \"totalElements\": 10,\n" + + " \"totalPages\": 1,\n" + + " \"size\": 100,\n" + + " \"numberOfElements\": 10\n" + + "}") + ) + ), + }) + ResponseEntity getExhibitions(Member member, DetectKeyword detectType, String keyword, int page, + int size); +} + diff --git a/src/main/java/com/olive/pribee/module/feed/domain/entity/DetectKeywordInMessage.java b/src/main/java/com/olive/pribee/module/feed/domain/entity/DetectKeywordInMessage.java new file mode 100644 index 0000000..9570614 --- /dev/null +++ b/src/main/java/com/olive/pribee/module/feed/domain/entity/DetectKeywordInMessage.java @@ -0,0 +1,59 @@ +package com.olive.pribee.module.feed.domain.entity; + +import com.olive.pribee.global.common.BaseTime; +import com.olive.pribee.global.enums.DetectKeyword; +import com.olive.pribee.global.enums.DetectLikelihood; + +import jakarta.persistence.Entity; +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.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "detect_keyword_in_message") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder(access = AccessLevel.PRIVATE) +public class DetectKeywordInMessage extends BaseTime { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne + @JoinColumn(name = "fb_post_id", nullable = false) + private FbPost fbPost; + + private String detectWord; + + private DetectKeyword keyword; + + private DetectLikelihood likelihood; + + private Integer startAt; + + private Integer endAt; + + public static DetectKeywordInMessage of(@NotNull FbPost fbPost, @NotNull String detectWord, + @NotNull DetectKeyword keyword, + @NotNull DetectLikelihood likelihood, @NotNull Integer startAt, @NotNull Integer endAt) { + return DetectKeywordInMessage.builder() + .fbPost(fbPost) + .detectWord(detectWord) + .keyword(keyword) + .likelihood(likelihood) + .startAt(startAt) + .endAt(endAt) + .build(); + } + +} \ No newline at end of file diff --git a/src/main/java/com/olive/pribee/module/feed/domain/entity/DetectKeywordInPhoto.java b/src/main/java/com/olive/pribee/module/feed/domain/entity/DetectKeywordInPhoto.java new file mode 100644 index 0000000..ac3c64f --- /dev/null +++ b/src/main/java/com/olive/pribee/module/feed/domain/entity/DetectKeywordInPhoto.java @@ -0,0 +1,65 @@ +package com.olive.pribee.module.feed.domain.entity; + +import java.util.ArrayList; +import java.util.List; + +import com.olive.pribee.global.common.BaseTime; +import com.olive.pribee.global.enums.DetectKeyword; +import com.olive.pribee.global.enums.DetectLikelihood; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "detect_keyword_in_photo") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder(access = AccessLevel.PRIVATE) +public class DetectKeywordInPhoto extends BaseTime { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne + @JoinColumn(name = "fb_posts_picture_url_id", nullable = false) + private FbPostPictureUrl fbPostPictureUrl; + + private String detectWord; + + private DetectKeyword keyword; + + private DetectLikelihood likelihood; + + @OneToMany(mappedBy = "detectKeywordInPhoto", cascade = CascadeType.ALL, orphanRemoval = true) + @Builder.Default + private List boundingBoxes = new ArrayList<>(); + + public static DetectKeywordInPhoto of(@NotNull FbPostPictureUrl fbPostPictureUrl, + @NotNull String detectWord, @NotNull DetectKeyword keyword, @NotNull DetectLikelihood likelihood) { + return DetectKeywordInPhoto.builder() + .fbPostPictureUrl(fbPostPictureUrl) + .detectWord(detectWord) + .keyword(keyword) + .likelihood(likelihood) + .build(); + } + + public void addBoundingBoxes(List boundingBoxes) { + this.boundingBoxes.addAll(boundingBoxes); + } + +} \ No newline at end of file diff --git a/src/main/java/com/olive/pribee/module/feed/domain/entity/DetectKeywordInPhotoBoundingBox.java b/src/main/java/com/olive/pribee/module/feed/domain/entity/DetectKeywordInPhotoBoundingBox.java new file mode 100644 index 0000000..8ed98b9 --- /dev/null +++ b/src/main/java/com/olive/pribee/module/feed/domain/entity/DetectKeywordInPhotoBoundingBox.java @@ -0,0 +1,57 @@ +package com.olive.pribee.module.feed.domain.entity; + +import com.olive.pribee.global.common.BaseTime; + +import jakarta.persistence.Entity; +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.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "detect_keyword_in_photo_bounding_box") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder(access = AccessLevel.PRIVATE) +public class DetectKeywordInPhotoBoundingBox extends BaseTime { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne + @JoinColumn(name = "detect_keyword_in_photo_id", nullable = false) + private DetectKeywordInPhoto detectKeywordInPhoto; + + @NotNull + private int y_position; + + @NotNull + private int x_position; + + @NotNull + private int width; + + @NotNull + private int height; + + public static DetectKeywordInPhotoBoundingBox of(@NotNull DetectKeywordInPhoto detectKeywordInPhoto, int y_position, + int x_position, int width, int height) { + return DetectKeywordInPhotoBoundingBox.builder() + .detectKeywordInPhoto(detectKeywordInPhoto) + .y_position(y_position) + .x_position(x_position) + .width(width) + .height(height) + .build(); + } + +} diff --git a/src/main/java/com/olive/pribee/module/feed/domain/entity/FbPost.java b/src/main/java/com/olive/pribee/module/feed/domain/entity/FbPost.java new file mode 100644 index 0000000..808ead5 --- /dev/null +++ b/src/main/java/com/olive/pribee/module/feed/domain/entity/FbPost.java @@ -0,0 +1,95 @@ +package com.olive.pribee.module.feed.domain.entity; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +import org.hibernate.annotations.BatchSize; + +import com.olive.pribee.global.common.BaseTime; +import com.olive.pribee.module.auth.domain.entity.Member; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "fb_post") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder(access = AccessLevel.PRIVATE) +public class FbPost extends BaseTime { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @NotNull + @Column(unique = true) + private String postId; + + @ManyToOne + @JoinColumn(name = "member_id", nullable = false) + private Member member; + + private LocalDateTime createdTime; + + private LocalDateTime updatedTime; + + @Column(columnDefinition = "TEXT") + private String message; + + @Column(columnDefinition = "TEXT") + private String permalinkUrl; + + private String place; + + @Column(columnDefinition = "TEXT") + private String safeMessage; + + private Integer dangerScore; + + @OneToMany(mappedBy = "fbPost", cascade = CascadeType.ALL, orphanRemoval = true) + @Builder.Default + @BatchSize(size = 100) + private List detectKeywordInMessages = new ArrayList<>(); + + @OneToMany(mappedBy = "fbPost", cascade = CascadeType.ALL, orphanRemoval = true) + @Builder.Default + @BatchSize(size = 100) + private List fbPostPictureUrls = new ArrayList<>(); + + public static FbPost of(@NotNull Member member, @NotNull String postId, LocalDateTime createdTime, + LocalDateTime updatedTime, String message, String permalinkUrl, String place) { + return FbPost.builder() + .member(member) + .postId(postId) + .createdTime(createdTime) + .updatedTime(updatedTime) + .message(message) + .permalinkUrl(permalinkUrl) + .place(place) + .build(); + } + + public void addPictureUrls(List urls) { + this.fbPostPictureUrls.addAll(urls); + } + + public void updateDangerScore(Integer dangerScore) { + this.dangerScore = dangerScore; + } +} \ No newline at end of file diff --git a/src/main/java/com/olive/pribee/module/feed/domain/entity/FbPostPictureUrl.java b/src/main/java/com/olive/pribee/module/feed/domain/entity/FbPostPictureUrl.java new file mode 100644 index 0000000..85d0dd0 --- /dev/null +++ b/src/main/java/com/olive/pribee/module/feed/domain/entity/FbPostPictureUrl.java @@ -0,0 +1,54 @@ +package com.olive.pribee.module.feed.domain.entity; + +import java.util.ArrayList; +import java.util.List; + +import com.olive.pribee.global.common.BaseTime; + +import jakarta.persistence.CascadeType; +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.OneToMany; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "fb_posts_picture_url") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder(access = AccessLevel.PRIVATE) +public class FbPostPictureUrl extends BaseTime { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne + @JoinColumn(name = "fb_post_id", nullable = false) + private FbPost fbPost; + + @Column(columnDefinition = "TEXT") + private String photoUrl; + + @OneToMany(mappedBy = "fbPostPictureUrl", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER) + @Builder.Default + private List detectKeywordInPhotos = new ArrayList<>(); + + public static FbPostPictureUrl of(@NotNull FbPost fbPost, @NotNull String photoUrl) { + return FbPostPictureUrl.builder() + .fbPost(fbPost) + .photoUrl(photoUrl) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/olive/pribee/module/feed/domain/repository/DetectKeywordInMessageRepository.java b/src/main/java/com/olive/pribee/module/feed/domain/repository/DetectKeywordInMessageRepository.java new file mode 100644 index 0000000..c97d787 --- /dev/null +++ b/src/main/java/com/olive/pribee/module/feed/domain/repository/DetectKeywordInMessageRepository.java @@ -0,0 +1,10 @@ +package com.olive.pribee.module.feed.domain.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import com.olive.pribee.module.feed.domain.entity.DetectKeywordInMessage; + +@Repository +public interface DetectKeywordInMessageRepository extends JpaRepository { +} diff --git a/src/main/java/com/olive/pribee/module/feed/domain/repository/DetectKeywordInPhotoRepository.java b/src/main/java/com/olive/pribee/module/feed/domain/repository/DetectKeywordInPhotoRepository.java new file mode 100644 index 0000000..085ee07 --- /dev/null +++ b/src/main/java/com/olive/pribee/module/feed/domain/repository/DetectKeywordInPhotoRepository.java @@ -0,0 +1,10 @@ +package com.olive.pribee.module.feed.domain.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import com.olive.pribee.module.feed.domain.entity.DetectKeywordInPhoto; + +@Repository +public interface DetectKeywordInPhotoRepository extends JpaRepository { +} diff --git a/src/main/java/com/olive/pribee/module/feed/domain/repository/FbPostPictureUrlRepository.java b/src/main/java/com/olive/pribee/module/feed/domain/repository/FbPostPictureUrlRepository.java new file mode 100644 index 0000000..c04765d --- /dev/null +++ b/src/main/java/com/olive/pribee/module/feed/domain/repository/FbPostPictureUrlRepository.java @@ -0,0 +1,11 @@ +package com.olive.pribee.module.feed.domain.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import com.olive.pribee.module.feed.domain.entity.FbPostPictureUrl; + +@Repository +public interface FbPostPictureUrlRepository extends JpaRepository { + +} diff --git a/src/main/java/com/olive/pribee/module/feed/domain/repository/FbPostRepository.java b/src/main/java/com/olive/pribee/module/feed/domain/repository/FbPostRepository.java new file mode 100644 index 0000000..8be7287 --- /dev/null +++ b/src/main/java/com/olive/pribee/module/feed/domain/repository/FbPostRepository.java @@ -0,0 +1,16 @@ +package com.olive.pribee.module.feed.domain.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +import com.olive.pribee.module.feed.domain.entity.FbPost; + +@Repository +public interface FbPostRepository extends JpaRepository { + + @Query("SELECT f FROM FbPost f ORDER BY f.createdTime DESC LIMIT 1") + Optional findLatestPost(); +} \ No newline at end of file diff --git a/src/main/java/com/olive/pribee/module/feed/domain/repository/custom/FbPostRepositoryCustom.java b/src/main/java/com/olive/pribee/module/feed/domain/repository/custom/FbPostRepositoryCustom.java new file mode 100644 index 0000000..7688dfc --- /dev/null +++ b/src/main/java/com/olive/pribee/module/feed/domain/repository/custom/FbPostRepositoryCustom.java @@ -0,0 +1,18 @@ +package com.olive.pribee.module.feed.domain.repository.custom; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; + +import com.olive.pribee.global.enums.DetectKeyword; +import com.olive.pribee.module.feed.dto.res.FbPostRes; +import com.olive.pribee.module.feed.dto.res.FbPostTotalRes; + +@Repository +public interface FbPostRepositoryCustom { + Page getFbPostByKeywordAndPaging(Long userId, DetectKeyword detectType, String keyword, + Pageable pageable); + + FbPostTotalRes getFbPostTotal(Long memberId, DetectKeyword detectType, String keyword, + Pageable pageable); +} diff --git a/src/main/java/com/olive/pribee/module/feed/domain/repository/custom/FbPostRepositoryImpl.java b/src/main/java/com/olive/pribee/module/feed/domain/repository/custom/FbPostRepositoryImpl.java new file mode 100644 index 0000000..f290a99 --- /dev/null +++ b/src/main/java/com/olive/pribee/module/feed/domain/repository/custom/FbPostRepositoryImpl.java @@ -0,0 +1,143 @@ +package com.olive.pribee.module.feed.domain.repository.custom; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; +import org.springframework.util.StringUtils; + +import com.olive.pribee.global.enums.DetectKeyword; +import com.olive.pribee.module.feed.domain.entity.DetectKeywordInMessage; +import com.olive.pribee.module.feed.domain.entity.FbPost; +import com.olive.pribee.module.feed.domain.entity.FbPostPictureUrl; +import com.olive.pribee.module.feed.domain.entity.QDetectKeywordInMessage; +import com.olive.pribee.module.feed.domain.entity.QDetectKeywordInPhoto; +import com.olive.pribee.module.feed.domain.entity.QFbPost; +import com.olive.pribee.module.feed.domain.entity.QFbPostPictureUrl; +import com.olive.pribee.module.feed.dto.res.FbPostRes; +import com.olive.pribee.module.feed.dto.res.FbPostTotalRes; +import com.querydsl.core.BooleanBuilder; +import com.querydsl.jpa.impl.JPAQueryFactory; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class FbPostRepositoryImpl implements FbPostRepositoryCustom { + + private final JPAQueryFactory jpaQueryFactory; + + @Override + public Page getFbPostByKeywordAndPaging(Long memberId, DetectKeyword detectType, String keyword, + Pageable pageable) { + + QFbPost fbPost = QFbPost.fbPost; + QDetectKeywordInMessage detectKeywordInMessage = QDetectKeywordInMessage.detectKeywordInMessage; + QDetectKeywordInPhoto detectKeywordInPhoto = QDetectKeywordInPhoto.detectKeywordInPhoto; + + BooleanBuilder builder = new BooleanBuilder(); + builder.and(fbPost.member.id.eq(memberId)); + + if (detectType != null) { + builder.and(detectKeywordInMessage.keyword.eq(detectType) + .or(detectKeywordInPhoto.keyword.eq(detectType))); + } + + if (StringUtils.hasText(keyword)) { + builder.and(fbPost.message.containsIgnoreCase(keyword)); + } + + // 1. 페이징 적용하여 FbPost ID 조회 + List postIds = jpaQueryFactory.select(fbPost.id) + .from(fbPost) + .where(builder) + .orderBy(fbPost.createdTime.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + if (postIds.isEmpty()) { + return new PageImpl<>(List.of(), pageable, 0); + } + + // 2. FbPost 엔티티 조회 (Batch Fetch 적용) + List posts = jpaQueryFactory.selectFrom(fbPost) + .where(fbPost.id.in(postIds)) + .fetch(); + + // 3. detectKeywordInMessages 조회 (Batch Fetch) + Map> detectKeywordMap = jpaQueryFactory + .selectFrom(detectKeywordInMessage) + .where(detectKeywordInMessage.fbPost.id.in(postIds)) + .fetch() + .stream() + .collect(Collectors.groupingBy(d -> d.getFbPost().getId())); + + // 4. fbPostPictureUrls 조회 (Batch Fetch) + Map> pictureUrlMap = jpaQueryFactory + .selectFrom(QFbPostPictureUrl.fbPostPictureUrl) + .where(QFbPostPictureUrl.fbPostPictureUrl.fbPost.id.in(postIds)) + .fetch() + .stream() + .collect(Collectors.groupingBy(p -> p.getFbPost().getId())); + + // 5. DTO 변환 + List resDtos = posts.stream().map(post -> FbPostRes.of( + post.getId(), + post.getCreatedTime(), + pictureUrlMap.getOrDefault(post.getId(), List.of()).stream() + .findFirst().map(FbPostPictureUrl::getPhotoUrl).orElse(null), + detectKeywordMap.getOrDefault(post.getId(), List.of()).stream() + .map(DetectKeywordInMessage::getKeyword) + .distinct() + .collect(Collectors.toList()) + )).collect(Collectors.toList()); + + long total = jpaQueryFactory.select(fbPost.count()) + .from(fbPost) + .where(builder) + .fetchOne(); + + return new PageImpl<>(resDtos, pageable, total); + } + + + @Override + public FbPostTotalRes getFbPostTotal(Long memberId, DetectKeyword detectType, String keyword, + Pageable pageable) { + + Page fbPostResPage = getFbPostByKeywordAndPaging(memberId, detectType, keyword, + pageable); + + QFbPost fbPost = QFbPost.fbPost; + + // 전체 게시물 수 + long totalPosts = jpaQueryFactory.select(fbPost.count()) + .from(fbPost) + .where(fbPost.member.id.eq(memberId)) + .fetchOne(); + + // danger_score > 0인 게시물 수 + long dangerPosts = jpaQueryFactory.select(fbPost.count()) + .from(fbPost) + .where(fbPost.member.id.eq(memberId).and(fbPost.dangerScore.gt(0))) + .fetchOne(); + + // danger_score 평균값 (전체 게시물 수가 0이면 0 반환) + Double averageDangerScore = jpaQueryFactory.select(fbPost.dangerScore.avg()) + .from(fbPost) + .where(fbPost.member.id.eq(memberId)) + .fetchOne(); + + return FbPostTotalRes.of( + totalPosts, + dangerPosts, + averageDangerScore != null ? averageDangerScore : 0.0, + fbPostResPage + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/olive/pribee/module/feed/dto/res/FbPostRes.java b/src/main/java/com/olive/pribee/module/feed/dto/res/FbPostRes.java new file mode 100644 index 0000000..24c853d --- /dev/null +++ b/src/main/java/com/olive/pribee/module/feed/dto/res/FbPostRes.java @@ -0,0 +1,42 @@ +package com.olive.pribee.module.feed.dto.res; + +import java.time.LocalDateTime; +import java.util.List; + +import com.olive.pribee.global.enums.DetectKeyword; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; + +@Schema(description = "Facebook 게시물 리스트 응답 DTO") +@Getter +@Builder(access = AccessLevel.PRIVATE) +public class FbPostRes { + @Schema(description = "게시물 ID", example = "123") + private Long id; + + @Schema(description = "게시물 생성 시간", example = "2024-03-07T12:34:56") + private LocalDateTime createdTime; + + @Schema(description = "게시물의 첫 번째 사진 URL", example = "https://example.com/photo1.jpg") + private String firstPhotoUrl; + + @Schema(description = "감지된 키워드 목록", example = "[\"EMAIL_ADDRESS\", \"PHONE_NUMBER\"]") + private List detectedKeywords; + + public static FbPostRes of( + Long id, + LocalDateTime createdTime, + String firstPhotoUrl, + List detectedKeywords + ) { + return FbPostRes.builder() + .id(id) + .createdTime(createdTime) + .firstPhotoUrl(firstPhotoUrl) + .detectedKeywords(detectedKeywords) + .build(); + } +} diff --git a/src/main/java/com/olive/pribee/module/feed/dto/res/FbPostTotalRes.java b/src/main/java/com/olive/pribee/module/feed/dto/res/FbPostTotalRes.java new file mode 100644 index 0000000..1b63516 --- /dev/null +++ b/src/main/java/com/olive/pribee/module/feed/dto/res/FbPostTotalRes.java @@ -0,0 +1,40 @@ +package com.olive.pribee.module.feed.dto.res; + +import org.springframework.data.domain.Page; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; + +@Schema(description = "Facebook 게시물 전체 정보 응답 DTO") +@Getter +@Builder(access = AccessLevel.PRIVATE) +public class FbPostTotalRes { + + @Schema(description = "전체 게시물 수") + private long totalPostCount; + + @Schema(description = "감지된 게시물 수") + private long detectPostCount; + + @Schema(description = "피해 위험도") + private Double averageDangerScore; + + @Schema(description = "게시물 정보") + private Page fbPostResPage; + + public static FbPostTotalRes of( + long totalPostCount, + long detectPostCount, + Double averageDangerScore, + Page fbPostResPage + ) { + return FbPostTotalRes.builder() + .totalPostCount(totalPostCount) + .detectPostCount(detectPostCount) + .averageDangerScore(averageDangerScore) + .fbPostResPage(fbPostResPage) + .build(); + } +} diff --git a/src/main/java/com/olive/pribee/module/feed/service/FbPostService.java b/src/main/java/com/olive/pribee/module/feed/service/FbPostService.java new file mode 100644 index 0000000..08015f4 --- /dev/null +++ b/src/main/java/com/olive/pribee/module/feed/service/FbPostService.java @@ -0,0 +1,235 @@ +package com.olive.pribee.module.feed.service; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.olive.pribee.global.enums.DetectKeyword; +import com.olive.pribee.global.enums.DetectLikelihood; +import com.olive.pribee.global.error.GlobalErrorCode; +import com.olive.pribee.global.error.exception.AppException; +import com.olive.pribee.infra.api.facebook.FacebookApiService; +import com.olive.pribee.infra.api.facebook.dto.res.post.FacebookPostAttachmentDataRes; +import com.olive.pribee.infra.api.facebook.dto.res.post.FacebookPostRes; +import com.olive.pribee.infra.api.google.dlp.GoogleDlpApiService; +import com.olive.pribee.infra.api.google.dlp.dto.res.DlpFinding; +import com.olive.pribee.module.auth.domain.entity.Member; +import com.olive.pribee.module.auth.domain.repository.MemberRepository; +import com.olive.pribee.module.feed.domain.entity.DetectKeywordInMessage; +import com.olive.pribee.module.feed.domain.entity.DetectKeywordInPhoto; +import com.olive.pribee.module.feed.domain.entity.DetectKeywordInPhotoBoundingBox; +import com.olive.pribee.module.feed.domain.entity.FbPost; +import com.olive.pribee.module.feed.domain.entity.FbPostPictureUrl; +import com.olive.pribee.module.feed.domain.repository.DetectKeywordInMessageRepository; +import com.olive.pribee.module.feed.domain.repository.DetectKeywordInPhotoRepository; +import com.olive.pribee.module.feed.domain.repository.FbPostRepository; +import com.olive.pribee.module.feed.domain.repository.custom.FbPostRepositoryImpl; +import com.olive.pribee.module.feed.dto.res.FbPostTotalRes; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +@Slf4j +public class FbPostService { + private final FacebookApiService facebookApiService; + private final GoogleDlpApiService googleDlpApiService; + + private final FbPostRepository fbPostRepository; + private final FbPostRepositoryImpl fbPostQueryRepository; + private final MemberRepository memberRepository; + private final DetectKeywordInMessageRepository detectKeywordInMessageRepository; + private final DetectKeywordInPhotoRepository detectKeywordInPhotoRepository; + + // 비동기적으로 게시물 저장 처리 + @Transactional + public void savePostsAsync(String accessToken, Long memberId, LocalDateTime sinceTime) { + fetchAndSavePosts(accessToken, memberId, sinceTime) + .onErrorResume(ex -> { + log.error("[Facebook] 게시물 저장 실패: {}", ex.getMessage(), ex); + return Mono.empty(); + }) + .subscribe(); + } + + // 게시물 저장 및 분석 API 호출 로직 + private Mono fetchAndSavePosts(String accessToken, Long memberId, LocalDateTime sinceTime) { + Member member = memberRepository.findById(memberId).orElseThrow( + () -> new AppException(GlobalErrorCode.USER_NOT_FOUND) + ); + + return facebookApiService.fetchFacebookUserPosts(accessToken, sinceTime) + .flatMap(posts -> { + List fbPosts = posts.stream() + .map(facebookPostRes -> { + FbPost fbPost = convertToFacebookPost(facebookPostRes, member); + extractPictureUrls(facebookPostRes, fbPost); + return fbPost; + }) + .collect(Collectors.toList()); + + fbPostRepository.saveAll(fbPosts); + + return Flux.fromIterable(fbPosts) + .flatMap(this::detectInfoFromFbPost) + .then(); + }); + } + + // dto에서 FbPost 엔티티 변환 로직 + private FbPost convertToFacebookPost(FacebookPostRes facebookPostRes, Member member) { + return FbPost.of( + member, + facebookPostRes.getId(), + facebookPostRes.getCreatedTime(), + facebookPostRes.getUpdatedTime(), + facebookPostRes.getMessage(), + facebookPostRes.getPermalinkUrl(), + facebookPostRes.getPlace() != null ? facebookPostRes.getPlace().getName() : null + ); + } + + // 게시물 사진 리스트 저장을 위한 추출 로직 + @Transactional + public void extractPictureUrls(FacebookPostRes facebookPostRes, FbPost fbPost) { + List fbPostPictureUrls = new ArrayList<>(); + + if (facebookPostRes.getAttachments() != null && !facebookPostRes.getAttachments().getData().isEmpty()) { + for (FacebookPostAttachmentDataRes attachment : facebookPostRes.getAttachments().getData()) { + if ("photo".equals(attachment.getType())) { + // "photo" 타입이면 즉시 postRes.getFullPicture() 저장 후 종료 + fbPostPictureUrls.add(FbPostPictureUrl.of(fbPost, facebookPostRes.getFullPicture())); + fbPost.addPictureUrls(fbPostPictureUrls); + return; + } else if ("album".equals(attachment.getType()) && attachment.getSubattachments() != null) { + // "album" 타입이면 subattachments의 모든 src 저장 + fbPostPictureUrls.addAll( + attachment.getSubattachments().getData().stream() + .map(media -> FbPostPictureUrl.of(fbPost, media.getMedia().getImage().getSrc())) + .toList() + ); + } + } + } else if (facebookPostRes.getFullPicture() != null) { + // 첨부 파일이 없고 FullPicture만 존재할 경우 + fbPostPictureUrls.add(FbPostPictureUrl.of(fbPost, facebookPostRes.getFullPicture())); + } + + fbPost.addPictureUrls(fbPostPictureUrls); + } + + // 게시물 별 개인정보 탐지 API 요청 및 응답 저장 로직 + private Mono detectInfoFromFbPost(FbPost post) { + return Mono.when( + googleDlpApiService.analyzeText(post.getMessage()) + .filter(findings -> !findings.isEmpty()) + .flatMap(findings -> saveDetectKeywordsInMessage(post, findings)) + .doOnNext(findings -> log.info("Message Findings: {}", findings)) + .then(), + + Flux.fromIterable(post.getFbPostPictureUrls()) + .flatMap(url -> googleDlpApiService.analyzeImage(url.getPhotoUrl()) + .filter(findings -> !findings.isEmpty()) + .flatMap(findings -> saveDetectKeywordsInPhoto(url, findings)) + .doOnNext(findings -> log.info("Image Findings for {}: {}", url, findings))) + .then() + ); + } + + // 메세지에서 탐지한 개인정보 엔티티에 저장 로직 + @Transactional + public Mono saveDetectKeywordsInMessage(FbPost fbPost, List findings) { + List detectedMessages = findings.stream() + .map(f -> DetectKeywordInMessage.of( + fbPost, + f.getQuote(), + DetectKeyword.of(f.getInfoType().getName()), + DetectLikelihood.of(f.getLikelihood()), + f.getLocation().getCodepointRange().getStart(), + f.getLocation().getCodepointRange().getEnd() + )) + .collect(Collectors.toList()); + + if (!detectedMessages.isEmpty()) { + detectKeywordInMessageRepository.saveAll(detectedMessages); + updateDangerScore(detectedMessages, fbPost); + } + + return Mono.empty(); + } + + // 개인정보 탐지에 따른 각 항목 별 점수 게시물(FbPost)에 업데이트 + private void updateDangerScore(List detectedMessages, FbPost fbPost) { + int dangerScore = detectedMessages.stream() + .mapToInt(detectKeywordInMessage -> detectKeywordInMessage.getKeyword().getScore()) + .sum(); + fbPost.updateDangerScore(dangerScore); + + // TODO 추후 계속 저장하는 형태가 아닐 수 있도록 수정하기 + fbPostRepository.save(fbPost); + } + + // 사진에서 탐지된 정보 엔티티에 저장 로직 + @Transactional + public Mono saveDetectKeywordsInPhoto(FbPostPictureUrl pictureUrl, List findings) { + List detectedPhotos = findings.stream() + .map(f -> { + DetectKeywordInPhoto detectKeywordInPhoto = DetectKeywordInPhoto.of( + pictureUrl, + f.getQuote(), + DetectKeyword.of(f.getInfoType().getName()), + DetectLikelihood.of(f.getLikelihood()) + ); + + // BoundingBox 데이터 변환 및 추가 + List boundingBoxes = f.getLocation() + .getContentLocations() + .get(0) + .getImageLocation() + .getBoundingBoxes() + .stream() + .map(b -> DetectKeywordInPhotoBoundingBox.of( + detectKeywordInPhoto, + b.getTop(), + b.getLeft(), + b.getWidth(), + b.getHeight() + )) + .collect(Collectors.toList()); + + detectKeywordInPhoto.addBoundingBoxes(boundingBoxes); + return detectKeywordInPhoto; + } + ).collect(Collectors.toList()); + + if (!detectedPhotos.isEmpty()) { + detectKeywordInPhotoRepository.saveAll(detectedPhotos); + } + + return Mono.empty(); + } + + // 최근 게시물 생성 시간 가져오는 로직(새로운 게시물 가져오는 기준점을 위해 사용) + public LocalDateTime getRecentPostsCreateTime() { + // TODO 추후에 어떤 값을 가져오는 게 좋을지 더 고민하기(updateTime, or createTime) + // 수정될 게시물에 대한 반영도 필요하기에... + Optional fbPost = fbPostRepository.findLatestPost(); + return fbPost.map(FbPost::getCreatedTime).orElse(null); + } + + public FbPostTotalRes getTotalPost(Long memberId, DetectKeyword detectType, String keyword, int page, int size) { + Pageable pageable = PageRequest.of(page, size); + return fbPostQueryRepository.getFbPostTotal(memberId, detectType, keyword, pageable); + } +}