diff --git a/src/main/java/com/itn/admin/itn/blog/mapper/BlogAccountMapper.java b/src/main/java/com/itn/admin/itn/blog/mapper/BlogAccountMapper.java new file mode 100644 index 0000000..da20043 --- /dev/null +++ b/src/main/java/com/itn/admin/itn/blog/mapper/BlogAccountMapper.java @@ -0,0 +1,16 @@ +package com.itn.admin.itn.blog.mapper; + +import com.itn.admin.itn.blog.mapper.domain.BlogAccountVO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + +@Mapper +public interface BlogAccountMapper { + List getBlogAccountList(); + List getBlogAccountListWithStats(); + BlogAccountVO getBlogAccountDetail(Long blogId); + int insertBlogAccount(BlogAccountVO blogAccountVO); + int updateBlogAccount(BlogAccountVO blogAccountVO); + int deleteBlogAccount(Long blogId); +} diff --git a/src/main/java/com/itn/admin/itn/blog/mapper/BlogCookieMappingMapper.java b/src/main/java/com/itn/admin/itn/blog/mapper/BlogCookieMappingMapper.java new file mode 100644 index 0000000..c15fd6f --- /dev/null +++ b/src/main/java/com/itn/admin/itn/blog/mapper/BlogCookieMappingMapper.java @@ -0,0 +1,47 @@ +package com.itn.admin.itn.blog.mapper; + +import com.itn.admin.itn.blog.mapper.domain.BlogCookieMappingVO; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface BlogCookieMappingMapper { + + /** + * 블로그 계정별 쿠키 매핑 정보 조회 + */ + BlogCookieMappingVO selectCookieMappingByBlogId(@Param("blogId") Long blogId); + + /** + * 활성화된 쿠키 매핑 정보 조회 + */ + List selectActiveCookieMappings(); + + /** + * 쿠키 매핑 정보 저장 + */ + int insertCookieMapping(BlogCookieMappingVO cookieMappingVO); + + /** + * 쿠키 매핑 정보 업데이트 + */ + int updateCookieMapping(BlogCookieMappingVO cookieMappingVO); + + /** + * 쿠키 유효성 검증 시간 업데이트 + */ + int updateLastValidated(@Param("mappingId") Long mappingId); + + /** + * 쿠키 만료 시간 업데이트 + */ + int updateCookieExpiration(@Param("mappingId") Long mappingId, + @Param("cookieExpiresAt") java.time.LocalDateTime cookieExpiresAt); + + /** + * 쿠키 매핑 비활성화 + */ + int deactivateCookieMapping(@Param("blogId") Long blogId); +} \ No newline at end of file diff --git a/src/main/java/com/itn/admin/itn/blog/mapper/BlogPostingMapper.java b/src/main/java/com/itn/admin/itn/blog/mapper/BlogPostingMapper.java new file mode 100644 index 0000000..d0d985c --- /dev/null +++ b/src/main/java/com/itn/admin/itn/blog/mapper/BlogPostingMapper.java @@ -0,0 +1,42 @@ +package com.itn.admin.itn.blog.mapper; + +import com.itn.admin.itn.blog.mapper.domain.BlogPostHistoryVO; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface BlogPostingMapper { + // 발행 히스토리 관련 메서드 + int insertBlogPostHistory(BlogPostHistoryVO historyVO); + List selectBlogPostHistories(@Param("blogId") String blogId, + @Param("urlId") String urlId, + @Param("limit") int limit, + @Param("offset") int offset); + + // 워크플로우 단계별 업데이트 메서드 + int updateHtmlGenerated(BlogPostHistoryVO historyVO); + int updatePublishStarted(BlogPostHistoryVO historyVO); + int updatePublishCompleted(BlogPostHistoryVO historyVO); + int updatePublishFailed(BlogPostHistoryVO historyVO); + + // 발행 히스토리 조회 메서드 + BlogPostHistoryVO selectBlogPostHistoryById(@Param("postId") Long postId); + + // 소스별 발행 통계 조회 + List> selectPublishStatsBySource(@Param("blogId") Long blogId); + + // 워크플로우 히스토리 조회 (필터링 및 페이징 지원) + List selectWorkflowHistory(@Param("blogId") String blogId, + @Param("urlId") String urlId, + @Param("status") String status, + @Param("limit") int limit, + @Param("offset") int offset); + + // 워크플로우 통계 조회 + java.util.Map selectWorkflowStatistics(@Param("blogId") Long blogId); + + // 실패 원인별 분석 + List> selectFailureAnalysis(@Param("blogId") Long blogId, @Param("days") int days); +} diff --git a/src/main/java/com/itn/admin/itn/blog/mapper/BlogScheduleExecutionMapper.java b/src/main/java/com/itn/admin/itn/blog/mapper/BlogScheduleExecutionMapper.java new file mode 100644 index 0000000..1a92418 --- /dev/null +++ b/src/main/java/com/itn/admin/itn/blog/mapper/BlogScheduleExecutionMapper.java @@ -0,0 +1,96 @@ +package com.itn.admin.itn.blog.mapper; + +import com.itn.admin.itn.blog.mapper.domain.BlogScheduleExecutionVO; +import com.itn.admin.itn.blog.mapper.domain.ScheduleSearchDTO; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.time.LocalDateTime; +import java.util.List; + +@Mapper +public interface BlogScheduleExecutionMapper { + + // 기본 CRUD 작업 + int insertScheduleExecution(BlogScheduleExecutionVO executionVO); + int updateScheduleExecution(BlogScheduleExecutionVO executionVO); + BlogScheduleExecutionVO getScheduleExecutionDetail(Long executionId); + + // 특정 스케줄의 실행 이력 조회 + List getExecutionHistory(@Param("scheduleId") Long scheduleId, + @Param("searchDTO") ScheduleSearchDTO searchDTO); + + int getExecutionHistoryCount(@Param("scheduleId") Long scheduleId, + @Param("searchDTO") ScheduleSearchDTO searchDTO); + + // 전체 실행 이력 조회 (관리자용) + List getAllExecutionHistory(ScheduleSearchDTO searchDTO); + int getAllExecutionHistoryCount(ScheduleSearchDTO searchDTO); + + // 실행 상태별 조회 + List getExecutionsByStatus(@Param("status") String status, + @Param("limit") int limit); + + List getRunningExecutions(); + + List getPendingExecutions(@Param("limit") int limit); + + List getFailedExecutions(@Param("fromTime") LocalDateTime fromTime, + @Param("toTime") LocalDateTime toTime); + + // 성능 통계 조회 + List getSlowExecutions(@Param("thresholdMs") int thresholdMs, + @Param("limit") int limit); + + Double getAverageExecutionTime(@Param("scheduleId") Long scheduleId, + @Param("days") int days); + + // 실행 상태 업데이트 + int updateExecutionStatus(@Param("executionId") Long executionId, + @Param("status") String status, + @Param("resultMessage") String resultMessage); + + int updateExecutionStartTime(@Param("executionId") Long executionId, + @Param("startedAt") LocalDateTime startedAt); + + int updateExecutionEndTime(@Param("executionId") Long executionId, + @Param("completedAt") LocalDateTime completedAt, + @Param("executionTimeMs") Integer executionTimeMs); + + int updateExecutionError(@Param("executionId") Long executionId, + @Param("errorDetails") String errorDetails, + @Param("resultMessage") String resultMessage); + + // 스케줄별 통계 + BlogScheduleExecutionVO getLastExecutionBySchedule(@Param("scheduleId") Long scheduleId); + + int getSuccessCountBySchedule(@Param("scheduleId") Long scheduleId, + @Param("days") int days); + + int getFailureCountBySchedule(@Param("scheduleId") Long scheduleId, + @Param("days") int days); + + int getTotalExecutionCount(@Param("scheduleId") Long scheduleId); + + // 재시도 관련 + List getRetryableExecutions(@Param("currentTime") LocalDateTime currentTime, + @Param("maxRetryInterval") int maxRetryInterval); + + int incrementAttemptCount(@Param("executionId") Long executionId); + + // 정리 작업 + int deleteOldExecutions(@Param("beforeDate") LocalDateTime beforeDate); + + int deleteExecutionsBySchedule(@Param("scheduleId") Long scheduleId); + + // 통계용 집계 쿼리 + List getExecutionStatsGroupByHour(@Param("fromTime") LocalDateTime fromTime, + @Param("toTime") LocalDateTime toTime); + + List getExecutionStatsGroupByDay(@Param("fromTime") LocalDateTime fromTime, + @Param("toTime") LocalDateTime toTime); + + List getExecutionStatsGroupByBlog(@Param("fromTime") LocalDateTime fromTime, + @Param("toTime") LocalDateTime toTime, + @Param("limit") int limit); +} \ No newline at end of file diff --git a/src/main/java/com/itn/admin/itn/blog/mapper/BlogScheduleMapper.java b/src/main/java/com/itn/admin/itn/blog/mapper/BlogScheduleMapper.java new file mode 100644 index 0000000..3970733 --- /dev/null +++ b/src/main/java/com/itn/admin/itn/blog/mapper/BlogScheduleMapper.java @@ -0,0 +1,76 @@ +package com.itn.admin.itn.blog.mapper; + +import com.itn.admin.itn.blog.mapper.domain.BlogScheduleVO; +import com.itn.admin.itn.blog.mapper.domain.ScheduleSearchDTO; +import com.itn.admin.itn.blog.mapper.domain.ScheduleStatisticsDTO; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.time.LocalDateTime; +import java.util.List; + +@Mapper +public interface BlogScheduleMapper { + + // 기본 CRUD 작업 + int insertBlogSchedule(BlogScheduleVO blogScheduleVO); + int updateBlogSchedule(BlogScheduleVO blogScheduleVO); + int deleteBlogSchedule(Long scheduleId); + BlogScheduleVO getBlogScheduleDetail(Long scheduleId); + + // 목록 조회 (검색 조건 + 페이징) + List getBlogScheduleList(ScheduleSearchDTO searchDTO); + int getBlogScheduleCount(ScheduleSearchDTO searchDTO); + + // 통계 포함 상세 목록 조회 + List getBlogScheduleListWithStats(ScheduleSearchDTO searchDTO); + + // 스케줄링 전용 조회 메서드들 + List getActiveSchedulesByTime(@Param("currentTime") LocalDateTime currentTime, + @Param("bufferMinutes") int bufferMinutes); + + List getPendingSchedulesByPriority(); + + List getFailedSchedulesForRetry(@Param("currentTime") LocalDateTime currentTime); + + // 상태 업데이트 + int updateScheduleStatus(@Param("scheduleId") Long scheduleId, + @Param("status") String status); + + int updateScheduleNextExecution(@Param("scheduleId") Long scheduleId, + @Param("nextExecuteAt") LocalDateTime nextExecuteAt); + + int incrementScheduleExecutionCount(@Param("scheduleId") Long scheduleId); + + // 중복 실행 방지를 위한 락킹 + int lockScheduleForExecution(@Param("scheduleId") Long scheduleId, + @Param("serverInfo") String serverInfo); + + int unlockSchedule(@Param("scheduleId") Long scheduleId); + + // 통계 조회 + ScheduleStatisticsDTO getOverallStatistics(); + + List getBlogScheduleStats(@Param("limit") int limit); + + List getHourlyExecutionStats(); + + List getDailyExecutionStats(@Param("days") int days); + + // 유지보수를 위한 쿼리들 + int deleteCompletedSchedules(@Param("beforeDate") LocalDateTime beforeDate); + + int cleanupFailedSchedules(@Param("beforeDate") LocalDateTime beforeDate, + @Param("maxFailures") int maxFailures); + + List getSchedulesRequiringCleanup(@Param("beforeDate") LocalDateTime beforeDate); + + // 블로그별 스케줄 조회 + List getSchedulesByBlogId(@Param("blogId") Long blogId, + @Param("status") String status); + + // 다음 실행 예정 스케줄 조회 + List getUpcomingSchedules(@Param("fromTime") LocalDateTime fromTime, + @Param("toTime") LocalDateTime toTime, + @Param("limit") int limit); +} \ No newline at end of file diff --git a/src/main/java/com/itn/admin/itn/blog/mapper/BlogSourceMapper.java b/src/main/java/com/itn/admin/itn/blog/mapper/BlogSourceMapper.java new file mode 100644 index 0000000..94a5da6 --- /dev/null +++ b/src/main/java/com/itn/admin/itn/blog/mapper/BlogSourceMapper.java @@ -0,0 +1,16 @@ +package com.itn.admin.itn.blog.mapper; + +import com.itn.admin.itn.blog.mapper.domain.BlogSourceVO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + +@Mapper +public interface BlogSourceMapper { + List selectBlogSourceList(); + List selectBlogSourceListWithStats(Long blogId); + BlogSourceVO getBlogSourceDetail(Long sourceId); + int insertBlogSource(BlogSourceVO blogSourceVO); + int updateBlogSource(BlogSourceVO blogSourceVO); + int deleteBlogSource(Long sourceId); +} diff --git a/src/main/java/com/itn/admin/itn/blog/mapper/domain/BlogAccountVO.java b/src/main/java/com/itn/admin/itn/blog/mapper/domain/BlogAccountVO.java new file mode 100644 index 0000000..712fa7b --- /dev/null +++ b/src/main/java/com/itn/admin/itn/blog/mapper/domain/BlogAccountVO.java @@ -0,0 +1,29 @@ +package com.itn.admin.itn.blog.mapper.domain; + +import com.itn.admin.cmn.vo.CmnVO; +import lombok.*; + +import java.time.LocalDateTime; // For datetime fields + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@ToString +public class BlogAccountVO extends CmnVO { + private Long blogId; // 블로그 고유 ID (bigint, AUTO_INCREMENT) + private String platform; // 플랫폼 (예: NAVER, TISTORY) + private String blogName; // 사용자가 식별할 블로그 이름 + private String blogUrl; // 블로그 주소 + private String apiKey; // API 키 + private String apiSecret; // API 시크릿 키 + private String authInfo1; // 추가 인증 정보 1 + private String authInfo2; // 추가 인증 정보 2 + private String status; // 계정 활성 상태 (enum 'Y','N') + + private String platformNm; + + // 발행 통계 필드 추가 + private Integer totalPostCount; // 총 포스팅 발행 횟수 + private LocalDateTime lastPublishedAt; // 마지막 발행 시간 +} diff --git a/src/main/java/com/itn/admin/itn/blog/mapper/domain/BlogAccountWithSourcesDTO.java b/src/main/java/com/itn/admin/itn/blog/mapper/domain/BlogAccountWithSourcesDTO.java new file mode 100644 index 0000000..4683bc5 --- /dev/null +++ b/src/main/java/com/itn/admin/itn/blog/mapper/domain/BlogAccountWithSourcesDTO.java @@ -0,0 +1,15 @@ +package com.itn.admin.itn.blog.mapper.domain; + +import lombok.*; + +import java.util.List; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@ToString +public class BlogAccountWithSourcesDTO { + private BlogAccountVO account; + private List sources; +} \ No newline at end of file diff --git a/src/main/java/com/itn/admin/itn/blog/mapper/domain/BlogCookieMappingVO.java b/src/main/java/com/itn/admin/itn/blog/mapper/domain/BlogCookieMappingVO.java new file mode 100644 index 0000000..9e7106c --- /dev/null +++ b/src/main/java/com/itn/admin/itn/blog/mapper/domain/BlogCookieMappingVO.java @@ -0,0 +1,114 @@ +package com.itn.admin.itn.blog.mapper.domain; + +import java.time.LocalDateTime; + +/** + * 블로그 계정별 쿠키 파일 매핑 정보 VO + */ +public class BlogCookieMappingVO { + + private Long mappingId; + private Long blogId; + private String cookieFilePath; + private String cookieFileName; + private String isActive; + private LocalDateTime lastValidatedAt; + private LocalDateTime cookieExpiresAt; + private String frstRegisterId; + private LocalDateTime frstRegistPnttm; + private String lastUpdusrId; + private LocalDateTime lastUpdtPnttm; + + // 조인 필드 + private String blogName; + private String blogUrl; + private String platform; + + // 기본 생성자 + public BlogCookieMappingVO() {} + + // 편의 생성자 + public BlogCookieMappingVO(Long blogId, String cookieFilePath, String cookieFileName) { + this.blogId = blogId; + this.cookieFilePath = cookieFilePath; + this.cookieFileName = cookieFileName; + this.isActive = "Y"; + } + + /** + * 쿠키 파일 전체 경로 반환 + */ + public String getFullCookieFilePath() { + if (cookieFilePath == null || cookieFileName == null) { + return null; + } + return cookieFilePath.endsWith("/") ? + cookieFilePath + cookieFileName : + cookieFilePath + "/" + cookieFileName; + } + + /** + * 쿠키가 활성 상태인지 확인 + */ + public boolean isActiveCookie() { + return "Y".equals(isActive); + } + + /** + * 쿠키가 만료되었는지 확인 + */ + public boolean isCookieExpired() { + return cookieExpiresAt != null && LocalDateTime.now().isAfter(cookieExpiresAt); + } + + /** + * 쿠키 유효성 검증이 필요한지 확인 (마지막 검증 후 1시간 경과) + */ + public boolean needsValidation() { + return lastValidatedAt == null || + LocalDateTime.now().isAfter(lastValidatedAt.plusHours(1)); + } + + // Getter & Setter + public Long getMappingId() { return mappingId; } + public void setMappingId(Long mappingId) { this.mappingId = mappingId; } + + public Long getBlogId() { return blogId; } + public void setBlogId(Long blogId) { this.blogId = blogId; } + + public String getCookieFilePath() { return cookieFilePath; } + public void setCookieFilePath(String cookieFilePath) { this.cookieFilePath = cookieFilePath; } + + public String getCookieFileName() { return cookieFileName; } + public void setCookieFileName(String cookieFileName) { this.cookieFileName = cookieFileName; } + + public String getIsActive() { return isActive; } + public void setIsActive(String isActive) { this.isActive = isActive; } + + public LocalDateTime getLastValidatedAt() { return lastValidatedAt; } + public void setLastValidatedAt(LocalDateTime lastValidatedAt) { this.lastValidatedAt = lastValidatedAt; } + + public LocalDateTime getCookieExpiresAt() { return cookieExpiresAt; } + public void setCookieExpiresAt(LocalDateTime cookieExpiresAt) { this.cookieExpiresAt = cookieExpiresAt; } + + public String getFrstRegisterId() { return frstRegisterId; } + public void setFrstRegisterId(String frstRegisterId) { this.frstRegisterId = frstRegisterId; } + + public LocalDateTime getFrstRegistPnttm() { return frstRegistPnttm; } + public void setFrstRegistPnttm(LocalDateTime frstRegistPnttm) { this.frstRegistPnttm = frstRegistPnttm; } + + public String getLastUpdusrId() { return lastUpdusrId; } + public void setLastUpdusrId(String lastUpdusrId) { this.lastUpdusrId = lastUpdusrId; } + + public LocalDateTime getLastUpdtPnttm() { return lastUpdtPnttm; } + public void setLastUpdtPnttm(LocalDateTime lastUpdtPnttm) { this.lastUpdtPnttm = lastUpdtPnttm; } + + public String getBlogName() { return blogName; } + public void setBlogName(String blogName) { this.blogName = blogName; } + + public String getBlogUrl() { return blogUrl; } + public void setBlogUrl(String blogUrl) { this.blogUrl = blogUrl; } + + public String getPlatform() { return platform; } + public void setPlatform(String platform) { this.platform = platform; } +} \ No newline at end of file diff --git a/src/main/java/com/itn/admin/itn/blog/mapper/domain/BlogPostHistoryVO.java b/src/main/java/com/itn/admin/itn/blog/mapper/domain/BlogPostHistoryVO.java new file mode 100644 index 0000000..f3b0d47 --- /dev/null +++ b/src/main/java/com/itn/admin/itn/blog/mapper/domain/BlogPostHistoryVO.java @@ -0,0 +1,51 @@ +package com.itn.admin.itn.blog.mapper.domain; + +import com.itn.admin.cmn.vo.CmnVO; +import lombok.*; + +import java.time.LocalDateTime; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@ToString +public class BlogPostHistoryVO extends CmnVO { + // 상태 상수 정의 + public static final String STATUS_IN_PROGRESS = "I"; + public static final String STATUS_SUCCESS = "S"; + public static final String STATUS_FAILED = "F"; + + private Long postId; // DB의 post_id (AUTO_INCREMENT) + private Long blogId; // blog_accounts.blog_id + private Long urlId; // blog_source_urls.url_id (소스-포스트 매칭) + private String postTitle; // 포스트 제목 + private String postContent; // 포스트 내용 (longtext) + private String publishedUrl; // 발행된 URL + private String status; // enum('I','S','F') - I(In Progress), S(Success), F(Failed) + private String errorMessage; // 에러 메시지 + private LocalDateTime publishedAt; // 발행 일시 + private LocalDateTime htmlGeneratedAt; // HTML 생성 완료 시간 + private LocalDateTime publishStartedAt; // 발행 시작 시간 + + // JOIN용 필드들 + private String blogName; // from blog_accounts + private String sourceTitle; // from blog_source_urls + + // 상태 확인 편의 메서드 + public boolean isInProgress() { return STATUS_IN_PROGRESS.equals(this.status); } + public boolean isSuccess() { return STATUS_SUCCESS.equals(this.status); } + public boolean isFailed() { return STATUS_FAILED.equals(this.status); } + + // 워크플로우 단계 확인 메서드 + public boolean isHtmlGenerated() { return this.htmlGeneratedAt != null; } + public boolean isPublishStarted() { return this.publishStartedAt != null; } + public boolean isPublishCompleted() { return this.publishedAt != null; } + + // MyBatis 결과 매핑용 별칭 (하위 호환성) + public Long getHistoryId() { return this.postId; } + public void setHistoryId(Long historyId) { this.postId = historyId; } + + public String getPublishStatus() { return this.status; } + public void setPublishStatus(String status) { this.status = status; } +} \ No newline at end of file diff --git a/src/main/java/com/itn/admin/itn/blog/mapper/domain/BlogScheduleExecutionVO.java b/src/main/java/com/itn/admin/itn/blog/mapper/domain/BlogScheduleExecutionVO.java new file mode 100644 index 0000000..4ce48a7 --- /dev/null +++ b/src/main/java/com/itn/admin/itn/blog/mapper/domain/BlogScheduleExecutionVO.java @@ -0,0 +1,84 @@ +package com.itn.admin.itn.blog.mapper.domain; + +import com.itn.admin.cmn.vo.CmnVO; +import lombok.*; + +import java.time.LocalDateTime; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@ToString +public class BlogScheduleExecutionVO extends CmnVO { + + // 기본 정보 + private Long executionId; // 실행 이력 ID (PK) + private Long scheduleId; // 스케줄 ID (FK) + + // 실행 시간 정보 + private LocalDateTime executedAt; // 실행 시도 시간 + private LocalDateTime startedAt; // 실제 실행 시작 시간 + private LocalDateTime completedAt; // 실행 완료 시간 + + // 실행 상태 + private String status; // 실행 상태 (PENDING/RUNNING/SUCCESS/FAILED/CANCELLED) + private String resultMessage; // 실행 결과 메시지 + private String errorDetails; // 에러 상세 정보 + + // 실행 메타데이터 + private Integer attemptCount; // 시도 횟수 (기본값: 1) + private String publishedUrl; // 발행된 URL + private Integer executionTimeMs; // 실행 시간 (밀리초) + private String serverInfo; // 실행 서버 정보 + + // JOIN용 필드들 (조회 시 사용) + private String scheduleTitle; // from blog_schedules.title + private String blogName; // from blog_accounts.blog_name + private String schedulePriority; // from blog_schedules.priority + + // 편의 메서드들 + public boolean isSuccess() { + return "SUCCESS".equals(this.status); + } + + public boolean isFailed() { + return "FAILED".equals(this.status); + } + + public boolean isRunning() { + return "RUNNING".equals(this.status); + } + + public boolean isPending() { + return "PENDING".equals(this.status); + } + + public boolean isCancelled() { + return "CANCELLED".equals(this.status); + } + + // 실행 시간 계산 (밀리초) + public Long getActualExecutionTimeMs() { + if (startedAt != null && completedAt != null) { + return java.time.Duration.between(startedAt, completedAt).toMillis(); + } + return this.executionTimeMs != null ? this.executionTimeMs.longValue() : null; + } + + // 실행 결과 요약 + public String getExecutionSummary() { + if (isSuccess()) { + return "성공 (" + (executionTimeMs != null ? executionTimeMs + "ms" : "시간 미측정") + ")"; + } else if (isFailed()) { + return "실패 (" + attemptCount + "번째 시도)"; + } else if (isRunning()) { + return "실행 중..."; + } else if (isPending()) { + return "대기 중"; + } else if (isCancelled()) { + return "취소됨"; + } + return "알 수 없는 상태"; + } +} \ No newline at end of file diff --git a/src/main/java/com/itn/admin/itn/blog/mapper/domain/BlogScheduleVO.java b/src/main/java/com/itn/admin/itn/blog/mapper/domain/BlogScheduleVO.java new file mode 100644 index 0000000..2cac5e8 --- /dev/null +++ b/src/main/java/com/itn/admin/itn/blog/mapper/domain/BlogScheduleVO.java @@ -0,0 +1,71 @@ +package com.itn.admin.itn.blog.mapper.domain; + +import com.itn.admin.cmn.vo.CmnVO; +import lombok.*; + +import java.time.LocalDateTime; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@ToString +public class BlogScheduleVO extends CmnVO { + + // 기본 정보 + private Long scheduleId; // 예약 ID (PK) + private Long blogId; // 블로그 계정 ID (FK) + private Long urlId; // 소스 URL ID (FK) + private String title; // 예약 제목 + private String content; // 예약 내용 + + // 스케줄링 설정 + private String scheduleType; // 스케줄 유형 (ONE_TIME/RECURRING) + private LocalDateTime scheduledAt; // 예약 실행 시간 + private String repeatInterval; // 반복 간격 (DAILY/WEEKLY/MONTHLY) + private Integer repeatValue; // 반복 주기 값 (기본값: 1) + private LocalDateTime endAt; // 반복 종료 시간 + + // 상태 및 우선순위 + private String status; // 스케줄 상태 (ACTIVE/INACTIVE/COMPLETED/FAILED) + private String priority; // 실행 우선순위 (HIGH/NORMAL/LOW) + + // 재시도 설정 + private Integer maxRetries; // 최대 재시도 횟수 (기본값: 3) + private Integer retryInterval; // 재시도 간격 (분, 기본값: 5) + + // 알림 설정 + private String notificationEmail; // 알림 이메일 + private String slackChannel; // 슬랙 채널명 + private Boolean enableNotification; // 알림 활성화 여부 (기본값: false) + + // JOIN용 필드들 (조회 시 사용) + private String blogName; // from blog_accounts.blog_name + private String blogUrl; // from blog_accounts.blog_url + private String sourceUrl; // from blog_source_urls.url + private String sourceTitle; // from blog_source_urls.title + + // 통계 정보 (조회 시 사용) + private Integer totalExecutions; // 총 실행 횟수 + private Integer successExecutions; // 성공 실행 횟수 + private Integer failedExecutions; // 실패 실행 횟수 + private LocalDateTime lastExecutedAt; // 마지막 실행 시간 + private LocalDateTime nextExecuteAt; // 다음 실행 예정 시간 + + // 편의 메서드들 + public boolean isActive() { + return "ACTIVE".equals(this.status); + } + + public boolean isRecurring() { + return "RECURRING".equals(this.scheduleType); + } + + public boolean isHighPriority() { + return "HIGH".equals(this.priority); + } + + public boolean isNotificationEnabled() { + return Boolean.TRUE.equals(this.enableNotification); + } +} \ No newline at end of file diff --git a/src/main/java/com/itn/admin/itn/blog/mapper/domain/BlogSourceVO.java b/src/main/java/com/itn/admin/itn/blog/mapper/domain/BlogSourceVO.java new file mode 100644 index 0000000..9bc711c --- /dev/null +++ b/src/main/java/com/itn/admin/itn/blog/mapper/domain/BlogSourceVO.java @@ -0,0 +1,24 @@ +package com.itn.admin.itn.blog.mapper.domain; + +import com.itn.admin.cmn.vo.CmnVO; +import lombok.*; + +import java.util.Date; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@ToString +public class BlogSourceVO extends CmnVO { + private Long urlId; + private String url; + private String title; + private String category; + private String isActive; + + // 발행 통계 필드 추가 + private Integer totalPublishCount; // 총 발행 횟수 + private Date lastPublishedAt; // 마지막 발행 시간 + private String lastPublishedUrl; // 마지막 발행된 URL +} diff --git a/src/main/java/com/itn/admin/itn/blog/service/BlogAccountService.java b/src/main/java/com/itn/admin/itn/blog/service/BlogAccountService.java new file mode 100644 index 0000000..2bb9f64 --- /dev/null +++ b/src/main/java/com/itn/admin/itn/blog/service/BlogAccountService.java @@ -0,0 +1,16 @@ +package com.itn.admin.itn.blog.service; + +import com.itn.admin.cmn.msg.RestResponse; +import com.itn.admin.itn.blog.mapper.domain.BlogAccountVO; + +import java.util.List; + +public interface BlogAccountService { + List getBlogAccountList(); + List getBlogAccountListWithStats(); + BlogAccountVO getBlogAccountDetail(Long blogId); + RestResponse insertBlogAccount(BlogAccountVO blogAccountVO); + RestResponse updateBlogAccount(BlogAccountVO blogAccountVO); + RestResponse deleteBlogAccount(Long blogId); + +} diff --git a/src/main/java/com/itn/admin/itn/blog/service/BlogCookieService.java b/src/main/java/com/itn/admin/itn/blog/service/BlogCookieService.java new file mode 100644 index 0000000..9709b52 --- /dev/null +++ b/src/main/java/com/itn/admin/itn/blog/service/BlogCookieService.java @@ -0,0 +1,38 @@ +package com.itn.admin.itn.blog.service; + +import com.itn.admin.itn.blog.mapper.domain.BlogCookieMappingVO; + +import java.util.List; + +public interface BlogCookieService { + + /** + * 블로그 계정별 쿠키 매핑 정보 조회 + */ + BlogCookieMappingVO getCookieMappingByBlogId(Long blogId); + + /** + * 활성화된 쿠키 매핑 정보 목록 조회 + */ + List getActiveCookieMappings(); + + /** + * 쿠키 매핑 정보 저장 + */ + void saveCookieMapping(BlogCookieMappingVO cookieMappingVO); + + /** + * 쿠키 유효성 검증 및 검증 시간 업데이트 + */ + boolean validateAndUpdateCookie(Long blogId); + + /** + * 쿠키 만료 시간 업데이트 + */ + void updateCookieExpiration(Long mappingId, java.time.LocalDateTime expiresAt); + + /** + * 쿠키 매핑 비활성화 + */ + void deactivateCookieMapping(Long blogId); +} \ No newline at end of file diff --git a/src/main/java/com/itn/admin/itn/blog/service/BlogPostingService.java b/src/main/java/com/itn/admin/itn/blog/service/BlogPostingService.java new file mode 100644 index 0000000..9fdb63c --- /dev/null +++ b/src/main/java/com/itn/admin/itn/blog/service/BlogPostingService.java @@ -0,0 +1,31 @@ +package com.itn.admin.itn.blog.service; + +import com.itn.admin.itn.blog.mapper.domain.BlogAccountWithSourcesDTO; +import com.itn.admin.itn.blog.mapper.domain.BlogPostHistoryVO; + +import java.util.List; +import java.util.Map; + +public interface BlogPostingService { + BlogAccountWithSourcesDTO getAccountWithSources(Long blogId); + + /** + * 워크플로우 히스토리 조회 (페이징 지원) + */ + List getWorkflowHistory(String blogId, String urlId, String status, int limit, int offset); + + /** + * 워크플로우 통계 조회 + */ + Map getWorkflowStatistics(Long blogId); + + /** + * 실시간 진행상황 조회 + */ + BlogPostHistoryVO getPublishProgress(Long postId); + + /** + * 실패 원인별 분석 + */ + List> getFailureAnalysis(Long blogId, int days); +} diff --git a/src/main/java/com/itn/admin/itn/blog/service/BlogPublishService.java b/src/main/java/com/itn/admin/itn/blog/service/BlogPublishService.java new file mode 100644 index 0000000..c5c7f5e --- /dev/null +++ b/src/main/java/com/itn/admin/itn/blog/service/BlogPublishService.java @@ -0,0 +1,16 @@ +package com.itn.admin.itn.blog.service; + +import com.itn.admin.itn.blog.mapper.domain.BlogPostHistoryVO; + +public interface BlogPublishService { + + /** + * 블로그 포스팅 발행 + * + * @param blogId 블로그 계정 ID + * @param urlId 소스 URL ID + * @param sourceUrl 소스 URL + * @return 발행 히스토리 VO + */ + BlogPostHistoryVO publishPost(String blogId, String urlId, String sourceUrl); +} \ No newline at end of file diff --git a/src/main/java/com/itn/admin/itn/blog/service/BlogSourceService.java b/src/main/java/com/itn/admin/itn/blog/service/BlogSourceService.java new file mode 100644 index 0000000..4ee3d9e --- /dev/null +++ b/src/main/java/com/itn/admin/itn/blog/service/BlogSourceService.java @@ -0,0 +1,14 @@ +package com.itn.admin.itn.blog.service; + +import com.itn.admin.cmn.msg.RestResponse; +import com.itn.admin.itn.blog.mapper.domain.BlogSourceVO; + +import java.util.List; + +public interface BlogSourceService { + List getBlogSourceList(); + BlogSourceVO getBlogSourceDetail(Long sourceId); + RestResponse insertBlogSource(BlogSourceVO blogSourceVO); + RestResponse updateBlogSource(BlogSourceVO blogSourceVO); + RestResponse deleteBlogSource(Long sourceId); +} diff --git a/src/main/java/com/itn/admin/itn/blog/service/impl/BlogAccountServiceImpl.java b/src/main/java/com/itn/admin/itn/blog/service/impl/BlogAccountServiceImpl.java new file mode 100644 index 0000000..5685012 --- /dev/null +++ b/src/main/java/com/itn/admin/itn/blog/service/impl/BlogAccountServiceImpl.java @@ -0,0 +1,78 @@ +package com.itn.admin.itn.blog.service.impl; + +import com.itn.admin.cmn.util.thymeleafUtils.TCodeUtils; +import com.itn.admin.itn.blog.mapper.domain.BlogAccountVO; +import com.itn.admin.itn.blog.mapper.BlogAccountMapper; +import com.itn.admin.itn.blog.service.BlogAccountService; +import com.itn.admin.cmn.msg.RestResponse; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +public class BlogAccountServiceImpl implements BlogAccountService { + + @Autowired + TCodeUtils tCodeUtils; + + private final BlogAccountMapper blogAccountMapper; + + public BlogAccountServiceImpl(BlogAccountMapper blogAccountMapper) { + this.blogAccountMapper = blogAccountMapper; + } + + @Override + public List getBlogAccountList() { + List list = blogAccountMapper.getBlogAccountList(); + list.forEach(blogAccountVO -> { + blogAccountVO.setPlatformNm(tCodeUtils.getCodeName("BLOG_PLATFORM", blogAccountVO.getPlatform())); + }); + return list; + } + + @Override + public List getBlogAccountListWithStats() { + List list = blogAccountMapper.getBlogAccountListWithStats(); + list.forEach(blogAccountVO -> { + blogAccountVO.setPlatformNm(tCodeUtils.getCodeName("BLOG_PLATFORM", blogAccountVO.getPlatform())); + }); + return list; + } + + @Override + public BlogAccountVO getBlogAccountDetail(Long blogId) { + return blogAccountMapper.getBlogAccountDetail(blogId); + } + + @Override + public RestResponse insertBlogAccount(BlogAccountVO blogAccountVO) { + int result = blogAccountMapper.insertBlogAccount(blogAccountVO); + if (result > 0) { + return new RestResponse(HttpStatus.OK, "등록되었습니다.", blogAccountVO.getBlogId()); + } else { + return new RestResponse(HttpStatus.INTERNAL_SERVER_ERROR, "등록에 실패했습니다."); + } + } + + @Override + public RestResponse updateBlogAccount(BlogAccountVO blogAccountVO) { + int result = blogAccountMapper.updateBlogAccount(blogAccountVO); + if (result > 0) { + return new RestResponse(HttpStatus.OK, "수정되었습니다.", blogAccountVO.getBlogId()); + } else { + return new RestResponse(HttpStatus.INTERNAL_SERVER_ERROR, "수정에 실패했습니다."); + } + } + + @Override + public RestResponse deleteBlogAccount(Long blogId) { + int result = blogAccountMapper.deleteBlogAccount(blogId); + if (result > 0) { + return new RestResponse(HttpStatus.OK, "삭제되었습니다.", blogId); + } else { + return new RestResponse(HttpStatus.INTERNAL_SERVER_ERROR, "삭제에 실패했습니다."); + } + } +} diff --git a/src/main/java/com/itn/admin/itn/blog/service/impl/BlogCookieServiceImpl.java b/src/main/java/com/itn/admin/itn/blog/service/impl/BlogCookieServiceImpl.java new file mode 100644 index 0000000..2d47988 --- /dev/null +++ b/src/main/java/com/itn/admin/itn/blog/service/impl/BlogCookieServiceImpl.java @@ -0,0 +1,118 @@ +package com.itn.admin.itn.blog.service.impl; + +import com.itn.admin.cmn.util.tistory.TistoryCookieUtil; +import com.itn.admin.itn.blog.mapper.BlogCookieMappingMapper; +import com.itn.admin.itn.blog.mapper.domain.BlogCookieMappingVO; +import com.itn.admin.itn.blog.service.BlogCookieService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +@Slf4j +@Service +@Transactional +public class BlogCookieServiceImpl implements BlogCookieService { + + @Autowired + private BlogCookieMappingMapper blogCookieMappingMapper; + + @Autowired + private TistoryCookieUtil tistoryCookieUtil; + + @Override + public BlogCookieMappingVO getCookieMappingByBlogId(Long blogId) { + try { + return blogCookieMappingMapper.selectCookieMappingByBlogId(blogId); + } catch (Exception e) { + log.error("블로그 쿠키 매핑 정보 조회 실패: blogId={}", blogId, e); + throw new RuntimeException("쿠키 매핑 정보 조회에 실패했습니다.", e); + } + } + + @Override + public List getActiveCookieMappings() { + try { + return blogCookieMappingMapper.selectActiveCookieMappings(); + } catch (Exception e) { + log.error("활성 쿠키 매핑 목록 조회 실패", e); + throw new RuntimeException("활성 쿠키 매핑 목록 조회에 실패했습니다.", e); + } + } + + @Override + public void saveCookieMapping(BlogCookieMappingVO cookieMappingVO) { + try { + if (cookieMappingVO.getMappingId() == null) { + // 새로운 매핑 저장 + cookieMappingVO.setFrstRegisterId("SYSTEM"); + cookieMappingVO.setLastUpdusrId("SYSTEM"); + blogCookieMappingMapper.insertCookieMapping(cookieMappingVO); + log.info("새로운 쿠키 매핑 저장 완료: blogId={}, file={}", + cookieMappingVO.getBlogId(), cookieMappingVO.getCookieFileName()); + } else { + // 기존 매핑 업데이트 + cookieMappingVO.setLastUpdusrId("SYSTEM"); + blogCookieMappingMapper.updateCookieMapping(cookieMappingVO); + log.info("쿠키 매핑 업데이트 완료: mappingId={}", cookieMappingVO.getMappingId()); + } + } catch (Exception e) { + log.error("쿠키 매핑 저장 실패: {}", cookieMappingVO, e); + throw new RuntimeException("쿠키 매핑 저장에 실패했습니다.", e); + } + } + + @Override + public boolean validateAndUpdateCookie(Long blogId) { + try { + BlogCookieMappingVO cookieMapping = getCookieMappingByBlogId(blogId); + + if (cookieMapping == null) { + log.warn("쿠키 매핑 정보를 찾을 수 없습니다: blogId={}", blogId); + return false; + } + + // 쿠키 유효성 검증 + boolean isValid = tistoryCookieUtil.validateCookieFile(cookieMapping); + + if (isValid) { + // 검증 성공 시 검증 시간 업데이트 + blogCookieMappingMapper.updateLastValidated(cookieMapping.getMappingId()); + log.info("쿠키 유효성 검증 성공: blogId={}", blogId); + } else { + log.warn("쿠키 유효성 검증 실패: blogId={}", blogId); + } + + return isValid; + + } catch (Exception e) { + log.error("쿠키 유효성 검증 중 오류 발생: blogId={}", blogId, e); + return false; + } + } + + @Override + public void updateCookieExpiration(Long mappingId, LocalDateTime expiresAt) { + try { + blogCookieMappingMapper.updateCookieExpiration(mappingId, expiresAt); + log.info("쿠키 만료 시간 업데이트 완료: mappingId={}, expiresAt={}", mappingId, expiresAt); + } catch (Exception e) { + log.error("쿠키 만료 시간 업데이트 실패: mappingId={}", mappingId, e); + throw new RuntimeException("쿠키 만료 시간 업데이트에 실패했습니다.", e); + } + } + + @Override + public void deactivateCookieMapping(Long blogId) { + try { + blogCookieMappingMapper.deactivateCookieMapping(blogId); + log.info("쿠키 매핑 비활성화 완료: blogId={}", blogId); + } catch (Exception e) { + log.error("쿠키 매핑 비활성화 실패: blogId={}", blogId, e); + throw new RuntimeException("쿠키 매핑 비활성화에 실패했습니다.", e); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/itn/admin/itn/blog/service/impl/BlogPostingServiceImpl.java b/src/main/java/com/itn/admin/itn/blog/service/impl/BlogPostingServiceImpl.java new file mode 100644 index 0000000..c59f940 --- /dev/null +++ b/src/main/java/com/itn/admin/itn/blog/service/impl/BlogPostingServiceImpl.java @@ -0,0 +1,62 @@ +package com.itn.admin.itn.blog.service.impl; + +import com.itn.admin.itn.blog.mapper.BlogPostingMapper; +import com.itn.admin.itn.blog.mapper.BlogAccountMapper; +import com.itn.admin.itn.blog.mapper.BlogSourceMapper; +import com.itn.admin.itn.blog.mapper.domain.BlogAccountVO; +import com.itn.admin.itn.blog.mapper.domain.BlogSourceVO; +import com.itn.admin.itn.blog.mapper.domain.BlogAccountWithSourcesDTO; +import com.itn.admin.itn.blog.mapper.domain.BlogPostHistoryVO; +import com.itn.admin.itn.blog.service.BlogPostingService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; + +@Service +public class BlogPostingServiceImpl implements BlogPostingService { + + @Autowired + private BlogPostingMapper blogPostingMapper; + + @Autowired + private BlogAccountMapper blogAccountMapper; + + @Autowired + private BlogSourceMapper blogSourceMapper; + + + @Override + public BlogAccountWithSourcesDTO getAccountWithSources(Long blogId) { + BlogAccountVO account = blogAccountMapper.getBlogAccountDetail(blogId); + List sources = blogSourceMapper.selectBlogSourceListWithStats(blogId); + + BlogAccountWithSourcesDTO result = new BlogAccountWithSourcesDTO(); + result.setAccount(account); + result.setSources(sources); + + return result; + } + + @Override + public List getWorkflowHistory(String blogId, String urlId, String status, int limit, int offset) { + return blogPostingMapper.selectWorkflowHistory(blogId, urlId, status, limit, offset); + } + + @Override + public Map getWorkflowStatistics(Long blogId) { + return blogPostingMapper.selectWorkflowStatistics(blogId); + } + + @Override + public BlogPostHistoryVO getPublishProgress(Long postId) { + return blogPostingMapper.selectBlogPostHistoryById(postId); + } + + @Override + public List> getFailureAnalysis(Long blogId, int days) { + return blogPostingMapper.selectFailureAnalysis(blogId, days); + } +} diff --git a/src/main/java/com/itn/admin/itn/blog/service/impl/BlogPublishServiceImpl.java b/src/main/java/com/itn/admin/itn/blog/service/impl/BlogPublishServiceImpl.java new file mode 100644 index 0000000..0ff446b --- /dev/null +++ b/src/main/java/com/itn/admin/itn/blog/service/impl/BlogPublishServiceImpl.java @@ -0,0 +1,220 @@ +package com.itn.admin.itn.blog.service.impl; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.itn.admin.itn.blog.mapper.BlogPostingMapper; +import com.itn.admin.itn.blog.mapper.domain.BlogPostHistoryVO; +import com.itn.admin.itn.blog.service.BlogPublishService; +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.StringUtils; +import org.springframework.web.client.RestTemplate; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional +public class BlogPublishServiceImpl implements BlogPublishService { + + private final BlogPostingMapper blogPostingMapper; + 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; + + @Override + public BlogPostHistoryVO publishPost(String blogId, String urlId, String sourceUrl) { + log.info("블로그 포스팅 발행 시작: blogId={}, urlId={}, sourceUrl={}", blogId, urlId, sourceUrl); + + BlogPostHistoryVO historyVO = new BlogPostHistoryVO(); + historyVO.setBlogId(Long.parseLong(blogId)); + // sourceUrl을 publishedUrl로 임시 설정 (추후 실제 발행 URL로 업데이트) + historyVO.setPublishedUrl(sourceUrl); + historyVO.setPublishedAt(LocalDateTime.now()); + + long startTime = System.currentTimeMillis(); + + try { + // Python 서비스에 요청 + 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 서비스 요청: URL={}, Body={}", blogGenerateUrl, requestBody); + + ResponseEntity response = restTemplate.exchange( + blogGenerateUrl, + HttpMethod.POST, + requestEntity, + String.class + ); + + long endTime = System.currentTimeMillis(); + int responseTime = (int)(endTime - startTime); + + if (response.getStatusCode() == HttpStatus.OK && response.getBody() != null) { + String htmlContent = response.getBody(); + + // HTML에서 타이틀 추출 + String extractedTitle = extractTitleFromHtml(htmlContent); + + // 성공 히스토리 저장 + historyVO.setPostContent(htmlContent); + historyVO.setPostTitle(extractedTitle); // 추출된 타이틀 저장 + historyVO.setStatus("Y"); + + // 발행된 URL 추출 (HTML에서 canonical URL이나 meta 정보에서 추출 가능) + String publishedUrl = extractPublishedUrl(htmlContent, sourceUrl); + historyVO.setPublishedUrl(publishedUrl); + + // 히스토리 저장 + blogPostingMapper.insertBlogPostHistory(historyVO); + + // 통계 업데이트는 현재 시스템에서 불필요 (매핑 테이블 미사용) + // TODO: 향후 매핑 테이블 활용 시 주석 해제 + // updatePublishStatistics(blogId, urlId, historyVO.getPublishedAt(), publishedUrl); + + log.info("블로그 포스팅 발행 성공: postId={}, responseTime={}ms", + historyVO.getPostId(), responseTime); + + } else { + throw new RuntimeException("Python 서비스에서 빈 응답을 받았습니다."); + } + + } catch (Exception e) { + log.error("블로그 포스팅 발행 실패: blogId={}, urlId={}, error={}", blogId, urlId, e.getMessage(), e); + + long endTime = System.currentTimeMillis(); + int responseTime = (int)(endTime - startTime); + + // 실패 히스토리 저장 + historyVO.setStatus("N"); + historyVO.setErrorMessage(e.getMessage()); + + blogPostingMapper.insertBlogPostHistory(historyVO); + + // 예외를 다시 throw하여 컨트롤러에서 처리할 수 있도록 함 + throw new RuntimeException("포스팅 발행에 실패했습니다: " + e.getMessage(), e); + } + + return historyVO; + } + + /** + * HTML 컨텐츠에서 타이틀 추출 + * + * @param htmlContent HTML 컨텐츠 + * @return 추출된 타이틀 (최대 255자) + */ + 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); + } + + // 4순위: twitter:title 메타 태그에서 추출 + Element twitterTitleElement = doc.selectFirst("meta[name=twitter:title]"); + if (twitterTitleElement != null && StringUtils.hasText(twitterTitleElement.attr("content"))) { + String title = twitterTitleElement.attr("content").trim(); + return truncateTitle(title); + } + + log.warn("HTML에서 타이틀을 찾을 수 없습니다. 기본값을 사용합니다."); + return "제목 없음"; + + } catch (Exception e) { + log.error("HTML 타이틀 추출 중 오류 발생: {}", e.getMessage(), e); + return "제목 추출 실패"; + } + } + + /** + * 타이틀을 데이터베이스 제한 길이에 맞게 자르기 + * + * @param title 원본 타이틀 + * @return 잘린 타이틀 (최대 255자) + */ + private String truncateTitle(String title) { + if (title == null) { + return "제목 없음"; + } + + // 데이터베이스 VARCHAR(255) 제한에 맞춰 자르기 + if (title.length() > 255) { + return title.substring(0, 252) + "..."; + } + + return title; + } + + /** + * HTML 컨텐츠에서 발행된 URL 추출 + * 실제 구현에서는 HTML 파싱을 통해 canonical URL이나 + * 블로그 플랫폼별 URL 패턴을 찾아야 함 + */ + private String extractPublishedUrl(String htmlContent, String sourceUrl) { + if (!StringUtils.hasText(htmlContent)) { + return sourceUrl + "#published"; + } + + try { + Document doc = Jsoup.parse(htmlContent); + + // 1순위: canonical URL 추출 + Element canonicalElement = doc.selectFirst("link[rel=canonical]"); + if (canonicalElement != null && StringUtils.hasText(canonicalElement.attr("href"))) { + return canonicalElement.attr("href"); + } + + // 2순위: og:url 메타 태그에서 추출 + Element ogUrlElement = doc.selectFirst("meta[property=og:url]"); + if (ogUrlElement != null && StringUtils.hasText(ogUrlElement.attr("content"))) { + return ogUrlElement.attr("content"); + } + + } catch (Exception e) { + log.error("HTML에서 발행된 URL 추출 중 오류 발생: {}", e.getMessage(), e); + } + + // 기본값: 소스 URL 기반으로 블로그 URL 생성 + return sourceUrl + "#published"; + } +} \ No newline at end of file diff --git a/src/main/java/com/itn/admin/itn/blog/service/impl/BlogSourceServiceImpl.java b/src/main/java/com/itn/admin/itn/blog/service/impl/BlogSourceServiceImpl.java new file mode 100644 index 0000000..d5edf0d --- /dev/null +++ b/src/main/java/com/itn/admin/itn/blog/service/impl/BlogSourceServiceImpl.java @@ -0,0 +1,58 @@ +package com.itn.admin.itn.blog.service.impl; + +import com.itn.admin.cmn.msg.RestResponse; +import com.itn.admin.itn.blog.mapper.domain.BlogSourceVO; +import com.itn.admin.itn.blog.mapper.BlogSourceMapper; +import com.itn.admin.itn.blog.service.BlogSourceService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class BlogSourceServiceImpl implements BlogSourceService { + + private final BlogSourceMapper blogSourceMapper; + + @Override + public List<BlogSourceVO> getBlogSourceList() { + return blogSourceMapper.selectBlogSourceList(); + } + + @Override + public BlogSourceVO getBlogSourceDetail(Long sourceId) { + return blogSourceMapper.getBlogSourceDetail(sourceId); + } + + @Override + public RestResponse insertBlogSource(BlogSourceVO blogSourceVO) { + int result = blogSourceMapper.insertBlogSource(blogSourceVO); + if (result > 0) { + return new RestResponse(HttpStatus.OK, "등록되었습니다."); + } else { + return new RestResponse(HttpStatus.INTERNAL_SERVER_ERROR, "등록에 실패했습니다."); + } + } + + @Override + public RestResponse updateBlogSource(BlogSourceVO blogSourceVO) { + int result = blogSourceMapper.updateBlogSource(blogSourceVO); + if (result > 0) { + return new RestResponse(HttpStatus.OK, "수정되었습니다."); + } else { + return new RestResponse(HttpStatus.INTERNAL_SERVER_ERROR, "수정에 실패했습니다."); + } + } + + @Override + public RestResponse deleteBlogSource(Long sourceId) { + int result = blogSourceMapper.deleteBlogSource(sourceId); + if (result > 0) { + return new RestResponse(HttpStatus.OK, "삭제되었습니다."); + } else { + return new RestResponse(HttpStatus.INTERNAL_SERVER_ERROR, "삭제에 실패했습니다."); + } + } +} diff --git a/src/main/java/com/itn/admin/itn/blog/web/BlogAccountController.java b/src/main/java/com/itn/admin/itn/blog/web/BlogAccountController.java new file mode 100644 index 0000000..26cf229 --- /dev/null +++ b/src/main/java/com/itn/admin/itn/blog/web/BlogAccountController.java @@ -0,0 +1,83 @@ +package com.itn.admin.itn.blog.web; + +import com.itn.admin.cmn.config.CustomUserDetails; +import com.itn.admin.itn.blog.mapper.domain.BlogAccountVO; +import com.itn.admin.itn.blog.service.BlogAccountService; +import com.itn.admin.cmn.msg.RestResponse; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Controller +@RequestMapping("/blog/accounts") +public class BlogAccountController { + + private final BlogAccountService blogAccountService; + + public BlogAccountController(BlogAccountService blogAccountService) { + this.blogAccountService = blogAccountService; + } + + @GetMapping("/list") + public String getBlogAccountListPage() { + return "itn/blog/account/list"; + } + + @GetMapping("/detail/{blogId}") + public String getBlogAccountDetailPage(@PathVariable Long blogId, Model model) { + BlogAccountVO detail = blogAccountService.getBlogAccountDetail(blogId); + model.addAttribute("blogAccount", detail); + return "itn/blog/account/edit"; + } + + @GetMapping("/edit") + public String editBlogAccountPage(Model model) { + model.addAttribute("blogAccount", new BlogAccountVO()); + return "itn/blog/account/edit"; + } + + @GetMapping("/api/list") + @ResponseBody + public ResponseEntity<List<BlogAccountVO>> getBlogAccountList() { + List<BlogAccountVO> list = blogAccountService.getBlogAccountList(); + return ResponseEntity.ok(list); + } + + @GetMapping("/api/detail/{blogId}") + @ResponseBody + public ResponseEntity<BlogAccountVO> getBlogAccountDetail(@PathVariable Long blogId) { + BlogAccountVO detail = blogAccountService.getBlogAccountDetail(blogId); + return ResponseEntity.ok(detail); + } + + @PostMapping("/api/insert") + @ResponseBody + public ResponseEntity<RestResponse> insertBlogAccount(@RequestBody BlogAccountVO blogAccountVO + , @AuthenticationPrincipal CustomUserDetails loginUser) { + + blogAccountVO.setFrstRegisterId(loginUser.getUser().getUniqId()); + blogAccountVO.setLastUpdusrId(loginUser.getUser().getUniqId()); + + return ResponseEntity.ok().body(blogAccountService.insertBlogAccount(blogAccountVO)); + } + + @PutMapping("/api/update") + @ResponseBody + public ResponseEntity<RestResponse> updateBlogAccount(@RequestBody BlogAccountVO blogAccountVO + , @AuthenticationPrincipal CustomUserDetails loginUser) { + + blogAccountVO.setLastUpdusrId(loginUser.getUser().getUniqId()); + + return ResponseEntity.ok().body(blogAccountService.updateBlogAccount(blogAccountVO)); + } + + @DeleteMapping("/api/delete/{blogId}") + @ResponseBody + public ResponseEntity<RestResponse> deleteBlogAccount(@PathVariable Long blogId) { + return ResponseEntity.ok().body(blogAccountService.deleteBlogAccount(blogId)); + } +} diff --git a/src/main/java/com/itn/admin/itn/blog/web/BlogCookieTestController.java b/src/main/java/com/itn/admin/itn/blog/web/BlogCookieTestController.java new file mode 100644 index 0000000..79d91f6 --- /dev/null +++ b/src/main/java/com/itn/admin/itn/blog/web/BlogCookieTestController.java @@ -0,0 +1,179 @@ +package com.itn.admin.itn.blog.web; + +import com.itn.admin.cmn.msg.RestResponse; +import com.itn.admin.cmn.util.tistory.TistoryCookieUtil; +import com.itn.admin.itn.blog.mapper.domain.BlogCookieMappingVO; +import com.itn.admin.itn.blog.service.BlogCookieService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.Map; + +/** + * 블로그 쿠키 매핑 테스트 컨트롤러 + */ +@Slf4j +@RestController +@RequestMapping("/api/blog/cookie/test") +public class BlogCookieTestController { + + @Autowired + private BlogCookieService blogCookieService; + + @Autowired + private TistoryCookieUtil tistoryCookieUtil; + + /** + * 블로그별 쿠키 매핑 정보 조회 테스트 + */ + @GetMapping("/mapping/{blogId}") + public ResponseEntity<RestResponse> testCookieMapping(@PathVariable Long blogId) { + try { + BlogCookieMappingVO cookieMapping = blogCookieService.getCookieMappingByBlogId(blogId); + + Map<String, Object> result = new HashMap<>(); + + if (cookieMapping != null) { + result.put("found", true); + result.put("mappingId", cookieMapping.getMappingId()); + result.put("blogId", cookieMapping.getBlogId()); + result.put("cookieFilePath", cookieMapping.getCookieFilePath()); + result.put("cookieFileName", cookieMapping.getCookieFileName()); + result.put("fullPath", cookieMapping.getFullCookieFilePath()); + result.put("isActive", cookieMapping.isActiveCookie()); + result.put("isExpired", cookieMapping.isCookieExpired()); + result.put("needsValidation", cookieMapping.needsValidation()); + result.put("blogName", cookieMapping.getBlogName()); + result.put("blogUrl", cookieMapping.getBlogUrl()); + + log.info("쿠키 매핑 정보 조회 성공: {}", result); + } else { + result.put("found", false); + result.put("message", "해당 블로그의 쿠키 매핑 정보를 찾을 수 없습니다."); + + log.warn("쿠키 매핑 정보 없음: blogId={}", blogId); + } + + return ResponseEntity.ok(new RestResponse(HttpStatus.OK, + "쿠키 매핑 정보 조회 완료", result)); + + } catch (Exception e) { + log.error("쿠키 매핑 정보 조회 실패: blogId={}", blogId, e); + return ResponseEntity.ok(new RestResponse(HttpStatus.INTERNAL_SERVER_ERROR, + "쿠키 매핑 정보 조회 실패: " + e.getMessage())); + } + } + + /** + * 블로그별 쿠키 문자열 생성 테스트 + */ + @GetMapping("/cookie-string/{blogId}") + public ResponseEntity<RestResponse> testCookieString(@PathVariable Long blogId) { + try { + BlogCookieMappingVO cookieMapping = blogCookieService.getCookieMappingByBlogId(blogId); + + Map<String, Object> result = new HashMap<>(); + + if (cookieMapping != null) { + // 블로그별 쿠키 문자열 생성 + String cookieString = tistoryCookieUtil.getCookieStringForBlog(cookieMapping); + + result.put("success", true); + result.put("cookieLength", cookieString != null ? cookieString.length() : 0); + result.put("cookiePreview", cookieString != null && cookieString.length() > 100 ? + cookieString.substring(0, 100) + "..." : cookieString); + result.put("containsTSSession", cookieString != null && cookieString.contains("TSSESSION")); + result.put("containsTistory", cookieString != null && cookieString.contains("tistory")); + + log.info("블로그별 쿠키 문자열 생성 성공: blogId={}, length={}", + blogId, cookieString != null ? cookieString.length() : 0); + } else { + result.put("success", false); + result.put("message", "해당 블로그의 쿠키 매핑 정보를 찾을 수 없습니다."); + + log.warn("쿠키 매핑 정보 없음: blogId={}", blogId); + } + + return ResponseEntity.ok(new RestResponse(HttpStatus.OK, + "쿠키 문자열 생성 테스트 완료", result)); + + } catch (Exception e) { + log.error("쿠키 문자열 생성 테스트 실패: blogId={}", blogId, e); + return ResponseEntity.ok(new RestResponse(HttpStatus.INTERNAL_SERVER_ERROR, + "쿠키 문자열 생성 테스트 실패: " + e.getMessage())); + } + } + + /** + * 쿠키 유효성 검증 테스트 + */ + @PostMapping("/validate/{blogId}") + public ResponseEntity<RestResponse> testCookieValidation(@PathVariable Long blogId) { + try { + boolean isValid = blogCookieService.validateAndUpdateCookie(blogId); + + Map<String, Object> result = new HashMap<>(); + result.put("blogId", blogId); + result.put("isValid", isValid); + result.put("validationTime", java.time.LocalDateTime.now()); + + log.info("쿠키 유효성 검증 완료: blogId={}, isValid={}", blogId, isValid); + + return ResponseEntity.ok(new RestResponse(HttpStatus.OK, + "쿠키 유효성 검증 완료", result)); + + } catch (Exception e) { + log.error("쿠키 유효성 검증 실패: blogId={}", blogId, e); + return ResponseEntity.ok(new RestResponse(HttpStatus.INTERNAL_SERVER_ERROR, + "쿠키 유효성 검증 실패: " + e.getMessage())); + } + } + + /** + * 기본 쿠키와 블로그별 쿠키 비교 테스트 + */ + @GetMapping("/compare/{blogId}") + public ResponseEntity<RestResponse> compareCookies(@PathVariable Long blogId) { + try { + BlogCookieMappingVO cookieMapping = blogCookieService.getCookieMappingByBlogId(blogId); + + Map<String, Object> result = new HashMap<>(); + + // 기본 쿠키 문자열 + String defaultCookie = tistoryCookieUtil.getTistoryCookieString(); + result.put("defaultCookieLength", defaultCookie != null ? defaultCookie.length() : 0); + result.put("defaultCookiePreview", defaultCookie != null && defaultCookie.length() > 50 ? + defaultCookie.substring(0, 50) + "..." : defaultCookie); + + if (cookieMapping != null) { + // 블로그별 쿠키 문자열 + String blogCookie = tistoryCookieUtil.getCookieStringForBlog(cookieMapping); + result.put("blogCookieLength", blogCookie != null ? blogCookie.length() : 0); + result.put("blogCookiePreview", blogCookie != null && blogCookie.length() > 50 ? + blogCookie.substring(0, 50) + "..." : blogCookie); + + // 비교 결과 + result.put("isSame", defaultCookie != null && defaultCookie.equals(blogCookie)); + result.put("cookieFilePath", cookieMapping.getFullCookieFilePath()); + } else { + result.put("blogCookieLength", 0); + result.put("blogCookiePreview", "매핑 정보 없음"); + result.put("isSame", false); + } + + log.info("쿠키 비교 테스트 완료: blogId={}", blogId); + + return ResponseEntity.ok(new RestResponse(HttpStatus.OK, + "쿠키 비교 테스트 완료", result)); + + } catch (Exception e) { + log.error("쿠키 비교 테스트 실패: blogId={}", blogId, e); + return ResponseEntity.ok(new RestResponse(HttpStatus.INTERNAL_SERVER_ERROR, + "쿠키 비교 테스트 실패: " + e.getMessage())); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/itn/admin/itn/blog/web/BlogPostingApiController.java b/src/main/java/com/itn/admin/itn/blog/web/BlogPostingApiController.java new file mode 100644 index 0000000..9fac436 --- /dev/null +++ b/src/main/java/com/itn/admin/itn/blog/web/BlogPostingApiController.java @@ -0,0 +1,337 @@ +package com.itn.admin.itn.blog.web; + +import com.itn.admin.itn.blog.mapper.BlogPostingMapper; +import com.itn.admin.itn.blog.mapper.domain.BlogAccountWithSourcesDTO; +import com.itn.admin.itn.blog.mapper.domain.BlogAccountVO; +import com.itn.admin.itn.blog.mapper.domain.BlogPostHistoryVO; +import com.itn.admin.itn.blog.mapper.domain.PostingRequestDTO; +import com.itn.admin.itn.blog.mapper.domain.TistoryPublishRequestDTO; +import com.itn.admin.itn.blog.service.BlogPostingService; +import com.itn.admin.itn.blog.service.BlogAccountService; +import com.itn.admin.itn.blog.service.BlogPublishService; +import com.itn.admin.itn.blog.service.TistoryPublishService; +import com.itn.admin.cmn.msg.RestResponse; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/blog/posting") +public class BlogPostingApiController { + + @Autowired + private BlogPostingService blogPostingService; + + @Autowired + private BlogAccountService blogAccountService; + + @Autowired + private BlogPublishService blogPublishService; + + @Autowired + private TistoryPublishService tistoryPublishService; + + @Autowired + private BlogPostingMapper blogPostingMapper; + + @GetMapping("/list") + public ResponseEntity<RestResponse> getBlogAccountList() { + try { + List<BlogAccountVO> list = blogAccountService.getBlogAccountListWithStats(); + return ResponseEntity.ok(new RestResponse(HttpStatus.OK, "조회되었습니다.", list)); + } catch (Exception e) { + return ResponseEntity.ok(new RestResponse(HttpStatus.INTERNAL_SERVER_ERROR, "조회에 실패했습니다.")); + } + } + + @GetMapping("/manage/{blogId}") + @ResponseBody + public ResponseEntity<RestResponse> getAccountWithSources(@PathVariable Long blogId) { + try { + BlogAccountWithSourcesDTO data = blogPostingService.getAccountWithSources(blogId); + return ResponseEntity.ok(new RestResponse(HttpStatus.OK, "조회되었습니다.", data)); + } catch (Exception e) { + return ResponseEntity.ok(new RestResponse(HttpStatus.INTERNAL_SERVER_ERROR, "조회에 실패했습니다.")); + } + } + + @PostMapping("/setting") + @ResponseBody + public ResponseEntity<RestResponse> saveSetting(@RequestBody PostingRequestDTO request) { + try { + // 포스팅 설정 저장 로직 + return ResponseEntity.ok(new RestResponse(HttpStatus.OK, "포스팅 설정이 저장되었습니다.")); + } catch (Exception e) { + return ResponseEntity.ok(new RestResponse(HttpStatus.INTERNAL_SERVER_ERROR, "설정 저장에 실패했습니다.")); + } + } + + @PostMapping("/execute") + @ResponseBody + public ResponseEntity<RestResponse> executePosting(@RequestBody PostingRequestDTO request) { + try { + String blogId = request.getBlogId(); + String sourceId = request.getSourceIds().get(0); // 첫 번째 소스 ID + + // TODO: sourceId로부터 실제 URL을 조회하는 로직 필요 + String sourceUrl = "https://example.com/post"; // 임시 + + BlogPostHistoryVO history = blogPublishService.publishPost(blogId, sourceId, sourceUrl); + + return ResponseEntity.ok(new RestResponse(HttpStatus.OK, "포스팅이 성공적으로 실행되었습니다.", history)); + } catch (Exception e) { + return ResponseEntity.ok(new RestResponse(HttpStatus.INTERNAL_SERVER_ERROR, "포스팅 실행에 실패했습니다: " + e.getMessage())); + } + } + + /** + * 개별 포스팅 발행 (기존 단순 발행 - 하위 호환성 유지) + */ + @PostMapping("/publish") + @ResponseBody + public ResponseEntity<RestResponse> publishSinglePost( + @RequestParam String blogId, + @RequestParam String urlId, + @RequestParam String sourceUrl) { + try { + BlogPostHistoryVO history = blogPublishService.publishPost(blogId, urlId, sourceUrl); + + return ResponseEntity.ok(new RestResponse(HttpStatus.OK, + "포스팅이 성공적으로 발행되었습니다.", history)); + } catch (Exception e) { + return ResponseEntity.ok(new RestResponse(HttpStatus.INTERNAL_SERVER_ERROR, + "포스팅 발행에 실패했습니다: " + e.getMessage())); + } + } + + /** + * 통합 발행 워크플로우 (HTML 생성 + 티스토리 발행) + */ + @PostMapping("/publish/integrated") + @ResponseBody + public ResponseEntity<RestResponse> publishIntegrated( + @RequestParam Long blogId, + @RequestParam Long urlId, + @RequestParam String sourceUrl) { + try { + // 입력 유효성 검증 + if (blogId == null || urlId == null || sourceUrl == null || sourceUrl.trim().isEmpty()) { + return ResponseEntity.ok(new RestResponse(HttpStatus.BAD_REQUEST, + "필수 파라미터가 누락되었습니다. (blogId, urlId, sourceUrl)")); + } + + // 통합 워크플로우 실행 + BlogPostHistoryVO history = tistoryPublishService.publishWithHtmlGeneration(blogId, urlId, sourceUrl); + + return ResponseEntity.ok(new RestResponse(HttpStatus.OK, + "통합 발행 워크플로우가 성공적으로 완료되었습니다.", history)); + + } catch (Exception e) { + return ResponseEntity.ok(new RestResponse(HttpStatus.INTERNAL_SERVER_ERROR, + "통합 발행 워크플로우 실패: " + e.getMessage())); + } + } + + /** + * 포스팅 히스토리 조회 + */ + @GetMapping("/history") + @ResponseBody + public ResponseEntity<RestResponse> getPostingHistory( + @RequestParam(required = false) String blogId, + @RequestParam(required = false) String urlId, + @RequestParam(defaultValue = "10") int limit, + @RequestParam(defaultValue = "0") int offset) { + try { + // TODO: BlogPostingService에 히스토리 조회 메서드 추가 필요 + return ResponseEntity.ok(new RestResponse(HttpStatus.OK, "조회되었습니다.")); + } catch (Exception e) { + return ResponseEntity.ok(new RestResponse(HttpStatus.INTERNAL_SERVER_ERROR, "조회에 실패했습니다.")); + } + } + + /** + * 발행 진행상황 실시간 조회 (특정 postId) + */ + @GetMapping("/publish/status/{postId}") + @ResponseBody + public ResponseEntity<RestResponse> getPublishStatus(@PathVariable Long postId) { + try { + BlogPostHistoryVO history = blogPostingMapper.selectBlogPostHistoryById(postId); + + if (history == null) { + return ResponseEntity.ok(new RestResponse(HttpStatus.NOT_FOUND, + "해당 발행 기록을 찾을 수 없습니다.")); + } + + // 진행상황 요약 정보 생성 + java.util.Map<String, Object> statusInfo = new java.util.HashMap<>(); + statusInfo.put("postId", history.getPostId()); + statusInfo.put("status", history.getStatus()); + statusInfo.put("postTitle", history.getPostTitle()); + statusInfo.put("isInProgress", history.isInProgress()); + statusInfo.put("isSuccess", history.isSuccess()); + statusInfo.put("isFailed", history.isFailed()); + statusInfo.put("isHtmlGenerated", history.isHtmlGenerated()); + statusInfo.put("isPublishStarted", history.isPublishStarted()); + statusInfo.put("isPublishCompleted", history.isPublishCompleted()); + statusInfo.put("htmlGeneratedAt", history.getHtmlGeneratedAt()); + statusInfo.put("publishStartedAt", history.getPublishStartedAt()); + statusInfo.put("publishedAt", history.getPublishedAt()); + statusInfo.put("publishedUrl", history.getPublishedUrl()); + statusInfo.put("errorMessage", history.getErrorMessage()); + + return ResponseEntity.ok(new RestResponse(HttpStatus.OK, + "진행상황을 조회했습니다.", statusInfo)); + + } catch (Exception e) { + return ResponseEntity.ok(new RestResponse(HttpStatus.INTERNAL_SERVER_ERROR, + "진행상황 조회에 실패했습니다: " + e.getMessage())); + } + } + + /** + * 소스별 발행 통계 조회 + */ + @GetMapping("/stats/sources/{blogId}") + @ResponseBody + public ResponseEntity<RestResponse> getSourcePublishStats(@PathVariable Long blogId) { + try { + java.util.List<java.util.Map<String, Object>> stats = + blogPostingMapper.selectPublishStatsBySource(blogId); + + return ResponseEntity.ok(new RestResponse(HttpStatus.OK, + "소스별 발행 통계를 조회했습니다.", stats)); + + } catch (Exception e) { + return ResponseEntity.ok(new RestResponse(HttpStatus.INTERNAL_SERVER_ERROR, + "통계 조회에 실패했습니다: " + e.getMessage())); + } + } + + /** + * 티스토리 자동 배포 + */ + @PostMapping("/publish/tistory") + @ResponseBody + public ResponseEntity<RestResponse> publishToTistory( + @RequestParam String title, + @RequestParam String htmlContent, + @RequestParam Long blogId, + @RequestParam(required = false) String sourceUrl) { + try { + BlogPostHistoryVO history; + + if (sourceUrl != null && !sourceUrl.trim().isEmpty()) { + history = tistoryPublishService.publishToTistory(title, htmlContent, sourceUrl, blogId); + } else { + history = tistoryPublishService.publishToTistory(title, htmlContent, blogId); + } + + if ("Y".equals(history.getStatus())) { + return ResponseEntity.ok(new RestResponse(HttpStatus.OK, + "티스토리에 성공적으로 발행되었습니다.", history)); + } else { + return ResponseEntity.ok(new RestResponse(HttpStatus.INTERNAL_SERVER_ERROR, + "티스토리 발행에 실패했습니다: " + history.getErrorMessage())); + } + } catch (Exception e) { + return ResponseEntity.ok(new RestResponse(HttpStatus.INTERNAL_SERVER_ERROR, + "티스토리 발행 중 오류가 발생했습니다: " + e.getMessage())); + } + } + + /** + * Python 블로그 생성 서비스 상태 확인 + */ + @GetMapping("/service/status") + @ResponseBody + public ResponseEntity<RestResponse> checkServiceStatus() { + try { + Map<String, Object> status = tistoryPublishService.checkBlogGenerationServiceStatus(); + + return ResponseEntity.ok(new RestResponse(HttpStatus.OK, + "서비스 상태를 확인했습니다.", status)); + + } catch (Exception e) { + return ResponseEntity.ok(new RestResponse(HttpStatus.INTERNAL_SERVER_ERROR, + "서비스 상태 확인에 실패했습니다: " + e.getMessage())); + } + } + + /** + * 티스토리 로그인 상태 및 토큰 확인 + */ + @GetMapping("/tistory/check-auth") + @ResponseBody + public ResponseEntity<RestResponse> checkTistoryAuth() { + try { + // 티스토리 토큰 획득 시도 + boolean authValid = true; + String errorMessage = null; + + try { + // private 메서드를 직접 호출할 수 없으므로 임시 HTML 생성으로 테스트 + Map<String, Object> result = tistoryPublishService.checkBlogGenerationServiceStatus(); + // 실제로는 TistoryPublishService에 public 메서드 추가 필요 + + Map<String, Object> authStatus = new HashMap<>(); + authStatus.put("isAuthenticated", authValid); + authStatus.put("message", authValid ? "티스토리 인증 상태가 정상입니다." : errorMessage); + authStatus.put("checkedAt", java.time.LocalDateTime.now()); + + return ResponseEntity.ok(new RestResponse(HttpStatus.OK, + "티스토리 인증 상태를 확인했습니다.", authStatus)); + + } catch (Exception e) { + authValid = false; + errorMessage = e.getMessage(); + + Map<String, Object> authStatus = new HashMap<>(); + authStatus.put("isAuthenticated", false); + authStatus.put("message", "티스토리 인증 실패: " + errorMessage); + authStatus.put("checkedAt", java.time.LocalDateTime.now()); + + return ResponseEntity.ok(new RestResponse(HttpStatus.OK, + "티스토리 인증 상태를 확인했습니다.", authStatus)); + } + + } catch (Exception e) { + return ResponseEntity.ok(new RestResponse(HttpStatus.INTERNAL_SERVER_ERROR, + "인증 상태 확인에 실패했습니다: " + e.getMessage())); + } + } + + /** + * HTML과 제목으로 티스토리 자동 배포 (간편 버전) + */ + @PostMapping("/publish/tistory/simple") + @ResponseBody + public ResponseEntity<RestResponse> publishToTistorySimple( + @RequestBody TistoryPublishRequestDTO request) { + try { + BlogPostHistoryVO history = tistoryPublishService.publishToTistory( + request.getTitle(), + request.getHtmlContent(), + request.getSourceUrl(), + request.getBlogId() + ); + + if ("Y".equals(history.getStatus())) { + return ResponseEntity.ok(new RestResponse(HttpStatus.OK, + "티스토리에 성공적으로 발행되었습니다.", history)); + } else { + return ResponseEntity.ok(new RestResponse(HttpStatus.INTERNAL_SERVER_ERROR, + "티스토리 발행에 실패했습니다: " + history.getErrorMessage())); + } + } catch (Exception e) { + return ResponseEntity.ok(new RestResponse(HttpStatus.INTERNAL_SERVER_ERROR, + "티스토리 발행 중 오류가 발생했습니다: " + e.getMessage())); + } + } +} diff --git a/src/main/java/com/itn/admin/itn/blog/web/BlogPostingController.java b/src/main/java/com/itn/admin/itn/blog/web/BlogPostingController.java new file mode 100644 index 0000000..ff88d33 --- /dev/null +++ b/src/main/java/com/itn/admin/itn/blog/web/BlogPostingController.java @@ -0,0 +1,39 @@ +package com.itn.admin.itn.blog.web; + +import com.itn.admin.itn.blog.service.BlogPostingService; +import com.itn.admin.itn.blog.mapper.domain.BlogAccountWithSourcesDTO; +import com.itn.admin.itn.blog.mapper.domain.PostingRequestDTO; +import com.itn.admin.cmn.msg.RestResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.Map; + +@Controller +@RequestMapping("/blog/posting") +@RequiredArgsConstructor +public class BlogPostingController { + + private final BlogPostingService blogPostingService; + + @GetMapping("/list") + public String getBlogPostingListPage() { + return "itn/blog/posting/list"; + } + + @GetMapping("/form") + public String getBlogPostingFormPage() { + return "itn/blog/posting/edit"; + } + + @GetMapping("/manage/{blogId}") + public String getBlogPostingManagePage(@PathVariable Long blogId, Model model) { + model.addAttribute("blogId", blogId); + return "itn/blog/posting/manage"; + } + +} diff --git a/src/main/java/com/itn/admin/itn/blog/web/BlogSourceController.java b/src/main/java/com/itn/admin/itn/blog/web/BlogSourceController.java new file mode 100644 index 0000000..5d5c353 --- /dev/null +++ b/src/main/java/com/itn/admin/itn/blog/web/BlogSourceController.java @@ -0,0 +1,75 @@ +package com.itn.admin.itn.blog.web; + +import com.itn.admin.cmn.config.CustomUserDetails; +import com.itn.admin.cmn.msg.RestResponse; +import com.itn.admin.itn.blog.mapper.domain.BlogSourceVO; +import com.itn.admin.itn.blog.service.BlogSourceService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Controller +@RequestMapping("/blog/sources") +@RequiredArgsConstructor +public class BlogSourceController { + + private final BlogSourceService blogSourceService; + + @GetMapping("/list") + public String getBlogSourceListPage() { + return "itn/blog/source/list"; + } + + @GetMapping("/detail/{sourceId}") + public String getBlogSourceDetailPage(@PathVariable Long sourceId, Model model) { + BlogSourceVO detail = blogSourceService.getBlogSourceDetail(sourceId); + model.addAttribute("blogSource", detail); + return "itn/blog/source/edit"; + } + + @GetMapping("/edit") + public String getBlogSourceEditPage(Model model) { + model.addAttribute("blogSource", new BlogSourceVO()); + return "itn/blog/source/edit"; + } + + @GetMapping("/api/list") + @ResponseBody + public ResponseEntity<List<BlogSourceVO>> getBlogSourceList() { + List<BlogSourceVO> list = blogSourceService.getBlogSourceList(); + return ResponseEntity.ok(list); + } + + @PostMapping("/api/insert") + @ResponseBody + public ResponseEntity<RestResponse> insertBlogSource(@RequestBody BlogSourceVO blogSourceVO + , @AuthenticationPrincipal CustomUserDetails loginUser) { + + blogSourceVO.setFrstRegisterId(loginUser.getUser().getUniqId()); + blogSourceVO.setLastUpdusrId(loginUser.getUser().getUniqId()); + + return ResponseEntity.ok().body(blogSourceService.insertBlogSource(blogSourceVO)); + } + + @PutMapping("/api/update") + @ResponseBody + public ResponseEntity<RestResponse> updateBlogSource(@RequestBody BlogSourceVO blogSourceVO + , @AuthenticationPrincipal CustomUserDetails loginUser) { + + blogSourceVO.setLastUpdusrId(loginUser.getUser().getUniqId()); + + return ResponseEntity.ok().body(blogSourceService.updateBlogSource(blogSourceVO)); + } + + @DeleteMapping("/api/delete/{sourceId}") + @ResponseBody + public ResponseEntity<RestResponse> deleteBlogSource(@PathVariable Long sourceId) { + RestResponse restResponse = blogSourceService.deleteBlogSource(sourceId); + return ResponseEntity.ok().body(restResponse); + } +} diff --git a/src/main/resources/mapper/itn/blog/BlogAccountMapper.xml b/src/main/resources/mapper/itn/blog/BlogAccountMapper.xml new file mode 100644 index 0000000..8769fb8 --- /dev/null +++ b/src/main/resources/mapper/itn/blog/BlogAccountMapper.xml @@ -0,0 +1,124 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> +<mapper namespace="com.itn.admin.itn.blog.mapper.BlogAccountMapper"> + + <sql id="blogAccountColumns"> + blog_id, + platform, + blog_name, + blog_url, + api_key, + api_secret, + auth_info_1, + auth_info_2, + status, + frst_register_id, + frst_regist_pnttm, + last_updusr_id, + last_updt_pnttm + </sql> + + <select id="getBlogAccountList" resultType="com.itn.admin.itn.blog.mapper.domain.BlogAccountVO"> + SELECT + <include refid="blogAccountColumns"/> + FROM + blog_accounts + ORDER BY frst_regist_pnttm DESC + </select> + + <!-- 발행 통계를 포함한 블로그 계정 목록 조회 --> + <select id="getBlogAccountListWithStats" resultType="com.itn.admin.itn.blog.mapper.domain.BlogAccountVO"> + SELECT + ba.blog_id, + ba.platform, + ba.blog_name, + ba.blog_url, + ba.api_key, + ba.api_secret, + ba.auth_info_1, + ba.auth_info_2, + ba.status, + ba.frst_register_id, + ba.frst_regist_pnttm, + ba.last_updusr_id, + ba.last_updt_pnttm, + COALESCE(stats.total_post_count, 0) AS totalPostCount, + stats.last_published_at AS lastPublishedAt + FROM + blog_accounts ba + LEFT JOIN ( + SELECT + bph.blog_id, + COUNT(*) AS total_post_count, + MAX(bph.published_at) AS last_published_at + FROM + blog_post_history bph + WHERE + bph.status = 'Y' + GROUP BY + bph.blog_id + ) stats ON ba.blog_id = stats.blog_id + ORDER BY + ba.frst_regist_pnttm DESC + </select> + + <select id="getBlogAccountDetail" resultType="com.itn.admin.itn.blog.mapper.domain.BlogAccountVO"> + SELECT + <include refid="blogAccountColumns"/> + FROM + blog_accounts + WHERE + blog_id = #{blogId} + </select> + + <insert id="insertBlogAccount" parameterType="com.itn.admin.itn.blog.mapper.domain.BlogAccountVO"> + INSERT INTO blog_accounts ( + platform, + blog_name, + blog_url, + api_key, + api_secret, + auth_info_1, + auth_info_2, + frst_register_id, + frst_regist_pnttm, + last_updusr_id, + last_updt_pnttm + ) VALUES ( + #{platform}, + #{blogName}, + #{blogUrl}, + #{apiKey}, + #{apiSecret}, + #{authInfo1}, + #{authInfo2}, + #{frstRegisterId}, + NOW(), + #{lastUpdusrId}, + NOW() + ) + </insert> + + <update id="updateBlogAccount" parameterType="com.itn.admin.itn.blog.mapper.domain.BlogAccountVO"> + UPDATE blog_accounts + SET + platform = #{platform}, + blog_name = #{blogName}, + blog_url = #{blogUrl}, + api_key = #{apiKey}, + api_secret = #{apiSecret}, + auth_info_1 = #{authInfo1}, + auth_info_2 = #{authInfo2}, + last_updusr_id = #{lastUpdusrId}, + last_updt_pnttm = NOW() + WHERE + blog_id = #{blogId} + </update> + + <delete id="deleteBlogAccount" parameterType="Long"> + DELETE FROM blog_accounts + WHERE + blog_id = #{blogId} + </delete> + +</mapper> diff --git a/src/main/resources/mapper/itn/blog/BlogScheduleExecutionMapper.xml b/src/main/resources/mapper/itn/blog/BlogScheduleExecutionMapper.xml new file mode 100644 index 0000000..578fe1a --- /dev/null +++ b/src/main/resources/mapper/itn/blog/BlogScheduleExecutionMapper.xml @@ -0,0 +1,447 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> +<mapper namespace="com.itn.admin.itn.blog.mapper.BlogScheduleExecutionMapper"> + + <sql id="executionColumns"> + execution_id, + schedule_id, + executed_at, + started_at, + completed_at, + status, + result_message, + error_details, + attempt_count, + published_url, + execution_time_ms, + server_info, + created_at + </sql> + + <sql id="executionColumnsWithJoin"> + bse.execution_id, + bse.schedule_id, + bse.executed_at, + bse.started_at, + bse.completed_at, + bse.status, + bse.result_message, + bse.error_details, + bse.attempt_count, + bse.published_url, + bse.execution_time_ms, + bse.server_info, + bse.created_at, + bs.title AS scheduleTitle, + ba.blog_name, + bs.priority AS schedulePriority + </sql> + + <sql id="executionSearchConditions"> + <where> + <if test="searchDTO.status != null and searchDTO.status != ''"> + AND bse.status = #{searchDTO.status} + </if> + <if test="searchDTO.lastExecutedFrom != null"> + AND bse.executed_at >= #{searchDTO.lastExecutedFrom} + </if> + <if test="searchDTO.lastExecutedTo != null"> + AND bse.executed_at <= #{searchDTO.lastExecutedTo} + </if> + <if test="searchDTO.blogId != null"> + AND bs.blog_id = #{searchDTO.blogId} + </if> + </where> + </sql> + + <!-- 기본 CRUD --> + <insert id="insertScheduleExecution" parameterType="com.itn.admin.itn.blog.mapper.domain.BlogScheduleExecutionVO" + useGeneratedKeys="true" keyProperty="executionId"> + INSERT INTO blog_schedule_executions ( + schedule_id, + executed_at, + started_at, + completed_at, + status, + result_message, + error_details, + attempt_count, + published_url, + execution_time_ms, + server_info, + created_at + ) VALUES ( + #{scheduleId}, + #{executedAt}, + #{startedAt}, + #{completedAt}, + #{status}, + #{resultMessage}, + #{errorDetails}, + #{attemptCount}, + #{publishedUrl}, + #{executionTimeMs}, + #{serverInfo}, + NOW() + ) + </insert> + + <update id="updateScheduleExecution" parameterType="com.itn.admin.itn.blog.mapper.domain.BlogScheduleExecutionVO"> + UPDATE blog_schedule_executions + SET + executed_at = #{executedAt}, + started_at = #{startedAt}, + completed_at = #{completedAt}, + status = #{status}, + result_message = #{resultMessage}, + error_details = #{errorDetails}, + attempt_count = #{attemptCount}, + published_url = #{publishedUrl}, + execution_time_ms = #{executionTimeMs}, + server_info = #{serverInfo} + WHERE + execution_id = #{executionId} + </update> + + <select id="getScheduleExecutionDetail" resultType="com.itn.admin.itn.blog.mapper.domain.BlogScheduleExecutionVO"> + SELECT + <include refid="executionColumnsWithJoin"/> + FROM + blog_schedule_executions bse + LEFT JOIN blog_schedules bs ON bse.schedule_id = bs.schedule_id + LEFT JOIN blog_accounts ba ON bs.blog_id = ba.blog_id + WHERE + bse.execution_id = #{executionId} + </select> + + <!-- 특정 스케줄의 실행 이력 --> + <select id="getExecutionHistory" resultType="com.itn.admin.itn.blog.mapper.domain.BlogScheduleExecutionVO"> + SELECT + <include refid="executionColumnsWithJoin"/> + FROM + blog_schedule_executions bse + LEFT JOIN blog_schedules bs ON bse.schedule_id = bs.schedule_id + LEFT JOIN blog_accounts ba ON bs.blog_id = ba.blog_id + WHERE + bse.schedule_id = #{scheduleId} + <if test="searchDTO.status != null and searchDTO.status != ''"> + AND bse.status = #{searchDTO.status} + </if> + <if test="searchDTO.lastExecutedFrom != null"> + AND bse.executed_at >= #{searchDTO.lastExecutedFrom} + </if> + <if test="searchDTO.lastExecutedTo != null"> + AND bse.executed_at <= #{searchDTO.lastExecutedTo} + </if> + ORDER BY + bse.executed_at DESC + <if test="searchDTO.limit != null and searchDTO.limit > 0"> + LIMIT #{searchDTO.offset}, #{searchDTO.limit} + </if> + </select> + + <select id="getExecutionHistoryCount" resultType="int"> + SELECT + COUNT(*) + FROM + blog_schedule_executions bse + LEFT JOIN blog_schedules bs ON bse.schedule_id = bs.schedule_id + WHERE + bse.schedule_id = #{scheduleId} + <if test="searchDTO.status != null and searchDTO.status != ''"> + AND bse.status = #{searchDTO.status} + </if> + <if test="searchDTO.lastExecutedFrom != null"> + AND bse.executed_at >= #{searchDTO.lastExecutedFrom} + </if> + <if test="searchDTO.lastExecutedTo != null"> + AND bse.executed_at <= #{searchDTO.lastExecutedTo} + </if> + </select> + + <!-- 전체 실행 이력 (관리자용) --> + <select id="getAllExecutionHistory" resultType="com.itn.admin.itn.blog.mapper.domain.BlogScheduleExecutionVO"> + SELECT + <include refid="executionColumnsWithJoin"/> + FROM + blog_schedule_executions bse + LEFT JOIN blog_schedules bs ON bse.schedule_id = bs.schedule_id + LEFT JOIN blog_accounts ba ON bs.blog_id = ba.blog_id + <include refid="executionSearchConditions"/> + ORDER BY + bse.executed_at DESC + <if test="limit != null and limit > 0"> + LIMIT #{offset}, #{limit} + </if> + </select> + + <select id="getAllExecutionHistoryCount" resultType="int"> + SELECT + COUNT(*) + FROM + blog_schedule_executions bse + LEFT JOIN blog_schedules bs ON bse.schedule_id = bs.schedule_id + LEFT JOIN blog_accounts ba ON bs.blog_id = ba.blog_id + <include refid="executionSearchConditions"/> + </select> + + <!-- 상태별 조회 --> + <select id="getExecutionsByStatus" resultType="com.itn.admin.itn.blog.mapper.domain.BlogScheduleExecutionVO"> + SELECT + <include refid="executionColumns"/> + FROM + blog_schedule_executions + WHERE + status = #{status} + ORDER BY + executed_at DESC + LIMIT #{limit} + </select> + + <select id="getRunningExecutions" resultType="com.itn.admin.itn.blog.mapper.domain.BlogScheduleExecutionVO"> + SELECT + <include refid="executionColumnsWithJoin"/> + FROM + blog_schedule_executions bse + LEFT JOIN blog_schedules bs ON bse.schedule_id = bs.schedule_id + LEFT JOIN blog_accounts ba ON bs.blog_id = ba.blog_id + WHERE + bse.status = 'RUNNING' + ORDER BY + bse.started_at ASC + </select> + + <select id="getPendingExecutions" resultType="com.itn.admin.itn.blog.mapper.domain.BlogScheduleExecutionVO"> + SELECT + <include refid="executionColumnsWithJoin"/> + FROM + blog_schedule_executions bse + LEFT JOIN blog_schedules bs ON bse.schedule_id = bs.schedule_id + LEFT JOIN blog_accounts ba ON bs.blog_id = ba.blog_id + WHERE + bse.status = 'PENDING' + ORDER BY + bse.executed_at ASC + LIMIT #{limit} + </select> + + <select id="getFailedExecutions" resultType="com.itn.admin.itn.blog.mapper.domain.BlogScheduleExecutionVO"> + SELECT + <include refid="executionColumnsWithJoin"/> + FROM + blog_schedule_executions bse + LEFT JOIN blog_schedules bs ON bse.schedule_id = bs.schedule_id + LEFT JOIN blog_accounts ba ON bs.blog_id = ba.blog_id + WHERE + bse.status = 'FAILED' + AND bse.executed_at BETWEEN #{fromTime} AND #{toTime} + ORDER BY + bse.executed_at DESC + </select> + + <!-- 성능 통계 --> + <select id="getSlowExecutions" resultType="com.itn.admin.itn.blog.mapper.domain.BlogScheduleExecutionVO"> + SELECT + <include refid="executionColumnsWithJoin"/> + FROM + blog_schedule_executions bse + LEFT JOIN blog_schedules bs ON bse.schedule_id = bs.schedule_id + LEFT JOIN blog_accounts ba ON bs.blog_id = ba.blog_id + WHERE + bse.execution_time_ms > #{thresholdMs} + AND bse.status = 'SUCCESS' + ORDER BY + bse.execution_time_ms DESC + LIMIT #{limit} + </select> + + <select id="getAverageExecutionTime" resultType="Double"> + SELECT + AVG(execution_time_ms) + FROM + blog_schedule_executions + WHERE + schedule_id = #{scheduleId} + AND status = 'SUCCESS' + AND executed_at >= DATE_SUB(NOW(), INTERVAL #{days} DAY) + </select> + + <!-- 상태 업데이트 --> + <update id="updateExecutionStatus"> + UPDATE blog_schedule_executions + SET + status = #{status}, + result_message = #{resultMessage} + WHERE + execution_id = #{executionId} + </update> + + <update id="updateExecutionStartTime"> + UPDATE blog_schedule_executions + SET + started_at = #{startedAt}, + status = 'RUNNING' + WHERE + execution_id = #{executionId} + </update> + + <update id="updateExecutionEndTime"> + UPDATE blog_schedule_executions + SET + completed_at = #{completedAt}, + execution_time_ms = #{executionTimeMs}, + status = 'SUCCESS' + WHERE + execution_id = #{executionId} + </update> + + <update id="updateExecutionError"> + UPDATE blog_schedule_executions + SET + error_details = #{errorDetails}, + result_message = #{resultMessage}, + status = 'FAILED' + WHERE + execution_id = #{executionId} + </update> + + <!-- 스케줄별 통계 --> + <select id="getLastExecutionBySchedule" resultType="com.itn.admin.itn.blog.mapper.domain.BlogScheduleExecutionVO"> + SELECT + <include refid="executionColumns"/> + FROM + blog_schedule_executions + WHERE + schedule_id = #{scheduleId} + ORDER BY + executed_at DESC + LIMIT 1 + </select> + + <select id="getSuccessCountBySchedule" resultType="int"> + SELECT + COUNT(*) + FROM + blog_schedule_executions + WHERE + schedule_id = #{scheduleId} + AND status = 'SUCCESS' + AND executed_at >= DATE_SUB(NOW(), INTERVAL #{days} DAY) + </select> + + <select id="getFailureCountBySchedule" resultType="int"> + SELECT + COUNT(*) + FROM + blog_schedule_executions + WHERE + schedule_id = #{scheduleId} + AND status = 'FAILED' + AND executed_at >= DATE_SUB(NOW(), INTERVAL #{days} DAY) + </select> + + <select id="getTotalExecutionCount" resultType="int"> + SELECT + COUNT(*) + FROM + blog_schedule_executions + WHERE + schedule_id = #{scheduleId} + </select> + + <!-- 재시도 관련 --> + <select id="getRetryableExecutions" resultType="com.itn.admin.itn.blog.mapper.domain.BlogScheduleExecutionVO"> + SELECT + bse.* + FROM + blog_schedule_executions bse + INNER JOIN blog_schedules bs ON bse.schedule_id = bs.schedule_id + WHERE + bse.status = 'FAILED' + AND bse.attempt_count < bs.max_retries + AND DATE_ADD(bse.executed_at, INTERVAL bs.retry_interval MINUTE) <= #{currentTime} + AND bs.retry_interval <= #{maxRetryInterval} + ORDER BY + bse.executed_at ASC + LIMIT 50 + </select> + + <update id="incrementAttemptCount"> + UPDATE blog_schedule_executions + SET + attempt_count = attempt_count + 1 + WHERE + execution_id = #{executionId} + </update> + + <!-- 정리 작업 --> + <delete id="deleteOldExecutions"> + DELETE FROM blog_schedule_executions + WHERE + created_at < #{beforeDate} + </delete> + + <delete id="deleteExecutionsBySchedule"> + DELETE FROM blog_schedule_executions + WHERE + schedule_id = #{scheduleId} + </delete> + + <!-- 통계용 집계 쿼리 --> + <select id="getExecutionStatsGroupByHour" resultType="Object[]"> + SELECT + HOUR(executed_at) AS hour, + COUNT(*) AS executionCount, + SUM(CASE WHEN status = 'SUCCESS' THEN 1 ELSE 0 END) AS successCount, + SUM(CASE WHEN status = 'FAILED' THEN 1 ELSE 0 END) AS failureCount + FROM + blog_schedule_executions + WHERE + executed_at BETWEEN #{fromTime} AND #{toTime} + GROUP BY + HOUR(executed_at) + ORDER BY + hour + </select> + + <select id="getExecutionStatsGroupByDay" resultType="Object[]"> + SELECT + DATE(executed_at) AS date, + COUNT(*) AS executionCount, + SUM(CASE WHEN status = 'SUCCESS' THEN 1 ELSE 0 END) AS successCount, + SUM(CASE WHEN status = 'FAILED' THEN 1 ELSE 0 END) AS failureCount, + CASE + WHEN COUNT(*) = 0 THEN 0.0 + ELSE (SUM(CASE WHEN status = 'SUCCESS' THEN 1 ELSE 0 END) * 100.0 / COUNT(*)) + END AS successRate + FROM + blog_schedule_executions + WHERE + executed_at BETWEEN #{fromTime} AND #{toTime} + GROUP BY + DATE(executed_at) + ORDER BY + date DESC + </select> + + <select id="getExecutionStatsGroupByBlog" resultType="Object[]"> + SELECT + bs.blog_id, + ba.blog_name, + COUNT(bse.execution_id) AS executionCount, + SUM(CASE WHEN bse.status = 'SUCCESS' THEN 1 ELSE 0 END) AS successCount, + SUM(CASE WHEN bse.status = 'FAILED' THEN 1 ELSE 0 END) AS failureCount + FROM + blog_schedule_executions bse + INNER JOIN blog_schedules bs ON bse.schedule_id = bs.schedule_id + INNER JOIN blog_accounts ba ON bs.blog_id = ba.blog_id + WHERE + bse.executed_at BETWEEN #{fromTime} AND #{toTime} + GROUP BY + bs.blog_id, ba.blog_name + ORDER BY + executionCount DESC + LIMIT #{limit} + </select> + +</mapper> \ No newline at end of file diff --git a/src/main/resources/mapper/itn/blog/BlogScheduleMapper.xml b/src/main/resources/mapper/itn/blog/BlogScheduleMapper.xml new file mode 100644 index 0000000..f1c4075 --- /dev/null +++ b/src/main/resources/mapper/itn/blog/BlogScheduleMapper.xml @@ -0,0 +1,474 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> +<mapper namespace="com.itn.admin.itn.blog.mapper.BlogScheduleMapper"> + + <sql id="blogScheduleColumns"> + schedule_id, + blog_id, + url_id, + title, + content, + schedule_type, + scheduled_at, + repeat_interval, + repeat_value, + end_at, + status, + priority, + max_retries, + retry_interval, + notification_email, + slack_channel, + enable_notification, + frst_register_id, + frst_regist_dt, + last_updusr_id, + last_updt_dt + </sql> + + <sql id="blogScheduleColumnsWithJoin"> + bs.schedule_id, + bs.blog_id, + bs.url_id, + bs.title, + bs.content, + bs.schedule_type, + bs.scheduled_at, + bs.repeat_interval, + bs.repeat_value, + bs.end_at, + bs.status, + bs.priority, + bs.max_retries, + bs.retry_interval, + bs.notification_email, + bs.slack_channel, + bs.enable_notification, + bs.frst_register_id, + bs.frst_regist_dt, + bs.last_updusr_id, + bs.last_updt_dt, + ba.blog_name, + ba.blog_url, + bsu.url AS sourceUrl, + bsu.title AS sourceTitle + </sql> + + <sql id="searchConditions"> + <where> + <if test="scheduleId != null"> + AND bs.schedule_id = #{scheduleId} + </if> + <if test="blogId != null"> + AND bs.blog_id = #{blogId} + </if> + <if test="urlId != null"> + AND bs.url_id = #{urlId} + </if> + <if test="title != null and title != ''"> + AND bs.title LIKE CONCAT('%', #{title}, '%') + </if> + <if test="scheduleType != null and scheduleType != ''"> + AND bs.schedule_type = #{scheduleType} + </if> + <if test="status != null and status != ''"> + AND bs.status = #{status} + </if> + <if test="statusList != null and statusList.size() > 0"> + AND bs.status IN + <foreach collection="statusList" item="status" open="(" separator="," close=")"> + #{status} + </foreach> + </if> + <if test="priority != null and priority != ''"> + AND bs.priority = #{priority} + </if> + <if test="scheduledAtFrom != null"> + AND bs.scheduled_at >= #{scheduledAtFrom} + </if> + <if test="scheduledAtTo != null"> + AND bs.scheduled_at <= #{scheduledAtTo} + </if> + <if test="createdAtFrom != null"> + AND bs.frst_regist_dt >= #{createdAtFrom} + </if> + <if test="createdAtTo != null"> + AND bs.frst_regist_dt <= #{createdAtTo} + </if> + <if test="enableNotification != null"> + AND bs.enable_notification = #{enableNotification} + </if> + <if test="frstRegisterId != null and frstRegisterId != ''"> + AND bs.frst_register_id = #{frstRegisterId} + </if> + </where> + </sql> + + <!-- 기본 CRUD 작업 --> + <insert id="insertBlogSchedule" parameterType="com.itn.admin.itn.blog.mapper.domain.BlogScheduleVO" + useGeneratedKeys="true" keyProperty="scheduleId"> + INSERT INTO blog_schedules ( + blog_id, + url_id, + title, + content, + schedule_type, + scheduled_at, + repeat_interval, + repeat_value, + end_at, + status, + priority, + max_retries, + retry_interval, + notification_email, + slack_channel, + enable_notification, + frst_register_id, + frst_regist_dt, + last_updusr_id, + last_updt_dt + ) VALUES ( + #{blogId}, + #{urlId}, + #{title}, + #{content}, + #{scheduleType}, + #{scheduledAt}, + #{repeatInterval}, + #{repeatValue}, + #{endAt}, + #{status}, + #{priority}, + #{maxRetries}, + #{retryInterval}, + #{notificationEmail}, + #{slackChannel}, + #{enableNotification}, + #{frstRegisterId}, + NOW(), + #{lastUpdusrId}, + NOW() + ) + </insert> + + <update id="updateBlogSchedule" parameterType="com.itn.admin.itn.blog.mapper.domain.BlogScheduleVO"> + UPDATE blog_schedules + SET + title = #{title}, + content = #{content}, + scheduled_at = #{scheduledAt}, + repeat_interval = #{repeatInterval}, + repeat_value = #{repeatValue}, + end_at = #{endAt}, + status = #{status}, + priority = #{priority}, + max_retries = #{maxRetries}, + retry_interval = #{retryInterval}, + notification_email = #{notificationEmail}, + slack_channel = #{slackChannel}, + enable_notification = #{enableNotification}, + last_updusr_id = #{lastUpdusrId}, + last_updt_dt = NOW() + WHERE + schedule_id = #{scheduleId} + </update> + + <delete id="deleteBlogSchedule" parameterType="Long"> + DELETE FROM blog_schedules + WHERE schedule_id = #{scheduleId} + </delete> + + <select id="getBlogScheduleDetail" resultType="com.itn.admin.itn.blog.mapper.domain.BlogScheduleVO"> + SELECT + <include refid="blogScheduleColumnsWithJoin"/> + FROM + blog_schedules bs + LEFT JOIN blog_accounts ba ON bs.blog_id = ba.blog_id + LEFT JOIN blog_source_urls bsu ON bs.url_id = bsu.url_id + WHERE + bs.schedule_id = #{scheduleId} + </select> + + <!-- 목록 조회 --> + <select id="getBlogScheduleList" resultType="com.itn.admin.itn.blog.mapper.domain.BlogScheduleVO"> + SELECT + <include refid="blogScheduleColumnsWithJoin"/> + FROM + blog_schedules bs + LEFT JOIN blog_accounts ba ON bs.blog_id = ba.blog_id + LEFT JOIN blog_source_urls bsu ON bs.url_id = bsu.url_id + <include refid="searchConditions"/> + ORDER BY + <choose> + <when test="sortBy != null and sortBy != ''"> + ${sortBy} + <if test="sortOrder != null and sortOrder != ''"> + ${sortOrder} + </if> + </when> + <otherwise> + bs.scheduled_at DESC + </otherwise> + </choose> + <if test="limit != null and limit > 0"> + LIMIT #{offset}, #{limit} + </if> + </select> + + <select id="getBlogScheduleCount" resultType="int"> + SELECT + COUNT(*) + FROM + blog_schedules bs + LEFT JOIN blog_accounts ba ON bs.blog_id = ba.blog_id + LEFT JOIN blog_source_urls bsu ON bs.url_id = bsu.url_id + <include refid="searchConditions"/> + </select> + + <!-- 통계 포함 목록 조회 --> + <select id="getBlogScheduleListWithStats" resultType="com.itn.admin.itn.blog.mapper.domain.BlogScheduleVO"> + SELECT + <include refid="blogScheduleColumnsWithJoin"/>, + COALESCE(exec_stats.total_executions, 0) AS totalExecutions, + COALESCE(exec_stats.success_executions, 0) AS successExecutions, + COALESCE(exec_stats.failed_executions, 0) AS failedExecutions, + exec_stats.last_executed_at AS lastExecutedAt + FROM + blog_schedules bs + LEFT JOIN blog_accounts ba ON bs.blog_id = ba.blog_id + LEFT JOIN blog_source_urls bsu ON bs.url_id = bsu.url_id + LEFT JOIN ( + SELECT + bse.schedule_id, + COUNT(*) AS total_executions, + SUM(CASE WHEN bse.status = 'SUCCESS' THEN 1 ELSE 0 END) AS success_executions, + SUM(CASE WHEN bse.status = 'FAILED' THEN 1 ELSE 0 END) AS failed_executions, + MAX(bse.executed_at) AS last_executed_at + FROM + blog_schedule_executions bse + GROUP BY + bse.schedule_id + ) exec_stats ON bs.schedule_id = exec_stats.schedule_id + <include refid="searchConditions"/> + ORDER BY + <choose> + <when test="sortBy != null and sortBy != ''"> + ${sortBy} + <if test="sortOrder != null and sortOrder != ''"> + ${sortOrder} + </if> + </when> + <otherwise> + bs.scheduled_at DESC + </otherwise> + </choose> + <if test="limit != null and limit > 0"> + LIMIT #{offset}, #{limit} + </if> + </select> + + <!-- 스케줄링 전용 조회 --> + <select id="getActiveSchedulesByTime" resultType="com.itn.admin.itn.blog.mapper.domain.BlogScheduleVO"> + SELECT + <include refid="blogScheduleColumnsWithJoin"/> + FROM + blog_schedules bs + LEFT JOIN blog_accounts ba ON bs.blog_id = ba.blog_id + LEFT JOIN blog_source_urls bsu ON bs.url_id = bsu.url_id + WHERE + bs.status = 'ACTIVE' + AND bs.scheduled_at <= DATE_ADD(#{currentTime}, INTERVAL #{bufferMinutes} MINUTE) + AND (bs.end_at IS NULL OR bs.end_at > #{currentTime}) + ORDER BY + bs.priority DESC, + bs.scheduled_at ASC + LIMIT 100 + </select> + + <select id="getPendingSchedulesByPriority" resultType="com.itn.admin.itn.blog.mapper.domain.BlogScheduleVO"> + SELECT + <include refid="blogScheduleColumns"/> + FROM + blog_schedules + WHERE + status = 'ACTIVE' + AND scheduled_at <= NOW() + ORDER BY + FIELD(priority, 'HIGH', 'NORMAL', 'LOW'), + scheduled_at ASC + LIMIT 50 + </select> + + <select id="getFailedSchedulesForRetry" resultType="com.itn.admin.itn.blog.mapper.domain.BlogScheduleVO"> + SELECT + bs.* + FROM + blog_schedules bs + INNER JOIN blog_schedule_executions bse ON bs.schedule_id = bse.schedule_id + WHERE + bs.status = 'FAILED' + AND bse.status = 'FAILED' + AND bse.attempt_count < bs.max_retries + AND DATE_ADD(bse.executed_at, INTERVAL bs.retry_interval MINUTE) <= #{currentTime} + GROUP BY + bs.schedule_id + HAVING + MAX(bse.executed_at) = bse.executed_at + ORDER BY + bs.priority DESC, + bse.executed_at ASC + LIMIT 20 + </select> + + <!-- 상태 업데이트 --> + <update id="updateScheduleStatus"> + UPDATE blog_schedules + SET + status = #{status}, + last_updt_dt = NOW() + WHERE + schedule_id = #{scheduleId} + </update> + + <update id="updateScheduleNextExecution"> + UPDATE blog_schedules + SET + scheduled_at = #{nextExecuteAt}, + last_updt_dt = NOW() + WHERE + schedule_id = #{scheduleId} + </update> + + <update id="incrementScheduleExecutionCount"> + UPDATE blog_schedules + SET + last_updt_dt = NOW() + WHERE + schedule_id = #{scheduleId} + </update> + + <!-- 락킹 (실제 구현에서는 SELECT FOR UPDATE 사용) --> + <update id="lockScheduleForExecution"> + UPDATE blog_schedules + SET + last_updt_dt = NOW() + WHERE + schedule_id = #{scheduleId} + AND status = 'ACTIVE' + </update> + + <update id="unlockSchedule"> + UPDATE blog_schedules + SET + last_updt_dt = NOW() + WHERE + schedule_id = #{scheduleId} + </update> + + <!-- 통계 조회 --> + <select id="getOverallStatistics" resultType="com.itn.admin.itn.blog.mapper.domain.ScheduleStatisticsDTO"> + SELECT + COUNT(*) AS totalSchedules, + SUM(CASE WHEN status = 'ACTIVE' THEN 1 ELSE 0 END) AS activeSchedules, + SUM(CASE WHEN status = 'INACTIVE' THEN 1 ELSE 0 END) AS inactiveSchedules, + SUM(CASE WHEN status = 'COMPLETED' THEN 1 ELSE 0 END) AS completedSchedules, + SUM(CASE WHEN status = 'FAILED' THEN 1 ELSE 0 END) AS failedSchedules, + SUM(CASE WHEN schedule_type = 'ONE_TIME' THEN 1 ELSE 0 END) AS oneTimeSchedules, + SUM(CASE WHEN schedule_type = 'RECURRING' THEN 1 ELSE 0 END) AS recurringSchedules, + SUM(CASE WHEN priority = 'HIGH' THEN 1 ELSE 0 END) AS highPrioritySchedules, + SUM(CASE WHEN priority = 'NORMAL' THEN 1 ELSE 0 END) AS normalPrioritySchedules, + SUM(CASE WHEN priority = 'LOW' THEN 1 ELSE 0 END) AS lowPrioritySchedules, + SUM(CASE WHEN enable_notification = true THEN 1 ELSE 0 END) AS enabledNotificationCount, + SUM(CASE WHEN notification_email IS NOT NULL THEN 1 ELSE 0 END) AS emailNotificationCount, + SUM(CASE WHEN slack_channel IS NOT NULL THEN 1 ELSE 0 END) AS slackNotificationCount + FROM + blog_schedules + </select> + + <select id="getBlogScheduleStats" resultType="com.itn.admin.itn.blog.mapper.domain.ScheduleStatisticsDTO$BlogScheduleStatsDTO"> + SELECT + bs.blog_id AS blogId, + ba.blog_name AS blogName, + COUNT(bs.schedule_id) AS scheduleCount, + COALESCE(exec_stats.success_count, 0) AS successCount, + COALESCE(exec_stats.failure_count, 0) AS failureCount, + CASE + WHEN COALESCE(exec_stats.total_count, 0) = 0 THEN 0.0 + ELSE (COALESCE(exec_stats.success_count, 0) * 100.0 / exec_stats.total_count) + END AS successRate + FROM + blog_schedules bs + LEFT JOIN blog_accounts ba ON bs.blog_id = ba.blog_id + LEFT JOIN ( + SELECT + bs2.blog_id, + COUNT(bse.execution_id) AS total_count, + SUM(CASE WHEN bse.status = 'SUCCESS' THEN 1 ELSE 0 END) AS success_count, + SUM(CASE WHEN bse.status = 'FAILED' THEN 1 ELSE 0 END) AS failure_count + FROM + blog_schedules bs2 + LEFT JOIN blog_schedule_executions bse ON bs2.schedule_id = bse.schedule_id + GROUP BY + bs2.blog_id + ) exec_stats ON bs.blog_id = exec_stats.blog_id + GROUP BY + bs.blog_id, ba.blog_name, exec_stats.success_count, exec_stats.failure_count, exec_stats.total_count + ORDER BY + scheduleCount DESC + LIMIT #{limit} + </select> + + <!-- 유지보수 쿼리 --> + <delete id="deleteCompletedSchedules"> + DELETE FROM blog_schedules + WHERE + status = 'COMPLETED' + AND schedule_type = 'ONE_TIME' + AND last_updt_dt < #{beforeDate} + </delete> + + <delete id="cleanupFailedSchedules"> + DELETE FROM blog_schedules + WHERE + status = 'FAILED' + AND last_updt_dt < #{beforeDate} + AND ( + SELECT COUNT(*) + FROM blog_schedule_executions bse + WHERE bse.schedule_id = blog_schedules.schedule_id + AND bse.status = 'FAILED' + ) >= #{maxFailures} + </delete> + + <!-- 블로그별/예정 스케줄 조회 --> + <select id="getSchedulesByBlogId" resultType="com.itn.admin.itn.blog.mapper.domain.BlogScheduleVO"> + SELECT + <include refid="blogScheduleColumns"/> + FROM + blog_schedules + WHERE + blog_id = #{blogId} + <if test="status != null and status != ''"> + AND status = #{status} + </if> + ORDER BY + scheduled_at ASC + </select> + + <select id="getUpcomingSchedules" resultType="com.itn.admin.itn.blog.mapper.domain.BlogScheduleVO"> + SELECT + <include refid="blogScheduleColumnsWithJoin"/> + FROM + blog_schedules bs + LEFT JOIN blog_accounts ba ON bs.blog_id = ba.blog_id + LEFT JOIN blog_source_urls bsu ON bs.url_id = bsu.url_id + WHERE + bs.status = 'ACTIVE' + AND bs.scheduled_at BETWEEN #{fromTime} AND #{toTime} + ORDER BY + bs.scheduled_at ASC + LIMIT #{limit} + </select> + +</mapper> \ No newline at end of file diff --git a/src/main/resources/mapper/itn/blog/BlogSourceMapper.xml b/src/main/resources/mapper/itn/blog/BlogSourceMapper.xml new file mode 100644 index 0000000..68ef69e --- /dev/null +++ b/src/main/resources/mapper/itn/blog/BlogSourceMapper.xml @@ -0,0 +1,100 @@ +<?xml version="1.0" encoding="UTF-8" ?> +<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> + +<mapper namespace="com.itn.admin.itn.blog.mapper.BlogSourceMapper"> + + <select id="selectBlogSourceList" resultType="com.itn.admin.itn.blog.mapper.domain.BlogSourceVO"> + SELECT + url_id, + url, + title, + category, + is_active, + frst_regist_pnttm, + last_updt_pnttm + FROM blog_source_urls + ORDER BY frst_regist_pnttm DESC + </select> + + <!-- 특정 블로그의 발행 통계를 포함한 소스 목록 조회 --> + <select id="selectBlogSourceListWithStats" parameterType="Long" resultType="com.itn.admin.itn.blog.mapper.domain.BlogSourceVO"> + SELECT + bsu.url_id, + bsu.url, + bsu.title, + bsu.category, + bsu.is_active, + bsu.frst_regist_pnttm AS frstRegistPnttm, + bsu.last_updt_pnttm AS lastUpdtPnttm, + COALESCE(stats.total_publish_count, 0) AS totalPublishCount, + stats.last_published_at AS lastPublishedAt, + stats.last_published_url AS lastPublishedUrl + FROM + blog_source_urls bsu + LEFT JOIN ( + SELECT + SUBSTRING_INDEX(bph.published_url, '#', 1) AS source_url, + COUNT(*) AS total_publish_count, + MAX(bph.published_at) AS last_published_at, + (SELECT bph2.published_url + FROM blog_post_history bph2 + WHERE bph2.blog_id = #{blogId} + AND SUBSTRING_INDEX(bph2.published_url, '#', 1) = SUBSTRING_INDEX(bph.published_url, '#', 1) + AND bph2.published_at = MAX(bph.published_at) + LIMIT 1) AS last_published_url + FROM + blog_post_history bph + WHERE + bph.blog_id = #{blogId} + AND bph.status = 'Y' + AND bph.published_url IS NOT NULL + GROUP BY + SUBSTRING_INDEX(bph.published_url, '#', 1) + ) stats ON bsu.url = stats.source_url + ORDER BY + bsu.frst_regist_pnttm DESC + </select> + + <select id="getBlogSourceDetail" parameterType="Long" resultType="com.itn.admin.itn.blog.mapper.domain.BlogSourceVO"> + SELECT + url_id AS urlId, + url, + title, + category, + is_active AS active, + frst_regist_pnttm AS frstRegistPnttm, + frst_register_id AS frstRegisterId, + last_updt_pnttm AS lastUpdtPnttm, + last_updusr_id AS lastUpdusrId + FROM blog_source_urls + WHERE url_id = #{urlId} + </select> + + <insert id="insertBlogSource" parameterType="com.itn.admin.itn.blog.mapper.domain.BlogSourceVO"> + INSERT INTO blog_source_urls ( + url, title, category, is_active, + frst_regist_pnttm, frst_register_id, last_updt_pnttm, last_updusr_id + ) VALUES ( + #{url}, #{title}, #{category}, #{isActive}, + NOW(), #{frstRegisterId}, NOW(), #{lastUpdusrId} + ) + </insert> + + <update id="updateBlogSource" parameterType="com.itn.admin.itn.blog.mapper.domain.BlogSourceVO"> + UPDATE blog_source_urls + SET + url = #{url}, + title = #{title}, + category = #{category}, + is_active = #{isActive}, + last_updt_pnttm = NOW(), + last_updusr_id = 'admin' + WHERE url_id = #{urlId} + </update> + + <delete id="deleteBlogSource" parameterType="Long"> + DELETE FROM blog_source_urls + WHERE url_id = #{urlId} + </delete> + +</mapper> \ No newline at end of file diff --git a/src/main/resources/mapper/itn/itn/blog/BlogCookieMappingMapper.xml b/src/main/resources/mapper/itn/itn/blog/BlogCookieMappingMapper.xml new file mode 100644 index 0000000..3f08b26 --- /dev/null +++ b/src/main/resources/mapper/itn/itn/blog/BlogCookieMappingMapper.xml @@ -0,0 +1,116 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> +<mapper namespace="com.itn.admin.itn.blog.mapper.BlogCookieMappingMapper"> + + <!-- 블로그 계정별 쿠키 매핑 정보 조회 --> + <select id="selectCookieMappingByBlogId" parameterType="long" resultType="blogCookieMappingVO"> + SELECT + bcm.mapping_id AS mappingId, + bcm.blog_id, + bcm.cookie_file_path, + bcm.cookie_file_name, + bcm.is_active, + bcm.last_validated_at, + bcm.cookie_expires_at, + bcm.frst_register_id, + bcm.frst_regist_pnttm, + bcm.last_updusr_id, + bcm.last_updt_pnttm, + ba.blog_name, + ba.blog_url, + ba.platform + FROM + blog_cookie_mapping bcm + LEFT JOIN + blog_accounts ba ON bcm.blog_id = ba.blog_id + WHERE + bcm.blog_id = #{blogId} + AND bcm.is_active = 'Y' + ORDER BY bcm.frst_regist_pnttm DESC + LIMIT 1 + </select> + + <!-- 활성화된 쿠키 매핑 정보 조회 --> + <select id="selectActiveCookieMappings" resultType="blogCookieMappingVO"> + SELECT + bcm.mapping_id AS mappingId, + bcm.blog_id, + bcm.cookie_file_path, + bcm.cookie_file_name, + bcm.is_active, + bcm.last_validated_at, + bcm.cookie_expires_at, + bcm.frst_register_id, + bcm.frst_regist_pnttm, + bcm.last_updusr_id, + bcm.last_updt_pnttm, + ba.blog_name, + ba.blog_url, + ba.platform + FROM + blog_cookie_mapping bcm + LEFT JOIN + blog_accounts ba ON bcm.blog_id = ba.blog_id + WHERE + bcm.is_active = 'Y' + AND ba.status = 'Y' + ORDER BY bcm.frst_regist_pnttm DESC + </select> + + <!-- 쿠키 매핑 정보 저장 --> + <insert id="insertCookieMapping" parameterType="blogCookieMappingVO" useGeneratedKeys="true" keyProperty="mappingId"> + INSERT INTO blog_cookie_mapping ( + blog_id, cookie_file_path, cookie_file_name, is_active, + last_validated_at, cookie_expires_at, frst_register_id, frst_regist_pnttm, + last_updusr_id, last_updt_pnttm + ) VALUES ( + #{blogId}, #{cookieFilePath}, #{cookieFileName}, #{isActive}, + #{lastValidatedAt}, #{cookieExpiresAt}, #{frstRegisterId}, NOW(), + #{lastUpdusrId}, NOW() + ) + </insert> + + <!-- 쿠키 매핑 정보 업데이트 --> + <update id="updateCookieMapping" parameterType="blogCookieMappingVO"> + UPDATE blog_cookie_mapping + SET + cookie_file_path = #{cookieFilePath}, + cookie_file_name = #{cookieFileName}, + is_active = #{isActive}, + cookie_expires_at = #{cookieExpiresAt}, + last_updusr_id = #{lastUpdusrId}, + last_updt_pnttm = NOW() + WHERE mapping_id = #{mappingId} + </update> + + <!-- 쿠키 유효성 검증 시간 업데이트 --> + <update id="updateLastValidated" parameterType="map"> + UPDATE blog_cookie_mapping + SET + last_validated_at = NOW(), + last_updusr_id = 'SYSTEM', + last_updt_pnttm = NOW() + WHERE mapping_id = #{mappingId} + </update> + + <!-- 쿠키 만료 시간 업데이트 --> + <update id="updateCookieExpiration" parameterType="map"> + UPDATE blog_cookie_mapping + SET + cookie_expires_at = #{cookieExpiresAt}, + last_updusr_id = 'SYSTEM', + last_updt_pnttm = NOW() + WHERE mapping_id = #{mappingId} + </update> + + <!-- 쿠키 매핑 비활성화 --> + <update id="deactivateCookieMapping" parameterType="map"> + UPDATE blog_cookie_mapping + SET + is_active = 'N', + last_updusr_id = 'SYSTEM', + last_updt_pnttm = NOW() + WHERE blog_id = #{blogId} + </update> + +</mapper> \ No newline at end of file diff --git a/src/main/resources/mapper/itn/itn/blog/BlogPostingMapper.xml b/src/main/resources/mapper/itn/itn/blog/BlogPostingMapper.xml new file mode 100644 index 0000000..3e4de52 --- /dev/null +++ b/src/main/resources/mapper/itn/itn/blog/BlogPostingMapper.xml @@ -0,0 +1,275 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> +<mapper namespace="com.itn.admin.itn.blog.mapper.BlogPostingMapper"> + + + <!-- 발행 히스토리 저장 --> + <insert id="insertBlogPostHistory" parameterType="com.itn.admin.itn.blog.mapper.domain.BlogPostHistoryVO" useGeneratedKeys="true" keyProperty="postId"> + INSERT INTO blog_post_history ( + blog_id, url_id, post_title, post_content, published_url, + status, error_message, published_at, html_generated_at, publish_started_at, + frst_register_id, frst_regist_pnttm, last_updusr_id, last_updt_pnttm + ) VALUES ( + #{blogId}, #{urlId}, #{postTitle}, #{postContent}, #{publishedUrl}, + #{status}, #{errorMessage}, #{publishedAt}, #{htmlGeneratedAt}, #{publishStartedAt}, + 'SYSTEM', NOW(), 'SYSTEM', NOW() + ) + </insert> + + + <!-- 특정 블로그-소스의 발행 히스토리 조회 --> + <select id="selectBlogPostHistories" resultType="com.itn.admin.itn.blog.mapper.domain.BlogPostHistoryVO"> + SELECT + bph.post_id AS postId, + bph.blog_id, + bph.url_id, + bph.post_title, + bph.post_content, + bph.published_url, + bph.status, + bph.error_message, + bph.published_at, + bph.html_generated_at, + bph.publish_started_at, + bph.frst_regist_pnttm, + bph.last_updt_pnttm, + ba.blog_name, + bsu.title AS sourceTitle + FROM + blog_post_history bph + LEFT JOIN + blog_accounts ba ON bph.blog_id = ba.blog_id + LEFT JOIN + blog_source_urls bsu ON bph.url_id = bsu.url_id + WHERE + 1=1 + <if test="blogId != null"> + AND bph.blog_id = #{blogId} + </if> + <if test="urlId != null"> + AND bph.url_id = #{urlId} + </if> + ORDER BY bph.frst_regist_pnttm DESC + LIMIT #{limit} OFFSET #{offset} + </select> + + <!-- 워크플로우 단계별 업데이트 쿼리 --> + + <!-- HTML 생성 완료 업데이트 --> + <update id="updateHtmlGenerated" parameterType="com.itn.admin.itn.blog.mapper.domain.BlogPostHistoryVO"> + UPDATE blog_post_history + SET + post_title = #{postTitle}, + post_content = #{postContent}, + html_generated_at = #{htmlGeneratedAt}, + last_updusr_id = 'SYSTEM', + last_updt_pnttm = NOW() + WHERE post_id = #{postId} + </update> + + <!-- 발행 시작 업데이트 --> + <update id="updatePublishStarted" parameterType="com.itn.admin.itn.blog.mapper.domain.BlogPostHistoryVO"> + UPDATE blog_post_history + SET + publish_started_at = #{publishStartedAt}, + last_updusr_id = 'SYSTEM', + last_updt_pnttm = NOW() + WHERE post_id = #{postId} + </update> + + <!-- 발행 완료(성공) 업데이트 --> + <update id="updatePublishCompleted" parameterType="com.itn.admin.itn.blog.mapper.domain.BlogPostHistoryVO"> + UPDATE blog_post_history + SET + status = #{status}, + published_url = #{publishedUrl}, + published_at = #{publishedAt}, + last_updusr_id = 'SYSTEM', + last_updt_pnttm = NOW() + WHERE post_id = #{postId} + </update> + + <!-- 발행 실패 업데이트 --> + <update id="updatePublishFailed" parameterType="com.itn.admin.itn.blog.mapper.domain.BlogPostHistoryVO"> + UPDATE blog_post_history + SET + status = #{status}, + error_message = #{errorMessage}, + last_updusr_id = 'SYSTEM', + last_updt_pnttm = NOW() + WHERE post_id = #{postId} + </update> + + <!-- 특정 발행 히스토리 조회 (ID로) --> + <select id="selectBlogPostHistoryById" parameterType="long" resultType="com.itn.admin.itn.blog.mapper.domain.BlogPostHistoryVO"> + SELECT + bph.post_id AS postId, + bph.blog_id, + bph.url_id, + bph.post_title, + bph.post_content, + bph.published_url, + bph.status, + bph.error_message, + bph.published_at, + bph.html_generated_at, + bph.publish_started_at, + bph.frst_regist_pnttm, + bph.last_updt_pnttm, + ba.blog_name, + bsu.title AS sourceTitle, + bsu.url AS sourceUrl + FROM + blog_post_history bph + LEFT JOIN + blog_accounts ba ON bph.blog_id = ba.blog_id + LEFT JOIN + blog_source_urls bsu ON bph.url_id = bsu.url_id + WHERE + bph.post_id = #{postId} + </select> + + <!-- 소스별 발행 통계 조회 --> + <select id="selectPublishStatsBySource" parameterType="map" resultType="map"> + SELECT + bsu.url_id, + bsu.title, + bsu.url, + COUNT(bph.post_id) AS totalPublishCount, + COUNT(CASE WHEN bph.status = 'S' THEN 1 END) AS successCount, + COUNT(CASE WHEN bph.status = 'F' THEN 1 END) AS failedCount, + MAX(CASE WHEN bph.status = 'S' THEN bph.published_at END) AS lastPublishedAt, + MAX(CASE WHEN bph.status = 'S' THEN bph.published_url END) AS lastPublishedUrl + FROM + blog_source_urls bsu + LEFT JOIN + blog_post_history bph ON bsu.url_id = bph.url_id AND bph.blog_id = #{blogId} + WHERE + bsu.is_active = 'Y' + GROUP BY bsu.url_id, bsu.title, bsu.url + ORDER BY bsu.frst_regist_pnttm DESC + </select> + + <!-- 워크플로우 히스토리 조회 (필터링 및 페이징 지원) --> + <select id="selectWorkflowHistory" parameterType="map" resultType="com.itn.admin.itn.blog.mapper.domain.BlogPostHistoryVO"> + SELECT + bph.post_id AS postId, + bph.blog_id, + bph.url_id, + bph.post_title, + bph.post_content, + bph.published_url, + bph.status, + bph.error_message, + bph.published_at, + bph.html_generated_at, + bph.publish_started_at, + bph.frst_regist_pnttm, + bph.last_updt_pnttm, + ba.blog_name, + bsu.title AS sourceTitle, + bsu.url AS sourceUrl, + CASE + WHEN bph.html_generated_at IS NOT NULL THEN 'HTML 생성 완료' + WHEN bph.status = 'I' THEN 'HTML 생성 중' + ELSE 'HTML 생성 대기' + END AS htmlStatus, + CASE + WHEN bph.published_at IS NOT NULL THEN '발행 완료' + WHEN bph.publish_started_at IS NOT NULL THEN '발행 중' + WHEN bph.html_generated_at IS NOT NULL THEN '발행 대기' + ELSE '발행 준비 중' + END AS publishStatus, + CASE + WHEN bph.html_generated_at IS NOT NULL AND bph.frst_regist_pnttm IS NOT NULL + THEN TIMESTAMPDIFF(SECOND, bph.frst_regist_pnttm, bph.html_generated_at) + ELSE NULL + END AS htmlGenerationTime, + CASE + WHEN bph.published_at IS NOT NULL AND bph.publish_started_at IS NOT NULL + THEN TIMESTAMPDIFF(SECOND, bph.publish_started_at, bph.published_at) + ELSE NULL + END AS publishTime, + CASE + WHEN bph.published_at IS NOT NULL AND bph.frst_regist_pnttm IS NOT NULL + THEN TIMESTAMPDIFF(SECOND, bph.frst_regist_pnttm, bph.published_at) + ELSE NULL + END AS totalTime + FROM + blog_post_history bph + LEFT JOIN + blog_accounts ba ON bph.blog_id = ba.blog_id + LEFT JOIN + blog_source_urls bsu ON bph.url_id = bsu.url_id + WHERE + 1=1 + <if test="blogId != null and blogId != ''"> + AND bph.blog_id = #{blogId} + </if> + <if test="urlId != null and urlId != ''"> + AND bph.url_id = #{urlId} + </if> + <if test="status != null and status != ''"> + AND bph.status = #{status} + </if> + ORDER BY bph.frst_regist_pnttm DESC + LIMIT #{limit} OFFSET #{offset} + </select> + + <!-- 워크플로우 통계 조회 --> + <select id="selectWorkflowStatistics" parameterType="map" resultType="map"> + SELECT + COUNT(*) AS totalWorkflows, + COUNT(CASE WHEN status = 'I' THEN 1 END) AS inProgressCount, + COUNT(CASE WHEN status = 'S' THEN 1 END) AS successCount, + COUNT(CASE WHEN status = 'F' THEN 1 END) AS failedCount, + ROUND(COUNT(CASE WHEN status = 'S' THEN 1 END) * 100.0 / COUNT(*), 2) AS successRate, + AVG(CASE + WHEN html_generated_at IS NOT NULL AND frst_regist_pnttm IS NOT NULL + THEN TIMESTAMPDIFF(SECOND, frst_regist_pnttm, html_generated_at) + ELSE NULL + END) AS avgHtmlGenerationTime, + AVG(CASE + WHEN published_at IS NOT NULL AND publish_started_at IS NOT NULL + THEN TIMESTAMPDIFF(SECOND, publish_started_at, published_at) + ELSE NULL + END) AS avgPublishTime, + AVG(CASE + WHEN published_at IS NOT NULL AND frst_regist_pnttm IS NOT NULL + THEN TIMESTAMPDIFF(SECOND, frst_regist_pnttm, published_at) + ELSE NULL + END) AS avgTotalTime, + COUNT(CASE WHEN DATE(frst_regist_pnttm) = CURDATE() THEN 1 END) AS todayCount, + COUNT(CASE WHEN DATE(frst_regist_pnttm) >= DATE_SUB(CURDATE(), INTERVAL 7 DAY) THEN 1 END) AS weekCount + FROM + blog_post_history + WHERE + blog_id = #{blogId} + </select> + + <!-- 실패 원인별 분석 --> + <select id="selectFailureAnalysis" parameterType="map" resultType="map"> + SELECT + CASE + WHEN error_message LIKE '%HTML 생성%' THEN 'HTML 생성 실패' + WHEN error_message LIKE '%티스토리%' THEN '티스토리 발행 실패' + WHEN error_message LIKE '%토큰%' THEN '인증 토큰 실패' + WHEN error_message LIKE '%네트워크%' OR error_message LIKE '%timeout%' THEN '네트워크 오류' + WHEN error_message IS NOT NULL THEN '기타 오류' + ELSE '알 수 없는 오류' + END AS failureCategory, + COUNT(*) AS failureCount, + ROUND(COUNT(*) * 100.0 / (SELECT COUNT(*) FROM blog_post_history WHERE blog_id = #{blogId} AND status = 'F' AND DATE(frst_regist_pnttm) >= DATE_SUB(CURDATE(), INTERVAL #{days} DAY)), 2) AS failureRate, + MAX(frst_regist_pnttm) AS lastFailureAt, + GROUP_CONCAT(DISTINCT SUBSTRING(error_message, 1, 50) ORDER BY frst_regist_pnttm DESC SEPARATOR '; ') AS sampleErrors + FROM + blog_post_history + WHERE + blog_id = #{blogId} + AND status = 'F' + AND DATE(frst_regist_pnttm) >= DATE_SUB(CURDATE(), INTERVAL #{days} DAY) + GROUP BY failureCategory + ORDER BY failureCount DESC + </select> + +</mapper>