diff --git a/src/main/java/com/itn/admin/cmn/util/slack/SlackNotificationService.java b/src/main/java/com/itn/admin/cmn/util/slack/SlackNotificationService.java new file mode 100644 index 0000000..354b2ad --- /dev/null +++ b/src/main/java/com/itn/admin/cmn/util/slack/SlackNotificationService.java @@ -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 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> request = new HttpEntity<>(payload, headers); + ResponseEntity 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 ? "활성화" : "비활성화"); + } +} \ No newline at end of file diff --git a/src/main/java/com/itn/admin/cmn/util/tistory/TistoryCookieUtil.java b/src/main/java/com/itn/admin/cmn/util/tistory/TistoryCookieUtil.java new file mode 100644 index 0000000..dcba742 --- /dev/null +++ b/src/main/java/com/itn/admin/cmn/util/tistory/TistoryCookieUtil.java @@ -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 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 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 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; + } +} \ No newline at end of file diff --git a/src/main/java/com/itn/admin/itn/blog/mapper/domain/ScheduleCreateRequestDTO.java b/src/main/java/com/itn/admin/itn/blog/mapper/domain/ScheduleCreateRequestDTO.java new file mode 100644 index 0000000..857c131 --- /dev/null +++ b/src/main/java/com/itn/admin/itn/blog/mapper/domain/ScheduleCreateRequestDTO.java @@ -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; + } +} \ No newline at end of file diff --git a/src/main/java/com/itn/admin/itn/blog/mapper/domain/ScheduleSearchDTO.java b/src/main/java/com/itn/admin/itn/blog/mapper/domain/ScheduleSearchDTO.java new file mode 100644 index 0000000..ade6eef --- /dev/null +++ b/src/main/java/com/itn/admin/itn/blog/mapper/domain/ScheduleSearchDTO.java @@ -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 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 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); + } +} \ No newline at end of file diff --git a/src/main/java/com/itn/admin/itn/blog/mapper/domain/ScheduleStatisticsDTO.java b/src/main/java/com/itn/admin/itn/blog/mapper/domain/ScheduleStatisticsDTO.java new file mode 100644 index 0000000..7d8bb7c --- /dev/null +++ b/src/main/java/com/itn/admin/itn/blog/mapper/domain/ScheduleStatisticsDTO.java @@ -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 topBlogStats; // 블로그별 통계 + + // 시간대별 실행 통계 (24시간) + private List hourlyStats; // 시간대별 실행 통계 + + // 일별 실행 통계 (최근 7일) + private List 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); + } +} \ No newline at end of file diff --git a/src/main/java/com/itn/admin/itn/blog/mapper/domain/ScheduleUpdateRequestDTO.java b/src/main/java/com/itn/admin/itn/blog/mapper/domain/ScheduleUpdateRequestDTO.java new file mode 100644 index 0000000..9742cb3 --- /dev/null +++ b/src/main/java/com/itn/admin/itn/blog/mapper/domain/ScheduleUpdateRequestDTO.java @@ -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; + } +} \ No newline at end of file diff --git a/src/main/java/com/itn/admin/itn/blog/service/TistoryPublishService.java b/src/main/java/com/itn/admin/itn/blog/service/TistoryPublishService.java new file mode 100644 index 0000000..0af415d --- /dev/null +++ b/src/main/java/com/itn/admin/itn/blog/service/TistoryPublishService.java @@ -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 checkBlogGenerationServiceStatus(); +} \ No newline at end of file diff --git a/src/main/java/com/itn/admin/itn/blog/service/impl/TistoryPublishServiceImpl.java b/src/main/java/com/itn/admin/itn/blog/service/impl/TistoryPublishServiceImpl.java new file mode 100644 index 0000000..abe8f38 --- /dev/null +++ b/src/main/java/com/itn/admin/itn/blog/service/impl/TistoryPublishServiceImpl.java @@ -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 checkBlogGenerationServiceStatus() { + Map status = new HashMap<>(); + + try { + // 간단한 헬스 체크 요청 + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity requestEntity = new HttpEntity<>("{\"test\": true}", headers); + + long startTime = System.currentTimeMillis(); + ResponseEntity 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 entity = new HttpEntity<>(headers); + + log.info("티스토리 관리 페이지 접속: {}", TISTORY_ADMIN_URL); + ResponseEntity 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(" 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("([^<]+)", 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 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> entity = new HttpEntity<>(formData, headers); + + log.info("티스토리 포스트 저장 요청: title={}, blogId={}", title, blogId); + ResponseEntity 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 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 requestBody = new HashMap<>(); + requestBody.put("url", sourceUrl); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity> requestEntity = new HttpEntity<>(requestBody, headers); + + log.info("Python HTML 생성 서비스 호출: URL={}, sourceUrl={}", blogGenerateUrl, sourceUrl); + + ResponseEntity 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순위: 태그에서 추출 + 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; + } +} \ No newline at end of file diff --git a/src/main/resources/templates/itn/blog/posting/manage.html b/src/main/resources/templates/itn/blog/posting/manage.html new file mode 100644 index 0000000..8bdc474 --- /dev/null +++ b/src/main/resources/templates/itn/blog/posting/manage.html @@ -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">블로그 포스팅 관리 + + + +
+
+ + + +
+
+
+
+
+

블로그 포스팅 관리

+
+
+ +
+
+
+
+ +
+
+ +
+
+
+
+

+ + 선택된 블로그 계정 +

+
+
+
+
+ Loading... +
+
+
+
+
+
+ + +
+
+
+
+

+ + 포스팅 소스 목록 +

+
+
+ + + + + + + + + + + + + + +
제목카테고리URL등록일발행통계상태액션
+
+
+
+
+ +
+
+
+ +
+
+ + +
+ + + + + + + + \ No newline at end of file