package com.zioinfo.cms.content; import com.zioinfo.cms.admin.AuditService; import com.zioinfo.cms.content.mapper.ContentMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; import java.util.List; import java.util.Map; import java.util.Set; /** * 콘텐츠 서비스 — 헤드리스 콘텐츠 CRUD + 게시 워크플로우 + 버전/롤백 + 예약 게시. * *

워크플로우: DRAFT → REVIEW → APPROVED → PUBLISHED (역방향: REJECT→DRAFT, ARCHIVE). * 게시 승인/발행은 EDITOR 이상만 가능(메서드 가드). 모든 전이는 버전 스냅샷 + 감사로그. */ @Slf4j @Service @RequiredArgsConstructor public class ContentService { private static final Set EDITOR_ROLES = Set.of("EDITOR", "SUPERADMIN"); private static final Set TYPES = Set.of("PAGE", "POST", "BLOCK", "PRODUCT_DETAIL"); private final ContentMapper mapper; private final AuditService auditService; public List list(String contentType, String status, String locale, String keyword) { return mapper.findAll(contentType, status, locale, keyword); } public CmsContent get(Long id) { CmsContent c = mapper.findById(id); if (c == null) { throw new RuntimeException("ERR-CMS-CONTENT-404: 콘텐츠 없음"); } return c; } @Transactional public CmsContent create(Map req, String actor) { CmsContent c = new CmsContent(); bind(c, req); if (c.getContentType() == null || !TYPES.contains(c.getContentType())) { c.setContentType("PAGE"); } if (c.getSlug() == null || c.getSlug().isBlank()) { throw new IllegalArgumentException("ERR-CMS-CONTENT-400: slug 필수"); } if (mapper.countBySlug(c.getSlug(), c.getLocale(), null) > 0) { throw new RuntimeException("ERR-CMS-CONTENT-409: 동일 locale 내 slug 중복"); } c.setStatus("DRAFT"); c.setVersion(1); c.setCreatedBy(actor); c.setUpdatedBy(actor); mapper.insert(c); snapshot(c, "created"); auditService.log("CONTENT_CREATE", c.getSlug(), "type=" + c.getContentType()); return mapper.findById(c.getId()); } @Transactional public CmsContent update(Long id, Map req, String actor) { CmsContent c = get(id); if ("PUBLISHED".equals(c.getStatus())) { // 발행본 직접 수정 시 DRAFT로 되돌려 재워크플로우 (콘텐츠 무결성) c.setStatus("DRAFT"); } bind(c, req); if (c.getSlug() != null && mapper.countBySlug(c.getSlug(), c.getLocale(), id) > 0) { throw new RuntimeException("ERR-CMS-CONTENT-409: 동일 locale 내 slug 중복"); } c.setVersion(c.getVersion() == null ? 1 : c.getVersion() + 1); c.setUpdatedBy(actor); mapper.update(c); snapshot(c, "updated"); auditService.log("CONTENT_UPDATE", c.getSlug(), "v" + c.getVersion()); return mapper.findById(id); } public void delete(Long id) { CmsContent c = get(id); mapper.delete(id); auditService.log("CONTENT_DELETE", c.getSlug(), "type=" + c.getContentType()); } /** * 게시 워크플로우 전이. * * @param action submit(→REVIEW) / approve(→APPROVED) / publish(→PUBLISHED) * / reject(→DRAFT) / archive(→ARCHIVED) / schedule(예약→APPROVED+scheduledAt) */ @Transactional public CmsContent transition(Long id, String action, String note, LocalDateTime scheduledAt, String actor, String role) { CmsContent c = get(id); String from = c.getStatus(); String to = switch (action == null ? "" : action.toLowerCase()) { case "submit" -> requireFrom(from, Set.of("DRAFT"), "REVIEW"); case "approve" -> { requireEditor(role); yield requireFrom(from, Set.of("REVIEW"), "APPROVED"); } case "publish" -> { requireEditor(role); yield requireFrom(from, Set.of("APPROVED", "REVIEW"), "PUBLISHED"); } case "reject" -> { requireEditor(role); yield requireFrom(from, Set.of("REVIEW", "APPROVED"), "DRAFT"); } case "archive" -> { requireEditor(role); yield "ARCHIVED"; } case "schedule" -> { requireEditor(role); if (scheduledAt == null) throw new IllegalArgumentException("ERR-CMS-CONTENT-400: scheduledAt 필수"); c.setScheduledAt(scheduledAt); yield "APPROVED"; } default -> throw new IllegalArgumentException("ERR-CMS-CONTENT-400: 알 수 없는 action"); }; mapper.updateStatus(id, to, actor); if ("PUBLISHED".equals(to)) { mapper.updatePublished(id, to); } if ("schedule".equalsIgnoreCase(action)) { mapper.update(c); // scheduledAt 반영 } CmsContent updated = mapper.findById(id); snapshot(updated, "transition " + from + "->" + to + (note == null ? "" : " : " + note)); auditService.log("CONTENT_" + (action == null ? "" : action.toUpperCase()), c.getSlug(), from + " -> " + to); return updated; } /** 특정 버전으로 롤백 — 해당 스냅샷 본문으로 새 버전 생성, 상태 DRAFT. */ @Transactional public CmsContent rollback(Long id, Integer version, String actor) { CmsContent c = get(id); CmsContentVersion v = mapper.findVersion(id, version); if (v == null) { throw new RuntimeException("ERR-CMS-CONTENT-404: 버전 없음"); } c.setTitle(v.getTitle()); c.setSummary(v.getSummary()); c.setBlocks(v.getBlocks()); c.setStatus("DRAFT"); c.setVersion(c.getVersion() + 1); c.setUpdatedBy(actor); mapper.update(c); snapshot(c, "rollback to v" + version); auditService.log("CONTENT_ROLLBACK", c.getSlug(), "to v" + version); return mapper.findById(id); } public List versions(Long id) { return mapper.findVersions(id); } /** 예약 게시 스케줄러 — 1분마다 예약 시각 도래한 APPROVED 콘텐츠를 PUBLISHED 처리. */ @Scheduled(fixedDelay = 60000) public void publishDue() { try { List due = mapper.findDueScheduled(); for (CmsContent c : due) { mapper.updatePublished(c.getId(), "PUBLISHED"); mapper.updateStatus(c.getId(), "PUBLISHED", "scheduler"); auditService.log("scheduler", "CONTENT_SCHEDULED_PUBLISH", c.getSlug(), "scheduledAt 도래"); log.info("예약 게시 발행: {}", c.getSlug()); } } catch (Exception e) { log.warn("예약 게시 스케줄러 오류: {}", e.getMessage()); } } // ── helpers ── private void requireEditor(String role) { if (role == null || !EDITOR_ROLES.contains(role.toUpperCase())) { throw new RuntimeException("ERR-CMS-403: 게시 승인/발행은 EDITOR 이상만 가능합니다"); } } private String requireFrom(String from, Set allowed, String to) { if (!allowed.contains(from)) { throw new RuntimeException("ERR-CMS-CONTENT-409: 현재 상태(" + from + ")에서 전이 불가"); } return to; } private void snapshot(CmsContent c, String note) { CmsContentVersion v = new CmsContentVersion(); v.setContentId(c.getId()); v.setVersion(c.getVersion()); v.setTitle(c.getTitle()); v.setSummary(c.getSummary()); v.setBlocks(c.getBlocks()); v.setStatus(c.getStatus()); v.setNote(note); v.setCreatedBy(c.getUpdatedBy() == null ? c.getCreatedBy() : c.getUpdatedBy()); mapper.insertVersion(v); } private void bind(CmsContent c, Map r) { if (r.containsKey("contentType")) c.setContentType(str(r.get("contentType"))); if (r.containsKey("slug")) c.setSlug(str(r.get("slug"))); if (r.containsKey("title")) c.setTitle(str(r.get("title"))); if (r.containsKey("summary")) c.setSummary(str(r.get("summary"))); if (r.containsKey("blocks")) c.setBlocks(jsonStr(r.get("blocks"))); if (r.containsKey("locale")) c.setLocale(str(r.get("locale"))); if (r.containsKey("menuId")) c.setMenuId(longOf(r.get("menuId"))); if (r.containsKey("tags")) c.setTags(str(r.get("tags"))); if (r.containsKey("seoMeta")) c.setSeoMeta(jsonStr(r.get("seoMeta"))); if (c.getLocale() == null) c.setLocale("ko"); } private String str(Object o) { return o == null ? null : String.valueOf(o); } /** Map/List 는 JSON 문자열로 직렬화(간단 toString — 실제 JSONB 컬럼). */ private String jsonStr(Object o) { if (o == null) return null; if (o instanceof String s) return s; try { return new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(o); } catch (Exception e) { return String.valueOf(o); } } private Long longOf(Object o) { if (o == null) return null; try { return Long.valueOf(String.valueOf(o).trim()); } catch (Exception e) { return null; } } }