From c20f0b7a96e083bb747e49fbed5497fbe7379eae Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 5 Apr 2026 21:21:27 +0500 Subject: [PATCH] . --- .../service/FacebookLeadCollectorService.java | 53 +++++-- .../service/FacebookLeadGraphApiClient.java | 76 ++++++---- src/main/resources/application.properties | 3 + .../FacebookLeadCollectorServiceTest.java | 130 ++++++++++-------- 4 files changed, 167 insertions(+), 95 deletions(-) diff --git a/src/main/java/kz/konturai/parser/service/FacebookLeadCollectorService.java b/src/main/java/kz/konturai/parser/service/FacebookLeadCollectorService.java index d8b3e03..bacff7e 100644 --- a/src/main/java/kz/konturai/parser/service/FacebookLeadCollectorService.java +++ b/src/main/java/kz/konturai/parser/service/FacebookLeadCollectorService.java @@ -4,8 +4,8 @@ import com.fasterxml.jackson.databind.JsonNode; import kz.konturai.parser.config.FacebookLeadProperties; import kz.konturai.parser.model.FacebookLead; import kz.konturai.parser.repository.FacebookLeadRepository; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.dao.DuplicateKeyException; import org.springframework.boot.context.event.ApplicationReadyEvent; import org.springframework.context.event.EventListener; @@ -13,6 +13,7 @@ import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; +import jakarta.annotation.PostConstruct; import java.time.LocalDateTime; import java.time.OffsetDateTime; import java.time.format.DateTimeFormatter; @@ -21,10 +22,10 @@ import java.util.List; import java.util.Locale; @Service -@RequiredArgsConstructor -@Slf4j public class FacebookLeadCollectorService { + private static final Logger log = LoggerFactory.getLogger(FacebookLeadCollectorService.class); + private static final DateTimeFormatter FACEBOOK_TIMESTAMP_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssZ"); @@ -42,6 +43,28 @@ public class FacebookLeadCollectorService { private final FacebookLeadRepository facebookLeadRepository; private final FacebookLeadCallCenterSyncService callCenterSyncService; + public FacebookLeadCollectorService( + FacebookLeadProperties properties, + FacebookLeadGraphApiClient graphApiClient, + FacebookLeadRepository facebookLeadRepository, + FacebookLeadCallCenterSyncService callCenterSyncService) { + this.properties = properties; + this.graphApiClient = graphApiClient; + this.facebookLeadRepository = facebookLeadRepository; + this.callCenterSyncService = callCenterSyncService; + } + + @PostConstruct + void logConfiguration() { + log.info("Facebook hot lead collector initialized. pageId={}, accessTokenPresent={}, cron={}, graphApiVersion={}, postsPageSize={}, commentsPageSize={}", + properties.getPageId(), + hasAccessToken(), + properties.getCron(), + properties.getGraphApiVersion(), + properties.getPostsPageSize(), + properties.getCommentsPageSize()); + } + @EventListener(ApplicationReadyEvent.class) public void collectHotLeadsOnStartup() { runCollection("startup"); @@ -53,8 +76,12 @@ public class FacebookLeadCollectorService { } private void runCollection(String trigger) { + log.info("Facebook hot lead trigger [{}] received. pageId={}, accessTokenPresent={}, cron={}", + trigger, properties.getPageId(), hasAccessToken(), properties.getCron()); + if (!isConfigured()) { - log.warn("Facebook lead collection [{}] skipped: pageId/accessToken are not configured", trigger); + log.warn("Facebook lead collection [{}] skipped: pageId/accessToken are not configured. pageId={}, accessTokenPresent={}, expectedEnvVar=FACEBOOK_LEADS_ACCESS_TOKEN", + trigger, properties.getPageId(), hasAccessToken()); return; } @@ -117,11 +144,17 @@ public class FacebookLeadCollectorService { String postId = post.path("id").asText(null); log.info("Processing Facebook post {} comments", StringUtils.hasText(postId) ? postId : ""); - int newLeadsCount = saveMatchingComments(post.path("comments").path("data")); - String commentsAfterCursor = extractAfterCursor(post.path("comments")); + if (!StringUtils.hasText(postId)) { + log.warn("Skipping Facebook post without id while collecting comments"); + return 0; + } + + JsonNode firstCommentsPage = graphApiClient.fetchCommentsPage(postId, null); + int newLeadsCount = saveMatchingComments(firstCommentsPage.path("data")); + String commentsAfterCursor = extractAfterCursor(firstCommentsPage); int commentsPageNumber = 2; - while (StringUtils.hasText(postId) && StringUtils.hasText(commentsAfterCursor)) { + while (StringUtils.hasText(commentsAfterCursor)) { log.info("Processing extra comments page {} for post {}. afterCursor={}", commentsPageNumber, postId, formatCursor(commentsAfterCursor)); JsonNode commentsPage = graphApiClient.fetchCommentsPage(postId, commentsAfterCursor); @@ -225,6 +258,10 @@ public class FacebookLeadCollectorService { return StringUtils.hasText(properties.getPageId()) && StringUtils.hasText(properties.getAccessToken()); } + private boolean hasAccessToken() { + return StringUtils.hasText(properties.getAccessToken()); + } + private String formatCursor(String cursor) { return StringUtils.hasText(cursor) ? cursor : ""; } diff --git a/src/main/java/kz/konturai/parser/service/FacebookLeadGraphApiClient.java b/src/main/java/kz/konturai/parser/service/FacebookLeadGraphApiClient.java index 97459f0..3a58275 100644 --- a/src/main/java/kz/konturai/parser/service/FacebookLeadGraphApiClient.java +++ b/src/main/java/kz/konturai/parser/service/FacebookLeadGraphApiClient.java @@ -2,19 +2,22 @@ package kz.konturai.parser.service; import com.fasterxml.jackson.databind.JsonNode; import kz.konturai.parser.config.FacebookLeadProperties; -import lombok.extern.slf4j.Slf4j; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; import org.springframework.web.reactive.function.client.WebClient; import org.springframework.web.reactive.function.client.WebClientResponseException; +import java.net.URI; import java.time.Duration; @Service -@Slf4j public class FacebookLeadGraphApiClient { + private static final Logger log = LoggerFactory.getLogger(FacebookLeadGraphApiClient.class); + private final FacebookLeadProperties properties; private final WebClient facebookLeadWebClient; @@ -26,25 +29,12 @@ public class FacebookLeadGraphApiClient { } public JsonNode fetchPostsPage(String afterCursor) { - String commentsField = "comments.limit(" + properties.getCommentsPageSize() + "){id,message,from,created_time}"; log.info("Requesting Facebook posts page. pageId={}, afterCursor={}, limit={}", properties.getPageId(), formatCursor(afterCursor), properties.getPostsPageSize()); try { JsonNode response = facebookLeadWebClient.get() - .uri(uriBuilder -> { - var builder = uriBuilder - .path("/{pageId}/posts") - .queryParam("fields", "id," + commentsField) - .queryParam("limit", properties.getPostsPageSize()) - .queryParam("access_token", properties.getAccessToken()); - - if (StringUtils.hasText(afterCursor)) { - builder.queryParam("after", afterCursor); - } - - return builder.build(properties.getPageId()); - }) + .uri(URI.create(buildPostsUri(afterCursor))) .retrieve() .bodyToMono(JsonNode.class) .block(Duration.ofMillis(properties.getTimeoutMs())); @@ -65,19 +55,7 @@ public class FacebookLeadGraphApiClient { try { JsonNode response = facebookLeadWebClient.get() - .uri(uriBuilder -> { - var builder = uriBuilder - .path("/{postId}/comments") - .queryParam("fields", "id,message,from,created_time") - .queryParam("limit", properties.getCommentsPageSize()) - .queryParam("access_token", properties.getAccessToken()); - - if (StringUtils.hasText(afterCursor)) { - builder.queryParam("after", afterCursor); - } - - return builder.build(postId); - }) + .uri(URI.create(buildCommentsUri(postId, afterCursor))) .retrieve() .bodyToMono(JsonNode.class) .block(Duration.ofMillis(properties.getTimeoutMs())); @@ -101,6 +79,46 @@ public class FacebookLeadGraphApiClient { return StringUtils.hasText(afterCursor) ? afterCursor : ""; } + private String buildPostsUri(String afterCursor) { + StringBuilder uri = new StringBuilder() + .append(graphApiBaseUrl()) + .append("/") + .append(properties.getPageId()) + .append("/posts?fields=id") + .append("&limit=") + .append(properties.getPostsPageSize()) + .append("&access_token=") + .append(properties.getAccessToken()); + + if (StringUtils.hasText(afterCursor)) { + uri.append("&after=").append(afterCursor); + } + + return uri.toString(); + } + + private String buildCommentsUri(String postId, String afterCursor) { + StringBuilder uri = new StringBuilder() + .append(graphApiBaseUrl()) + .append("/") + .append(postId) + .append("/comments?fields=id,message,from,created_time") + .append("&limit=") + .append(properties.getCommentsPageSize()) + .append("&access_token=") + .append(properties.getAccessToken()); + + if (StringUtils.hasText(afterCursor)) { + uri.append("&after=").append(afterCursor); + } + + return uri.toString(); + } + + private String graphApiBaseUrl() { + return "https://graph.facebook.com/" + properties.getGraphApiVersion(); + } + private String formatCursor(String cursor) { return StringUtils.hasText(cursor) ? cursor : ""; } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 42772bb..3db5cd5 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -47,6 +47,9 @@ logging.level.kz.konturai.parser.controller.MarketingController=DEBUG logging.level.kz.konturai.parser.service.MarketingAnalysisService=DEBUG logging.level.kz.konturai.parser.service.GeminiVideoGenerationService=INFO logging.level.kz.konturai.parser.service.NanoBananaImageGenerationService=INFO +logging.level.kz.konturai.parser.service.FacebookLeadCollectorService=INFO +logging.level.kz.konturai.parser.service.FacebookLeadGraphApiClient=INFO +logging.level.kz.konturai.parser.service.FacebookLeadCallCenterSyncService=INFO logging.level.kz.konturai.parser.config.MongoConfig=WARN logging.level.org.springframework.data.mongodb.core.convert=WARN diff --git a/src/test/java/kz/konturai/parser/service/FacebookLeadCollectorServiceTest.java b/src/test/java/kz/konturai/parser/service/FacebookLeadCollectorServiceTest.java index 42fa58e..1337bf7 100644 --- a/src/test/java/kz/konturai/parser/service/FacebookLeadCollectorServiceTest.java +++ b/src/test/java/kz/konturai/parser/service/FacebookLeadCollectorServiceTest.java @@ -58,34 +58,37 @@ class FacebookLeadCollectorServiceTest { { "data": [ { - "id": "post-1", - "comments": { - "data": [ - { - "id": "comment-1", - "message": "Хочу купить, какая цена?", - "created_time": "2026-04-05T08:15:30+0000", - "from": { - "name": "Alice" - } - }, - { - "id": "comment-2", - "message": "Спасибо за пост", - "created_time": "2026-04-05T08:16:30+0000", - "from": { - "name": "Bob" - } - }, - { - "id": "comment-3", - "message": "НАПИШЕТЕ В ЛС", - "created_time": "2026-04-05T08:17:30+0000", - "from": { - "name": "Carol" - } - } - ] + "id": "post-1" + } + ] + } + """); + + JsonNode firstCommentsPage = objectMapper.readTree(""" + { + "data": [ + { + "id": "comment-1", + "message": "Хочу купить, какая цена?", + "created_time": "2026-04-05T08:15:30+0000", + "from": { + "name": "Alice" + } + }, + { + "id": "comment-2", + "message": "Спасибо за пост", + "created_time": "2026-04-05T08:16:30+0000", + "from": { + "name": "Bob" + } + }, + { + "id": "comment-3", + "message": "НАПИШЕТЕ В ЛС", + "created_time": "2026-04-05T08:17:30+0000", + "from": { + "name": "Carol" } } ] @@ -93,6 +96,7 @@ class FacebookLeadCollectorServiceTest { """); when(graphApiClient.fetchPostsPage(null)).thenReturn(firstPostsPage); + when(graphApiClient.fetchCommentsPage("post-1", null)).thenReturn(firstCommentsPage); when(facebookLeadRepository.existsByExternalCommentId("comment-1")).thenReturn(false); when(facebookLeadRepository.existsByExternalCommentId("comment-3")).thenReturn(true); when(facebookLeadRepository.save(any(FacebookLead.class))).thenAnswer(invocation -> invocation.getArgument(0)); @@ -121,24 +125,7 @@ class FacebookLeadCollectorServiceTest { { "data": [ { - "id": "post-1", - "comments": { - "data": [ - { - "id": "comment-1", - "message": "Сколько стоит?", - "created_time": "2026-04-05T08:15:30+0000", - "from": { - "name": "Alice" - } - } - ], - "paging": { - "cursors": { - "after": "comments-cursor-1" - } - } - } + "id": "post-1" } ], "paging": { @@ -149,6 +136,26 @@ class FacebookLeadCollectorServiceTest { } """); + JsonNode firstCommentsPage = objectMapper.readTree(""" + { + "data": [ + { + "id": "comment-1", + "message": "Сколько стоит?", + "created_time": "2026-04-05T08:15:30+0000", + "from": { + "name": "Alice" + } + } + ], + "paging": { + "cursors": { + "after": "comments-cursor-1" + } + } + } + """); + JsonNode secondCommentsPage = objectMapper.readTree(""" { "data": [ @@ -168,18 +175,21 @@ class FacebookLeadCollectorServiceTest { { "data": [ { - "id": "post-2", - "comments": { - "data": [ - { - "id": "comment-3", - "message": "цена", - "created_time": "2026-04-05T08:17:30+0000", - "from": { - "name": "Carol" - } - } - ] + "id": "post-2" + } + ] + } + """); + + JsonNode thirdCommentsPage = objectMapper.readTree(""" + { + "data": [ + { + "id": "comment-3", + "message": "цена", + "created_time": "2026-04-05T08:17:30+0000", + "from": { + "name": "Carol" } } ] @@ -187,8 +197,10 @@ class FacebookLeadCollectorServiceTest { """); when(graphApiClient.fetchPostsPage(null)).thenReturn(firstPostsPage); + when(graphApiClient.fetchCommentsPage("post-1", null)).thenReturn(firstCommentsPage); when(graphApiClient.fetchCommentsPage("post-1", "comments-cursor-1")).thenReturn(secondCommentsPage); when(graphApiClient.fetchPostsPage("posts-cursor-1")).thenReturn(secondPostsPage); + when(graphApiClient.fetchCommentsPage("post-2", null)).thenReturn(thirdCommentsPage); when(facebookLeadRepository.existsByExternalCommentId(eq("comment-1"))).thenReturn(false); when(facebookLeadRepository.existsByExternalCommentId(eq("comment-2"))).thenReturn(false); when(facebookLeadRepository.existsByExternalCommentId(eq("comment-3"))).thenReturn(false); @@ -198,8 +210,10 @@ class FacebookLeadCollectorServiceTest { assertEquals(3, savedCount); verify(graphApiClient).fetchPostsPage(null); + verify(graphApiClient).fetchCommentsPage("post-1", null); verify(graphApiClient).fetchCommentsPage("post-1", "comments-cursor-1"); verify(graphApiClient).fetchPostsPage("posts-cursor-1"); + verify(graphApiClient).fetchCommentsPage("post-2", null); verify(facebookLeadRepository, times(3)).save(any(FacebookLead.class)); verify(callCenterSyncService, times(3)).syncLead(any(FacebookLead.class)); }