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