target fix

This commit is contained in:
arys
2026-04-06 15:47:09 +05:00
parent a40ed1b1cb
commit db565e6f95
@@ -20,12 +20,6 @@ import java.time.Duration;
import java.util.List;
import java.util.stream.Collectors;
/**
* Сервис публикации постов в Facebook.
*
* Использует надежную схему с Long-Lived Page Access Token.
* Настройки приоритетно берутся из application.properties.
*/
@Service
@Slf4j
public class FacebookPostingService {
@@ -38,7 +32,7 @@ public class FacebookPostingService {
@Value("${facebook.app.secret:}")
private String appSecret;
@Value("${facebook.page.token:EAAaocqgT3JoBQhROtnPQ3echpBNU7pnrRxmj7XOoyFzDzOeJIZCjjck4ilraxYEBNXgDZCWfvUZBjDWPLGKrvF7ZCc1ZAmnDj1RHnW06I69ZBvCVbpoiKzsMqPihLZAwZB7WJykDAlaCVbhSBFmOY2YdMlf1fwi3cUq0tvKAaiJesKJbfOuXgMV0c7lSFKNz2yxsWiRfTzpMjhjk8Y9EsYpM8FW0HMEktzeHaoGOGOoI8oEZD}")
@Value("${facebook.page.token:}")
private String pageToken;
@Value("${facebook.page.id:918835061309575}")
@@ -54,83 +48,146 @@ public class FacebookPostingService {
this.objectMapper = new ObjectMapper();
HttpClient httpClient = HttpClient.create()
.responseTimeout(Duration.ofMillis(30000));
.responseTimeout(Duration.ofMillis(60000)); // увеличен таймаут для видео
this.webClient = WebClient.builder()
.baseUrl(FACEBOOK_GRAPH_API_BASE)
.clientConnector(new ReactorClientHttpConnector(httpClient))
.codecs(configurer -> configurer.defaultCodecs()
.maxInMemorySize(100 * 1024 * 1024)) // 100MB для видео
.build();
}
// ── Credentials management ────────────────────────────────────────────────
// ── Credentials ──────────────────────────────────────────────────────────
public PageCredentials getPageCredentials() {
return new PageCredentials(pageId, pageToken);
}
// ── Public posting methods ────────────────────────────────────────────────
/**
* Обмен короткого токена пользователя на долгосрочный токен страницы.
* Публикация текстового поста
*/
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 {
// 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));
String longLivedUserToken = userTokenNode.path("access_token").asText();
log.info("[Facebook] Получен долгосрочный User Token");
// 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);
return postTextOnly(getPageCredentials(), postText, hashtags);
}
/**
* Публикация поста с фото
*/
public String postToPageWithImage(String postText, List<String> hashtags, byte[] imageData) {
return internalPost(getPageCredentials(), postText, hashtags, imageData);
return postWithPhoto(getPageCredentials(), postText, hashtags, imageData);
}
// ── Messaging ────────────────────────────────────────────────────────────
/**
* Публикация поста с видео — НОВЫЙ МЕТОД
*/
public String postToPageWithVideo(String postText, List<String> hashtags, byte[] videoData) {
return postWithVideo(getPageCredentials(), postText, hashtags, videoData);
}
// ── Core posting implementations ─────────────────────────────────────────
/**
* Текстовый пост через /feed
*/
private String postTextOnly(PageCredentials creds, String postText, List<String> hashtags) {
validateToken(creds);
try {
String fullText = buildPostText(postText, hashtags);
MultiValueMap<String, Object> formData = new LinkedMultiValueMap<>();
formData.add("access_token", creds.pageToken);
formData.add("message", fullText);
String response = webClient.post()
.uri(u -> u.path("/{pageId}/feed").build(creds.pageId))
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.body(BodyInserters.fromMultipartData(formData))
.retrieve()
.bodyToMono(String.class)
.block(Duration.ofMillis(timeoutMs));
return extractPostId(response, creds.pageId);
} catch (WebClientResponseException e) {
handleFacebookError(e);
return null;
} catch (Exception e) {
throw new RuntimeException("[Facebook] Ошибка публикации текста: " + e.getMessage(), e);
}
}
/**
* Пост с фото через /{pageId}/photos
*/
private String postWithPhoto(PageCredentials creds, String postText, List<String> hashtags, byte[] imageData) {
validateToken(creds);
try {
String fullText = buildPostText(postText, hashtags);
MultiValueMap<String, Object> formData = new LinkedMultiValueMap<>();
formData.add("access_token", creds.pageToken);
formData.add("caption", fullText); // для фото — caption, не message
formData.add("source", new ByteArrayResource(imageData) {
@Override public String getFilename() { return "post.jpg"; }
});
String response = webClient.post()
.uri(u -> u.path("/{pageId}/photos").build(creds.pageId))
.contentType(MediaType.MULTIPART_FORM_DATA)
.body(BodyInserters.fromMultipartData(formData))
.retrieve()
.bodyToMono(String.class)
.block(Duration.ofMillis(timeoutMs * 2L));
return extractPostId(response, creds.pageId);
} catch (WebClientResponseException e) {
handleFacebookError(e);
return null;
} catch (Exception e) {
throw new RuntimeException("[Facebook] Ошибка публикации фото: " + e.getMessage(), e);
}
}
/**
* Пост с видео через /{pageId}/videos
* Facebook требует отдельный endpoint для видео!
*/
private String postWithVideo(PageCredentials creds, String postText, List<String> hashtags, byte[] videoData) {
validateToken(creds);
try {
String fullText = buildPostText(postText, hashtags);
log.info("[Facebook] Загружаю видео размером {} MB для страницы {}",
String.format("%.1f", videoData.length / 1024.0 / 1024.0), creds.pageId);
MultiValueMap<String, Object> formData = new LinkedMultiValueMap<>();
formData.add("access_token", creds.pageToken);
formData.add("description", fullText); // для видео — description
formData.add("title", extractTitle(postText)); // короткий заголовок
formData.add("source", new ByteArrayResource(videoData) {
@Override public String getFilename() { return "video.mp4"; }
});
String response = webClient.post()
.uri(u -> u.path("/{pageId}/videos").build(creds.pageId))
.contentType(MediaType.MULTIPART_FORM_DATA)
.body(BodyInserters.fromMultipartData(formData))
.retrieve()
.bodyToMono(String.class)
.block(Duration.ofMillis(timeoutMs * 6L)); // видео грузится дольше
return extractPostId(response, creds.pageId);
} catch (WebClientResponseException e) {
handleFacebookError(e);
return null;
} catch (Exception e) {
throw new RuntimeException("[Facebook] Ошибка публикации видео: " + e.getMessage(), e);
}
}
// ── Messaging ─────────────────────────────────────────────────────────────
public void sendPrivateMessage(String recipientId, String messageText) {
PageCredentials creds = getPageCredentials();
@@ -150,7 +207,8 @@ public class FacebookPostingService {
.block(Duration.ofMillis(timeoutMs));
log.info("[Facebook] Сообщение отправлено пользователю {}", recipientId);
} catch (Exception e) {
log.error("[Facebook] Ошибка отправки сообщения: {}", e.getMessage());
// Messaging ошибки не должны ломать основной флоу публикации
log.warn("[Facebook] Не удалось отправить сообщение (это не критично): {}", e.getMessage());
}
}
@@ -177,50 +235,62 @@ public class FacebookPostingService {
log.info("[Facebook] Изображение отправлено пользователю {}", recipientId);
} catch (Exception e) {
log.error("[Facebook] Ошибка отправки изображения: {}", e.getMessage());
log.warn("[Facebook] Не удалось отправить изображение в личку (это не критично): {}", e.getMessage());
}
}
// ── Internal ──────────────────────────────────────────────────────────────
// ── Token exchange ────────────────────────────────────────────────────────
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");
public String exchangeForLongLivedPageToken(String shortLivedUserToken, String targetPageId) {
log.info("[Facebook] Запуск обмена токена для страницы {}", targetPageId);
if (appId == null || appId.isBlank() || appSecret == null || appSecret.isBlank()) {
throw new IllegalStateException("Необходимо настроить facebook.app.id и facebook.app.secret");
}
try {
String fullText = buildPostText(postText, hashtags);
MultiValueMap<String, Object> formData = new LinkedMultiValueMap<>();
formData.add("access_token", creds.pageToken);
formData.add("message", fullText);
String endpoint;
if (imageData != null) {
endpoint = "/{pageId}/photos";
formData.add("source", new ByteArrayResource(imageData) {
@Override public String getFilename() { return "post.jpg"; }
});
} else {
endpoint = "/{pageId}/feed";
}
String response = webClient.post()
.uri(u -> u.path(endpoint).build(creds.pageId))
.contentType(imageData != null ? MediaType.MULTIPART_FORM_DATA : MediaType.APPLICATION_FORM_URLENCODED)
.body(BodyInserters.fromMultipartData(formData))
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(String.class)
.block(Duration.ofMillis(timeoutMs * 2));
.bodyToMono(JsonNode.class)
.block(Duration.ofMillis(timeoutMs));
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);
return null;
String longLivedUserToken = userTokenNode.path("access_token").asText();
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);
return permToken;
}
}
}
throw new RuntimeException("Страница " + targetPageId + " не найдена в аккаунте");
} catch (Exception e) {
throw new RuntimeException("[Facebook] Ошибка: " + e.getMessage(), e);
log.error("[Facebook] Ошибка обмена токена: {}", e.getMessage());
throw new RuntimeException("Ошибка Meta API: " + e.getMessage(), e);
}
}
// ── Helpers ───────────────────────────────────────────────────────────────
private void validateToken(PageCredentials creds) {
if (creds.pageToken == null || creds.pageToken.length() < 10) {
throw new IllegalStateException("Facebook Access Token не настроен! Проверьте application.properties");
}
}
@@ -235,11 +305,33 @@ public class FacebookPostingService {
return sb.toString();
}
/**
* Извлекаем короткий заголовок из текста поста (первые 100 символов до точки/переноса)
*/
private String extractTitle(String postText) {
if (postText == null || postText.isBlank()) return "Новый пост";
String[] lines = postText.split("\n");
String firstLine = lines[0].trim();
return firstLine.length() > 100 ? firstLine.substring(0, 97) + "..." : firstLine;
}
private String extractPostId(String response, String pageId) throws Exception {
JsonNode json = objectMapper.readTree(response);
String postId = json.path("id").asText(null);
if (postId == null || postId.isBlank()) {
log.warn("[Facebook] Пост опубликован, но id не получен. Response: {}", response);
return pageId + "_unknown";
}
log.info("[Facebook] Пост опубликован. id={}, pageId={}", postId, pageId);
return postId;
}
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 истёк. Получите новый через утилиту обмена.",
if (body.contains("access token") || body.contains("expired") || body.contains("463") || body.contains("467")) {
throw new FacebookTokenExpiredException(
"Токен Facebook истёк. Получите новый через утилиту обмена.",
"Token Expired", 190, 463, e);
}
throw new RuntimeException("[Facebook] API Error: " + body, e);
@@ -253,4 +345,4 @@ public class FacebookPostingService {
this.pageToken = pageToken;
}
}
}
}