target fix
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user