229 lines
9.4 KiB
Java
229 lines
9.4 KiB
Java
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 + 게시 워크플로우 + 버전/롤백 + 예약 게시.
|
|
*
|
|
* <p>워크플로우: DRAFT → REVIEW → APPROVED → PUBLISHED (역방향: REJECT→DRAFT, ARCHIVE).
|
|
* 게시 승인/발행은 EDITOR 이상만 가능(메서드 가드). 모든 전이는 버전 스냅샷 + 감사로그.
|
|
*/
|
|
@Slf4j
|
|
@Service
|
|
@RequiredArgsConstructor
|
|
public class ContentService {
|
|
|
|
private static final Set<String> EDITOR_ROLES = Set.of("EDITOR", "SUPERADMIN");
|
|
private static final Set<String> TYPES = Set.of("PAGE", "POST", "BLOCK", "PRODUCT_DETAIL");
|
|
|
|
private final ContentMapper mapper;
|
|
private final AuditService auditService;
|
|
|
|
public List<CmsContent> 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<String, Object> 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<String, Object> 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<CmsContentVersion> versions(Long id) {
|
|
return mapper.findVersions(id);
|
|
}
|
|
|
|
/** 예약 게시 스케줄러 — 1분마다 예약 시각 도래한 APPROVED 콘텐츠를 PUBLISHED 처리. */
|
|
@Scheduled(fixedDelay = 60000)
|
|
public void publishDue() {
|
|
try {
|
|
List<CmsContent> 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<String> 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<String, Object> 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; }
|
|
}
|
|
}
|