.
This commit is contained in:
@@ -0,0 +1,38 @@
|
|||||||
|
package kz.konturai.parser.exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Исключение, выбрасываемое когда Facebook access token истек
|
||||||
|
*/
|
||||||
|
public class FacebookTokenExpiredException extends RuntimeException {
|
||||||
|
|
||||||
|
private final String errorMessage;
|
||||||
|
private final int errorCode;
|
||||||
|
private final int errorSubcode;
|
||||||
|
|
||||||
|
public FacebookTokenExpiredException(String message, String errorMessage, int errorCode, int errorSubcode) {
|
||||||
|
super(message);
|
||||||
|
this.errorMessage = errorMessage;
|
||||||
|
this.errorCode = errorCode;
|
||||||
|
this.errorSubcode = errorSubcode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public FacebookTokenExpiredException(String message, String errorMessage, int errorCode, int errorSubcode,
|
||||||
|
Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
this.errorMessage = errorMessage;
|
||||||
|
this.errorCode = errorCode;
|
||||||
|
this.errorSubcode = errorSubcode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getErrorMessage() {
|
||||||
|
return errorMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getErrorCode() {
|
||||||
|
return errorCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getErrorSubcode() {
|
||||||
|
return errorSubcode;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package kz.konturai.parser.exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Исключение, выбрасываемое когда LinkedIn access token истек
|
||||||
|
*/
|
||||||
|
public class LinkedInTokenExpiredException extends RuntimeException {
|
||||||
|
|
||||||
|
private final String errorMessage;
|
||||||
|
private final int statusCode;
|
||||||
|
|
||||||
|
public LinkedInTokenExpiredException(String message, String errorMessage, int statusCode) {
|
||||||
|
super(message);
|
||||||
|
this.errorMessage = errorMessage;
|
||||||
|
this.statusCode = statusCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LinkedInTokenExpiredException(String message, String errorMessage, int statusCode,
|
||||||
|
Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
this.errorMessage = errorMessage;
|
||||||
|
this.statusCode = statusCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getErrorMessage() {
|
||||||
|
return errorMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getStatusCode() {
|
||||||
|
return statusCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -3,6 +3,7 @@ package kz.konturai.parser.service;
|
|||||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import kz.konturai.parser.exception.FacebookTokenExpiredException;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
@@ -54,7 +55,8 @@ public class FacebookPostingService {
|
|||||||
* @param postText Текст поста
|
* @param postText Текст поста
|
||||||
* @param hashtags Список хештегов
|
* @param hashtags Список хештегов
|
||||||
* @return ID опубликованного поста
|
* @return ID опубликованного поста
|
||||||
* @throws RuntimeException если публикация не удалась
|
* @throws FacebookTokenExpiredException если токен истек
|
||||||
|
* @throws RuntimeException если публикация не удалась
|
||||||
*/
|
*/
|
||||||
public String postToFacebook(String accessToken, String postText, List<String> hashtags) {
|
public String postToFacebook(String accessToken, String postText, List<String> hashtags) {
|
||||||
try {
|
try {
|
||||||
@@ -71,8 +73,13 @@ public class FacebookPostingService {
|
|||||||
return postId;
|
return postId;
|
||||||
|
|
||||||
} catch (WebClientResponseException e) {
|
} catch (WebClientResponseException e) {
|
||||||
|
if (isTokenExpiredError(e)) {
|
||||||
|
throw parseFacebookError(e);
|
||||||
|
}
|
||||||
logger.error("Facebook API error: {} - {}", e.getStatusCode(), e.getResponseBodyAsString());
|
logger.error("Facebook API error: {} - {}", e.getStatusCode(), e.getResponseBodyAsString());
|
||||||
throw new RuntimeException("Failed to post to Facebook: " + e.getMessage(), e);
|
throw new RuntimeException("Failed to post to Facebook: " + e.getMessage(), e);
|
||||||
|
} catch (FacebookTokenExpiredException e) {
|
||||||
|
throw e;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
logger.error("Unexpected error posting to Facebook", e);
|
logger.error("Unexpected error posting to Facebook", e);
|
||||||
throw new RuntimeException("Failed to post to Facebook", e);
|
throw new RuntimeException("Failed to post to Facebook", e);
|
||||||
@@ -96,11 +103,21 @@ public class FacebookPostingService {
|
|||||||
|
|
||||||
JsonNode jsonNode = objectMapper.readTree(response);
|
JsonNode jsonNode = objectMapper.readTree(response);
|
||||||
return jsonNode.get("id").asText();
|
return jsonNode.get("id").asText();
|
||||||
|
} catch (WebClientResponseException e) {
|
||||||
|
if (isTokenExpiredError(e)) {
|
||||||
|
throw parseFacebookError(e);
|
||||||
|
}
|
||||||
|
logger.error("Failed to get Facebook page ID: {} - {}", e.getStatusCode(), e.getResponseBodyAsString());
|
||||||
|
// Если не удалось получить ID страницы, используем "me" для публикации на стене
|
||||||
|
// пользователя
|
||||||
|
return "me";
|
||||||
} catch (JsonProcessingException e) {
|
} catch (JsonProcessingException e) {
|
||||||
logger.error("Failed to parse Facebook page ID response", e);
|
logger.error("Failed to parse Facebook page ID response", e);
|
||||||
// Если не удалось получить ID страницы, используем "me" для публикации на стене
|
// Если не удалось получить ID страницы, используем "me" для публикации на стене
|
||||||
// пользователя
|
// пользователя
|
||||||
return "me";
|
return "me";
|
||||||
|
} catch (FacebookTokenExpiredException e) {
|
||||||
|
throw e;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
logger.error("Failed to get Facebook page ID", e);
|
logger.error("Failed to get Facebook page ID", e);
|
||||||
// Если не удалось получить ID страницы, используем "me" для публикации на стене
|
// Если не удалось получить ID страницы, используем "me" для публикации на стене
|
||||||
@@ -114,18 +131,26 @@ public class FacebookPostingService {
|
|||||||
*/
|
*/
|
||||||
private String publishPost(String accessToken, String pageId, String message)
|
private String publishPost(String accessToken, String pageId, String message)
|
||||||
throws JsonProcessingException {
|
throws JsonProcessingException {
|
||||||
String response = webClient.post()
|
try {
|
||||||
.uri(uriBuilder -> uriBuilder
|
String response = webClient.post()
|
||||||
.path("/{pageId}/feed")
|
.uri(uriBuilder -> uriBuilder
|
||||||
.queryParam("access_token", accessToken)
|
.path("/{pageId}/feed")
|
||||||
.queryParam("message", message)
|
.queryParam("access_token", accessToken)
|
||||||
.build(pageId))
|
.queryParam("message", message)
|
||||||
.retrieve()
|
.build(pageId))
|
||||||
.bodyToMono(String.class)
|
.retrieve()
|
||||||
.block(Duration.ofMillis(timeoutMs));
|
.bodyToMono(String.class)
|
||||||
|
.block(Duration.ofMillis(timeoutMs));
|
||||||
|
|
||||||
JsonNode jsonNode = objectMapper.readTree(response);
|
JsonNode jsonNode = objectMapper.readTree(response);
|
||||||
return jsonNode.get("id").asText();
|
return jsonNode.get("id").asText();
|
||||||
|
} catch (WebClientResponseException e) {
|
||||||
|
if (isTokenExpiredError(e)) {
|
||||||
|
throw parseFacebookError(e);
|
||||||
|
}
|
||||||
|
logger.error("Facebook API error: {} - {}", e.getStatusCode(), e.getResponseBodyAsString());
|
||||||
|
throw new RuntimeException("Failed to publish post: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -137,7 +162,8 @@ public class FacebookPostingService {
|
|||||||
* @param imageData Данные изображения
|
* @param imageData Данные изображения
|
||||||
* @param imageContentType MIME тип изображения (например, "image/png")
|
* @param imageContentType MIME тип изображения (например, "image/png")
|
||||||
* @return ID опубликованного поста
|
* @return ID опубликованного поста
|
||||||
* @throws RuntimeException если публикация не удалась
|
* @throws FacebookTokenExpiredException если токен истек
|
||||||
|
* @throws RuntimeException если публикация не удалась
|
||||||
*/
|
*/
|
||||||
public String postToFacebookWithImage(String accessToken, String postText, List<String> hashtags,
|
public String postToFacebookWithImage(String accessToken, String postText, List<String> hashtags,
|
||||||
byte[] imageData, String imageContentType) {
|
byte[] imageData, String imageContentType) {
|
||||||
@@ -155,8 +181,13 @@ public class FacebookPostingService {
|
|||||||
return postId;
|
return postId;
|
||||||
|
|
||||||
} catch (WebClientResponseException e) {
|
} catch (WebClientResponseException e) {
|
||||||
|
if (isTokenExpiredError(e)) {
|
||||||
|
throw parseFacebookError(e);
|
||||||
|
}
|
||||||
logger.error("Facebook API error: {} - {}", e.getStatusCode(), e.getResponseBodyAsString());
|
logger.error("Facebook API error: {} - {}", e.getStatusCode(), e.getResponseBodyAsString());
|
||||||
throw new RuntimeException("Failed to post to Facebook: " + e.getMessage(), e);
|
throw new RuntimeException("Failed to post to Facebook: " + e.getMessage(), e);
|
||||||
|
} catch (FacebookTokenExpiredException e) {
|
||||||
|
throw e;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
logger.error("Unexpected error posting to Facebook", e);
|
logger.error("Unexpected error posting to Facebook", e);
|
||||||
throw new RuntimeException("Failed to post to Facebook", e);
|
throw new RuntimeException("Failed to post to Facebook", e);
|
||||||
@@ -170,30 +201,38 @@ public class FacebookPostingService {
|
|||||||
private String publishPostWithImage(String accessToken, String pageId, String message,
|
private String publishPostWithImage(String accessToken, String pageId, String message,
|
||||||
byte[] imageData, String imageContentType)
|
byte[] imageData, String imageContentType)
|
||||||
throws JsonProcessingException {
|
throws JsonProcessingException {
|
||||||
// Создаем multipart form data для загрузки изображения
|
try {
|
||||||
DataBufferFactory bufferFactory = new DefaultDataBufferFactory();
|
// Создаем multipart form data для загрузки изображения
|
||||||
DataBuffer imageBuffer = bufferFactory.wrap(imageData);
|
DataBufferFactory bufferFactory = new DefaultDataBufferFactory();
|
||||||
|
DataBuffer imageBuffer = bufferFactory.wrap(imageData);
|
||||||
|
|
||||||
MultiValueMap<String, Object> formData = new LinkedMultiValueMap<>();
|
MultiValueMap<String, Object> formData = new LinkedMultiValueMap<>();
|
||||||
formData.add("message", message);
|
formData.add("message", message);
|
||||||
formData.add("source", imageBuffer);
|
formData.add("source", imageBuffer);
|
||||||
|
|
||||||
String response = webClient.post()
|
String response = webClient.post()
|
||||||
.uri(uriBuilder -> uriBuilder
|
.uri(uriBuilder -> uriBuilder
|
||||||
.path("/{pageId}/photos")
|
.path("/{pageId}/photos")
|
||||||
.queryParam("access_token", accessToken)
|
.queryParam("access_token", accessToken)
|
||||||
.build(pageId))
|
.build(pageId))
|
||||||
.contentType(MediaType.MULTIPART_FORM_DATA)
|
.contentType(MediaType.MULTIPART_FORM_DATA)
|
||||||
.body(BodyInserters.fromMultipartData(formData))
|
.body(BodyInserters.fromMultipartData(formData))
|
||||||
.retrieve()
|
.retrieve()
|
||||||
.bodyToMono(String.class)
|
.bodyToMono(String.class)
|
||||||
.block(Duration.ofMillis(timeoutMs * 2)); // Увеличиваем таймаут для загрузки изображения
|
.block(Duration.ofMillis(timeoutMs * 2)); // Увеличиваем таймаут для загрузки изображения
|
||||||
|
|
||||||
JsonNode jsonNode = objectMapper.readTree(response);
|
JsonNode jsonNode = objectMapper.readTree(response);
|
||||||
String photoId = jsonNode.get("id").asText();
|
String photoId = jsonNode.get("id").asText();
|
||||||
|
|
||||||
// Возвращаем ID фото, который также является ID поста
|
// Возвращаем ID фото, который также является ID поста
|
||||||
return photoId;
|
return photoId;
|
||||||
|
} catch (WebClientResponseException e) {
|
||||||
|
if (isTokenExpiredError(e)) {
|
||||||
|
throw parseFacebookError(e);
|
||||||
|
}
|
||||||
|
logger.error("Facebook API error: {} - {}", e.getStatusCode(), e.getResponseBodyAsString());
|
||||||
|
throw new RuntimeException("Failed to publish post with image: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -217,4 +256,66 @@ public class FacebookPostingService {
|
|||||||
|
|
||||||
return fullText.toString();
|
return fullText.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверяет, является ли ошибка истечением токена
|
||||||
|
* Facebook возвращает код 190 с error_subcode 463 для истекших токенов
|
||||||
|
*/
|
||||||
|
private boolean isTokenExpiredError(WebClientResponseException e) {
|
||||||
|
if (e.getStatusCode().value() != 400) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
String responseBody = e.getResponseBodyAsString();
|
||||||
|
if (responseBody == null || responseBody.isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonNode errorNode = objectMapper.readTree(responseBody);
|
||||||
|
if (!errorNode.has("error")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonNode error = errorNode.get("error");
|
||||||
|
int errorCode = error.has("code") ? error.get("code").asInt() : 0;
|
||||||
|
int errorSubcode = error.has("error_subcode") ? error.get("error_subcode").asInt() : 0;
|
||||||
|
String errorType = error.has("type") ? error.get("type").asText() : "";
|
||||||
|
|
||||||
|
// Код 190 с error_subcode 463 означает истекший токен
|
||||||
|
// Также проверяем тип ошибки OAuthException
|
||||||
|
return errorCode == 190 && errorSubcode == 463 && "OAuthException".equals(errorType);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
logger.warn("Failed to parse Facebook error response", ex);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Парсит ошибку Facebook API и создает исключение для истекшего токена
|
||||||
|
*/
|
||||||
|
private FacebookTokenExpiredException parseFacebookError(WebClientResponseException e) {
|
||||||
|
try {
|
||||||
|
String responseBody = e.getResponseBodyAsString();
|
||||||
|
JsonNode errorNode = objectMapper.readTree(responseBody);
|
||||||
|
JsonNode error = errorNode.get("error");
|
||||||
|
|
||||||
|
String errorMessage = error.has("message") ? error.get("message").asText() : "Access token expired";
|
||||||
|
int errorCode = error.has("code") ? error.get("code").asInt() : 190;
|
||||||
|
int errorSubcode = error.has("error_subcode") ? error.get("error_subcode").asInt() : 463;
|
||||||
|
|
||||||
|
String userMessage = "Facebook access token has expired. Please update your credentials. " +
|
||||||
|
"Error: " + errorMessage;
|
||||||
|
|
||||||
|
return new FacebookTokenExpiredException(userMessage, errorMessage, errorCode, errorSubcode, e);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
logger.warn("Failed to parse Facebook error details", ex);
|
||||||
|
return new FacebookTokenExpiredException(
|
||||||
|
"Facebook access token has expired. Please update your credentials.",
|
||||||
|
"Access token expired",
|
||||||
|
190,
|
||||||
|
463,
|
||||||
|
e);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,372 @@
|
|||||||
|
package kz.konturai.parser.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
|
import kz.konturai.parser.exception.LinkedInTokenExpiredException;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.reactive.function.BodyInserters;
|
||||||
|
import org.springframework.web.reactive.function.client.WebClient;
|
||||||
|
import org.springframework.web.reactive.function.client.WebClientResponseException;
|
||||||
|
import reactor.netty.http.client.HttpClient;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class LinkedInPostingService {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(LinkedInPostingService.class);
|
||||||
|
private static final String LINKEDIN_API_BASE = "https://api.linkedin.com/v2";
|
||||||
|
|
||||||
|
private final WebClient webClient;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
@Value("${linkedin.api.timeout:30000}")
|
||||||
|
private int timeoutMs;
|
||||||
|
|
||||||
|
public LinkedInPostingService() {
|
||||||
|
HttpClient httpClient = HttpClient.create()
|
||||||
|
.responseTimeout(Duration.ofMillis(30000));
|
||||||
|
|
||||||
|
this.webClient = WebClient.builder()
|
||||||
|
.baseUrl(LINKEDIN_API_BASE)
|
||||||
|
.clientConnector(new ReactorClientHttpConnector(httpClient))
|
||||||
|
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
this.objectMapper = new ObjectMapper();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Публикует пост в LinkedIn через REST API v2
|
||||||
|
*
|
||||||
|
* @param accessToken Access Token пользователя
|
||||||
|
* @param postText Текст поста
|
||||||
|
* @param hashtags Список хештегов
|
||||||
|
* @return ID опубликованного поста
|
||||||
|
* @throws LinkedInTokenExpiredException если токен истек
|
||||||
|
* @throws RuntimeException если публикация не удалась
|
||||||
|
*/
|
||||||
|
public String postToLinkedIn(String accessToken, String postText, List<String> hashtags) {
|
||||||
|
try {
|
||||||
|
// Формируем полный текст поста с хештегами
|
||||||
|
String fullPostText = buildPostText(postText, hashtags);
|
||||||
|
|
||||||
|
// Получаем URN пользователя
|
||||||
|
String personUrn = getPersonUrn(accessToken);
|
||||||
|
|
||||||
|
// Публикуем пост
|
||||||
|
String postId = publishPost(accessToken, personUrn, fullPostText, null);
|
||||||
|
|
||||||
|
logger.info("Successfully posted to LinkedIn. Post ID: {}", postId);
|
||||||
|
return postId;
|
||||||
|
|
||||||
|
} catch (WebClientResponseException e) {
|
||||||
|
if (isTokenExpiredError(e)) {
|
||||||
|
throw parseLinkedInError(e);
|
||||||
|
}
|
||||||
|
logger.error("LinkedIn API error: {} - {}", e.getStatusCode(), e.getResponseBodyAsString());
|
||||||
|
throw new RuntimeException("Failed to post to LinkedIn: " + e.getMessage(), e);
|
||||||
|
} catch (LinkedInTokenExpiredException e) {
|
||||||
|
throw e;
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Unexpected error posting to LinkedIn", e);
|
||||||
|
throw new RuntimeException("Failed to post to LinkedIn", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Публикует пост в LinkedIn с изображением через REST API v2
|
||||||
|
*
|
||||||
|
* @param accessToken Access Token пользователя
|
||||||
|
* @param postText Текст поста
|
||||||
|
* @param hashtags Список хештегов
|
||||||
|
* @param imageData Данные изображения
|
||||||
|
* @param imageContentType MIME тип изображения (например, "image/png")
|
||||||
|
* @return ID опубликованного поста
|
||||||
|
* @throws LinkedInTokenExpiredException если токен истек
|
||||||
|
* @throws RuntimeException если публикация не удалась
|
||||||
|
*/
|
||||||
|
public String postToLinkedInWithImage(String accessToken, String postText, List<String> hashtags,
|
||||||
|
byte[] imageData, String imageContentType) {
|
||||||
|
try {
|
||||||
|
// Формируем полный текст поста с хештегами
|
||||||
|
String fullPostText = buildPostText(postText, hashtags);
|
||||||
|
|
||||||
|
// Получаем URN пользователя
|
||||||
|
String personUrn = getPersonUrn(accessToken);
|
||||||
|
|
||||||
|
// Загружаем изображение и получаем его URN
|
||||||
|
String imageUrn = uploadImage(accessToken, imageData, imageContentType);
|
||||||
|
|
||||||
|
// Публикуем пост с изображением
|
||||||
|
String postId = publishPost(accessToken, personUrn, fullPostText, imageUrn);
|
||||||
|
|
||||||
|
logger.info("Successfully posted to LinkedIn with image. Post ID: {}", postId);
|
||||||
|
return postId;
|
||||||
|
|
||||||
|
} catch (WebClientResponseException e) {
|
||||||
|
if (isTokenExpiredError(e)) {
|
||||||
|
throw parseLinkedInError(e);
|
||||||
|
}
|
||||||
|
logger.error("LinkedIn API error: {} - {}", e.getStatusCode(), e.getResponseBodyAsString());
|
||||||
|
throw new RuntimeException("Failed to post to LinkedIn: " + e.getMessage(), e);
|
||||||
|
} catch (LinkedInTokenExpiredException e) {
|
||||||
|
throw e;
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Unexpected error posting to LinkedIn", e);
|
||||||
|
throw new RuntimeException("Failed to post to LinkedIn", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получает URN пользователя через /me endpoint
|
||||||
|
*/
|
||||||
|
private String getPersonUrn(String accessToken) {
|
||||||
|
try {
|
||||||
|
String response = webClient.get()
|
||||||
|
.uri("/me")
|
||||||
|
.header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken)
|
||||||
|
.retrieve()
|
||||||
|
.bodyToMono(String.class)
|
||||||
|
.block(Duration.ofMillis(timeoutMs));
|
||||||
|
|
||||||
|
JsonNode jsonNode = objectMapper.readTree(response);
|
||||||
|
String id = jsonNode.get("id").asText();
|
||||||
|
|
||||||
|
// Формируем URN в формате urn:li:person:{id}
|
||||||
|
return "urn:li:person:" + id;
|
||||||
|
} catch (WebClientResponseException e) {
|
||||||
|
if (isTokenExpiredError(e)) {
|
||||||
|
throw parseLinkedInError(e);
|
||||||
|
}
|
||||||
|
logger.error("Failed to get LinkedIn person URN: {} - {}", e.getStatusCode(), e.getResponseBodyAsString());
|
||||||
|
throw new RuntimeException("Failed to get LinkedIn person URN: " + e.getMessage(), e);
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
logger.error("Failed to parse LinkedIn person URN response", e);
|
||||||
|
throw new RuntimeException("Failed to parse LinkedIn person URN response", e);
|
||||||
|
} catch (LinkedInTokenExpiredException e) {
|
||||||
|
throw e;
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Failed to get LinkedIn person URN", e);
|
||||||
|
throw new RuntimeException("Failed to get LinkedIn person URN", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Загружает изображение в LinkedIn и возвращает его URN
|
||||||
|
*/
|
||||||
|
private String uploadImage(String accessToken, byte[] imageData, String imageContentType) {
|
||||||
|
try {
|
||||||
|
// Шаг 1: Инициализируем загрузку изображения
|
||||||
|
ObjectNode initializeRequest = objectMapper.createObjectNode();
|
||||||
|
ObjectNode initializeUploadRequest = objectMapper.createObjectNode();
|
||||||
|
initializeUploadRequest.put("owner", getPersonUrn(accessToken));
|
||||||
|
initializeRequest.set("initializeUploadRequest", initializeUploadRequest);
|
||||||
|
|
||||||
|
String initializeResponse = webClient.post()
|
||||||
|
.uri("/images?action=initializeUpload")
|
||||||
|
.header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(BodyInserters.fromValue(initializeRequest.toString()))
|
||||||
|
.retrieve()
|
||||||
|
.bodyToMono(String.class)
|
||||||
|
.block(Duration.ofMillis(timeoutMs));
|
||||||
|
|
||||||
|
JsonNode initializeJson = objectMapper.readTree(initializeResponse);
|
||||||
|
JsonNode value = initializeJson.get("value");
|
||||||
|
String uploadUrl = value.get("uploadUrl").asText();
|
||||||
|
String imageUrn = value.get("image").asText();
|
||||||
|
|
||||||
|
// Шаг 2: Загружаем изображение по полученному URL
|
||||||
|
uploadImageToUrl(uploadUrl, imageData, imageContentType);
|
||||||
|
|
||||||
|
return imageUrn;
|
||||||
|
} catch (WebClientResponseException e) {
|
||||||
|
if (isTokenExpiredError(e)) {
|
||||||
|
throw parseLinkedInError(e);
|
||||||
|
}
|
||||||
|
logger.error("LinkedIn API error uploading image: {} - {}", e.getStatusCode(), e.getResponseBodyAsString());
|
||||||
|
throw new RuntimeException("Failed to upload image to LinkedIn: " + e.getMessage(), e);
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
logger.error("Failed to parse LinkedIn image upload response", e);
|
||||||
|
throw new RuntimeException("Failed to parse LinkedIn image upload response", e);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Failed to upload image to LinkedIn", e);
|
||||||
|
throw new RuntimeException("Failed to upload image to LinkedIn", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Загружает изображение по указанному URL
|
||||||
|
*/
|
||||||
|
private void uploadImageToUrl(String uploadUrl, byte[] imageData, String imageContentType) {
|
||||||
|
try {
|
||||||
|
WebClient uploadClient = WebClient.builder()
|
||||||
|
.build();
|
||||||
|
|
||||||
|
uploadClient.put()
|
||||||
|
.uri(uploadUrl)
|
||||||
|
.contentType(MediaType.parseMediaType(imageContentType))
|
||||||
|
.body(BodyInserters.fromValue(imageData))
|
||||||
|
.retrieve()
|
||||||
|
.bodyToMono(Void.class)
|
||||||
|
.block(Duration.ofMillis(timeoutMs * 2));
|
||||||
|
|
||||||
|
logger.debug("Image uploaded successfully to LinkedIn");
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Failed to upload image to LinkedIn upload URL", e);
|
||||||
|
throw new RuntimeException("Failed to upload image to LinkedIn upload URL", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Публикует пост в LinkedIn через /ugcPosts endpoint
|
||||||
|
*/
|
||||||
|
private String publishPost(String accessToken, String personUrn, String message, String imageUrn)
|
||||||
|
throws JsonProcessingException {
|
||||||
|
try {
|
||||||
|
// Создаем JSON структуру для UGC Post
|
||||||
|
ObjectNode requestBody = objectMapper.createObjectNode();
|
||||||
|
|
||||||
|
// Author
|
||||||
|
requestBody.put("author", personUrn);
|
||||||
|
|
||||||
|
// Lifecycle state
|
||||||
|
requestBody.put("lifecycleState", "PUBLISHED");
|
||||||
|
|
||||||
|
// Specific content
|
||||||
|
ObjectNode specificContent = objectMapper.createObjectNode();
|
||||||
|
ObjectNode shareContent = objectMapper.createObjectNode();
|
||||||
|
|
||||||
|
// Share commentary (текст поста)
|
||||||
|
ObjectNode shareCommentary = objectMapper.createObjectNode();
|
||||||
|
shareCommentary.put("text", message);
|
||||||
|
shareContent.set("shareCommentary", shareCommentary);
|
||||||
|
|
||||||
|
// Share media category
|
||||||
|
if (imageUrn != null && !imageUrn.isEmpty()) {
|
||||||
|
shareContent.put("shareMediaCategory", "IMAGE");
|
||||||
|
|
||||||
|
// Media
|
||||||
|
com.fasterxml.jackson.databind.node.ArrayNode mediaArray = objectMapper.createArrayNode();
|
||||||
|
ObjectNode mediaItem = objectMapper.createObjectNode();
|
||||||
|
mediaItem.put("status", "READY");
|
||||||
|
mediaItem.put("media", imageUrn);
|
||||||
|
mediaArray.add(mediaItem);
|
||||||
|
|
||||||
|
ObjectNode media = objectMapper.createObjectNode();
|
||||||
|
media.set("media", mediaArray);
|
||||||
|
shareContent.set("media", media);
|
||||||
|
} else {
|
||||||
|
shareContent.put("shareMediaCategory", "NONE");
|
||||||
|
}
|
||||||
|
|
||||||
|
specificContent.set("com.linkedin.ugc.ShareContent", shareContent);
|
||||||
|
requestBody.set("specificContent", specificContent);
|
||||||
|
|
||||||
|
// Visibility
|
||||||
|
ObjectNode visibility = objectMapper.createObjectNode();
|
||||||
|
visibility.put("com.linkedin.ugc.MemberNetworkVisibility", "PUBLIC");
|
||||||
|
requestBody.set("visibility", visibility);
|
||||||
|
|
||||||
|
String response = webClient.post()
|
||||||
|
.uri("/ugcPosts")
|
||||||
|
.header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(BodyInserters.fromValue(requestBody.toString()))
|
||||||
|
.retrieve()
|
||||||
|
.bodyToMono(String.class)
|
||||||
|
.block(Duration.ofMillis(timeoutMs));
|
||||||
|
|
||||||
|
JsonNode jsonNode = objectMapper.readTree(response);
|
||||||
|
String postId = jsonNode.get("id").asText();
|
||||||
|
|
||||||
|
return postId;
|
||||||
|
} catch (WebClientResponseException e) {
|
||||||
|
if (isTokenExpiredError(e)) {
|
||||||
|
throw parseLinkedInError(e);
|
||||||
|
}
|
||||||
|
logger.error("LinkedIn API error: {} - {}", e.getStatusCode(), e.getResponseBodyAsString());
|
||||||
|
throw new RuntimeException("Failed to publish post: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Формирует полный текст поста с хештегами
|
||||||
|
*/
|
||||||
|
private String buildPostText(String postText, List<String> hashtags) {
|
||||||
|
StringBuilder fullText = 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
return fullText.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверяет, является ли ошибка истечением токена
|
||||||
|
* LinkedIn возвращает HTTP 401 (Unauthorized) для истекших токенов
|
||||||
|
*/
|
||||||
|
private boolean isTokenExpiredError(WebClientResponseException e) {
|
||||||
|
return e.getStatusCode().value() == 401;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Парсит ошибку LinkedIn API и создает исключение для истекшего токена
|
||||||
|
*/
|
||||||
|
private LinkedInTokenExpiredException parseLinkedInError(WebClientResponseException e) {
|
||||||
|
try {
|
||||||
|
String responseBody = e.getResponseBodyAsString();
|
||||||
|
String errorMessage = "Access token expired or invalid";
|
||||||
|
|
||||||
|
if (responseBody != null && !responseBody.isEmpty()) {
|
||||||
|
try {
|
||||||
|
JsonNode errorNode = objectMapper.readTree(responseBody);
|
||||||
|
if (errorNode.has("message")) {
|
||||||
|
errorMessage = errorNode.get("message").asText();
|
||||||
|
} else if (errorNode.has("error")) {
|
||||||
|
JsonNode error = errorNode.get("error");
|
||||||
|
if (error.has("message")) {
|
||||||
|
errorMessage = error.get("message").asText();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception ex) {
|
||||||
|
logger.warn("Failed to parse LinkedIn error response", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String userMessage = "LinkedIn access token has expired. Please update your credentials. " +
|
||||||
|
"Error: " + errorMessage;
|
||||||
|
|
||||||
|
return new LinkedInTokenExpiredException(userMessage, errorMessage, e.getStatusCode().value(), e);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
logger.warn("Failed to parse LinkedIn error details", ex);
|
||||||
|
return new LinkedInTokenExpiredException(
|
||||||
|
"LinkedIn access token has expired. Please update your credentials.",
|
||||||
|
"Access token expired",
|
||||||
|
401,
|
||||||
|
e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
package kz.konturai.parser.service;
|
package kz.konturai.parser.service;
|
||||||
|
|
||||||
|
import kz.konturai.parser.exception.FacebookTokenExpiredException;
|
||||||
|
import kz.konturai.parser.exception.LinkedInTokenExpiredException;
|
||||||
import kz.konturai.parser.model.MarketingStrategy;
|
import kz.konturai.parser.model.MarketingStrategy;
|
||||||
import kz.konturai.parser.model.PostingTask;
|
import kz.konturai.parser.model.PostingTask;
|
||||||
import kz.konturai.parser.repository.MarketingStrategyRepository;
|
import kz.konturai.parser.repository.MarketingStrategyRepository;
|
||||||
@@ -23,6 +25,7 @@ public class PostingTaskService {
|
|||||||
private final MarketingStrategyRepository strategyRepository;
|
private final MarketingStrategyRepository strategyRepository;
|
||||||
private final SocialMediaCredentialsService credentialsService;
|
private final SocialMediaCredentialsService credentialsService;
|
||||||
private final FacebookPostingService facebookPostingService;
|
private final FacebookPostingService facebookPostingService;
|
||||||
|
private final LinkedInPostingService linkedInPostingService;
|
||||||
private final MinIOService minIOService;
|
private final MinIOService minIOService;
|
||||||
|
|
||||||
public PostingTaskService(
|
public PostingTaskService(
|
||||||
@@ -30,11 +33,13 @@ public class PostingTaskService {
|
|||||||
MarketingStrategyRepository strategyRepository,
|
MarketingStrategyRepository strategyRepository,
|
||||||
SocialMediaCredentialsService credentialsService,
|
SocialMediaCredentialsService credentialsService,
|
||||||
FacebookPostingService facebookPostingService,
|
FacebookPostingService facebookPostingService,
|
||||||
|
LinkedInPostingService linkedInPostingService,
|
||||||
MinIOService minIOService) {
|
MinIOService minIOService) {
|
||||||
this.taskRepository = taskRepository;
|
this.taskRepository = taskRepository;
|
||||||
this.strategyRepository = strategyRepository;
|
this.strategyRepository = strategyRepository;
|
||||||
this.credentialsService = credentialsService;
|
this.credentialsService = credentialsService;
|
||||||
this.facebookPostingService = facebookPostingService;
|
this.facebookPostingService = facebookPostingService;
|
||||||
|
this.linkedInPostingService = linkedInPostingService;
|
||||||
this.minIOService = minIOService;
|
this.minIOService = minIOService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,6 +199,20 @@ public class PostingTaskService {
|
|||||||
task.getPostText(),
|
task.getPostText(),
|
||||||
task.getHashtags());
|
task.getHashtags());
|
||||||
}
|
}
|
||||||
|
} else if ("linkedin".equalsIgnoreCase(task.getPlatform())) {
|
||||||
|
if (imageData != null && imageData.length > 0) {
|
||||||
|
postId = linkedInPostingService.postToLinkedInWithImage(
|
||||||
|
credentials,
|
||||||
|
task.getPostText(),
|
||||||
|
task.getHashtags(),
|
||||||
|
imageData,
|
||||||
|
"image/png");
|
||||||
|
} else {
|
||||||
|
postId = linkedInPostingService.postToLinkedIn(
|
||||||
|
credentials,
|
||||||
|
task.getPostText(),
|
||||||
|
task.getHashtags());
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
throw new UnsupportedOperationException("Platform not supported: " + task.getPlatform());
|
throw new UnsupportedOperationException("Platform not supported: " + task.getPlatform());
|
||||||
}
|
}
|
||||||
@@ -206,6 +225,28 @@ public class PostingTaskService {
|
|||||||
|
|
||||||
logger.info("Task {} executed successfully. Post ID: {}", taskId, postId);
|
logger.info("Task {} executed successfully. Post ID: {}", taskId, postId);
|
||||||
|
|
||||||
|
} catch (FacebookTokenExpiredException e) {
|
||||||
|
logger.error("Failed to execute task {}: Facebook access token has expired", taskId, e);
|
||||||
|
|
||||||
|
// Обновляем статус на failed с понятным сообщением об истекшем токене
|
||||||
|
task.setStatus("failed");
|
||||||
|
task.setExecutedAt(LocalDateTime.now());
|
||||||
|
task.setErrorMessage(
|
||||||
|
"Facebook access token has expired. Please update your Facebook credentials in the social media settings. "
|
||||||
|
+
|
||||||
|
e.getErrorMessage());
|
||||||
|
taskRepository.save(task);
|
||||||
|
} catch (LinkedInTokenExpiredException e) {
|
||||||
|
logger.error("Failed to execute task {}: LinkedIn access token has expired", taskId, e);
|
||||||
|
|
||||||
|
// Обновляем статус на failed с понятным сообщением об истекшем токене
|
||||||
|
task.setStatus("failed");
|
||||||
|
task.setExecutedAt(LocalDateTime.now());
|
||||||
|
task.setErrorMessage(
|
||||||
|
"LinkedIn access token has expired. Please update your LinkedIn credentials in the social media settings. "
|
||||||
|
+
|
||||||
|
e.getErrorMessage());
|
||||||
|
taskRepository.save(task);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
logger.error("Failed to execute task {}", taskId, e);
|
logger.error("Failed to execute task {}", taskId, e);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user