블로그 게시판 관련 java 수정
This commit is contained in:
parent
38cb5608d8
commit
42f26ea8d7
@ -0,0 +1,327 @@
|
||||
package com.itn.admin.cmn.util.slack;
|
||||
|
||||
import com.itn.admin.itn.blog.mapper.domain.BlogScheduleVO;
|
||||
import com.itn.admin.itn.blog.mapper.domain.BlogScheduleExecutionVO;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.*;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 범용 Slack 알림 서비스
|
||||
* 블로그 예약 발행 시스템을 위한 다양한 알림 기능을 제공
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class SlackNotificationService {
|
||||
|
||||
private final RestTemplate restTemplate;
|
||||
|
||||
@Value("${slack.notification.enabled:false}")
|
||||
private boolean notificationEnabled;
|
||||
|
||||
@Value("${slack.webhook.default:}")
|
||||
private String defaultWebhookUrl;
|
||||
|
||||
@Value("${slack.webhook.blog-schedule:}")
|
||||
private String blogScheduleWebhookUrl;
|
||||
|
||||
@Value("${slack.webhook.system-alert:}")
|
||||
private String systemAlertWebhookUrl;
|
||||
|
||||
@Value("${slack.channel.default:general}")
|
||||
private String defaultChannel;
|
||||
|
||||
@Value("${slack.channel.blog-schedule:blog-alerts}")
|
||||
private String blogScheduleChannel;
|
||||
|
||||
@Value("${slack.channel.system-alert:system-alerts}")
|
||||
private String systemAlertChannel;
|
||||
|
||||
@Value("${slack.username:ITN-Admin}")
|
||||
private String username;
|
||||
|
||||
@Value("${slack.icon.emoji::robot_face:}")
|
||||
private String iconEmoji;
|
||||
|
||||
private static final DateTimeFormatter DATETIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
|
||||
public SlackNotificationService(RestTemplate restTemplate) {
|
||||
this.restTemplate = restTemplate;
|
||||
}
|
||||
|
||||
/**
|
||||
* 예약 생성 알림
|
||||
*/
|
||||
@Async
|
||||
public void sendScheduleCreatedNotification(BlogScheduleVO schedule) {
|
||||
if (!notificationEnabled || !schedule.isNotificationEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
String title = "📅 새로운 블로그 예약이 생성되었습니다";
|
||||
String message = buildScheduleCreatedMessage(schedule);
|
||||
String webhookUrl = getWebhookUrl("blog-schedule");
|
||||
String channel = getChannelForSchedule(schedule);
|
||||
|
||||
sendNotification(webhookUrl, channel, title, message, ":calendar:");
|
||||
}
|
||||
|
||||
/**
|
||||
* 발행 성공 알림
|
||||
*/
|
||||
@Async
|
||||
public void sendPublishSuccessNotification(BlogScheduleVO schedule, BlogScheduleExecutionVO execution) {
|
||||
if (!notificationEnabled || !schedule.isNotificationEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
String title = "✅ 블로그 발행이 성공했습니다";
|
||||
String message = buildPublishSuccessMessage(schedule, execution);
|
||||
String webhookUrl = getWebhookUrl("blog-schedule");
|
||||
String channel = getChannelForSchedule(schedule);
|
||||
|
||||
sendNotification(webhookUrl, channel, title, message, ":white_check_mark:");
|
||||
}
|
||||
|
||||
/**
|
||||
* 발행 실패 알림
|
||||
*/
|
||||
@Async
|
||||
public void sendPublishFailureNotification(BlogScheduleVO schedule, BlogScheduleExecutionVO execution) {
|
||||
if (!notificationEnabled || !schedule.isNotificationEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
String title = "❌ 블로그 발행이 실패했습니다";
|
||||
String message = buildPublishFailureMessage(schedule, execution);
|
||||
String webhookUrl = getWebhookUrl("blog-schedule");
|
||||
String channel = getChannelForSchedule(schedule);
|
||||
|
||||
sendNotification(webhookUrl, channel, title, message, ":x:");
|
||||
}
|
||||
|
||||
/**
|
||||
* 재시도 알림
|
||||
*/
|
||||
@Async
|
||||
public void sendRetryNotification(BlogScheduleVO schedule, BlogScheduleExecutionVO execution, int retryCount) {
|
||||
if (!notificationEnabled || !schedule.isNotificationEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
String title = "🔄 블로그 발행 재시도 중";
|
||||
String message = buildRetryMessage(schedule, execution, retryCount);
|
||||
String webhookUrl = getWebhookUrl("blog-schedule");
|
||||
String channel = getChannelForSchedule(schedule);
|
||||
|
||||
sendNotification(webhookUrl, channel, title, message, ":arrows_counterclockwise:");
|
||||
}
|
||||
|
||||
/**
|
||||
* 시스템 오류 알림
|
||||
*/
|
||||
@Async
|
||||
public void sendSystemErrorNotification(String component, String errorMessage, Exception exception) {
|
||||
if (!notificationEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
String title = "🚨 시스템 오류 발생";
|
||||
String message = buildSystemErrorMessage(component, errorMessage, exception);
|
||||
String webhookUrl = getWebhookUrl("system-alert");
|
||||
|
||||
sendNotification(webhookUrl, systemAlertChannel, title, message, ":rotating_light:");
|
||||
}
|
||||
|
||||
/**
|
||||
* 스케줄러 상태 알림
|
||||
*/
|
||||
@Async
|
||||
public void sendSchedulerStatusNotification(String status, int activeSchedules, int runningTasks) {
|
||||
if (!notificationEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
String title = "📊 스케줄러 상태 보고";
|
||||
String message = buildSchedulerStatusMessage(status, activeSchedules, runningTasks);
|
||||
String webhookUrl = getWebhookUrl("system-alert");
|
||||
|
||||
sendNotification(webhookUrl, systemAlertChannel, title, message, ":bar_chart:");
|
||||
}
|
||||
|
||||
/**
|
||||
* 커스텀 메시지 발송
|
||||
*/
|
||||
@Async
|
||||
public void sendCustomMessage(String channel, String title, String message, String emoji) {
|
||||
if (!notificationEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
String webhookUrl = getWebhookUrl("default");
|
||||
sendNotification(webhookUrl, channel, title, message, emoji);
|
||||
}
|
||||
|
||||
/**
|
||||
* 핵심 알림 발송 메서드
|
||||
*/
|
||||
private void sendNotification(String webhookUrl, String channel, String title, String message, String emoji) {
|
||||
if (webhookUrl == null || webhookUrl.trim().isEmpty() || webhookUrl.contains("YOUR_")) {
|
||||
log.warn("Slack webhook URL이 설정되지 않았습니다. 알림을 건너뜁니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Map<String, Object> payload = new HashMap<>();
|
||||
payload.put("channel", channel);
|
||||
payload.put("username", username);
|
||||
payload.put("icon_emoji", emoji != null ? emoji : iconEmoji);
|
||||
payload.put("text", title + "\n" + message);
|
||||
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
|
||||
HttpEntity<Map<String, Object>> request = new HttpEntity<>(payload, headers);
|
||||
ResponseEntity<String> response = restTemplate.postForEntity(webhookUrl, request, String.class);
|
||||
|
||||
if (response.getStatusCode() == HttpStatus.OK) {
|
||||
log.debug("Slack 알림 발송 성공: {} to {}", title, channel);
|
||||
} else {
|
||||
log.warn("Slack 알림 발송 실패: {} - {}", response.getStatusCode(), response.getBody());
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Slack 알림 발송 중 오류 발생: {}", e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
// 메시지 빌더 메서드들
|
||||
private String buildScheduleCreatedMessage(BlogScheduleVO schedule) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("**제목:** ").append(schedule.getTitle()).append("\n");
|
||||
sb.append("**블로그:** ").append(schedule.getBlogName()).append("\n");
|
||||
sb.append("**유형:** ").append(getScheduleTypeKorean(schedule.getScheduleType())).append("\n");
|
||||
sb.append("**예약 시간:** ").append(schedule.getScheduledAt().format(DATETIME_FORMATTER)).append("\n");
|
||||
sb.append("**우선순위:** ").append(getPriorityKorean(schedule.getPriority())).append("\n");
|
||||
if (schedule.isRecurring()) {
|
||||
sb.append("**반복 설정:** ").append(getRepeatIntervalKorean(schedule.getRepeatInterval())).append("\n");
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private String buildPublishSuccessMessage(BlogScheduleVO schedule, BlogScheduleExecutionVO execution) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("**제목:** ").append(schedule.getTitle()).append("\n");
|
||||
sb.append("**블로그:** ").append(schedule.getBlogName()).append("\n");
|
||||
sb.append("**실행 시간:** ").append(execution.getExecutedAt().format(DATETIME_FORMATTER)).append("\n");
|
||||
if (execution.getExecutionTimeMs() != null) {
|
||||
sb.append("**소요 시간:** ").append(execution.getExecutionTimeMs()).append("ms\n");
|
||||
}
|
||||
if (execution.getPublishedUrl() != null) {
|
||||
sb.append("**발행 URL:** ").append(execution.getPublishedUrl()).append("\n");
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private String buildPublishFailureMessage(BlogScheduleVO schedule, BlogScheduleExecutionVO execution) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("**제목:** ").append(schedule.getTitle()).append("\n");
|
||||
sb.append("**블로그:** ").append(schedule.getBlogName()).append("\n");
|
||||
sb.append("**실행 시간:** ").append(execution.getExecutedAt().format(DATETIME_FORMATTER)).append("\n");
|
||||
sb.append("**시도 횟수:** ").append(execution.getAttemptCount()).append("/").append(schedule.getMaxRetries()).append("\n");
|
||||
if (execution.getErrorDetails() != null) {
|
||||
sb.append("**오류 내용:** ").append(execution.getErrorDetails()).append("\n");
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private String buildRetryMessage(BlogScheduleVO schedule, BlogScheduleExecutionVO execution, int retryCount) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("**제목:** ").append(schedule.getTitle()).append("\n");
|
||||
sb.append("**블로그:** ").append(schedule.getBlogName()).append("\n");
|
||||
sb.append("**재시도 횟수:** ").append(retryCount).append("/").append(schedule.getMaxRetries()).append("\n");
|
||||
sb.append("**다음 재시도:** ").append(schedule.getRetryInterval()).append("분 후\n");
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private String buildSystemErrorMessage(String component, String errorMessage, Exception exception) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("**컴포넌트:** ").append(component).append("\n");
|
||||
sb.append("**오류 메시지:** ").append(errorMessage).append("\n");
|
||||
if (exception != null) {
|
||||
sb.append("**예외 유형:** ").append(exception.getClass().getSimpleName()).append("\n");
|
||||
sb.append("**상세 내용:** ").append(exception.getMessage()).append("\n");
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private String buildSchedulerStatusMessage(String status, int activeSchedules, int runningTasks) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("**스케줄러 상태:** ").append(status).append("\n");
|
||||
sb.append("**활성 스케줄:** ").append(activeSchedules).append("개\n");
|
||||
sb.append("**실행 중인 작업:** ").append(runningTasks).append("개\n");
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
// 유틸리티 메서드들
|
||||
private String getWebhookUrl(String type) {
|
||||
switch (type) {
|
||||
case "blog-schedule":
|
||||
return blogScheduleWebhookUrl.isEmpty() ? defaultWebhookUrl : blogScheduleWebhookUrl;
|
||||
case "system-alert":
|
||||
return systemAlertWebhookUrl.isEmpty() ? defaultWebhookUrl : systemAlertWebhookUrl;
|
||||
default:
|
||||
return defaultWebhookUrl;
|
||||
}
|
||||
}
|
||||
|
||||
private String getChannelForSchedule(BlogScheduleVO schedule) {
|
||||
if (schedule.getSlackChannel() != null && !schedule.getSlackChannel().trim().isEmpty()) {
|
||||
return schedule.getSlackChannel();
|
||||
}
|
||||
return blogScheduleChannel;
|
||||
}
|
||||
|
||||
private String getScheduleTypeKorean(String scheduleType) {
|
||||
switch (scheduleType) {
|
||||
case "ONE_TIME": return "일회성";
|
||||
case "RECURRING": return "반복";
|
||||
default: return scheduleType;
|
||||
}
|
||||
}
|
||||
|
||||
private String getPriorityKorean(String priority) {
|
||||
switch (priority) {
|
||||
case "HIGH": return "높음";
|
||||
case "NORMAL": return "보통";
|
||||
case "LOW": return "낮음";
|
||||
default: return priority;
|
||||
}
|
||||
}
|
||||
|
||||
private String getRepeatIntervalKorean(String interval) {
|
||||
switch (interval) {
|
||||
case "DAILY": return "매일";
|
||||
case "WEEKLY": return "매주";
|
||||
case "MONTHLY": return "매월";
|
||||
default: return interval;
|
||||
}
|
||||
}
|
||||
|
||||
// 설정 확인 메서드
|
||||
public boolean isNotificationEnabled() {
|
||||
return notificationEnabled;
|
||||
}
|
||||
|
||||
public void setNotificationEnabled(boolean enabled) {
|
||||
this.notificationEnabled = enabled;
|
||||
log.info("Slack 알림 상태 변경: {}", enabled ? "활성화" : "비활성화");
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,212 @@
|
||||
package com.itn.admin.cmn.util.tistory;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.itn.admin.itn.blog.mapper.domain.BlogCookieMappingVO;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
public class TistoryCookieUtil {
|
||||
|
||||
private static final String COOKIE_FILE_PATH = "docs/munjaon_tstory_state.json";
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
/**
|
||||
* 쿠키 상태 JSON 파일에서 쿠키 문자열을 생성합니다.
|
||||
*
|
||||
* @return 쿠키 문자열 (예: "name1=value1; name2=value2")
|
||||
*/
|
||||
public String getCookieString() {
|
||||
try {
|
||||
ClassPathResource resource = new ClassPathResource(COOKIE_FILE_PATH);
|
||||
JsonNode rootNode = objectMapper.readTree(resource.getInputStream());
|
||||
JsonNode cookiesNode = rootNode.get("cookies");
|
||||
|
||||
if (cookiesNode == null || !cookiesNode.isArray()) {
|
||||
log.warn("쿠키 정보를 찾을 수 없습니다.");
|
||||
return "";
|
||||
}
|
||||
|
||||
List<String> cookieList = new ArrayList<>();
|
||||
for (JsonNode cookieNode : cookiesNode) {
|
||||
String name = cookieNode.get("name").asText();
|
||||
String value = cookieNode.get("value").asText();
|
||||
|
||||
// 유효한 쿠키만 추가 (빈 값이나 null 제외)
|
||||
if (name != null && !name.trim().isEmpty() &&
|
||||
value != null && !value.trim().isEmpty()) {
|
||||
cookieList.add(name + "=" + value);
|
||||
}
|
||||
}
|
||||
|
||||
String cookieString = String.join("; ", cookieList);
|
||||
log.info("티스토리 쿠키 문자열 생성 완료: {} 개 쿠키", cookieList.size());
|
||||
|
||||
return cookieString;
|
||||
|
||||
} catch (IOException e) {
|
||||
log.error("쿠키 파일 읽기 실패: {}", COOKIE_FILE_PATH, e);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 도메인의 쿠키만 필터링하여 반환합니다.
|
||||
*
|
||||
* @param domain 필터링할 도메인 (예: "tistory.com")
|
||||
* @return 해당 도메인의 쿠키 문자열
|
||||
*/
|
||||
public String getCookieStringForDomain(String domain) {
|
||||
try {
|
||||
ClassPathResource resource = new ClassPathResource(COOKIE_FILE_PATH);
|
||||
JsonNode rootNode = objectMapper.readTree(resource.getInputStream());
|
||||
JsonNode cookiesNode = rootNode.get("cookies");
|
||||
|
||||
if (cookiesNode == null || !cookiesNode.isArray()) {
|
||||
log.warn("쿠키 정보를 찾을 수 없습니다.");
|
||||
return "";
|
||||
}
|
||||
|
||||
List<String> cookieList = new ArrayList<>();
|
||||
for (JsonNode cookieNode : cookiesNode) {
|
||||
String cookieDomain = cookieNode.get("domain").asText();
|
||||
String name = cookieNode.get("name").asText();
|
||||
String value = cookieNode.get("value").asText();
|
||||
|
||||
// 도메인이 일치하고 유효한 쿠키만 추가
|
||||
if (cookieDomain.contains(domain) &&
|
||||
name != null && !name.trim().isEmpty() &&
|
||||
value != null && !value.trim().isEmpty()) {
|
||||
cookieList.add(name + "=" + value);
|
||||
}
|
||||
}
|
||||
|
||||
String cookieString = String.join("; ", cookieList);
|
||||
log.info("티스토리 도메인({}) 쿠키 문자열 생성 완료: {} 개 쿠키", domain, cookieList.size());
|
||||
|
||||
return cookieString;
|
||||
|
||||
} catch (IOException e) {
|
||||
log.error("쿠키 파일 읽기 실패: {}", COOKIE_FILE_PATH, e);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 티스토리 전용 쿠키 문자열을 반환합니다.
|
||||
*
|
||||
* @return 티스토리 관련 쿠키 문자열
|
||||
*/
|
||||
public String getTistoryCookieString() {
|
||||
return getCookieStringForDomain("tistory.com");
|
||||
}
|
||||
|
||||
/**
|
||||
* 블로그 계정별 쿠키 파일에서 쿠키 문자열 반환
|
||||
*/
|
||||
public String getCookieStringForBlog(BlogCookieMappingVO cookieMapping) {
|
||||
return getCookieStringForBlog(cookieMapping, "tistory.com");
|
||||
}
|
||||
|
||||
/**
|
||||
* 블로그 계정별 쿠키 파일에서 특정 도메인의 쿠키 문자열 반환
|
||||
*/
|
||||
public String getCookieStringForBlog(BlogCookieMappingVO cookieMapping, String domain) {
|
||||
if (cookieMapping == null || !cookieMapping.isActiveCookie()) {
|
||||
throw new RuntimeException("유효하지 않은 쿠키 매핑 정보입니다.");
|
||||
}
|
||||
|
||||
if (cookieMapping.isCookieExpired()) {
|
||||
throw new RuntimeException("쿠키가 만료되었습니다. 재로그인이 필요합니다.");
|
||||
}
|
||||
|
||||
try {
|
||||
String fullPath = cookieMapping.getFullCookieFilePath();
|
||||
File cookieFile = new File(fullPath);
|
||||
|
||||
if (!cookieFile.exists()) {
|
||||
throw new RuntimeException("쿠키 파일을 찾을 수 없습니다: " + fullPath);
|
||||
}
|
||||
|
||||
JsonNode rootNode = objectMapper.readTree(cookieFile);
|
||||
return parseCookieStringFromJson(rootNode, domain);
|
||||
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("쿠키 파일 읽기 실패: " + cookieMapping.getFullCookieFilePath(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 쿠키 파일 경로로부터 직접 쿠키 문자열 반환
|
||||
*/
|
||||
public String getCookieStringFromFile(String cookieFilePath, String domain) {
|
||||
try {
|
||||
File cookieFile = new File(cookieFilePath);
|
||||
|
||||
if (!cookieFile.exists()) {
|
||||
throw new RuntimeException("쿠키 파일을 찾을 수 없습니다: " + cookieFilePath);
|
||||
}
|
||||
|
||||
JsonNode rootNode = objectMapper.readTree(cookieFile);
|
||||
return parseCookieStringFromJson(rootNode, domain);
|
||||
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("쿠키 파일 읽기 실패: " + cookieFilePath, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 쿠키 파일 유효성 검증
|
||||
*/
|
||||
public boolean validateCookieFile(BlogCookieMappingVO cookieMapping) {
|
||||
try {
|
||||
String cookieString = getCookieStringForBlog(cookieMapping);
|
||||
return cookieString != null && !cookieString.trim().isEmpty() &&
|
||||
cookieString.contains("TSSESSION"); // 티스토리 세션 쿠키 필수
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("쿠키 파일 유효성 검증 실패: {}", cookieMapping.getFullCookieFilePath(), e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON 노드에서 쿠키 문자열 파싱
|
||||
*/
|
||||
private String parseCookieStringFromJson(JsonNode rootNode, String domain) {
|
||||
JsonNode cookiesNode = rootNode.get("cookies");
|
||||
|
||||
if (cookiesNode == null || !cookiesNode.isArray()) {
|
||||
throw new RuntimeException("쿠키 데이터 형식이 올바르지 않습니다.");
|
||||
}
|
||||
|
||||
List<String> cookieList = new ArrayList<>();
|
||||
|
||||
for (JsonNode cookieNode : cookiesNode) {
|
||||
String cookieDomain = cookieNode.get("domain").asText();
|
||||
String cookieName = cookieNode.get("name").asText();
|
||||
String cookieValue = cookieNode.get("value").asText();
|
||||
|
||||
// 도메인 매칭 (점으로 시작하는 도메인 포함)
|
||||
if (cookieDomain.contains(domain) || cookieDomain.endsWith("." + domain)) {
|
||||
if (cookieName != null && !cookieName.trim().isEmpty() &&
|
||||
cookieValue != null && !cookieValue.trim().isEmpty()) {
|
||||
cookieList.add(cookieName + "=" + cookieValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String cookieString = String.join("; ", cookieList);
|
||||
log.info("도메인({}) 쿠키 문자열 생성 완료: {} 개 쿠키", domain, cookieList.size());
|
||||
|
||||
return cookieString;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,89 @@
|
||||
package com.itn.admin.itn.blog.mapper.domain;
|
||||
|
||||
import lombok.*;
|
||||
|
||||
import jakarta.validation.constraints.*;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@ToString
|
||||
public class ScheduleCreateRequestDTO {
|
||||
|
||||
@NotNull(message = "블로그 ID는 필수입니다")
|
||||
private Long blogId; // 블로그 계정 ID
|
||||
|
||||
@NotNull(message = "소스 URL ID는 필수입니다")
|
||||
private Long urlId; // 소스 URL ID
|
||||
|
||||
@NotBlank(message = "제목은 필수입니다")
|
||||
@Size(max = 255, message = "제목은 255자를 초과할 수 없습니다")
|
||||
private String title; // 예약 제목
|
||||
|
||||
@Size(max = 65535, message = "내용이 너무 깁니다")
|
||||
private String content; // 예약 내용
|
||||
|
||||
// 스케줄링 설정
|
||||
@NotBlank(message = "스케줄 유형은 필수입니다")
|
||||
@Pattern(regexp = "^(ONE_TIME|RECURRING)$", message = "스케줄 유형은 ONE_TIME 또는 RECURRING이어야 합니다")
|
||||
private String scheduleType; // 스케줄 유형
|
||||
|
||||
@NotNull(message = "예약 실행 시간은 필수입니다")
|
||||
@Future(message = "예약 실행 시간은 현재 시간보다 이후여야 합니다")
|
||||
private LocalDateTime scheduledAt; // 예약 실행 시간
|
||||
|
||||
@Pattern(regexp = "^(DAILY|WEEKLY|MONTHLY)$", message = "반복 간격은 DAILY, WEEKLY, MONTHLY 중 하나여야 합니다")
|
||||
private String repeatInterval; // 반복 간격 (RECURRING일 때만)
|
||||
|
||||
@Min(value = 1, message = "반복 주기 값은 1 이상이어야 합니다")
|
||||
@Max(value = 365, message = "반복 주기 값은 365 이하여야 합니다")
|
||||
private Integer repeatValue; // 반복 주기 값
|
||||
|
||||
private LocalDateTime endAt; // 반복 종료 시간 (RECURRING일 때만)
|
||||
|
||||
// 상태 및 우선순위
|
||||
@Pattern(regexp = "^(ACTIVE|INACTIVE)$", message = "상태는 ACTIVE 또는 INACTIVE여야 합니다")
|
||||
private String status = "ACTIVE"; // 스케줄 상태 (기본값: ACTIVE)
|
||||
|
||||
@Pattern(regexp = "^(HIGH|NORMAL|LOW)$", message = "우선순위는 HIGH, NORMAL, LOW 중 하나여야 합니다")
|
||||
private String priority = "NORMAL"; // 실행 우선순위 (기본값: NORMAL)
|
||||
|
||||
// 재시도 설정
|
||||
@Min(value = 0, message = "최대 재시도 횟수는 0 이상이어야 합니다")
|
||||
@Max(value = 10, message = "최대 재시도 횟수는 10 이하여야 합니다")
|
||||
private Integer maxRetries = 3; // 최대 재시도 횟수 (기본값: 3)
|
||||
|
||||
@Min(value = 1, message = "재시도 간격은 1분 이상이어야 합니다")
|
||||
@Max(value = 1440, message = "재시도 간격은 1440분(24시간) 이하여야 합니다")
|
||||
private Integer retryInterval = 5; // 재시도 간격 (분, 기본값: 5)
|
||||
|
||||
// 알림 설정
|
||||
@Email(message = "올바른 이메일 형식이 아닙니다")
|
||||
@Size(max = 255, message = "이메일은 255자를 초과할 수 없습니다")
|
||||
private String notificationEmail; // 알림 이메일
|
||||
|
||||
@Size(max = 100, message = "슬랙 채널명은 100자를 초과할 수 없습니다")
|
||||
@Pattern(regexp = "^[#@]?[a-z0-9-_]+$", message = "올바른 슬랙 채널명 형식이 아닙니다", flags = Pattern.Flag.CASE_INSENSITIVE)
|
||||
private String slackChannel; // 슬랙 채널명
|
||||
|
||||
private Boolean enableNotification = false; // 알림 활성화 여부 (기본값: false)
|
||||
|
||||
// 검증 메서드들
|
||||
@AssertTrue(message = "RECURRING 스케줄의 경우 반복 간격이 필요합니다")
|
||||
private boolean isRepeatIntervalValidForRecurring() {
|
||||
if ("RECURRING".equals(scheduleType)) {
|
||||
return repeatInterval != null && !repeatInterval.trim().isEmpty();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@AssertTrue(message = "반복 종료 시간은 예약 실행 시간보다 이후여야 합니다")
|
||||
private boolean isEndAtAfterScheduledAt() {
|
||||
if (endAt != null && scheduledAt != null) {
|
||||
return endAt.isAfter(scheduledAt);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,107 @@
|
||||
package com.itn.admin.itn.blog.mapper.domain;
|
||||
|
||||
import com.itn.admin.cmn.vo.CmnVO;
|
||||
import lombok.*;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@ToString
|
||||
public class ScheduleSearchDTO extends CmnVO {
|
||||
|
||||
// 기본 검색 조건
|
||||
private Long scheduleId; // 특정 스케줄 ID
|
||||
private Long blogId; // 블로그 계정 ID
|
||||
private Long urlId; // 소스 URL ID
|
||||
private String title; // 제목 (부분 일치)
|
||||
|
||||
// 스케줄 유형 및 상태
|
||||
private String scheduleType; // ONE_TIME 또는 RECURRING
|
||||
private String status; // ACTIVE, INACTIVE, COMPLETED, FAILED
|
||||
private String priority; // HIGH, NORMAL, LOW
|
||||
private List<String> statusList; // 여러 상태 조건 (IN 절용)
|
||||
|
||||
// 시간 범위 검색
|
||||
private LocalDateTime scheduledAtFrom; // 예약 시간 시작
|
||||
private LocalDateTime scheduledAtTo; // 예약 시간 종료
|
||||
private LocalDateTime createdAtFrom; // 생성일 시작
|
||||
private LocalDateTime createdAtTo; // 생성일 종료
|
||||
|
||||
// 실행 관련 검색
|
||||
private Boolean hasExecutions; // 실행 이력 존재 여부
|
||||
private String lastExecutionStatus; // 마지막 실행 상태
|
||||
private LocalDateTime lastExecutedFrom; // 마지막 실행일 시작
|
||||
private LocalDateTime lastExecutedTo; // 마지막 실행일 종료
|
||||
|
||||
// 알림 설정 검색
|
||||
private Boolean enableNotification; // 알림 활성화 여부
|
||||
private String notificationEmail; // 알림 이메일 (부분 일치)
|
||||
private String slackChannel; // 슬랙 채널 (부분 일치)
|
||||
|
||||
// 등록자/수정자 검색
|
||||
private String frstRegisterId; // 등록자 ID
|
||||
private String lastUpdusrId; // 수정자 ID
|
||||
|
||||
// 정렬 조건
|
||||
private String sortBy = "scheduled_at"; // 정렬 기준 (scheduled_at, created_at, priority 등)
|
||||
private String sortOrder = "DESC"; // 정렬 순서 (ASC, DESC)
|
||||
|
||||
// 통계 조건
|
||||
private Boolean includeStatistics = false; // 통계 정보 포함 여부
|
||||
private Boolean includeExecutionCount = false; // 실행 횟수 포함 여부
|
||||
|
||||
// 편의 메서드들
|
||||
public boolean hasTimeRange() {
|
||||
return scheduledAtFrom != null || scheduledAtTo != null;
|
||||
}
|
||||
|
||||
public boolean hasCreatedAtRange() {
|
||||
return createdAtFrom != null || createdAtTo != null;
|
||||
}
|
||||
|
||||
public boolean hasLastExecutedRange() {
|
||||
return lastExecutedFrom != null || lastExecutedTo != null;
|
||||
}
|
||||
|
||||
public boolean hasMultipleStatus() {
|
||||
return statusList != null && !statusList.isEmpty();
|
||||
}
|
||||
|
||||
public boolean hasNotificationFilter() {
|
||||
return enableNotification != null ||
|
||||
(notificationEmail != null && !notificationEmail.trim().isEmpty()) ||
|
||||
(slackChannel != null && !slackChannel.trim().isEmpty());
|
||||
}
|
||||
|
||||
// 검색 조건 검증
|
||||
public boolean isValidDateRange() {
|
||||
if (scheduledAtFrom != null && scheduledAtTo != null) {
|
||||
return !scheduledAtFrom.isAfter(scheduledAtTo);
|
||||
}
|
||||
if (createdAtFrom != null && createdAtTo != null) {
|
||||
return !createdAtFrom.isAfter(createdAtTo);
|
||||
}
|
||||
if (lastExecutedFrom != null && lastExecutedTo != null) {
|
||||
return !lastExecutedFrom.isAfter(lastExecutedTo);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// 정렬 조건 검증
|
||||
public boolean isValidSortBy() {
|
||||
if (sortBy == null) return false;
|
||||
List<String> validSortFields = List.of(
|
||||
"schedule_id", "blog_id", "title", "scheduled_at",
|
||||
"status", "priority", "frst_regist_dt", "last_updt_dt"
|
||||
);
|
||||
return validSortFields.contains(sortBy.toLowerCase());
|
||||
}
|
||||
|
||||
public boolean isValidSortOrder() {
|
||||
return "ASC".equalsIgnoreCase(sortOrder) || "DESC".equalsIgnoreCase(sortOrder);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,147 @@
|
||||
package com.itn.admin.itn.blog.mapper.domain;
|
||||
|
||||
import lombok.*;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@ToString
|
||||
public class ScheduleStatisticsDTO {
|
||||
|
||||
// 전체 통계
|
||||
private Integer totalSchedules; // 전체 스케줄 수
|
||||
private Integer activeSchedules; // 활성 스케줄 수
|
||||
private Integer inactiveSchedules; // 비활성 스케줄 수
|
||||
private Integer completedSchedules; // 완료된 스케줄 수
|
||||
private Integer failedSchedules; // 실패한 스케줄 수
|
||||
|
||||
// 스케줄 유형별 통계
|
||||
private Integer oneTimeSchedules; // 일회성 스케줄 수
|
||||
private Integer recurringSchedules; // 반복 스케줄 수
|
||||
|
||||
// 우선순위별 통계
|
||||
private Integer highPrioritySchedules; // 높은 우선순위 스케줄 수
|
||||
private Integer normalPrioritySchedules; // 일반 우선순위 스케줄 수
|
||||
private Integer lowPrioritySchedules; // 낮은 우선순위 스케줄 수
|
||||
|
||||
// 실행 통계
|
||||
private Integer totalExecutions; // 전체 실행 횟수
|
||||
private Integer successfulExecutions; // 성공한 실행 횟수
|
||||
private Integer failedExecutions; // 실패한 실행 횟수
|
||||
private Integer pendingExecutions; // 대기 중인 실행 횟수
|
||||
private Integer runningExecutions; // 실행 중인 작업 수
|
||||
|
||||
// 성공률 통계
|
||||
private Double successRate; // 전체 성공률 (%)
|
||||
private Double todaySuccessRate; // 오늘 성공률 (%)
|
||||
private Double weeklySuccessRate; // 주간 성공률 (%)
|
||||
private Double monthlySuccessRate; // 월간 성공률 (%)
|
||||
|
||||
// 시간 관련 통계
|
||||
private LocalDateTime lastExecutionTime; // 마지막 실행 시간
|
||||
private LocalDateTime nextScheduledTime; // 다음 예정 실행 시간
|
||||
private Integer schedulesTodayCount; // 오늘 예정된 스케줄 수
|
||||
private Integer schedulesThisWeekCount; // 이번 주 예정된 스케줄 수
|
||||
|
||||
// 성능 통계
|
||||
private Double averageExecutionTimeMs; // 평균 실행 시간 (밀리초)
|
||||
private Long maxExecutionTimeMs; // 최대 실행 시간 (밀리초)
|
||||
private Long minExecutionTimeMs; // 최소 실행 시간 (밀리초)
|
||||
|
||||
// 재시도 통계
|
||||
private Integer totalRetries; // 전체 재시도 횟수
|
||||
private Double averageRetriesPerFailure; // 실패당 평균 재시도 횟수
|
||||
|
||||
// 알림 통계
|
||||
private Integer enabledNotificationCount; // 알림 활성화된 스케줄 수
|
||||
private Integer emailNotificationCount; // 이메일 알림 설정 수
|
||||
private Integer slackNotificationCount; // 슬랙 알림 설정 수
|
||||
|
||||
// 블로그별 통계 (상위 N개)
|
||||
private List<BlogScheduleStatsDTO> topBlogStats; // 블로그별 통계
|
||||
|
||||
// 시간대별 실행 통계 (24시간)
|
||||
private List<HourlyExecutionStatsDTO> hourlyStats; // 시간대별 실행 통계
|
||||
|
||||
// 일별 실행 통계 (최근 7일)
|
||||
private List<DailyExecutionStatsDTO> dailyStats; // 일별 실행 통계
|
||||
|
||||
// 내부 클래스들
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@ToString
|
||||
public static class BlogScheduleStatsDTO {
|
||||
private Long blogId;
|
||||
private String blogName;
|
||||
private Integer scheduleCount;
|
||||
private Integer successCount;
|
||||
private Integer failureCount;
|
||||
private Double successRate;
|
||||
}
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@ToString
|
||||
public static class HourlyExecutionStatsDTO {
|
||||
private Integer hour; // 시간 (0-23)
|
||||
private Integer executionCount; // 실행 횟수
|
||||
private Integer successCount; // 성공 횟수
|
||||
private Integer failureCount; // 실패 횟수
|
||||
}
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@ToString
|
||||
public static class DailyExecutionStatsDTO {
|
||||
private String date; // 날짜 (YYYY-MM-DD)
|
||||
private Integer executionCount; // 실행 횟수
|
||||
private Integer successCount; // 성공 횟수
|
||||
private Integer failureCount; // 실패 횟수
|
||||
private Double successRate; // 성공률
|
||||
}
|
||||
|
||||
// 편의 메서드들
|
||||
public double calculateSuccessRate() {
|
||||
if (totalExecutions == null || totalExecutions == 0) {
|
||||
return 0.0;
|
||||
}
|
||||
return (successfulExecutions != null ? successfulExecutions : 0) * 100.0 / totalExecutions;
|
||||
}
|
||||
|
||||
public double calculateFailureRate() {
|
||||
if (totalExecutions == null || totalExecutions == 0) {
|
||||
return 0.0;
|
||||
}
|
||||
return (failedExecutions != null ? failedExecutions : 0) * 100.0 / totalExecutions;
|
||||
}
|
||||
|
||||
public boolean hasActiveSchedules() {
|
||||
return activeSchedules != null && activeSchedules > 0;
|
||||
}
|
||||
|
||||
public boolean hasFailedSchedules() {
|
||||
return failedSchedules != null && failedSchedules > 0;
|
||||
}
|
||||
|
||||
public boolean hasRunningExecutions() {
|
||||
return runningExecutions != null && runningExecutions > 0;
|
||||
}
|
||||
|
||||
public String getPerformanceSummary() {
|
||||
if (averageExecutionTimeMs == null) {
|
||||
return "성능 데이터 없음";
|
||||
}
|
||||
return String.format("평균: %.1fms, 최대: %dms, 최소: %dms",
|
||||
averageExecutionTimeMs, maxExecutionTimeMs, minExecutionTimeMs);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,96 @@
|
||||
package com.itn.admin.itn.blog.mapper.domain;
|
||||
|
||||
import lombok.*;
|
||||
|
||||
import jakarta.validation.constraints.*;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@ToString
|
||||
public class ScheduleUpdateRequestDTO {
|
||||
|
||||
@NotNull(message = "스케줄 ID는 필수입니다")
|
||||
private Long scheduleId; // 예약 ID (수정 대상)
|
||||
|
||||
@Size(max = 255, message = "제목은 255자를 초과할 수 없습니다")
|
||||
private String title; // 예약 제목
|
||||
|
||||
@Size(max = 65535, message = "내용이 너무 깁니다")
|
||||
private String content; // 예약 내용
|
||||
|
||||
// 스케줄링 설정 (수정 가능한 항목들만)
|
||||
private LocalDateTime scheduledAt; // 예약 실행 시간
|
||||
|
||||
@Pattern(regexp = "^(DAILY|WEEKLY|MONTHLY)$", message = "반복 간격은 DAILY, WEEKLY, MONTHLY 중 하나여야 합니다")
|
||||
private String repeatInterval; // 반복 간격
|
||||
|
||||
@Min(value = 1, message = "반복 주기 값은 1 이상이어야 합니다")
|
||||
@Max(value = 365, message = "반복 주기 값은 365 이하여야 합니다")
|
||||
private Integer repeatValue; // 반복 주기 값
|
||||
|
||||
private LocalDateTime endAt; // 반복 종료 시간
|
||||
|
||||
// 상태 및 우선순위
|
||||
@Pattern(regexp = "^(ACTIVE|INACTIVE|COMPLETED|FAILED)$", message = "상태는 ACTIVE, INACTIVE, COMPLETED, FAILED 중 하나여야 합니다")
|
||||
private String status; // 스케줄 상태
|
||||
|
||||
@Pattern(regexp = "^(HIGH|NORMAL|LOW)$", message = "우선순위는 HIGH, NORMAL, LOW 중 하나여야 합니다")
|
||||
private String priority; // 실행 우선순위
|
||||
|
||||
// 재시도 설정
|
||||
@Min(value = 0, message = "최대 재시도 횟수는 0 이상이어야 합니다")
|
||||
@Max(value = 10, message = "최대 재시도 횟수는 10 이하여야 합니다")
|
||||
private Integer maxRetries; // 최대 재시도 횟수
|
||||
|
||||
@Min(value = 1, message = "재시도 간격은 1분 이상이어야 합니다")
|
||||
@Max(value = 1440, message = "재시도 간격은 1440분(24시간) 이하여야 합니다")
|
||||
private Integer retryInterval; // 재시도 간격 (분)
|
||||
|
||||
// 알림 설정
|
||||
@Email(message = "올바른 이메일 형식이 아닙니다")
|
||||
@Size(max = 255, message = "이메일은 255자를 초과할 수 없습니다")
|
||||
private String notificationEmail; // 알림 이메일
|
||||
|
||||
@Size(max = 100, message = "슬랙 채널명은 100자를 초과할 수 없습니다")
|
||||
@Pattern(regexp = "^[#@]?[a-z0-9-_]+$", message = "올바른 슬랙 채널명 형식이 아닙니다", flags = Pattern.Flag.CASE_INSENSITIVE)
|
||||
private String slackChannel; // 슬랙 채널명
|
||||
|
||||
private Boolean enableNotification; // 알림 활성화 여부
|
||||
|
||||
// 검증 메서드들
|
||||
@AssertTrue(message = "반복 종료 시간은 예약 실행 시간보다 이후여야 합니다")
|
||||
private boolean isEndAtAfterScheduledAt() {
|
||||
if (endAt != null && scheduledAt != null) {
|
||||
return endAt.isAfter(scheduledAt);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// 수정 가능한 필드인지 확인하는 편의 메서드들
|
||||
public boolean hasTitle() {
|
||||
return title != null && !title.trim().isEmpty();
|
||||
}
|
||||
|
||||
public boolean hasContent() {
|
||||
return content != null;
|
||||
}
|
||||
|
||||
public boolean hasScheduledAt() {
|
||||
return scheduledAt != null;
|
||||
}
|
||||
|
||||
public boolean hasStatus() {
|
||||
return status != null && !status.trim().isEmpty();
|
||||
}
|
||||
|
||||
public boolean hasPriority() {
|
||||
return priority != null && !priority.trim().isEmpty();
|
||||
}
|
||||
|
||||
public boolean hasNotificationSettings() {
|
||||
return notificationEmail != null || slackChannel != null || enableNotification != null;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,49 @@
|
||||
package com.itn.admin.itn.blog.service;
|
||||
|
||||
import com.itn.admin.itn.blog.mapper.domain.BlogPostHistoryVO;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 티스토리 자동 배포 서비스 인터페이스
|
||||
*/
|
||||
public interface TistoryPublishService {
|
||||
|
||||
/**
|
||||
* 티스토리 블로그에 포스트를 자동 발행합니다.
|
||||
*
|
||||
* @param title HTML 제목
|
||||
* @param htmlContent HTML 내용
|
||||
* @param blogId 블로그 ID (blog_accounts 테이블의 blog_id)
|
||||
* @return 발행 결과 (BlogPostHistoryVO)
|
||||
*/
|
||||
BlogPostHistoryVO publishToTistory(String title, String htmlContent, Long blogId);
|
||||
|
||||
/**
|
||||
* 티스토리 블로그에 포스트를 자동 발행합니다. (소스 URL 포함)
|
||||
*
|
||||
* @param title HTML 제목
|
||||
* @param htmlContent HTML 내용
|
||||
* @param sourceUrl 원본 소스 URL
|
||||
* @param blogId 블로그 ID
|
||||
* @return 발행 결과 (BlogPostHistoryVO)
|
||||
*/
|
||||
BlogPostHistoryVO publishToTistory(String title, String htmlContent, String sourceUrl, Long blogId);
|
||||
|
||||
/**
|
||||
* HTML 생성부터 티스토리 발행까지 통합 워크플로우로 처리합니다.
|
||||
*
|
||||
* @param blogId 블로그 ID
|
||||
* @param urlId 소스 URL ID
|
||||
* @param sourceUrl 원본 소스 URL
|
||||
* @return 발행 결과 (BlogPostHistoryVO)
|
||||
*/
|
||||
BlogPostHistoryVO publishWithHtmlGeneration(Long blogId, Long urlId, String sourceUrl);
|
||||
|
||||
/**
|
||||
* Python 블로그 생성 서비스 상태를 확인합니다.
|
||||
*
|
||||
* @return 서비스 상태 정보 (가용성, 응답시간, 메시지 등)
|
||||
*/
|
||||
Map<String, Object> checkBlogGenerationServiceStatus();
|
||||
}
|
||||
@ -0,0 +1,889 @@
|
||||
package com.itn.admin.itn.blog.service.impl;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.itn.admin.cmn.util.tistory.TistoryCookieUtil;
|
||||
import com.itn.admin.itn.blog.mapper.BlogPostingMapper;
|
||||
import com.itn.admin.itn.blog.mapper.domain.BlogCookieMappingVO;
|
||||
import com.itn.admin.itn.blog.mapper.domain.BlogPostHistoryVO;
|
||||
import com.itn.admin.itn.blog.service.BlogCookieService;
|
||||
import com.itn.admin.itn.blog.service.TistoryPublishService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.jsoup.Jsoup;
|
||||
import org.jsoup.nodes.Document;
|
||||
import org.jsoup.nodes.Element;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.*;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.LinkedMultiValueMap;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Transactional
|
||||
public class TistoryPublishServiceImpl implements TistoryPublishService {
|
||||
|
||||
private final TistoryCookieUtil cookieUtil;
|
||||
private final BlogPostingMapper blogPostingMapper;
|
||||
private final BlogCookieService blogCookieService;
|
||||
private final RestTemplate restTemplate = new RestTemplate();
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@Value("${blog.generate.url:http://192.168.0.78:5000/blog/generate}")
|
||||
private String blogGenerateUrl;
|
||||
|
||||
// 티스토리 관리 페이지 URL
|
||||
private static final String TISTORY_ADMIN_URL = "https://munjaon.tistory.com/admin/entry/post";
|
||||
private static final String TISTORY_POST_SAVE_URL = "https://munjaon.tistory.com/admin/entry/post";
|
||||
|
||||
@Override
|
||||
public BlogPostHistoryVO publishToTistory(String title, String htmlContent, Long blogId) {
|
||||
return publishToTistory(title, htmlContent, null, blogId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public BlogPostHistoryVO publishToTistory(String title, String htmlContent, String sourceUrl, Long blogId) {
|
||||
log.info("티스토리 포스트 발행 시작: title={}, blogId={}, sourceUrl={}", title, blogId, sourceUrl);
|
||||
|
||||
BlogPostHistoryVO historyVO = new BlogPostHistoryVO();
|
||||
historyVO.setBlogId(blogId);
|
||||
historyVO.setPostTitle(title);
|
||||
historyVO.setPostContent(htmlContent);
|
||||
historyVO.setPublishedAt(LocalDateTime.now());
|
||||
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
try {
|
||||
// 1. 티스토리 관리 페이지에서 필요한 토큰들을 가져옵니다 (DB 기반 쿠키 사용)
|
||||
String[] tokens = getTistoryTokens(blogId);
|
||||
if (tokens == null || tokens.length < 2) {
|
||||
log.warn("블로그별 쿠키로 토큰 획득 실패, 기본 쿠키로 재시도: blogId={}", blogId);
|
||||
tokens = getTistoryTokens(null); // 기본 쿠키로 폴백
|
||||
if (tokens == null || tokens.length < 2) {
|
||||
throw new RuntimeException("티스토리 토큰 획득 실패");
|
||||
}
|
||||
}
|
||||
|
||||
String xsrfToken = tokens[0];
|
||||
String blogIdFromPage = tokens[1];
|
||||
|
||||
// 2. 포스트 데이터를 티스토리에 전송합니다 (DB 기반 쿠키 사용)
|
||||
String publishedUrl = publishPost(title, htmlContent, xsrfToken, blogIdFromPage, blogId);
|
||||
|
||||
if (StringUtils.hasText(publishedUrl)) {
|
||||
// 성공
|
||||
historyVO.setStatus(BlogPostHistoryVO.STATUS_SUCCESS);
|
||||
historyVO.setPublishedUrl(publishedUrl);
|
||||
|
||||
log.info("티스토리 포스트 발행 성공: title={}, publishedUrl={}", title, publishedUrl);
|
||||
} else {
|
||||
throw new RuntimeException("발행 URL을 가져올 수 없습니다.");
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("티스토리 포스트 발행 실패: title={}, error={}", title, e.getMessage(), e);
|
||||
|
||||
// 실패
|
||||
historyVO.setStatus(BlogPostHistoryVO.STATUS_FAILED);
|
||||
historyVO.setErrorMessage(e.getMessage());
|
||||
}
|
||||
|
||||
// 3. 히스토리 저장
|
||||
blogPostingMapper.insertBlogPostHistory(historyVO);
|
||||
|
||||
long endTime = System.currentTimeMillis();
|
||||
log.info("티스토리 포스트 발행 완료: title={}, status={}, 소요시간={}ms",
|
||||
title, historyVO.getStatus(), (endTime - startTime));
|
||||
|
||||
return historyVO;
|
||||
}
|
||||
|
||||
/**
|
||||
* Python 블로그 생성 서비스 상태를 확인합니다.
|
||||
*/
|
||||
public Map<String, Object> checkBlogGenerationServiceStatus() {
|
||||
Map<String, Object> status = new HashMap<>();
|
||||
|
||||
try {
|
||||
// 간단한 헬스 체크 요청
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
|
||||
HttpEntity<String> requestEntity = new HttpEntity<>("{\"test\": true}", headers);
|
||||
|
||||
long startTime = System.currentTimeMillis();
|
||||
ResponseEntity<String> response = restTemplate.exchange(
|
||||
blogGenerateUrl,
|
||||
HttpMethod.POST,
|
||||
requestEntity,
|
||||
String.class
|
||||
);
|
||||
long responseTime = System.currentTimeMillis() - startTime;
|
||||
|
||||
status.put("available", true);
|
||||
status.put("responseTime", responseTime);
|
||||
status.put("statusCode", response.getStatusCode().value());
|
||||
status.put("message", "서비스가 정상적으로 응답합니다.");
|
||||
|
||||
} catch (Exception e) {
|
||||
status.put("available", false);
|
||||
status.put("responseTime", -1);
|
||||
status.put("error", e.getMessage());
|
||||
|
||||
if (e.getMessage() != null && e.getMessage().contains("Connection")) {
|
||||
status.put("message", "블로그 생성 서비스에 연결할 수 없습니다. 네트워크 또는 서비스 상태를 확인해주세요.");
|
||||
} else {
|
||||
status.put("message", "블로그 생성 서비스에서 오류가 발생했습니다: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
status.put("serviceUrl", blogGenerateUrl);
|
||||
status.put("checkedAt", LocalDateTime.now());
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
/**
|
||||
* 티스토리 관리 페이지에서 필요한 토큰들을 추출합니다.
|
||||
*
|
||||
* @return [xsrfToken, blogId]
|
||||
*/
|
||||
private String[] getTistoryTokens() {
|
||||
return getTistoryTokens(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 블로그 계정별 쿠키를 사용하여 티스토리 관리 페이지에서 필요한 토큰들을 추출합니다.
|
||||
*
|
||||
* @param blogAccountId 블로그 계정 ID (null이면 기본 쿠키 사용)
|
||||
* @return [xsrfToken, blogId]
|
||||
*/
|
||||
private String[] getTistoryTokens(Long blogAccountId) {
|
||||
try {
|
||||
// 최대 2번 시도 (첫 번째 실패시 쿠키 갱신 후 재시도)
|
||||
for (int attempt = 1; attempt <= 2; attempt++) {
|
||||
log.info("티스토리 토큰 획득 시도 {}/2", attempt);
|
||||
|
||||
HttpHeaders headers;
|
||||
if (blogAccountId != null) {
|
||||
headers = createHeadersForBlog(blogAccountId);
|
||||
log.info("블로그별 쿠키로 티스토리 토큰 요청: blogAccountId={}", blogAccountId);
|
||||
} else {
|
||||
headers = createHeaders();
|
||||
log.info("기본 쿠키로 티스토리 토큰 요청");
|
||||
}
|
||||
HttpEntity<String> entity = new HttpEntity<>(headers);
|
||||
|
||||
log.info("티스토리 관리 페이지 접속: {}", TISTORY_ADMIN_URL);
|
||||
ResponseEntity<String> response = restTemplate.exchange(
|
||||
TISTORY_ADMIN_URL,
|
||||
HttpMethod.GET,
|
||||
entity,
|
||||
String.class
|
||||
);
|
||||
|
||||
if (response.getStatusCode() != HttpStatus.OK) {
|
||||
log.error("티스토리 관리 페이지 접속 실패: {}", response.getStatusCode());
|
||||
if (attempt == 2) return null;
|
||||
continue;
|
||||
}
|
||||
|
||||
String pageContent = response.getBody();
|
||||
if (pageContent == null) {
|
||||
log.error("티스토리 관리 페이지 내용이 비어있습니다.");
|
||||
if (attempt == 2) return null;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 로그인 페이지로 리다이렉션 되었는지 먼저 확인
|
||||
if (isLoginPage(pageContent)) {
|
||||
log.warn("티스토리 로그인 페이지로 리다이렉션됨. 쿠키가 만료된 것으로 보입니다.");
|
||||
|
||||
if (attempt == 1) {
|
||||
log.info("쿠키 갱신을 시도합니다...");
|
||||
if (blogAccountId != null) {
|
||||
blogCookieService.validateAndUpdateCookie(blogAccountId);
|
||||
}
|
||||
continue; // 재시도
|
||||
} else {
|
||||
log.error("쿠키 갱신 후에도 로그인 페이지로 리다이렉션됩니다. 수동 로그인이 필요할 수 있습니다.");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// XSRF 토큰 추출
|
||||
String xsrfToken = extractXsrfToken(pageContent);
|
||||
if (xsrfToken == null) {
|
||||
log.error("XSRF 토큰을 찾을 수 없습니다. HTML 내용 디버깅을 시작합니다.");
|
||||
debugTokenExtraction(pageContent);
|
||||
if (attempt == 2) return null;
|
||||
continue; // 재시도
|
||||
}
|
||||
|
||||
// 블로그 ID 추출
|
||||
String blogIdFromPage = extractBlogId(pageContent);
|
||||
if (blogIdFromPage == null) {
|
||||
log.error("블로그 ID를 찾을 수 없습니다.");
|
||||
if (attempt == 2) return null;
|
||||
continue; // 재시도
|
||||
}
|
||||
|
||||
log.info("티스토리 토큰 추출 성공: xsrfToken={}, blogId={}",
|
||||
xsrfToken.substring(0, Math.min(10, xsrfToken.length())) + "...", blogIdFromPage);
|
||||
|
||||
return new String[]{xsrfToken, blogIdFromPage};
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("티스토리 토큰 획득 중 오류 발생", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인 페이지인지 확인합니다.
|
||||
*/
|
||||
private boolean isLoginPage(String html) {
|
||||
String[] loginIndicators = {
|
||||
"login", "sign-in", "로그인", "accounts.kakao.com",
|
||||
"oauth", "authentication", "tistory.com/auth",
|
||||
"password", "비밀번호", "로그인하기"
|
||||
};
|
||||
|
||||
String lowerHtml = html.toLowerCase();
|
||||
for (String indicator : loginIndicators) {
|
||||
if (lowerHtml.contains(indicator.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML에서 XSRF 토큰을 추출합니다.
|
||||
*/
|
||||
private String extractXsrfToken(String html) {
|
||||
// 다양한 XSRF 토큰 패턴들을 시도합니다
|
||||
Pattern[] patterns = {
|
||||
// 기본 input 필드 패턴들
|
||||
Pattern.compile("name=[\"'](?:XSRF-TOKEN|_token|csrf-token)[\"']\\s+value=[\"']([^\"']+)[\"']", Pattern.CASE_INSENSITIVE),
|
||||
Pattern.compile("value=[\"']([^\"']+)[\"']\\s+name=[\"'](?:XSRF-TOKEN|_token|csrf-token)[\"']", Pattern.CASE_INSENSITIVE),
|
||||
|
||||
// meta 태그 패턴들
|
||||
Pattern.compile("<meta\\s+name=[\"'](?:csrf-token|_token|XSRF-TOKEN)[\"']\\s+content=[\"']([^\"']+)[\"']", Pattern.CASE_INSENSITIVE),
|
||||
Pattern.compile("<meta\\s+content=[\"']([^\"']+)[\"']\\s+name=[\"'](?:csrf-token|_token|XSRF-TOKEN)[\"']", Pattern.CASE_INSENSITIVE),
|
||||
|
||||
// JavaScript 변수 패턴들
|
||||
Pattern.compile("(?:csrf_token|xsrfToken|_token)\\s*[:=]\\s*[\"']([^\"']+)[\"']", Pattern.CASE_INSENSITIVE),
|
||||
Pattern.compile("window\\._token\\s*=\\s*[\"']([^\"']+)[\"']", Pattern.CASE_INSENSITIVE),
|
||||
|
||||
// 티스토리 특화 패턴들
|
||||
Pattern.compile("data-csrf-token=[\"']([^\"']+)[\"']", Pattern.CASE_INSENSITIVE),
|
||||
Pattern.compile("csrf[\"']?\\s*[:=]\\s*[\"']([^\"']+)[\"']", Pattern.CASE_INSENSITIVE)
|
||||
};
|
||||
|
||||
for (Pattern pattern : patterns) {
|
||||
Matcher matcher = pattern.matcher(html);
|
||||
if (matcher.find()) {
|
||||
String token = matcher.group(1);
|
||||
log.info("XSRF 토큰 발견: 패턴={}, 토큰={}", pattern.pattern(), token.substring(0, Math.min(10, token.length())) + "...");
|
||||
return token;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* XSRF 토큰 추출 실패 시 디버깅을 위한 메서드
|
||||
*/
|
||||
private void debugTokenExtraction(String html) {
|
||||
log.debug("HTML 응답 길이: {} characters", html.length());
|
||||
|
||||
// HTML의 일부분을 로깅 (보안상 전체는 X)
|
||||
if (html.length() > 0) {
|
||||
String preview = html.length() > 500 ? html.substring(0, 500) + "..." : html;
|
||||
log.debug("HTML 응답 미리보기: {}", preview);
|
||||
}
|
||||
|
||||
// 가능한 토큰 관련 키워드들 검색
|
||||
String[] keywords = {"token", "csrf", "xsrf", "_token", "csrf-token", "XSRF-TOKEN"};
|
||||
for (String keyword : keywords) {
|
||||
if (html.toLowerCase().contains(keyword.toLowerCase())) {
|
||||
log.info("키워드 '{}' 발견됨", keyword);
|
||||
|
||||
// 해당 키워드 주변 내용 추출
|
||||
int index = html.toLowerCase().indexOf(keyword.toLowerCase());
|
||||
if (index >= 0) {
|
||||
int start = Math.max(0, index - 50);
|
||||
int end = Math.min(html.length(), index + keyword.length() + 100);
|
||||
String context = html.substring(start, end);
|
||||
log.info("키워드 '{}' 주변 내용: {}", keyword, context);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 로그인 페이지로 리다이렉션 되었는지 확인
|
||||
if (html.contains("login") || html.contains("sign-in") || html.contains("로그인")) {
|
||||
log.error("티스토리 로그인 페이지로 리다이렉션된 것으로 보입니다. 쿠키가 만료되었을 가능성이 있습니다.");
|
||||
}
|
||||
|
||||
// 페이지 제목 확인
|
||||
Pattern titlePattern = Pattern.compile("<title>([^<]+)</title>", Pattern.CASE_INSENSITIVE);
|
||||
Matcher titleMatcher = titlePattern.matcher(html);
|
||||
if (titleMatcher.find()) {
|
||||
log.info("페이지 제목: {}", titleMatcher.group(1));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML에서 블로그 ID를 추출합니다.
|
||||
*/
|
||||
private String extractBlogId(String html) {
|
||||
// 다양한 패턴으로 블로그 ID를 찾습니다
|
||||
Pattern[] patterns = {
|
||||
Pattern.compile("blogId[\"']?\\s*[:=]\\s*[\"']?(\\d+)[\"']?"),
|
||||
Pattern.compile("blog_id[\"']?\\s*[:=]\\s*[\"']?(\\d+)[\"']?"),
|
||||
Pattern.compile("data-blog-id=[\"']?(\\d+)[\"']?"),
|
||||
Pattern.compile("/admin/entry/post\\?(.*&)?blogId=(\\d+)")
|
||||
};
|
||||
|
||||
for (Pattern pattern : patterns) {
|
||||
Matcher matcher = pattern.matcher(html);
|
||||
if (matcher.find()) {
|
||||
// 그룹 2가 있으면 그것을 사용, 없으면 그룹 1 사용
|
||||
return matcher.groupCount() >= 2 && matcher.group(2) != null ?
|
||||
matcher.group(2) : matcher.group(1);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 실제로 포스트를 발행합니다.
|
||||
*/
|
||||
private String publishPost(String title, String htmlContent, String xsrfToken, String blogId) {
|
||||
return publishPost(title, htmlContent, xsrfToken, blogId, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 블로그 계정별 쿠키를 사용하여 실제로 포스트를 발행합니다.
|
||||
*/
|
||||
private String publishPost(String title, String htmlContent, String xsrfToken, String blogId, Long blogAccountId) {
|
||||
try {
|
||||
HttpHeaders headers;
|
||||
if (blogAccountId != null) {
|
||||
headers = createHeadersForBlog(blogAccountId);
|
||||
} else {
|
||||
headers = createHeaders();
|
||||
}
|
||||
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
|
||||
|
||||
// 폼 데이터 구성
|
||||
MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
|
||||
formData.add("XSRF-TOKEN", xsrfToken);
|
||||
formData.add("blogId", blogId);
|
||||
formData.add("title", title);
|
||||
formData.add("content", htmlContent);
|
||||
formData.add("visibility", "3"); // 공개 발행
|
||||
formData.add("acceptComment", "1"); // 댓글 허용
|
||||
formData.add("category", "0"); // 기본 카테고리
|
||||
formData.add("tag", ""); // 태그 없음
|
||||
formData.add("published", "1"); // 즉시 발행
|
||||
|
||||
HttpEntity<MultiValueMap<String, String>> entity = new HttpEntity<>(formData, headers);
|
||||
|
||||
log.info("티스토리 포스트 저장 요청: title={}, blogId={}", title, blogId);
|
||||
ResponseEntity<String> response = restTemplate.exchange(
|
||||
TISTORY_POST_SAVE_URL,
|
||||
HttpMethod.POST,
|
||||
entity,
|
||||
String.class
|
||||
);
|
||||
|
||||
if (response.getStatusCode() == HttpStatus.OK ||
|
||||
response.getStatusCode() == HttpStatus.FOUND) {
|
||||
|
||||
// 성공 시 발행된 URL 추출
|
||||
String publishedUrl = extractPublishedUrl(response);
|
||||
if (publishedUrl == null) {
|
||||
// 기본 URL 패턴으로 생성
|
||||
publishedUrl = "https://munjaon.tistory.com/entry/" +
|
||||
title.replaceAll("[^a-zA-Z0-9가-힣]", "-");
|
||||
}
|
||||
|
||||
return publishedUrl;
|
||||
} else {
|
||||
log.error("티스토리 포스트 저장 실패: status={}", response.getStatusCode());
|
||||
return null;
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("티스토리 포스트 저장 중 오류 발생", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 응답에서 발행된 URL을 추출합니다.
|
||||
*/
|
||||
private String extractPublishedUrl(ResponseEntity<String> response) {
|
||||
// Location 헤더에서 리다이렉트 URL 확인
|
||||
String location = response.getHeaders().getFirst(HttpHeaders.LOCATION);
|
||||
if (StringUtils.hasText(location)) {
|
||||
return location;
|
||||
}
|
||||
|
||||
// 응답 본문에서 URL 패턴 찾기
|
||||
String body = response.getBody();
|
||||
if (StringUtils.hasText(body)) {
|
||||
Pattern pattern = Pattern.compile("https://munjaon\\.tistory\\.com/\\d+");
|
||||
Matcher matcher = pattern.matcher(body);
|
||||
if (matcher.find()) {
|
||||
return matcher.group();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP 헤더를 생성합니다.
|
||||
*/
|
||||
private HttpHeaders createHeaders() {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
|
||||
// 쿠키 설정 (기본 방식 - 하위 호환성)
|
||||
String cookieString = cookieUtil.getTistoryCookieString();
|
||||
if (StringUtils.hasText(cookieString)) {
|
||||
headers.set(HttpHeaders.COOKIE, cookieString);
|
||||
}
|
||||
|
||||
return createHeaders(headers);
|
||||
}
|
||||
|
||||
/**
|
||||
* 블로그 계정별 쿠키를 사용하여 HTTP 헤더를 생성합니다.
|
||||
*/
|
||||
private HttpHeaders createHeadersForBlog(Long blogId) {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
|
||||
try {
|
||||
// 블로그별 쿠키 매핑 정보 조회
|
||||
BlogCookieMappingVO cookieMapping = blogCookieService.getCookieMappingByBlogId(blogId);
|
||||
|
||||
if (cookieMapping != null && cookieMapping.isActiveCookie()) {
|
||||
// 쿠키 유효성 검증 (필요시)
|
||||
if (cookieMapping.needsValidation()) {
|
||||
boolean isValid = blogCookieService.validateAndUpdateCookie(blogId);
|
||||
if (!isValid) {
|
||||
log.warn("쿠키 유효성 검증 실패: blogId={}", blogId);
|
||||
// 유효하지 않은 쿠키지만 일단 시도해보기 위해 계속 진행
|
||||
}
|
||||
}
|
||||
|
||||
// 블로그별 쿠키 문자열 생성
|
||||
String cookieString = cookieUtil.getCookieStringForBlog(cookieMapping);
|
||||
if (StringUtils.hasText(cookieString)) {
|
||||
headers.set(HttpHeaders.COOKIE, cookieString);
|
||||
log.info("블로그별 쿠키 헤더 설정 완료: blogId={}", blogId);
|
||||
} else {
|
||||
log.warn("블로그별 쿠키 문자열이 비어있음: blogId={}", blogId);
|
||||
}
|
||||
} else {
|
||||
log.warn("유효한 쿠키 매핑 정보를 찾을 수 없음: blogId={}", blogId);
|
||||
// 기본 쿠키로 폴백
|
||||
String defaultCookie = cookieUtil.getTistoryCookieString();
|
||||
if (StringUtils.hasText(defaultCookie)) {
|
||||
headers.set(HttpHeaders.COOKIE, defaultCookie);
|
||||
log.info("기본 쿠키로 폴백: blogId={}", blogId);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("블로그별 쿠키 헤더 생성 실패: blogId={}, 기본 쿠키로 폴백", blogId, e);
|
||||
// 오류 발생 시 기본 쿠키로 폴백
|
||||
String defaultCookie = cookieUtil.getTistoryCookieString();
|
||||
if (StringUtils.hasText(defaultCookie)) {
|
||||
headers.set(HttpHeaders.COOKIE, defaultCookie);
|
||||
}
|
||||
}
|
||||
|
||||
return createHeaders(headers);
|
||||
}
|
||||
|
||||
/**
|
||||
* 공통 헤더 설정 완료
|
||||
*/
|
||||
private HttpHeaders createHeaders(HttpHeaders headers) {
|
||||
|
||||
// 기본 헤더 설정
|
||||
headers.set(HttpHeaders.USER_AGENT,
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36");
|
||||
headers.set(HttpHeaders.ACCEPT,
|
||||
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8");
|
||||
headers.set(HttpHeaders.ACCEPT_LANGUAGE, "ko-KR,ko;q=0.9,en;q=0.8");
|
||||
headers.set(HttpHeaders.ACCEPT_ENCODING, "gzip, deflate, br");
|
||||
headers.set("Sec-Fetch-Dest", "document");
|
||||
headers.set("Sec-Fetch-Mode", "navigate");
|
||||
headers.set("Sec-Fetch-Site", "same-origin");
|
||||
headers.set("Upgrade-Insecure-Requests", "1");
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
@Override
|
||||
public BlogPostHistoryVO publishWithHtmlGeneration(Long blogId, Long urlId, String sourceUrl) {
|
||||
log.info("통합 발행 워크플로우 시작: blogId={}, urlId={}, sourceUrl={}", blogId, urlId, sourceUrl);
|
||||
|
||||
long workflowStartTime = System.currentTimeMillis();
|
||||
BlogPostHistoryVO historyVO = null;
|
||||
|
||||
try {
|
||||
// 1. 초기 히스토리 생성 (I: In Progress 상태)
|
||||
historyVO = createInitialHistory(blogId, urlId, sourceUrl);
|
||||
|
||||
// 2. HTML 생성 단계
|
||||
try {
|
||||
generateHtmlFromSource(historyVO, sourceUrl);
|
||||
log.info("2단계 HTML 생성 완료, 3단계 티스토리 발행 진행: postId={}", historyVO.getPostId());
|
||||
} catch (Exception htmlError) {
|
||||
log.error("2단계 HTML 생성 실패로 워크플로우 중단: postId={}, error={}",
|
||||
historyVO.getPostId(), htmlError.getMessage());
|
||||
|
||||
// HTML 생성 실패 시 워크플로우 즉시 중단
|
||||
updatePublishFailure(historyVO, htmlError.getMessage());
|
||||
throw new RuntimeException("HTML 생성 단계 실패: " + htmlError.getMessage(), htmlError);
|
||||
}
|
||||
|
||||
// 3. 티스토리 발행 단계 (2단계 성공 시에만 실행)
|
||||
try {
|
||||
publishToTistoryInternal(historyVO);
|
||||
log.info("3단계 티스토리 발행 완료, 최종 성공 처리 진행: postId={}", historyVO.getPostId());
|
||||
} catch (Exception publishError) {
|
||||
log.error("3단계 티스토리 발행 실패: postId={}, error={}",
|
||||
historyVO.getPostId(), publishError.getMessage());
|
||||
|
||||
// 티스토리 발행 실패 시 워크플로우 중단
|
||||
updatePublishFailure(historyVO, publishError.getMessage());
|
||||
throw new RuntimeException("티스토리 발행 단계 실패: " + publishError.getMessage(), publishError);
|
||||
}
|
||||
|
||||
// 4. 최종 성공 처리 (모든 단계 성공 시에만 실행)
|
||||
updatePublishSuccess(historyVO);
|
||||
|
||||
long workflowEndTime = System.currentTimeMillis();
|
||||
log.info("통합 발행 워크플로우 완료: postId={}, 총 소요시간={}ms",
|
||||
historyVO.getPostId(), (workflowEndTime - workflowStartTime));
|
||||
|
||||
} catch (RuntimeException e) {
|
||||
// 이미 구체적으로 처리된 예외들은 그대로 재throw
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
// 예상치 못한 일반적인 예외 처리
|
||||
log.error("통합 발행 워크플로우 예상치 못한 오류: blogId={}, urlId={}, error={}",
|
||||
blogId, urlId, e.getMessage(), e);
|
||||
|
||||
if (historyVO != null) {
|
||||
updatePublishFailure(historyVO, "시스템 오류: " + e.getMessage());
|
||||
}
|
||||
|
||||
throw new RuntimeException("워크플로우 시스템 오류: " + e.getMessage(), e);
|
||||
}
|
||||
|
||||
return historyVO;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1단계: 초기 히스토리 생성 (I: In Progress 상태)
|
||||
*/
|
||||
private BlogPostHistoryVO createInitialHistory(Long blogId, Long urlId, String sourceUrl) {
|
||||
log.info("1단계: 초기 히스토리 생성 시작");
|
||||
|
||||
BlogPostHistoryVO historyVO = new BlogPostHistoryVO();
|
||||
historyVO.setBlogId(blogId);
|
||||
historyVO.setUrlId(urlId);
|
||||
historyVO.setStatus(BlogPostHistoryVO.STATUS_IN_PROGRESS);
|
||||
historyVO.setPostTitle("HTML 생성 중...");
|
||||
historyVO.setPostContent("");
|
||||
|
||||
// 초기 히스토리 저장
|
||||
blogPostingMapper.insertBlogPostHistory(historyVO);
|
||||
|
||||
log.info("1단계 완료: 초기 히스토리 생성됨, postId={}", historyVO.getPostId());
|
||||
return historyVO;
|
||||
}
|
||||
|
||||
/**
|
||||
* 2단계: HTML 생성
|
||||
*/
|
||||
private void generateHtmlFromSource(BlogPostHistoryVO historyVO, String sourceUrl) {
|
||||
log.info("2단계: HTML 생성 시작, sourceUrl={}", sourceUrl);
|
||||
|
||||
long htmlStartTime = System.currentTimeMillis();
|
||||
|
||||
try {
|
||||
// Python 서비스에 HTML 생성 요청
|
||||
Map<String, String> requestBody = new HashMap<>();
|
||||
requestBody.put("url", sourceUrl);
|
||||
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
|
||||
HttpEntity<Map<String, String>> requestEntity = new HttpEntity<>(requestBody, headers);
|
||||
|
||||
log.info("Python HTML 생성 서비스 호출: URL={}, sourceUrl={}", blogGenerateUrl, sourceUrl);
|
||||
|
||||
ResponseEntity<String> response = restTemplate.exchange(
|
||||
blogGenerateUrl,
|
||||
HttpMethod.POST,
|
||||
requestEntity,
|
||||
String.class
|
||||
);
|
||||
|
||||
log.info("Python 서비스 응답: status={}, bodyLength={}",
|
||||
response.getStatusCode(),
|
||||
response.getBody() != null ? response.getBody().length() : 0);
|
||||
|
||||
if (response.getStatusCode() == HttpStatus.OK && response.getBody() != null) {
|
||||
String htmlContent = response.getBody();
|
||||
String extractedTitle = extractTitleFromHtml(htmlContent);
|
||||
|
||||
// HTML 생성 완료 상태 업데이트
|
||||
historyVO.setPostTitle(extractedTitle);
|
||||
historyVO.setPostContent(htmlContent);
|
||||
historyVO.setHtmlGeneratedAt(LocalDateTime.now());
|
||||
|
||||
blogPostingMapper.updateHtmlGenerated(historyVO);
|
||||
|
||||
long htmlEndTime = System.currentTimeMillis();
|
||||
log.info("2단계 완료: HTML 생성 성공, title={}, 소요시간={}ms",
|
||||
extractedTitle, (htmlEndTime - htmlStartTime));
|
||||
} else {
|
||||
throw new RuntimeException("Python 서비스에서 HTML 생성 실패: " + response.getStatusCode());
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("2단계 실패: HTML 생성 중 오류 발생", e);
|
||||
|
||||
// 구체적인 오류 분석 및 사용자 친화적 메시지 생성
|
||||
String errorMessage = analyzeHtmlGenerationError(e, sourceUrl);
|
||||
throw new RuntimeException(errorMessage, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML 생성 오류를 분석하여 사용자 친화적인 메시지를 생성합니다.
|
||||
*/
|
||||
private String analyzeHtmlGenerationError(Exception e, String sourceUrl) {
|
||||
String errorMessage = e.getMessage();
|
||||
|
||||
if (errorMessage != null) {
|
||||
// Google AI API 500 Internal Server Error
|
||||
if (errorMessage.contains("500") && errorMessage.toLowerCase().contains("internal")) {
|
||||
return String.format(
|
||||
"[2단계 HTML 생성 실패] Google AI API 서버 내부 오류가 발생했습니다. " +
|
||||
"AI 서비스가 일시적으로 불안정하거나 과부하 상태일 수 있습니다. " +
|
||||
"몇 분 후 다시 시도하거나 수동으로 HTML을 작성해서 티스토리에 직접 발행해주세요. " +
|
||||
"(오류: Google AI 500 Internal Error, 소스: %s)",
|
||||
sourceUrl
|
||||
);
|
||||
}
|
||||
|
||||
// Google AI API finish_reason 오류
|
||||
if (errorMessage.contains("finish_reason") && errorMessage.contains("1")) {
|
||||
return String.format(
|
||||
"[2단계 HTML 생성 실패] AI가 콘텐츠를 처리할 수 없습니다. " +
|
||||
"소스 URL의 내용이 AI 정책에 위배되거나 너무 복잡할 수 있습니다. " +
|
||||
"다른 URL을 시도하거나 수동으로 HTML을 작성해주세요. " +
|
||||
"(오류코드: finish_reason=1, 소스: %s)",
|
||||
sourceUrl
|
||||
);
|
||||
}
|
||||
|
||||
// HTTP 상태 코드 관련 오류
|
||||
if (errorMessage.contains("status") && errorMessage.contains("50")) {
|
||||
return String.format(
|
||||
"[2단계 HTML 생성 실패] AI 서비스에서 서버 오류가 발생했습니다. " +
|
||||
"API 서버 문제일 가능성이 높으므로 잠시 후 다시 시도하거나 " +
|
||||
"수동으로 HTML을 작성해서 발행해주세요. " +
|
||||
"(오류: %s, 소스: %s)",
|
||||
errorMessage, sourceUrl
|
||||
);
|
||||
}
|
||||
|
||||
// 네트워크 연결 오류
|
||||
if (errorMessage.contains("Connection") || errorMessage.contains("timeout")) {
|
||||
return String.format(
|
||||
"[2단계 HTML 생성 실패] 블로그 생성 서비스에 연결할 수 없습니다. " +
|
||||
"네트워크 상태를 확인하거나 잠시 후 다시 시도해주세요. " +
|
||||
"또는 수동으로 HTML을 작성해서 발행해주세요. " +
|
||||
"(서비스: %s, 소스: %s)",
|
||||
blogGenerateUrl, sourceUrl
|
||||
);
|
||||
}
|
||||
|
||||
// 잘못된 URL 오류
|
||||
if (errorMessage.contains("404") || errorMessage.contains("NOT_FOUND")) {
|
||||
return String.format(
|
||||
"[2단계 HTML 생성 실패] 소스 URL에 접근할 수 없습니다. " +
|
||||
"URL이 올바른지 확인하고 접근 권한이 있는지 확인해주세요. " +
|
||||
"(오류: 404 Not Found, 소스: %s)",
|
||||
sourceUrl
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 기본 오류 메시지
|
||||
return String.format(
|
||||
"[2단계 HTML 생성 실패] 예상치 못한 오류가 발생했습니다. " +
|
||||
"수동으로 HTML을 작성해서 티스토리에 직접 발행하거나 관리자에게 문의해주세요. " +
|
||||
"(오류: %s, 소스: %s)",
|
||||
errorMessage != null ? errorMessage : "알 수 없는 오류",
|
||||
sourceUrl
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 3단계: 티스토리 발행
|
||||
*/
|
||||
private void publishToTistoryInternal(BlogPostHistoryVO historyVO) {
|
||||
log.info("3단계: 티스토리 발행 시작, title={}", historyVO.getPostTitle());
|
||||
|
||||
// 발행 시작 시간 기록
|
||||
historyVO.setPublishStartedAt(LocalDateTime.now());
|
||||
blogPostingMapper.updatePublishStarted(historyVO);
|
||||
|
||||
long publishStartTime = System.currentTimeMillis();
|
||||
|
||||
try {
|
||||
// 티스토리 토큰 획득 (DB 기반 쿠키 사용)
|
||||
String[] tokens = getTistoryTokens(historyVO.getBlogId());
|
||||
if (tokens == null || tokens.length < 2) {
|
||||
log.warn("블로그별 쿠키로 토큰 획득 실패, 기본 쿠키로 재시도: blogId={}", historyVO.getBlogId());
|
||||
tokens = getTistoryTokens(null); // 기본 쿠키로 폴백
|
||||
if (tokens == null || tokens.length < 2) {
|
||||
throw new RuntimeException("티스토리 토큰 획득 실패");
|
||||
}
|
||||
}
|
||||
|
||||
String xsrfToken = tokens[0];
|
||||
String blogIdFromPage = tokens[1];
|
||||
|
||||
// 포스트 발행 (DB 기반 쿠키 사용)
|
||||
String publishedUrl = publishPost(historyVO.getPostTitle(), historyVO.getPostContent(),
|
||||
xsrfToken, blogIdFromPage, historyVO.getBlogId());
|
||||
|
||||
if (StringUtils.hasText(publishedUrl)) {
|
||||
historyVO.setPublishedUrl(publishedUrl);
|
||||
historyVO.setPublishedAt(LocalDateTime.now());
|
||||
|
||||
long publishEndTime = System.currentTimeMillis();
|
||||
log.info("3단계 완료: 티스토리 발행 성공, publishedUrl={}, 소요시간={}ms",
|
||||
publishedUrl, (publishEndTime - publishStartTime));
|
||||
} else {
|
||||
throw new RuntimeException("티스토리 발행 URL을 가져올 수 없습니다.");
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("3단계 실패: 티스토리 발행 중 오류 발생", e);
|
||||
throw new RuntimeException("티스토리 발행 실패: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 4단계: 최종 성공 처리
|
||||
*/
|
||||
private void updatePublishSuccess(BlogPostHistoryVO historyVO) {
|
||||
log.info("4단계: 최종 성공 처리 시작");
|
||||
|
||||
historyVO.setStatus(BlogPostHistoryVO.STATUS_SUCCESS);
|
||||
blogPostingMapper.updatePublishCompleted(historyVO);
|
||||
|
||||
log.info("4단계 완료: 최종 성공 처리 완료, status={}", historyVO.getStatus());
|
||||
}
|
||||
|
||||
/**
|
||||
* 실패 처리
|
||||
*/
|
||||
private void updatePublishFailure(BlogPostHistoryVO historyVO, String errorMessage) {
|
||||
log.info("실패 처리 시작: errorMessage={}", errorMessage);
|
||||
|
||||
historyVO.setStatus(BlogPostHistoryVO.STATUS_FAILED);
|
||||
historyVO.setErrorMessage(errorMessage);
|
||||
blogPostingMapper.updatePublishFailed(historyVO);
|
||||
|
||||
log.info("실패 처리 완료: status={}", historyVO.getStatus());
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML 컨텐츠에서 타이틀 추출
|
||||
*/
|
||||
private String extractTitleFromHtml(String htmlContent) {
|
||||
if (!StringUtils.hasText(htmlContent)) {
|
||||
return "제목 없음";
|
||||
}
|
||||
|
||||
try {
|
||||
Document doc = Jsoup.parse(htmlContent);
|
||||
|
||||
// 1순위: <title> 태그에서 추출
|
||||
Element titleElement = doc.selectFirst("title");
|
||||
if (titleElement != null && StringUtils.hasText(titleElement.text())) {
|
||||
String title = titleElement.text().trim();
|
||||
return truncateTitle(title);
|
||||
}
|
||||
|
||||
// 2순위: og:title 메타 태그에서 추출
|
||||
Element ogTitleElement = doc.selectFirst("meta[property=og:title]");
|
||||
if (ogTitleElement != null && StringUtils.hasText(ogTitleElement.attr("content"))) {
|
||||
String title = ogTitleElement.attr("content").trim();
|
||||
return truncateTitle(title);
|
||||
}
|
||||
|
||||
// 3순위: h1 태그에서 추출
|
||||
Element h1Element = doc.selectFirst("h1");
|
||||
if (h1Element != null && StringUtils.hasText(h1Element.text())) {
|
||||
String title = h1Element.text().trim();
|
||||
return truncateTitle(title);
|
||||
}
|
||||
|
||||
log.warn("HTML에서 타이틀을 찾을 수 없습니다. 기본값을 사용합니다.");
|
||||
return "제목 없음";
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("HTML 타이틀 추출 중 오류 발생: {}", e.getMessage(), e);
|
||||
return "제목 추출 실패";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 타이틀을 데이터베이스 제한 길이에 맞게 자르기
|
||||
*/
|
||||
private String truncateTitle(String title) {
|
||||
if (title == null) {
|
||||
return "제목 없음";
|
||||
}
|
||||
|
||||
// 데이터베이스 VARCHAR(255) 제한에 맞춰 자르기
|
||||
if (title.length() > 255) {
|
||||
return title.substring(0, 252) + "...";
|
||||
}
|
||||
|
||||
return title;
|
||||
}
|
||||
}
|
||||
525
src/main/resources/templates/itn/blog/posting/manage.html
Normal file
525
src/main/resources/templates/itn/blog/posting/manage.html
Normal file
@ -0,0 +1,525 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en"
|
||||
xmlns:th="http://www.thymeleaf.org"
|
||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
|
||||
layout:decorate="~{layout}">
|
||||
<head>
|
||||
<title layout:title-fragment="title">블로그 포스팅 관리</title>
|
||||
</head>
|
||||
<body layout:fragment="body">
|
||||
|
||||
<div class="wrapper">
|
||||
<div th:replace="~{fragments/top_nav :: topFragment}"/>
|
||||
|
||||
<aside class="main-sidebar sidebar-dark-primary elevation-4"
|
||||
th:insert="~{fragments/mainsidebar :: sidebarFragment}">
|
||||
</aside>
|
||||
|
||||
<div class="content-wrapper">
|
||||
<section class="content-header">
|
||||
<div class="container-fluid">
|
||||
<div class="row mb-2">
|
||||
<div class="col-sm-6">
|
||||
<h1>블로그 포스팅 관리</h1>
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<ol class="breadcrumb float-sm-right">
|
||||
<li class="breadcrumb-item"><a href="#">Home</a></li>
|
||||
<li class="breadcrumb-item"><a href="/blog/posing/list">블로그 포스팅</a></li>
|
||||
<li class="breadcrumb-item active">관리</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="content">
|
||||
<div class="container-fluid">
|
||||
<!-- 선택된 계정 정보 카드 -->
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card card-primary">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">
|
||||
<i class="fas fa-blog mr-1"></i>
|
||||
선택된 블로그 계정
|
||||
</h3>
|
||||
</div>
|
||||
<div class="card-body" id="accountInfo">
|
||||
<div class="d-flex justify-content-center">
|
||||
<div class="spinner-border" role="status">
|
||||
<span class="sr-only">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 포스팅 소스 목록 -->
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">
|
||||
<i class="fas fa-newspaper mr-1"></i>
|
||||
포스팅 소스 목록
|
||||
</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table id="sourceTable" class="table table-bordered table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>제목</th>
|
||||
<th>카테고리</th>
|
||||
<th>URL</th>
|
||||
<th>등록일</th>
|
||||
<th>발행통계</th>
|
||||
<th>상태</th>
|
||||
<th width="120px">액션</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<footer class="main-footer"
|
||||
th:insert="~{fragments/footer :: footerFragment}">
|
||||
</footer>
|
||||
|
||||
<aside class="control-sidebar control-sidebar-dark">
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<th:block layout:fragment="script">
|
||||
<script src="/bower_components/moment/moment.js"></script>
|
||||
<script th:inline="javascript">
|
||||
$(document).ready(function() {
|
||||
const blogId = /*[[${blogId}]]*/ '';
|
||||
|
||||
// 계정 정보 및 소스 목록 로드
|
||||
loadAccountAndSources();
|
||||
|
||||
// 개별 발행 버튼 클릭 이벤트
|
||||
$(document).on('click', '.publish-btn', function() {
|
||||
const sourceId = $(this).data('source-id');
|
||||
const button = $(this);
|
||||
|
||||
if (!confirm('이 소스로 포스팅을 실행하시겠습니까?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
executeIndividualPosting(sourceId, button);
|
||||
});
|
||||
|
||||
function loadAccountAndSources() {
|
||||
$.ajax({
|
||||
url: '/api/blog/posing/manage/' + blogId,
|
||||
method: 'GET',
|
||||
success: function(response) {
|
||||
if (response.data) {
|
||||
renderAccountInfo(response.data.account);
|
||||
renderSourceTable(response.data.sources);
|
||||
} else {
|
||||
alert('데이터를 찾을 수 없습니다.');
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
alert('데이터 로드에 실패했습니다.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderAccountInfo(account) {
|
||||
const statusBadge = account.status === 'Y'
|
||||
? '<span class="badge badge-success">활성</span>'
|
||||
: '<span class="badge badge-danger">비활성</span>';
|
||||
|
||||
const html = `
|
||||
<div class="row">
|
||||
<div class="col-md-3 text-center">
|
||||
<i class="fas fa-blog fa-3x text-primary"></i>
|
||||
</div>
|
||||
<div class="col-md-9">
|
||||
<h4>${account.blogName}</h4>
|
||||
<p class="text-muted mb-1">
|
||||
<i class="fas fa-link"></i> ${account.blogUrl}
|
||||
</p>
|
||||
<p class="text-muted mb-1">
|
||||
<i class="fas fa-tags"></i> ${account.platform} 플랫폼
|
||||
</p>
|
||||
<p class="mb-0">
|
||||
<i class="fas fa-check-circle"></i> 상태: ${statusBadge}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
$('#accountInfo').html(html);
|
||||
}
|
||||
|
||||
function renderSourceTable(sources) {
|
||||
const tbody = $('#sourceTable tbody');
|
||||
tbody.empty();
|
||||
|
||||
sources.forEach(function(source) {
|
||||
const statusBadge = source.isActive === 'Y'
|
||||
? '<span class="badge badge-success">활성</span>'
|
||||
: '<span class="badge badge-secondary">비활성</span>';
|
||||
|
||||
const publishButton = source.isActive === 'Y'
|
||||
? `<button class="btn btn-success btn-sm publish-btn" data-source-id="${source.urlId}">
|
||||
<i class="fas fa-paper-plane"></i> 발행
|
||||
</button>`
|
||||
: `<button class="btn btn-secondary btn-sm" disabled>
|
||||
<i class="fas fa-ban"></i> 비활성
|
||||
</button>`;
|
||||
|
||||
// 발행 통계 정보
|
||||
const publishCount = source.totalPublishCount || 0;
|
||||
const lastPublished = source.lastPublishedAt ?
|
||||
moment(source.lastPublishedAt).format('YYYY-MM-DD HH:mm') : '-';
|
||||
|
||||
// 발행 횟수에 따른 배지 색상 결정
|
||||
let countBadgeClass = 'badge-secondary';
|
||||
if (publishCount > 10) countBadgeClass = 'badge-success';
|
||||
else if (publishCount > 5) countBadgeClass = 'badge-info';
|
||||
else if (publishCount > 0) countBadgeClass = 'badge-primary';
|
||||
|
||||
const publishStats = `
|
||||
<div class="text-center">
|
||||
<span class="badge ${countBadgeClass} mb-1">${publishCount}회 발행</span>
|
||||
<br>
|
||||
<small class="text-muted">
|
||||
<i class="far fa-clock"></i> ${lastPublished}
|
||||
</small>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 마지막 발행된 URL이 있는 경우 링크 추가
|
||||
const lastPublishedUrl = source.lastPublishedUrl ?
|
||||
`<br><a href="${source.lastPublishedUrl}" target="_blank" class="text-success">
|
||||
<i class="fas fa-external-link-alt"></i> 발행글
|
||||
</a>` : '';
|
||||
|
||||
const row = `
|
||||
<tr>
|
||||
<td>${source.title || ''}</td>
|
||||
<td>${source.category || ''}</td>
|
||||
<td>
|
||||
<a href="${source.url}" target="_blank" class="text-primary">
|
||||
<i class="fas fa-external-link-alt"></i> 링크
|
||||
</a>
|
||||
${lastPublishedUrl}
|
||||
</td>
|
||||
<td>${moment(source.regDate).format('YYYY-MM-DD')}</td>
|
||||
<td>${publishStats}</td>
|
||||
<td>${statusBadge}</td>
|
||||
<td>${publishButton}</td>
|
||||
</tr>
|
||||
`;
|
||||
tbody.append(row);
|
||||
});
|
||||
}
|
||||
|
||||
function executeIndividualPosting(sourceId, button) {
|
||||
// 소스 URL 가져오기 (테이블에서 추출)
|
||||
const row = button.closest('tr');
|
||||
const sourceUrl = row.find('a').attr('href');
|
||||
|
||||
if (!sourceUrl) {
|
||||
alert('소스 URL을 찾을 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 통합 워크플로우 실행
|
||||
executeIntegratedPosting(sourceId, sourceUrl, button, row);
|
||||
}
|
||||
|
||||
function executeIntegratedPosting(sourceId, sourceUrl, button, row) {
|
||||
const originalHtml = button.html();
|
||||
let currentPostId = null;
|
||||
let progressInterval = null;
|
||||
|
||||
// 진행상황 표시 시작
|
||||
showProgressSteps(button, row);
|
||||
|
||||
// 통합 발행 요청
|
||||
$.ajax({
|
||||
url: '/api/blog/posing/publish/integrated',
|
||||
method: 'POST',
|
||||
data: {
|
||||
blogId: blogId,
|
||||
urlId: sourceId,
|
||||
sourceUrl: sourceUrl
|
||||
},
|
||||
success: function(response) {
|
||||
clearInterval(progressInterval);
|
||||
|
||||
if (response.resultCd === 'OK') {
|
||||
const historyData = response.data;
|
||||
currentPostId = historyData.postId;
|
||||
|
||||
// 성공 처리
|
||||
handlePublishSuccess(button, row, historyData, originalHtml);
|
||||
|
||||
} else {
|
||||
// 실패 처리
|
||||
handlePublishFailure(button, row, response.msg || '통합 발행에 실패했습니다.', originalHtml);
|
||||
}
|
||||
},
|
||||
error: function(xhr, status, error) {
|
||||
clearInterval(progressInterval);
|
||||
console.error('Integrated publish error:', error);
|
||||
|
||||
let errorMessage = '통합 발행 중 오류가 발생했습니다.';
|
||||
if (xhr.responseJSON && xhr.responseJSON.msg) {
|
||||
errorMessage = xhr.responseJSON.msg;
|
||||
}
|
||||
|
||||
handlePublishFailure(button, row, errorMessage, originalHtml);
|
||||
},
|
||||
timeout: 120000 // 2분 타임아웃 (통합 워크플로우는 더 긴 시간 필요)
|
||||
});
|
||||
|
||||
// 진행상황 실시간 업데이트 시작 (응답을 받으면 postId를 얻을 수 있지만, 즉시 시작을 위해 일단 기본 진행상황 표시)
|
||||
startProgressTracking(button, currentPostId);
|
||||
}
|
||||
|
||||
function showProgressSteps(button, row) {
|
||||
// 버튼 상태를 1단계로 변경
|
||||
button.prop('disabled', true)
|
||||
.removeClass('btn-success')
|
||||
.addClass('btn-warning')
|
||||
.html('<i class="fas fa-spinner fa-spin"></i> 1단계: HTML 생성 중...');
|
||||
|
||||
// 진행상황 표시를 위한 임시 HTML 추가 (통계 셀에)
|
||||
const statsCell = row.find('td:eq(4)');
|
||||
const originalStats = statsCell.html();
|
||||
statsCell.data('original-stats', originalStats);
|
||||
|
||||
statsCell.html(`
|
||||
<div class="progress-container">
|
||||
<div class="progress mb-1" style="height: 20px;">
|
||||
<div class="progress-bar progress-bar-striped progress-bar-animated bg-info"
|
||||
role="progressbar" style="width: 25%">
|
||||
HTML 생성
|
||||
</div>
|
||||
</div>
|
||||
<small class="text-muted">
|
||||
<i class="fas fa-clock"></i> <span class="elapsed-time">0초</span>
|
||||
</small>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
function startProgressTracking(button, postId) {
|
||||
let startTime = Date.now();
|
||||
let step = 1;
|
||||
|
||||
const updateProgress = () => {
|
||||
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
||||
$('.elapsed-time').text(elapsed + '초');
|
||||
|
||||
// 단계별 진행상황 시뮬레이션 (실제로는 서버에서 상태를 조회해야 함)
|
||||
if (elapsed > 10 && step === 1) {
|
||||
step = 2;
|
||||
updateProgressStep(button, 2, '2단계: 티스토리 발행 중...');
|
||||
} else if (elapsed > 25 && step === 2) {
|
||||
step = 3;
|
||||
updateProgressStep(button, 3, '3단계: 최종 처리 중...');
|
||||
}
|
||||
};
|
||||
|
||||
return setInterval(updateProgress, 1000);
|
||||
}
|
||||
|
||||
function updateProgressStep(button, step, message) {
|
||||
button.html(`<i class="fas fa-spinner fa-spin"></i> ${message}`);
|
||||
|
||||
const progressBar = $('.progress-bar');
|
||||
const widthPercent = step * 25 + '%';
|
||||
progressBar.css('width', widthPercent);
|
||||
|
||||
if (step === 2) {
|
||||
progressBar.removeClass('bg-info').addClass('bg-warning').text('티스토리 발행');
|
||||
} else if (step === 3) {
|
||||
progressBar.removeClass('bg-warning').addClass('bg-success').text('최종 처리');
|
||||
}
|
||||
}
|
||||
|
||||
function handlePublishSuccess(button, row, historyData, originalHtml) {
|
||||
// 성공 메시지 표시
|
||||
showEnhancedSuccessMessage('통합 발행이 성공적으로 완료되었습니다!', historyData);
|
||||
|
||||
// 버튼을 완료 상태로 변경
|
||||
button.removeClass('btn-warning').addClass('btn-success')
|
||||
.html('<i class="fas fa-check-circle"></i> 발행 완료')
|
||||
.prop('disabled', true);
|
||||
|
||||
// 진행상황을 완료 상태로 업데이트
|
||||
const progressBar = $('.progress-bar');
|
||||
progressBar.css('width', '100%')
|
||||
.removeClass('bg-warning bg-info')
|
||||
.addClass('bg-success')
|
||||
.text('완료');
|
||||
|
||||
// 통계 정보 업데이트
|
||||
setTimeout(() => {
|
||||
updatePublishCountEnhanced(row, historyData);
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function handlePublishFailure(button, row, errorMessage, originalHtml) {
|
||||
// 실패 메시지 표시
|
||||
showErrorMessage('통합 발행 실패', errorMessage);
|
||||
|
||||
// 버튼을 실패 상태로 변경 (재시도 가능)
|
||||
button.removeClass('btn-warning').addClass('btn-danger')
|
||||
.html('<i class="fas fa-exclamation-triangle"></i> 실패 - 재시도')
|
||||
.prop('disabled', false);
|
||||
|
||||
// 진행상황을 실패 상태로 업데이트
|
||||
const progressBar = $('.progress-bar');
|
||||
progressBar.css('width', '100%')
|
||||
.removeClass('bg-warning bg-info bg-success')
|
||||
.addClass('bg-danger')
|
||||
.text('실패');
|
||||
|
||||
// 3초 후 원래 상태로 복원
|
||||
setTimeout(() => {
|
||||
const statsCell = row.find('td:eq(4)');
|
||||
const originalStats = statsCell.data('original-stats');
|
||||
if (originalStats) {
|
||||
statsCell.html(originalStats);
|
||||
}
|
||||
button.removeClass('btn-danger').addClass('btn-success')
|
||||
.html(originalHtml)
|
||||
.prop('disabled', false);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
function showSuccessMessage(message, historyData) {
|
||||
// AdminLTE 스타일 알림 메시지
|
||||
$(document).Toasts('create', {
|
||||
class: 'bg-success',
|
||||
title: '발행 성공',
|
||||
subtitle: moment().format('HH:mm:ss'),
|
||||
body: message + '<br><small>응답시간: ' + (historyData.responseTimeMs || 0) + 'ms</small>',
|
||||
autohide: true,
|
||||
delay: 5000
|
||||
});
|
||||
}
|
||||
|
||||
function showEnhancedSuccessMessage(message, historyData) {
|
||||
// 통합 워크플로우 성공 메시지 (더 상세한 정보 포함)
|
||||
let bodyContent = message;
|
||||
|
||||
if (historyData.publishedUrl) {
|
||||
bodyContent += '<br><a href="' + historyData.publishedUrl + '" target="_blank" class="text-white">' +
|
||||
'<i class="fas fa-external-link-alt"></i> 발행된 글 보기</a>';
|
||||
}
|
||||
|
||||
if (historyData.postTitle) {
|
||||
bodyContent += '<br><small>제목: ' + historyData.postTitle + '</small>';
|
||||
}
|
||||
|
||||
$(document).Toasts('create', {
|
||||
class: 'bg-success',
|
||||
title: '통합 발행 완료',
|
||||
subtitle: moment().format('HH:mm:ss'),
|
||||
body: bodyContent,
|
||||
autohide: true,
|
||||
delay: 8000
|
||||
});
|
||||
}
|
||||
|
||||
function showErrorMessage(title, errorMessage) {
|
||||
// 에러 메시지 표시
|
||||
$(document).Toasts('create', {
|
||||
class: 'bg-danger',
|
||||
title: title,
|
||||
subtitle: moment().format('HH:mm:ss'),
|
||||
body: errorMessage,
|
||||
autohide: true,
|
||||
delay: 10000
|
||||
});
|
||||
}
|
||||
|
||||
function updatePublishCount(row, historyData) {
|
||||
// 발행 통계 컬럼 업데이트 (5번째 컬럼)
|
||||
const statsCell = row.find('td:eq(4)');
|
||||
|
||||
// 현재 발행 횟수 가져오기 (배지에서 숫자 추출)
|
||||
const currentCountText = statsCell.find('.badge').text();
|
||||
const currentCount = parseInt(currentCountText.match(/\d+/)?.[0] || '0');
|
||||
const newCount = currentCount + 1;
|
||||
|
||||
// 새로운 배지 색상 결정
|
||||
let countBadgeClass = 'badge-secondary';
|
||||
if (newCount > 10) countBadgeClass = 'badge-success';
|
||||
else if (newCount > 5) countBadgeClass = 'badge-info';
|
||||
else if (newCount > 0) countBadgeClass = 'badge-primary';
|
||||
|
||||
// 현재 시간으로 마지막 발행 시간 업데이트
|
||||
const now = moment().format('YYYY-MM-DD HH:mm');
|
||||
|
||||
// 통계 컬럼 업데이트
|
||||
const newStatsHtml = `
|
||||
<div class="text-center">
|
||||
<span class="badge ${countBadgeClass} mb-1">${newCount}회 발행</span>
|
||||
<br>
|
||||
<small class="text-muted">
|
||||
<i class="far fa-clock"></i> ${now}
|
||||
</small>
|
||||
</div>
|
||||
`;
|
||||
|
||||
statsCell.html(newStatsHtml);
|
||||
|
||||
console.log('발행 통계 업데이트 완료:', {
|
||||
previousCount: currentCount,
|
||||
newCount: newCount,
|
||||
publishedAt: now,
|
||||
historyData: historyData
|
||||
});
|
||||
}
|
||||
|
||||
function updatePublishCountEnhanced(row, historyData) {
|
||||
// 통합 워크플로우 전용 통계 업데이트 (더 상세한 정보 포함)
|
||||
const statsCell = row.find('td:eq(4)');
|
||||
|
||||
// 원본 통계 복원
|
||||
const originalStats = statsCell.data('original-stats');
|
||||
if (originalStats) {
|
||||
statsCell.html(originalStats);
|
||||
}
|
||||
|
||||
// 기본 카운트 업데이트
|
||||
updatePublishCount(row, historyData);
|
||||
|
||||
// 발행된 URL 링크 추가 (URL 컬럼에)
|
||||
if (historyData.publishedUrl) {
|
||||
const urlCell = row.find('td:eq(2)');
|
||||
const existingContent = urlCell.html();
|
||||
|
||||
// 이미 발행된 글 링크가 없는 경우에만 추가
|
||||
if (!existingContent.includes('발행글')) {
|
||||
urlCell.append('<br><a href="' + historyData.publishedUrl + '" target="_blank" class="text-success">' +
|
||||
'<i class="fas fa-external-link-alt"></i> 발행글</a>');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</th:block>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in New Issue
Block a user