블로그 게시판 관련 java 수정

This commit is contained in:
hehihoho3@gmail.com 2025-12-01 17:42:26 +09:00
parent 38cb5608d8
commit 42f26ea8d7
9 changed files with 2441 additions and 0 deletions

View File

@ -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 ? "활성화" : "비활성화");
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -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();
}

View File

@ -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;
}
}

View 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>