target fix

This commit is contained in:
arys
2026-04-03 10:57:54 +05:00
parent 630ddb9561
commit 52156c824b
8 changed files with 242 additions and 100 deletions
@@ -0,0 +1,65 @@
package kz.konturai.parser.controller;
import kz.konturai.parser.dto.ApiResponse;
import kz.konturai.parser.service.FacebookPostingService;
import kz.konturai.parser.service.JwtService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
/**
* Контроллер для настройки Facebook (обмен токенов и т.д.)
*/
@RestController
@RequestMapping("/api/facebook/config")
@RequiredArgsConstructor
public class FacebookConfigController {
private final FacebookPostingService facebookPostingService;
private final JwtService jwtService;
private String extractUserIdFromHeader(String authHeader) {
if (authHeader == null || authHeader.isEmpty()) {
return null;
}
try {
return jwtService.extractUserIdFromHeader(authHeader);
} catch (Exception e) {
return null;
}
}
/**
* Помощник для обмена временного токена на долгосрочный.
*
* POST /api/facebook/config/exchange-token
* Body: { "shortToken": "...", "pageId": "..." }
*/
@PostMapping("/exchange-token")
public ResponseEntity<?> exchangeToken(
@RequestHeader(value = "Authorization", required = false) String authHeader,
@RequestBody Map<String, String> request) {
String userId = extractUserIdFromHeader(authHeader);
if (userId == null) {
return ResponseEntity.status(401).body(ApiResponse.error("Не авторизован", null));
}
String shortToken = request.get("shortToken");
String pageId = request.get("pageId");
if (shortToken == null || pageId == null) {
return ResponseEntity.badRequest().body(ApiResponse.error("shortToken и pageId обязательны", null));
}
try {
String longLivedToken = facebookPostingService.exchangeForLongLivedPageToken(shortToken, pageId);
return ResponseEntity.ok(ApiResponse.success("Токен успешно обменян. Сохраните его в настройках.",
Map.of("longLivedToken", longLivedToken)));
} catch (Exception e) {
return ResponseEntity.status(500).body(ApiResponse.error("Ошибка обмена: " + e.getMessage(), null));
}
}
}
@@ -286,8 +286,8 @@ public class TargetingCampaignController {
// ── Инсайты ───────────────────────────────────────────────────────────────
@GetMapping("/campaigns/{id}/insights")
public ResponseEntity<?> getInsights(
@GetMapping("/campaigns/{id}/insights/raw")
public ResponseEntity<?> getRawInsights(
@RequestHeader(value = "X-User-Id", required = false) String userIdHeader,
@RequestHeader(value = "Authorization", required = false) String authHeader,
@PathVariable String id) {
@@ -24,6 +24,9 @@ public class SocialMediaCredentials {
@Field("ad_account_id")
private String adAccountId;
@Field("page_id")
private String pageId;
@Field("created_at")
private LocalDateTime createdAt;
@@ -84,6 +87,15 @@ public class SocialMediaCredentials {
this.updatedAt = LocalDateTime.now();
}
public String getPageId() {
return pageId;
}
public void setPageId(String pageId) {
this.pageId = pageId;
this.updatedAt = LocalDateTime.now();
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
@@ -485,13 +485,13 @@ public class AiTargetingSystemService {
if (imageBytes != null) {
facebookPostId = facebookPostingService.postToPageWithImage(
null, post.getPostText(), post.getHashtags(), imageBytes);
post.getPostText(), post.getHashtags(), imageBytes);
imagePosted = true;
facebookStatus = "PUBLISHED";
logger.info("Facebook post published WITH image. postId={}", facebookPostId);
} else {
facebookPostId = facebookPostingService.postToPage(
null, post.getPostText(), post.getHashtags());
post.getPostText(), post.getHashtags());
facebookStatus = "TEXT_ONLY";
logger.info("Facebook post published TEXT ONLY. postId={}", facebookPostId);
}
@@ -1,11 +1,9 @@
package kz.konturai.parser.service;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import kz.konturai.parser.exception.FacebookTokenExpiredException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.MediaType;
@@ -19,27 +17,42 @@ import org.springframework.web.reactive.function.client.WebClientResponseExcepti
import reactor.netty.http.client.HttpClient;
import java.time.Duration;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* Сервис публикации постов в Facebook.
*
* Использует надежную схему с Long-Lived Page Access Token.
* Настройки приоритетно берутся из application.properties.
*/
@Service
@Slf4j
public class FacebookPostingService {
private static final Logger logger = LoggerFactory.getLogger(FacebookPostingService.class);
private static final String FACEBOOK_GRAPH_API_BASE = "https://graph.facebook.com/v19.0";
private static final String HARDCODED_PAGE_TOKEN = "EAAaocqgT3JoBQhROtnPQ3echpBNU7pnrRxmj7XOoyFzDzOeJIZCjjck4ilraxYEBNXgDZCWfvUZBjDWPLGKrvF7ZCc1ZAmnDj1RHnW06I69ZBvCVbpoiKzsMqPihLZAwZB7WJykDAlaCVbhSBFmOY2YdMlf1fwi3cUq0tvKAaiJesKJbfOuXgMV0c7lSFKNz2yxsWiRfTzpMjhjk8Y9EsYpM8FW0HMEktzeHaoGOGOoI8oEZD";
private static final String HARDCODED_PAGE_ID = "918835061309575";
@Value("${facebook.app.id:}")
private String appId;
private final WebClient webClient;
private final ObjectMapper objectMapper;
@Value("${facebook.app.secret:}")
private String appSecret;
@Value("${facebook.page.token:EAAaocqgT3JoBQhROtnPQ3echpBNU7pnrRxmj7XOoyFzDzOeJIZCjjck4ilraxYEBNXgDZCWfvUZBjDWPLGKrvF7ZCc1ZAmnDj1RHnW06I69ZBvCVbpoiKzsMqPihLZAwZB7WJykDAlaCVbhSBFmOY2YdMlf1fwi3cUq0tvKAaiJesKJbfOuXgMV0c7lSFKNz2yxsWiRfTzpMjhjk8Y9EsYpM8FW0HMEktzeHaoGOGOoI8oEZD}")
private String pageToken;
@Value("${facebook.page.id:918835061309575}")
private String pageId;
@Value("${facebook.api.timeout:30000}")
private int timeoutMs;
private final WebClient webClient;
private final ObjectMapper objectMapper;
public FacebookPostingService() {
this.objectMapper = new ObjectMapper();
HttpClient httpClient = HttpClient.create()
.responseTimeout(Duration.ofMillis(30000));
@@ -47,89 +60,141 @@ public class FacebookPostingService {
.baseUrl(FACEBOOK_GRAPH_API_BASE)
.clientConnector(new ReactorClientHttpConnector(httpClient))
.build();
this.objectMapper = new ObjectMapper();
}
public PageCredentials getPageCredentials(String ignoredAccessToken) {
return new PageCredentials(HARDCODED_PAGE_ID, HARDCODED_PAGE_TOKEN);
// ── Credentials management ────────────────────────────────────────────────
public PageCredentials getPageCredentials() {
return new PageCredentials(pageId, pageToken);
}
public void sendPrivateMessage(String ignoredToken, String recipientId, String messageText) {
/**
* Обмен короткого токена пользователя на долгосрочный токен страницы.
*/
public String exchangeForLongLivedPageToken(String shortLivedUserToken, String targetPageId) {
log.info("[Facebook] Запуск обмена токена для страницы {}", targetPageId);
if (appId == null || appId.contains("dummy") || appSecret == null || appSecret.contains("dummy")) {
throw new IllegalStateException("Hеобходимo настроить facebook.app.id и facebook.app.secret");
}
try {
Map<String, Object> recipient = new HashMap<>();
recipient.put("id", recipientId);
// 1. Короткий токен -> Долгосрочный User Token (60 дней)
JsonNode userTokenNode = webClient.get()
.uri(u -> u.path("/oauth/access_token")
.queryParam("grant_type", "fb_exchange_token")
.queryParam("client_id", appId)
.queryParam("client_secret", appSecret)
.queryParam("fb_exchange_token", shortLivedUserToken)
.build())
.retrieve()
.bodyToMono(JsonNode.class)
.block(Duration.ofMillis(timeoutMs));
Map<String, String> messageContent = new HashMap<>();
messageContent.put("text", messageText);
String longLivedUserToken = userTokenNode.path("access_token").asText();
log.info("[Facebook] Получен долгосрочный User Token");
Map<String, Object> body = new HashMap<>();
body.put("recipient", recipient);
body.put("message", messageContent);
// 2. Список страниц этого пользователя
JsonNode accounts = webClient.get()
.uri(u -> u.path("/me/accounts")
.queryParam("access_token", longLivedUserToken)
.build())
.retrieve()
.bodyToMono(JsonNode.class)
.block(Duration.ofMillis(timeoutMs));
if (accounts != null && accounts.has("data")) {
for (JsonNode page : accounts.get("data")) {
if (page.path("id").asText().equals(targetPageId)) {
String permToken = page.path("access_token").asText();
log.info("[Facebook] УСПЕХ! Получен надежный Page Token для {}", targetPageId);
log.info("[Facebook] ПЕРЕЗАПИШИТЕ ЕГО В application.properties: {}", permToken);
return permToken;
}
}
}
throw new RuntimeException("Страница " + targetPageId + " не найдена в аккаунте");
} catch (Exception e) {
log.error("[Facebook] Ошибка обмена токена: {}", e.getMessage());
throw new RuntimeException("Ошибка Meta API: " + e.getMessage(), e);
}
}
// ── Posting methods ───────────────────────────────────────────────────────
public String postToPage(String postText, List<String> hashtags) {
return internalPost(getPageCredentials(), postText, hashtags, null);
}
public String postToPageWithImage(String postText, List<String> hashtags, byte[] imageData) {
return internalPost(getPageCredentials(), postText, hashtags, imageData);
}
// ── Messaging ────────────────────────────────────────────────────────────
public void sendPrivateMessage(String recipientId, String messageText) {
PageCredentials creds = getPageCredentials();
try {
java.util.Map<String, Object> body = new java.util.HashMap<>();
body.put("recipient", java.util.Map.of("id", recipientId));
body.put("message", java.util.Map.of("text", messageText));
body.put("messaging_type", "RESPONSE");
webClient.post()
.uri(uriBuilder -> uriBuilder.path("/me/messages")
.queryParam("access_token", HARDCODED_PAGE_TOKEN).build())
.uri(u -> u.path("/me/messages")
.queryParam("access_token", creds.pageToken).build())
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(body)
.retrieve()
.bodyToMono(String.class)
.block(Duration.ofMillis(timeoutMs));
logger.info("Message sent to user {}", recipientId);
log.info("[Facebook] Сообщение отправлено пользователю {}", recipientId);
} catch (Exception e) {
logger.error("Failed to send text message: ", e);
log.error("[Facebook] Ошибка отправки сообщения: {}", e.getMessage());
}
}
public void sendPrivateImageMessage(String ignoredToken, String recipientId, byte[] imageData) {
public void sendPrivateImageMessage(String recipientId, byte[] imageData) {
PageCredentials creds = getPageCredentials();
try {
ByteArrayResource imageResource = new ByteArrayResource(imageData) {
@Override public String getFilename() { return "image.jpg"; }
};
String recipientJson = "{\"id\":\"" + recipientId + "\"}";
String messageJson = "{\"attachment\":{\"type\":\"image\", \"payload\":{\"is_reusable\":true}}}";
MultiValueMap<String, Object> formData = new LinkedMultiValueMap<>();
formData.add("recipient", recipientJson);
formData.add("message", messageJson);
formData.add("recipient", "{\"id\":\"" + recipientId + "\"}");
formData.add("message", "{\"attachment\":{\"type\":\"image\", \"payload\":{\"is_reusable\":true}}}");
formData.add("filedata", imageResource);
formData.add("access_token", HARDCODED_PAGE_TOKEN);
formData.add("access_token", creds.pageToken);
webClient.post()
.uri(uriBuilder -> uriBuilder.path("/me/messages").build())
.uri(u -> u.path("/me/messages").build())
.contentType(MediaType.MULTIPART_FORM_DATA)
.body(BodyInserters.fromMultipartData(formData))
.retrieve()
.bodyToMono(String.class)
.block(Duration.ofMillis(timeoutMs * 2));
logger.info("Image message sent to user {}", recipientId);
log.info("[Facebook] Изображение отправлено пользователю {}", recipientId);
} catch (Exception e) {
logger.error("Failed to send image message: ", e);
log.error("[Facebook] Ошибка отправки изображения: {}", e.getMessage());
}
}
public String postToPage(String ignoredToken, String postText, List<String> hashtags) throws JsonProcessingException {
return internalPost(postText, hashtags, null);
}
// ── Internal ──────────────────────────────────────────────────────────────
public String postToPageWithImage(String ignoredToken, String postText, List<String> hashtags, byte[] imageData) {
return internalPost(postText, hashtags, imageData);
}
private String internalPost(PageCredentials creds, String postText, List<String> hashtags, byte[] imageData) {
if (creds.pageToken == null || creds.pageToken.length() < 10) {
throw new IllegalStateException("Facebook Access Token не настроен! Проверьте application.properties");
}
private String internalPost(String postText, List<String> hashtags, byte[] imageData) {
try {
String fullPostText = buildPostText(postText, hashtags);
String fullText = buildPostText(postText, hashtags);
MultiValueMap<String, Object> formData = new LinkedMultiValueMap<>();
formData.add("access_token", HARDCODED_PAGE_TOKEN);
formData.add("message", fullPostText);
formData.add("access_token", creds.pageToken);
formData.add("message", fullText);
String endpoint;
if (imageData != null) {
endpoint = "/{pageId}/photos";
formData.add("source", new ByteArrayResource(imageData) {
@@ -140,54 +205,52 @@ public class FacebookPostingService {
}
String response = webClient.post()
.uri(uriBuilder -> uriBuilder.path(endpoint).build(HARDCODED_PAGE_ID))
.uri(u -> u.path(endpoint).build(creds.pageId))
.contentType(imageData != null ? MediaType.MULTIPART_FORM_DATA : MediaType.APPLICATION_FORM_URLENCODED)
.body(BodyInserters.fromMultipartData(formData))
.retrieve()
.bodyToMono(String.class)
.block(Duration.ofMillis(timeoutMs * 2));
JsonNode jsonNode = objectMapper.readTree(response);
String postId = jsonNode.get("id").asText();
logger.info("Published to Facebook. ID: {}", postId);
JsonNode json = objectMapper.readTree(response);
String postId = json.path("id").asText(null);
log.info("[Facebook] Пост опубликован. id={}, pageId={}", postId, creds.pageId);
return postId;
} catch (WebClientResponseException e) {
handleFacebookError(e, "internalPost");
handleFacebookError(e);
return null;
} catch (Exception e) {
throw new RuntimeException("Error posting: " + e.getMessage(), e);
throw new RuntimeException("[Facebook] Ошибка: " + e.getMessage(), e);
}
}
private String buildPostText(String postText, List<String> hashtags) {
StringBuilder fullText = new StringBuilder(postText != null ? postText : "");
StringBuilder sb = new StringBuilder(postText != null ? postText : "");
if (hashtags != null && !hashtags.isEmpty()) {
if (fullText.length() > 0) fullText.append("\n\n");
String hashtagsText = hashtags.stream()
.map(tag -> tag.startsWith("#") ? tag : "#" + tag)
.collect(Collectors.joining(" "));
fullText.append(hashtagsText);
if (sb.length() > 0) sb.append("\n\n");
sb.append(hashtags.stream()
.map(t -> t.startsWith("#") ? t : "#" + t)
.collect(Collectors.joining(" ")));
}
return fullText.toString();
return sb.toString();
}
private void handleFacebookError(WebClientResponseException e) {
log.error("[Facebook] API Error: status={}, body={}", e.getStatusCode().value(), e.getResponseBodyAsString());
String body = e.getResponseBodyAsString();
if (body.contains("access token") || body.contains("expired")) {
throw new FacebookTokenExpiredException("Токен Facebook истёк. Получите новый через утилиту обмена.",
"Token Expired", 190, 463, e);
}
throw new RuntimeException("[Facebook] API Error: " + body, e);
}
public static class PageCredentials {
public String pageId; public String pageToken;
public PageCredentials(String pageId, String pageToken) { this.pageId = pageId; this.pageToken = pageToken; }
public final String pageId;
public final String pageToken;
public PageCredentials(String pageId, String pageToken) {
this.pageId = pageId;
this.pageToken = pageToken;
}
}
private void handleFacebookError(WebClientResponseException e, String context) {
logger.error("FB API Error [{}]: Body={}", context, e.getResponseBodyAsString());
if (isTokenExpiredError(e)) throw parseFacebookError(e);
throw new RuntimeException("Facebook API Error: " + e.getResponseBodyAsString(), e);
}
private boolean isTokenExpiredError(WebClientResponseException e) {
return e.getStatusCode().value() == 400 || e.getStatusCode().value() == 401;
}
private FacebookTokenExpiredException parseFacebookError(WebClientResponseException e) {
return new FacebookTokenExpiredException("Token Expired", "Token Expired", 190, 463, e);
}
}
}
@@ -146,9 +146,9 @@ public class PostingTaskService {
if ("facebook".equalsIgnoreCase(task.getPlatform())) {
if (imageData != null && imageData.length > 0) {
postId = facebookPostingService.postToPageWithImage(credentials, task.getPostText(), task.getHashtags(), imageData);
postId = facebookPostingService.postToPageWithImage(task.getPostText(), task.getHashtags(), imageData);
} else {
postId = facebookPostingService.postToPage(credentials, task.getPostText(), task.getHashtags());
postId = facebookPostingService.postToPage(task.getPostText(), task.getHashtags());
}
if (postId != null) {
@@ -158,10 +158,10 @@ public class PostingTaskService {
"🔗 Ссылка: https://facebook.com/" + postId + "\n" +
"🕒 Время: " + LocalDateTime.now();
facebookPostingService.sendPrivateMessage(credentials, TEST_RECIPIENT_ID, reportText);
facebookPostingService.sendPrivateMessage(TEST_RECIPIENT_ID, reportText);
if (imageData != null && imageData.length > 0) {
facebookPostingService.sendPrivateImageMessage(credentials, TEST_RECIPIENT_ID, imageData);
facebookPostingService.sendPrivateImageMessage(TEST_RECIPIENT_ID, imageData);
}
} catch (Exception msgEx) {
logger.warn("Post published but failed to send private notification: {}", msgEx.getMessage());
@@ -134,6 +134,12 @@ telegram.api.timeout=30000
# targeting.tiktok.oauth-redirect-uri=${TIKTOK_REDIRECT_URI:http://localhost:8080/api/v1/targeting/tiktok/callback}
# targeting.tiktok.base-url=https://business-api.tiktok.com/open_api/v1.3
# Facebook Posting Configuration
facebook.app.id=1874060259875994
facebook.app.secret=1e096c34d375641b7c4d05fff64299ea
facebook.page.id=918835061309575
facebook.page.token=EAAaocqgT3JoBRIdErZC0vZAleU3lrszUXzX8aSylq7sr9aFDGnxUt4cZAJGPec3uUONzEdlgwNclXyDhp77vJMTie5PcSb9CLAZAQZBMX3KR3RkmLOZChZCjgjoN58RJ6DwQqZA8N0PbW27Hdr7xGfdlobYopEL2nNbpdX1DHvMdXbGa4M3C92crZAjqyMkggCeiSjTQ8WU5FuZB8T8bQXrAYjGuZAImJF82mx9gcjqtDkxcarLxZA1M41nJpf5Q2Dx6F3tpcsuc1hcHbnhdZCn96nq8aHwGa
targeting.ai.audience-model=gpt-4o
targeting.ai.max-audience-tokens=2500
targeting.ai.budget-model=gpt-4o-mini
@@ -240,8 +240,8 @@ class PostingTaskServiceTest {
savedStatuses.add(savedTask.getStatus());
return savedTask;
});
when(credentialsService.getCredentials("user-1", "facebook")).thenReturn("fb-token");
when(facebookPostingService.postToPage("fb-token", "Product launch", List.of("#launch")))
// FacebookPostingService now handles credentials internally
when(facebookPostingService.postToPage("Product launch", List.of("#launch")))
.thenReturn("fb-post-1");
postingTaskService.executeTask("task-1");
@@ -250,12 +250,11 @@ class PostingTaskServiceTest {
assertEquals("completed", task.getStatus());
assertNull(task.getErrorMessage());
assertNotNull(task.getExecutedAt());
verify(facebookPostingService).postToPage("fb-token", "Product launch", List.of("#launch"));
verify(facebookPostingService).postToPage("Product launch", List.of("#launch"));
verify(facebookPostingService).sendPrivateMessage(
eq("fb-token"),
eq(TEST_RECIPIENT_ID),
argThat(message -> message.contains("Product launch") && message.contains("https://facebook.com/fb-post-1")));
verify(facebookPostingService, never()).sendPrivateImageMessage(anyString(), anyString(), any());
verify(facebookPostingService, never()).sendPrivateImageMessage(anyString(), any());
verify(minIOService, never()).downloadFile(anyString());
}
@@ -269,28 +268,25 @@ class PostingTaskServiceTest {
when(taskRepository.findById("task-1")).thenReturn(Optional.of(task));
when(taskRepository.save(any(PostingTask.class))).thenAnswer(invocation -> invocation.getArgument(0));
when(credentialsService.getCredentials("user-1", "facebook")).thenReturn("fb-token");
when(minIOService.downloadFile("banner.png")).thenReturn(streamOf(imageBytes));
when(facebookPostingService.postToPageWithImage(
eq("fb-token"),
eq("Image post"),
eq(List.of("#image")),
aryEq(imageBytes)))
.thenReturn("fb-post-2");
doThrow(new RuntimeException("Messenger unavailable"))
.when(facebookPostingService)
.sendPrivateMessage(eq("fb-token"), eq(TEST_RECIPIENT_ID), anyString());
.sendPrivateMessage(eq(TEST_RECIPIENT_ID), anyString());
postingTaskService.executeTask("task-1");
assertEquals("completed", task.getStatus());
assertNull(task.getErrorMessage());
verify(facebookPostingService).postToPageWithImage(
eq("fb-token"),
eq("Image post"),
eq(List.of("#image")),
aryEq(imageBytes));
verify(facebookPostingService, never()).sendPrivateImageMessage(anyString(), anyString(), any());
verify(facebookPostingService, never()).sendPrivateImageMessage(anyString(), any());
}
@Test
@@ -393,8 +389,8 @@ class PostingTaskServiceTest {
when(taskRepository.findById("task-1")).thenReturn(Optional.of(task));
when(taskRepository.save(any(PostingTask.class))).thenAnswer(invocation -> invocation.getArgument(0));
when(credentialsService.getCredentials("user-1", "facebook")).thenReturn("fb-token");
when(facebookPostingService.postToPage("fb-token", task.getPostText(), task.getHashtags()))
// FacebookPostingService now handles credentials internally
when(facebookPostingService.postToPage(task.getPostText(), task.getHashtags()))
.thenThrow(new FacebookTokenExpiredException("Expired", "code 190", 190, 463));
postingTaskService.executeTask("task-1");