.
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.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import kz.konturai.parser.exception.FacebookTokenExpiredException;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
@@ -54,7 +55,8 @@ public class FacebookPostingService {
|
||||
* @param postText Текст поста
|
||||
* @param hashtags Список хештегов
|
||||
* @return ID опубликованного поста
|
||||
* @throws RuntimeException если публикация не удалась
|
||||
* @throws FacebookTokenExpiredException если токен истек
|
||||
* @throws RuntimeException если публикация не удалась
|
||||
*/
|
||||
public String postToFacebook(String accessToken, String postText, List<String> hashtags) {
|
||||
try {
|
||||
@@ -71,8 +73,13 @@ public class FacebookPostingService {
|
||||
return postId;
|
||||
|
||||
} catch (WebClientResponseException e) {
|
||||
if (isTokenExpiredError(e)) {
|
||||
throw parseFacebookError(e);
|
||||
}
|
||||
logger.error("Facebook API error: {} - {}", e.getStatusCode(), e.getResponseBodyAsString());
|
||||
throw new RuntimeException("Failed to post to Facebook: " + e.getMessage(), e);
|
||||
} catch (FacebookTokenExpiredException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
logger.error("Unexpected error posting to Facebook", e);
|
||||
throw new RuntimeException("Failed to post to Facebook", e);
|
||||
@@ -96,11 +103,21 @@ public class FacebookPostingService {
|
||||
|
||||
JsonNode jsonNode = objectMapper.readTree(response);
|
||||
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) {
|
||||
logger.error("Failed to parse Facebook page ID response", e);
|
||||
// Если не удалось получить ID страницы, используем "me" для публикации на стене
|
||||
// пользователя
|
||||
return "me";
|
||||
} catch (FacebookTokenExpiredException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to get Facebook page ID", e);
|
||||
// Если не удалось получить ID страницы, используем "me" для публикации на стене
|
||||
@@ -114,18 +131,26 @@ public class FacebookPostingService {
|
||||
*/
|
||||
private String publishPost(String accessToken, String pageId, String message)
|
||||
throws JsonProcessingException {
|
||||
String response = webClient.post()
|
||||
.uri(uriBuilder -> uriBuilder
|
||||
.path("/{pageId}/feed")
|
||||
.queryParam("access_token", accessToken)
|
||||
.queryParam("message", message)
|
||||
.build(pageId))
|
||||
.retrieve()
|
||||
.bodyToMono(String.class)
|
||||
.block(Duration.ofMillis(timeoutMs));
|
||||
try {
|
||||
String response = webClient.post()
|
||||
.uri(uriBuilder -> uriBuilder
|
||||
.path("/{pageId}/feed")
|
||||
.queryParam("access_token", accessToken)
|
||||
.queryParam("message", message)
|
||||
.build(pageId))
|
||||
.retrieve()
|
||||
.bodyToMono(String.class)
|
||||
.block(Duration.ofMillis(timeoutMs));
|
||||
|
||||
JsonNode jsonNode = objectMapper.readTree(response);
|
||||
return jsonNode.get("id").asText();
|
||||
JsonNode jsonNode = objectMapper.readTree(response);
|
||||
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 imageContentType MIME тип изображения (например, "image/png")
|
||||
* @return ID опубликованного поста
|
||||
* @throws RuntimeException если публикация не удалась
|
||||
* @throws FacebookTokenExpiredException если токен истек
|
||||
* @throws RuntimeException если публикация не удалась
|
||||
*/
|
||||
public String postToFacebookWithImage(String accessToken, String postText, List<String> hashtags,
|
||||
byte[] imageData, String imageContentType) {
|
||||
@@ -155,8 +181,13 @@ public class FacebookPostingService {
|
||||
return postId;
|
||||
|
||||
} catch (WebClientResponseException e) {
|
||||
if (isTokenExpiredError(e)) {
|
||||
throw parseFacebookError(e);
|
||||
}
|
||||
logger.error("Facebook API error: {} - {}", e.getStatusCode(), e.getResponseBodyAsString());
|
||||
throw new RuntimeException("Failed to post to Facebook: " + e.getMessage(), e);
|
||||
} catch (FacebookTokenExpiredException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
logger.error("Unexpected error posting 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,
|
||||
byte[] imageData, String imageContentType)
|
||||
throws JsonProcessingException {
|
||||
// Создаем multipart form data для загрузки изображения
|
||||
DataBufferFactory bufferFactory = new DefaultDataBufferFactory();
|
||||
DataBuffer imageBuffer = bufferFactory.wrap(imageData);
|
||||
try {
|
||||
// Создаем multipart form data для загрузки изображения
|
||||
DataBufferFactory bufferFactory = new DefaultDataBufferFactory();
|
||||
DataBuffer imageBuffer = bufferFactory.wrap(imageData);
|
||||
|
||||
MultiValueMap<String, Object> formData = new LinkedMultiValueMap<>();
|
||||
formData.add("message", message);
|
||||
formData.add("source", imageBuffer);
|
||||
MultiValueMap<String, Object> formData = new LinkedMultiValueMap<>();
|
||||
formData.add("message", message);
|
||||
formData.add("source", imageBuffer);
|
||||
|
||||
String response = webClient.post()
|
||||
.uri(uriBuilder -> uriBuilder
|
||||
.path("/{pageId}/photos")
|
||||
.queryParam("access_token", accessToken)
|
||||
.build(pageId))
|
||||
.contentType(MediaType.MULTIPART_FORM_DATA)
|
||||
.body(BodyInserters.fromMultipartData(formData))
|
||||
.retrieve()
|
||||
.bodyToMono(String.class)
|
||||
.block(Duration.ofMillis(timeoutMs * 2)); // Увеличиваем таймаут для загрузки изображения
|
||||
String response = webClient.post()
|
||||
.uri(uriBuilder -> uriBuilder
|
||||
.path("/{pageId}/photos")
|
||||
.queryParam("access_token", accessToken)
|
||||
.build(pageId))
|
||||
.contentType(MediaType.MULTIPART_FORM_DATA)
|
||||
.body(BodyInserters.fromMultipartData(formData))
|
||||
.retrieve()
|
||||
.bodyToMono(String.class)
|
||||
.block(Duration.ofMillis(timeoutMs * 2)); // Увеличиваем таймаут для загрузки изображения
|
||||
|
||||
JsonNode jsonNode = objectMapper.readTree(response);
|
||||
String photoId = jsonNode.get("id").asText();
|
||||
JsonNode jsonNode = objectMapper.readTree(response);
|
||||
String photoId = jsonNode.get("id").asText();
|
||||
|
||||
// Возвращаем ID фото, который также является ID поста
|
||||
return photoId;
|
||||
// Возвращаем ID фото, который также является ID поста
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет, является ли ошибка истечением токена
|
||||
* 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;
|
||||
|
||||
import kz.konturai.parser.exception.FacebookTokenExpiredException;
|
||||
import kz.konturai.parser.exception.LinkedInTokenExpiredException;
|
||||
import kz.konturai.parser.model.MarketingStrategy;
|
||||
import kz.konturai.parser.model.PostingTask;
|
||||
import kz.konturai.parser.repository.MarketingStrategyRepository;
|
||||
@@ -23,6 +25,7 @@ public class PostingTaskService {
|
||||
private final MarketingStrategyRepository strategyRepository;
|
||||
private final SocialMediaCredentialsService credentialsService;
|
||||
private final FacebookPostingService facebookPostingService;
|
||||
private final LinkedInPostingService linkedInPostingService;
|
||||
private final MinIOService minIOService;
|
||||
|
||||
public PostingTaskService(
|
||||
@@ -30,11 +33,13 @@ public class PostingTaskService {
|
||||
MarketingStrategyRepository strategyRepository,
|
||||
SocialMediaCredentialsService credentialsService,
|
||||
FacebookPostingService facebookPostingService,
|
||||
LinkedInPostingService linkedInPostingService,
|
||||
MinIOService minIOService) {
|
||||
this.taskRepository = taskRepository;
|
||||
this.strategyRepository = strategyRepository;
|
||||
this.credentialsService = credentialsService;
|
||||
this.facebookPostingService = facebookPostingService;
|
||||
this.linkedInPostingService = linkedInPostingService;
|
||||
this.minIOService = minIOService;
|
||||
}
|
||||
|
||||
@@ -194,6 +199,20 @@ public class PostingTaskService {
|
||||
task.getPostText(),
|
||||
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 {
|
||||
throw new UnsupportedOperationException("Platform not supported: " + task.getPlatform());
|
||||
}
|
||||
@@ -206,6 +225,28 @@ public class PostingTaskService {
|
||||
|
||||
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) {
|
||||
logger.error("Failed to execute task {}", taskId, e);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user