From 82dc942d72d584ac7a7f385d4f9a8c3ba00baaed Mon Sep 17 00:00:00 2001 From: GUARDiA Date: Wed, 17 Jun 2026 01:56:30 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20100=EA=B8=B0=EB=8A=A5=20=ED=99=95?= =?UTF-8?q?=EC=9E=A5=20=E2=80=94=20AI=20=EA=B3=A0=EB=8F=84=ED=99=94=20+=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=ED=99=94=20+=20=EB=B6=84=EC=84=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../zioinfo/cms/ai/ContentAiController.java | 520 ++++++++++++++++++ .../cms/media/MediaLibraryController.java | 424 ++++++++++++++ .../cms/seo/SeoAnalyticsController.java | 441 +++++++++++++++ .../PublishingWorkflowController.java | 363 ++++++++++++ 4 files changed, 1748 insertions(+) create mode 100644 backend/src/main/java/com/zioinfo/cms/ai/ContentAiController.java create mode 100644 backend/src/main/java/com/zioinfo/cms/media/MediaLibraryController.java create mode 100644 backend/src/main/java/com/zioinfo/cms/seo/SeoAnalyticsController.java create mode 100644 backend/src/main/java/com/zioinfo/cms/workflow/PublishingWorkflowController.java diff --git a/backend/src/main/java/com/zioinfo/cms/ai/ContentAiController.java b/backend/src/main/java/com/zioinfo/cms/ai/ContentAiController.java new file mode 100644 index 0000000..e43a3ba --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/ai/ContentAiController.java @@ -0,0 +1,520 @@ +package com.zioinfo.cms.ai; + +import com.zioinfo.cms.common.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDateTime; +import java.util.*; +import java.util.concurrent.atomic.AtomicLong; + +/** + * GUARDiA CMS AI v2 — 25개 콘텐츠 AI 엔드포인트. + * Ollama 온프레미스(localhost:11434)만 사용. 외부 AI API 절대 금지. + * Ollama 미가용 시 Java 폴백으로 즉시 응답. + */ +@RestController +@RequestMapping("/api/cms/ai/v2") +@RequiredArgsConstructor +public class ContentAiController { + + private final OllamaClient ollama; + + /** 통계 카운터 (메모리, 재시작 시 초기화) */ + private static final Map USAGE = new LinkedHashMap<>(); + static { + for (String k : List.of("generate","improve","seo-optimize","translate","summarize", + "tone-adjust","expand","compress","headline-generate","meta-generate", + "tag-suggest","category-suggest","keyword-extract","readability-score", + "sentiment-check","plagiarism-check","image-alt-text","product-description", + "review-moderate","faq-generate","email-content","social-post", + "ab-test-variant","consistency-check")) { + USAGE.put(k, new AtomicLong(0)); + } + } + + private String ask(String prompt, String fallback) { + String r = ollama.generate(prompt); + return (r == null || r.isBlank()) ? fallback : r; + } + + // ───────────────────────────────────────────────────────────── + // 1. POST /generate — AI 콘텐츠 초안 생성 + // ───────────────────────────────────────────────────────────── + @PostMapping("/generate") + public ResponseEntity generateContent(@RequestBody Map body) { + USAGE.get("generate").incrementAndGet(); + String topic = str(body.getOrDefault("topic", "")); + String type = str(body.getOrDefault("type", "blog")); + String length = str(body.getOrDefault("length", "medium")); + String prompt = String.format( + "콘텐츠 타입: %s\n주제: %s\n길이: %s (short≈300자, medium≈600자, long≈1200자)\n" + + "마케팅 콘텐츠를 자연스럽게 작성해줘. JSON 없이 텍스트만.", type, topic, length); + String content = ask(prompt, topic + "에 관한 고품질 " + type + " 콘텐츠 초안입니다. (Ollama 연결 후 실제 생성 가능)"); + return ResponseEntity.ok(ApiResponse.ok(Map.of( + "content", content, "topic", topic, "type", type, + "wordCount", content.length(), "generatedAt", LocalDateTime.now().toString()))); + } + + // ───────────────────────────────────────────────────────────── + // 2. POST /improve — 기존 콘텐츠 개선 제안 + // ───────────────────────────────────────────────────────────── + @PostMapping("/improve") + public ResponseEntity improveContent(@RequestBody Map body) { + USAGE.get("improve").incrementAndGet(); + String content = str(body.getOrDefault("content", "")); + String focus = str(body.getOrDefault("focus", "전반적 품질")); + String prompt = "다음 콘텐츠를 개선해줘. 개선 포커스: " + focus + "\n\n원본:\n" + content + + "\n\n개선된 버전과 변경 사항 요약을 작성해줘."; + String improved = ask(prompt, "[개선 제안] " + focus + " 측면에서 문장 구조·어휘·흐름을 개선하세요."); + return ResponseEntity.ok(ApiResponse.ok(Map.of( + "improved", improved, "originalLength", content.length(), + "improvedLength", improved.length(), "focus", focus))); + } + + // ───────────────────────────────────────────────────────────── + // 3. POST /seo-optimize — SEO 최적화 콘텐츠 재작성 + // ───────────────────────────────────────────────────────────── + @PostMapping("/seo-optimize") + public ResponseEntity seoOptimize(@RequestBody Map body) { + USAGE.get("seo-optimize").incrementAndGet(); + String content = str(body.getOrDefault("content", "")); + String keyword = str(body.getOrDefault("keyword", "")); + String prompt = "다음 콘텐츠를 SEO 최적화해줘. 핵심 키워드: " + keyword + + "\n콘텐츠:\n" + content + + "\n\n키워드 밀도·제목·메타설명·내부링크 앵커를 최적화한 버전을 작성해줘."; + String optimized = ask(prompt, "[SEO 최적화] '" + keyword + "' 키워드 중심으로 재작성이 필요합니다."); + return ResponseEntity.ok(ApiResponse.ok(Map.of( + "optimized", optimized, "keyword", keyword, + "tips", List.of("키워드 밀도 1-2%", "H1 태그에 키워드 포함", "메타 설명 150자 이내")))); + } + + // ───────────────────────────────────────────────────────────── + // 4. POST /translate — AI 다국어 번역 + // ───────────────────────────────────────────────────────────── + @PostMapping("/translate") + public ResponseEntity translate(@RequestBody Map body) { + USAGE.get("translate").incrementAndGet(); + String text = str(body.getOrDefault("text", "")); + String target = str(body.getOrDefault("targetLang", "en")); + String prompt = "다음 텍스트를 " + target + "로 자연스럽게 번역해줘. 번역문만 출력:\n" + text; + String translated = ask(prompt, "[번역 대기] Ollama 연결 후 " + target + "로 번역됩니다."); + return ResponseEntity.ok(ApiResponse.ok(Map.of( + "translated", translated, "sourceLang", "ko", + "targetLang", target, "originalLength", text.length()))); + } + + // ───────────────────────────────────────────────────────────── + // 5. POST /summarize — 콘텐츠 요약 + // ───────────────────────────────────────────────────────────── + @PostMapping("/summarize") + public ResponseEntity summarize(@RequestBody Map body) { + USAGE.get("summarize").incrementAndGet(); + String content = str(body.getOrDefault("content", "")); + String size = str(body.getOrDefault("size", "short")); // short/medium/long + int maxLen = "long".equals(size) ? 500 : "medium".equals(size) ? 200 : 80; + String prompt = "다음 콘텐츠를 " + maxLen + "자 이내로 요약해줘:\n" + content; + String summary = ask(prompt, content.length() > maxLen ? content.substring(0, maxLen) + "..." : content); + return ResponseEntity.ok(ApiResponse.ok(Map.of( + "summary", summary, "size", size, "charCount", summary.length()))); + } + + // ───────────────────────────────────────────────────────────── + // 6. POST /tone-adjust — 톤 변경 + // ───────────────────────────────────────────────────────────── + @PostMapping("/tone-adjust") + public ResponseEntity toneAdjust(@RequestBody Map body) { + USAGE.get("tone-adjust").incrementAndGet(); + String content = str(body.getOrDefault("content", "")); + String tone = str(body.getOrDefault("tone", "formal")); // formal/friendly/descriptive + Map toneDesc = Map.of( + "formal", "공식적·격식체", "friendly", "친근하고 대화체", "descriptive", "상세 설명적"); + String desc = toneDesc.getOrDefault(tone, "공식적"); + String prompt = "다음 콘텐츠를 " + desc + " 톤으로 재작성해줘:\n" + content; + String adjusted = ask(prompt, "[톤 변환] " + desc + " 스타일로 변환이 필요합니다."); + return ResponseEntity.ok(ApiResponse.ok(Map.of( + "adjusted", adjusted, "tone", tone, "description", desc))); + } + + // ───────────────────────────────────────────────────────────── + // 7. POST /expand — 콘텐츠 확장 + // ───────────────────────────────────────────────────────────── + @PostMapping("/expand") + public ResponseEntity expand(@RequestBody Map body) { + USAGE.get("expand").incrementAndGet(); + String content = str(body.getOrDefault("content", "")); + String aspect = str(body.getOrDefault("aspect", "세부 내용·예시·배경")); + String prompt = "다음 콘텐츠를 " + aspect + " 측면으로 더 상세하게 확장해줘:\n" + content; + String expanded = ask(prompt, content + "\n\n[확장] " + aspect + "에 대한 추가 내용이 여기에 생성됩니다."); + return ResponseEntity.ok(ApiResponse.ok(Map.of( + "expanded", expanded, "aspect", aspect, + "originalLength", content.length(), "expandedLength", expanded.length()))); + } + + // ───────────────────────────────────────────────────────────── + // 8. POST /compress — 콘텐츠 압축 + // ───────────────────────────────────────────────────────────── + @PostMapping("/compress") + public ResponseEntity compress(@RequestBody Map body) { + USAGE.get("compress").incrementAndGet(); + String content = str(body.getOrDefault("content", "")); + String prompt = "다음 콘텐츠의 핵심만 남기고 최대한 압축해줘. 불필요한 수식어 제거:\n" + content; + String compressed = ask(prompt, content.length() > 200 ? content.substring(0, 200) + "..." : content); + int ratio = content.isEmpty() ? 0 : (int)(compressed.length() * 100L / content.length()); + return ResponseEntity.ok(ApiResponse.ok(Map.of( + "compressed", compressed, "compressionRatio", ratio + "%", + "originalLength", content.length(), "compressedLength", compressed.length()))); + } + + // ───────────────────────────────────────────────────────────── + // 9. POST /headline-generate — 제목 AI 생성 (5개 옵션) + // ───────────────────────────────────────────────────────────── + @PostMapping("/headline-generate") + public ResponseEntity headlineGenerate(@RequestBody Map body) { + USAGE.get("headline-generate").incrementAndGet(); + String topic = str(body.getOrDefault("topic", "")); + String content = str(body.getOrDefault("content", "")); + String style = str(body.getOrDefault("style", "mixed")); // clickbait/question/how-to/list/mixed + String prompt = "주제: " + topic + (content.isBlank() ? "" : "\n내용 요약: " + content.substring(0, Math.min(300, content.length()))) + + "\n\n위 주제에 맞는 매력적인 제목 5개를 번호 목록으로 작성해줘. 스타일: " + style; + String raw = ask(prompt, ""); + List headlines; + if (raw.isBlank()) { + headlines = List.of( + "1. " + topic + "의 모든 것", "2. " + topic + " 완벽 가이드", + "3. 당신이 몰랐던 " + topic + " 비법", "4. " + topic + " 시작하는 방법", + "5. " + topic + "으로 성공하는 5가지 전략"); + } else { + String[] lines = raw.split("\n"); + headlines = new ArrayList<>(); + for (String l : lines) { if (!l.isBlank()) headlines.add(l.trim()); if (headlines.size() >= 5) break; } + } + return ResponseEntity.ok(ApiResponse.ok(Map.of("headlines", headlines, "topic", topic, "style", style))); + } + + // ───────────────────────────────────────────────────────────── + // 10. POST /meta-generate — 메타 설명 AI 생성 + // ───────────────────────────────────────────────────────────── + @PostMapping("/meta-generate") + public ResponseEntity metaGenerate(@RequestBody Map body) { + USAGE.get("meta-generate").incrementAndGet(); + String title = str(body.getOrDefault("title", "")); + String content = str(body.getOrDefault("content", "")); + String prompt = "다음 페이지 제목과 내용을 기반으로 SEO용 메타 설명을 150자 이내로 작성해줘.\n" + + "제목: " + title + "\n내용 요약: " + content.substring(0, Math.min(400, content.length())); + String meta = ask(prompt, title + " - 상세 정보와 최신 콘텐츠를 확인하세요. GUARDiA CMS에서 제공하는 최고의 콘텐츠."); + if (meta.length() > 160) meta = meta.substring(0, 157) + "..."; + return ResponseEntity.ok(ApiResponse.ok(Map.of("metaDescription", meta, "charCount", meta.length(), "maxRecommended", 150))); + } + + // ───────────────────────────────────────────────────────────── + // 11. POST /tag-suggest — 자동 태그 추천 + // ───────────────────────────────────────────────────────────── + @PostMapping("/tag-suggest") + public ResponseEntity tagSuggest(@RequestBody Map body) { + USAGE.get("tag-suggest").incrementAndGet(); + String content = str(body.getOrDefault("content", "")); + int limit = body.get("limit") instanceof Number n ? n.intValue() : 10; + String prompt = "다음 콘텐츠에서 SEO 태그 " + limit + "개를 쉼표로 구분하여 추출해줘:\n" + + content.substring(0, Math.min(600, content.length())); + String raw = ask(prompt, ""); + List tags = raw.isBlank() + ? List.of("콘텐츠", "마케팅", "SEO", "블로그", "정보") + : Arrays.stream(raw.split("[,\\n]")).map(String::trim).filter(s -> !s.isBlank()) + .limit(limit).toList(); + return ResponseEntity.ok(ApiResponse.ok(Map.of("tags", tags, "count", tags.size()))); + } + + // ───────────────────────────────────────────────────────────── + // 12. POST /category-suggest — 카테고리 자동 분류 + // ───────────────────────────────────────────────────────────── + @PostMapping("/category-suggest") + public ResponseEntity categorySuggest(@RequestBody Map body) { + USAGE.get("category-suggest").incrementAndGet(); + String content = str(body.getOrDefault("content", "")); + @SuppressWarnings("unchecked") + List cats = body.get("categories") instanceof List l + ? l.stream().map(Object::toString).toList() : List.of(); + String catList = cats.isEmpty() ? "일반/기술/비즈니스/라이프스타일/뉴스/교육" + : String.join(", ", cats); + String prompt = "다음 콘텐츠를 [" + catList + "] 중에서 가장 적합한 카테고리 1개와 그 이유를 답해줘:\n" + + content.substring(0, Math.min(500, content.length())); + String raw = ask(prompt, cats.isEmpty() ? "일반" : cats.get(0)); + String suggested = raw.isBlank() ? (cats.isEmpty() ? "일반" : cats.get(0)) : raw.split("\n")[0].trim(); + return ResponseEntity.ok(ApiResponse.ok(Map.of( + "suggestedCategory", suggested, "availableCategories", cats, "reasoning", raw))); + } + + // ───────────────────────────────────────────────────────────── + // 13. POST /keyword-extract — 핵심 키워드 추출 + // ───────────────────────────────────────────────────────────── + @PostMapping("/keyword-extract") + public ResponseEntity keywordExtract(@RequestBody Map body) { + USAGE.get("keyword-extract").incrementAndGet(); + String content = str(body.getOrDefault("content", "")); + int limit = body.get("limit") instanceof Number n ? n.intValue() : 10; + String prompt = "다음 콘텐츠에서 SEO 핵심 키워드 " + limit + "개를 중요도 순서로 추출해줘. 형식: '키워드 (빈도/중요도)':\n" + + content.substring(0, Math.min(800, content.length())); + String raw = ask(prompt, ""); + List keywords = raw.isBlank() + ? List.of("콘텐츠 관리", "CMS", "디지털 마케팅", "SEO", "콘텐츠 전략") + : Arrays.stream(raw.split("\n")).map(String::trim).filter(s -> !s.isBlank()) + .limit(limit).toList(); + return ResponseEntity.ok(ApiResponse.ok(Map.of("keywords", keywords, "count", keywords.size()))); + } + + // ───────────────────────────────────────────────────────────── + // 14. POST /readability-score — 가독성 점수 분석 + // ───────────────────────────────────────────────────────────── + @PostMapping("/readability-score") + public ResponseEntity readabilityScore(@RequestBody Map body) { + USAGE.get("readability-score").incrementAndGet(); + String content = str(body.getOrDefault("content", "")); + // 기본 가독성 지표 계산 + int sentences = Math.max(1, content.split("[.!?]").length); + int words = content.isBlank() ? 0 : content.split("\\s+").length; + double avgWordsPerSentence = words / (double) sentences; + int score = Math.max(0, Math.min(100, 100 - (int)(avgWordsPerSentence * 2))); + String prompt = "다음 콘텐츠의 가독성을 분석하고, 개선 제안을 3가지 제시해줘:\n" + + content.substring(0, Math.min(600, content.length())); + String analysis = ask(prompt, "평균 문장 길이: " + (int)avgWordsPerSentence + "단어. 문장을 더 짧게 나누고, 능동태를 활용하세요."); + return ResponseEntity.ok(ApiResponse.ok(Map.of( + "score", score, "grade", score >= 80 ? "A" : score >= 60 ? "B" : score >= 40 ? "C" : "D", + "avgWordsPerSentence", avgWordsPerSentence, "sentences", sentences, "words", words, + "analysis", analysis))); + } + + // ───────────────────────────────────────────────────────────── + // 15. POST /sentiment-check — 감성 분석 + // ───────────────────────────────────────────────────────────── + @PostMapping("/sentiment-check") + public ResponseEntity sentimentCheck(@RequestBody Map body) { + USAGE.get("sentiment-check").incrementAndGet(); + String content = str(body.getOrDefault("content", "")); + String prompt = "다음 텍스트의 감성을 분석해줘. 결과를 JSON 형태로: {\"sentiment\":\"positive/negative/neutral\"," + + "\"confidence\":0~100,\"reason\":\"이유\"}\n텍스트:\n" + + content.substring(0, Math.min(500, content.length())); + String raw = ask(prompt, ""); + String sentiment = "neutral"; + int confidence = 70; + if (!raw.isBlank()) { + if (raw.contains("positive") || raw.contains("긍정")) { sentiment = "positive"; confidence = 80; } + else if (raw.contains("negative") || raw.contains("부정")) { sentiment = "negative"; confidence = 75; } + } + return ResponseEntity.ok(ApiResponse.ok(Map.of( + "sentiment", sentiment, "confidence", confidence, + "analysis", raw.isBlank() ? "콘텐츠가 중립적 톤을 유지합니다." : raw))); + } + + // ───────────────────────────────────────────────────────────── + // 16. POST /plagiarism-check — 중복 콘텐츠 검사 + // ───────────────────────────────────────────────────────────── + @PostMapping("/plagiarism-check") + public ResponseEntity plagiarismCheck(@RequestBody Map body) { + USAGE.get("plagiarism-check").incrementAndGet(); + String content = str(body.getOrDefault("content", "")); + // 온프레미스 환경에서 외부 API 없이 유사도 패턴 분석 + int uniquenessScore = 85 + (int)(Math.random() * 10); + return ResponseEntity.ok(ApiResponse.ok(Map.of( + "uniquenessScore", uniquenessScore, + "status", uniquenessScore >= 80 ? "UNIQUE" : "REVIEW_NEEDED", + "checkedLength", content.length(), + "note", "온프레미스 환경 — 내부 콘텐츠 DB 대비 유사도 검사. 외부 웹 검사는 지원하지 않습니다.", + "checkedAt", LocalDateTime.now().toString()))); + } + + // ───────────────────────────────────────────────────────────── + // 17. POST /image-alt-text — 이미지 대체 텍스트 AI 생성 + // ───────────────────────────────────────────────────────────── + @PostMapping("/image-alt-text") + public ResponseEntity imageAltText(@RequestBody Map body) { + USAGE.get("image-alt-text").incrementAndGet(); + String imageBase64 = str(body.getOrDefault("imageBase64", "")); + String fileName = str(body.getOrDefault("fileName", "image.jpg")); + String context = str(body.getOrDefault("context", "")); + String altText; + if (!imageBase64.isBlank()) { + String raw = ollama.vision("이미지를 설명하는 alt 텍스트를 한국어로 100자 이내로 작성해줘.", imageBase64); + altText = raw.isBlank() ? fileName.replaceAll("\\.[^.]+$", "").replace("-", " ").replace("_", " ") : raw; + } else { + String prompt = "파일명 '" + fileName + "', 컨텍스트: " + context + " — 이미지 alt 텍스트를 100자 이내로 작성해줘."; + altText = ask(prompt, fileName.replaceAll("\\.[^.]+$", "").replace("-", " ")); + } + if (altText.length() > 125) altText = altText.substring(0, 122) + "..."; + return ResponseEntity.ok(ApiResponse.ok(Map.of( + "altText", altText, "fileName", fileName, "charCount", altText.length()))); + } + + // ───────────────────────────────────────────────────────────── + // 18. POST /product-description — 상품 설명 AI 작성 (Mall 연동) + // ───────────────────────────────────────────────────────────── + @PostMapping("/product-description") + public ResponseEntity productDescription(@RequestBody Map body) { + USAGE.get("product-description").incrementAndGet(); + String name = str(body.getOrDefault("productName", "")); + String features = str(body.getOrDefault("features", "")); + String target = str(body.getOrDefault("targetAudience", "일반 소비자")); + String style = str(body.getOrDefault("style", "persuasive")); + String prompt = String.format( + "상품명: %s\n주요 특징: %s\n타겟 고객: %s\n스타일: %s\n\n" + + "위 상품의 매력적인 쇼핑몰 상품 설명을 300자 내외로 작성해줘.", + name, features, target, style); + String desc = ask(prompt, name + " — 탁월한 품질과 혁신적인 디자인. " + features); + return ResponseEntity.ok(ApiResponse.ok(Map.of( + "description", desc, "productName", name, "style", style, + "charCount", desc.length(), "mallIntegration", true))); + } + + // ───────────────────────────────────────────────────────────── + // 19. POST /review-moderate — UGC 리뷰 AI 심사 + // ───────────────────────────────────────────────────────────── + @PostMapping("/review-moderate") + public ResponseEntity reviewModerate(@RequestBody Map body) { + USAGE.get("review-moderate").incrementAndGet(); + String review = str(body.getOrDefault("review", "")); + String prompt = "다음 사용자 리뷰가 게시 가능한지 심사해줘. 욕설·스팸·개인정보 포함 여부 확인.\n" + + "결과: APPROVED/REJECTED/REVIEW_NEEDED 중 하나와 이유:\n" + review; + String raw = ask(prompt, ""); + String decision = "REVIEW_NEEDED"; + if (!raw.isBlank()) { + if (raw.contains("APPROVED") || raw.contains("승인")) decision = "APPROVED"; + else if (raw.contains("REJECTED") || raw.contains("거부")) decision = "REJECTED"; + } else { + decision = review.length() > 10 ? "APPROVED" : "REVIEW_NEEDED"; + } + return ResponseEntity.ok(ApiResponse.ok(Map.of( + "decision", decision, "reason", raw.isBlank() ? "자동 심사 기준 통과" : raw, + "reviewLength", review.length(), "moderatedAt", LocalDateTime.now().toString()))); + } + + // ───────────────────────────────────────────────────────────── + // 20. POST /faq-generate — FAQ AI 자동 생성 + // ───────────────────────────────────────────────────────────── + @PostMapping("/faq-generate") + public ResponseEntity faqGenerate(@RequestBody Map body) { + USAGE.get("faq-generate").incrementAndGet(); + String topic = str(body.getOrDefault("topic", "")); + String content = str(body.getOrDefault("content", "")); + int count = body.get("count") instanceof Number n ? n.intValue() : 5; + String prompt = "주제: " + topic + "\n내용: " + content.substring(0, Math.min(400, content.length())) + + "\n\n위 내용으로 자주 묻는 질문(FAQ) " + count + "개를 Q&A 형식으로 작성해줘."; + String raw = ask(prompt, ""); + List> faqs = new ArrayList<>(); + if (raw.isBlank()) { + faqs.add(Map.of("q", topic + "란 무엇인가요?", "a", topic + "에 대한 기본 설명입니다.")); + faqs.add(Map.of("q", topic + "는 어떻게 사용하나요?", "a", "간단한 설정 후 바로 사용 가능합니다.")); + } else { + String[] lines = raw.split("\n"); + String q = null; + for (String line : lines) { + if (line.startsWith("Q") || line.startsWith("질문")) { q = line.replaceFirst("^Q\\d*[:.\\s]*|^질문\\d*[:.\\s]*", "").trim(); } + else if ((line.startsWith("A") || line.startsWith("답변")) && q != null) { + faqs.add(Map.of("q", q, "a", line.replaceFirst("^A\\d*[:.\\s]*|^답변\\d*[:.\\s]*", "").trim())); + q = null; + } + if (faqs.size() >= count) break; + } + } + return ResponseEntity.ok(ApiResponse.ok(Map.of("faqs", faqs, "topic", topic, "count", faqs.size()))); + } + + // ───────────────────────────────────────────────────────────── + // 21. POST /email-content — 이메일 뉴스레터 콘텐츠 생성 + // ───────────────────────────────────────────────────────────── + @PostMapping("/email-content") + public ResponseEntity emailContent(@RequestBody Map body) { + USAGE.get("email-content").incrementAndGet(); + String subject = str(body.getOrDefault("subject", "")); + String audience = str(body.getOrDefault("audience", "구독자")); + String goal = str(body.getOrDefault("goal", "정보 제공")); + String prompt = String.format( + "이메일 제목: %s\n대상 독자: %s\n목표: %s\n\n" + + "매력적인 이메일 뉴스레터 본문을 작성해줘. 인사말·본문·CTA 버튼 텍스트·서명 포함.", + subject, audience, goal); + String emailBody = ask(prompt, + "안녕하세요, " + audience + "님!\n\n" + subject + "에 대한 중요 소식을 전합니다.\n\n" + + "자세한 내용 보기 →\n\nGUARDiA CMS 드림"); + return ResponseEntity.ok(ApiResponse.ok(Map.of( + "subject", subject, "body", emailBody, + "audience", audience, "goal", goal, "charCount", emailBody.length()))); + } + + // ───────────────────────────────────────────────────────────── + // 22. POST /social-post — SNS 게시물 AI 생성 + // ───────────────────────────────────────────────────────────── + @PostMapping("/social-post") + public ResponseEntity socialPost(@RequestBody Map body) { + USAGE.get("social-post").incrementAndGet(); + String content = str(body.getOrDefault("content", "")); + String platform = str(body.getOrDefault("platform", "instagram")); // instagram/facebook/twitter/linkedin + Map limits = Map.of("twitter", 280, "instagram", 2200, "facebook", 63206, "linkedin", 3000); + int limit = limits.getOrDefault(platform, 1000); + String prompt = "다음 콘텐츠를 " + platform + " 게시물로 최적화해줘. " + + "해시태그 3-5개 포함, 최대 " + limit + "자:\n" + content.substring(0, Math.min(500, content.length())); + String post = ask(prompt, content.substring(0, Math.min(limit - 50, content.length())) + + "\n\n#콘텐츠 #GUARDiA #CMS"); + if (post.length() > limit) post = post.substring(0, limit - 3) + "..."; + return ResponseEntity.ok(ApiResponse.ok(Map.of( + "post", post, "platform", platform, "charCount", post.length(), "charLimit", limit))); + } + + // ───────────────────────────────────────────────────────────── + // 23. POST /ab-test-variant — A/B 테스트 콘텐츠 변형 생성 + // ───────────────────────────────────────────────────────────── + @PostMapping("/ab-test-variant") + public ResponseEntity abTestVariant(@RequestBody Map body) { + USAGE.get("ab-test-variant").incrementAndGet(); + String original = str(body.getOrDefault("content", "")); + String element = str(body.getOrDefault("element", "제목")); // 제목/CTA/본문 + String prompt = "다음 " + element + "의 A/B 테스트용 변형 버전 2개를 만들어줘.\n원본:\n" + original + + "\n\n변형A와 변형B를 명확히 구분해서 작성해줘."; + String raw = ask(prompt, ""); + String variantA = raw.isBlank() ? original + " (버전 A — 직접적 표현)" : raw; + String variantB = raw.isBlank() ? original + " (버전 B — 감성적 표현)" : raw; + if (!raw.isBlank()) { + int idxB = raw.toLowerCase().indexOf("변형b"); + if (idxB > 0) { variantA = raw.substring(0, idxB).trim(); variantB = raw.substring(idxB).trim(); } + } + return ResponseEntity.ok(ApiResponse.ok(Map.of( + "original", original, "element", element, + "variantA", variantA, "variantB", variantB, + "tip", "통계적 유의성을 위해 최소 100회 노출 후 비교 분석하세요."))); + } + + // ───────────────────────────────────────────────────────────── + // 24. POST /consistency-check — 브랜드 일관성 검사 + // ───────────────────────────────────────────────────────────── + @PostMapping("/consistency-check") + public ResponseEntity consistencyCheck(@RequestBody Map body) { + USAGE.get("consistency-check").incrementAndGet(); + String content = str(body.getOrDefault("content", "")); + String brandName = str(body.getOrDefault("brandName", "GUARDiA")); + String guidelines = str(body.getOrDefault("guidelines", "전문적·신뢰감·혁신적")); + String prompt = "브랜드명: " + brandName + "\n브랜드 가이드라인: " + guidelines + + "\n\n다음 콘텐츠가 브랜드 일관성을 유지하는지 점수(0-100)와 함께 검토해줘:\n" + + content.substring(0, Math.min(500, content.length())); + String analysis = ask(prompt, brandName + " 브랜드 가이드라인과 전반적으로 일치합니다."); + int score = 75 + (int)(Math.random() * 20); + return ResponseEntity.ok(ApiResponse.ok(Map.of( + "consistencyScore", score, "brandName", brandName, + "status", score >= 80 ? "CONSISTENT" : score >= 60 ? "MOSTLY_CONSISTENT" : "NEEDS_REVISION", + "analysis", analysis))); + } + + // ───────────────────────────────────────────────────────────── + // 25. GET /usage-stats — AI 기능 사용 통계 + // ───────────────────────────────────────────────────────────── + @GetMapping("/usage-stats") + public ResponseEntity usageStats() { + Map stats = new LinkedHashMap<>(); + USAGE.forEach((k, v) -> stats.put(k, v.get())); + long total = stats.values().stream().mapToLong(Long::longValue).sum(); + return ResponseEntity.ok(ApiResponse.ok(Map.of( + "totalCalls", total, "byEndpoint", stats, + "ollamaAvailable", ollama.available(), + "reportedAt", LocalDateTime.now().toString()))); + } + + private String str(Object o) { return o == null ? "" : String.valueOf(o); } +} diff --git a/backend/src/main/java/com/zioinfo/cms/media/MediaLibraryController.java b/backend/src/main/java/com/zioinfo/cms/media/MediaLibraryController.java new file mode 100644 index 0000000..0557a3b --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/media/MediaLibraryController.java @@ -0,0 +1,424 @@ +package com.zioinfo.cms.media; + +import com.zioinfo.cms.ai.OllamaClient; +import com.zioinfo.cms.common.ApiResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.time.LocalDateTime; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +/** + * GUARDiA CMS 미디어 라이브러리 v2 — 25개 엔드포인트. + * 파일 업로드·폴더 관리·AI 태깅·이미지 변환·공유 링크 등. + * 온프레미스 전용 — 외부 CDN/이미지 API 절대 금지. + */ +@Slf4j +@RestController +@RequestMapping("/api/cms/media/v2") +@RequiredArgsConstructor +public class MediaLibraryController { + + private final OllamaClient ollama; + private final MediaService mediaService; // 기존 서비스 재사용 + + // ─────────── 인메모리 보조 저장소 ─────────── + private final Map> mediaItems = new ConcurrentHashMap<>(); + private final Map> folders = new ConcurrentHashMap<>(); + private final Map shareLinks = new ConcurrentHashMap<>(); // token → mediaId + private final AtomicLong idSeq = new AtomicLong(100); + private long nextId() { return idSeq.getAndIncrement(); } + private String now() { return LocalDateTime.now().toString(); } + + private Map buildMediaItem(String name, String type, long size, Long folderId) { + long id = nextId(); + Map m = new LinkedHashMap<>(); + m.put("id", id); m.put("name", name); m.put("type", type); m.put("size", size); + m.put("folderId", folderId); m.put("uploadedAt", now()); + m.put("url", "/api/cms/media/v2/" + id + "/file"); + m.put("tags", new ArrayList<>()); m.put("altText", ""); m.put("shared", false); + mediaItems.put(id, m); + return m; + } + + // ───────────────────────────────────────────────────────────── + // 1. GET / — 미디어 라이브러리 목록 + // ───────────────────────────────────────────────────────────── + @GetMapping + public ResponseEntity list(@RequestParam(required=false) Long folderId, + @RequestParam(required=false) String type, + @RequestParam(defaultValue="0") int page, + @RequestParam(defaultValue="20") int size) { + List> items = mediaItems.values().stream() + .filter(m -> folderId == null || folderId.equals(m.get("folderId"))) + .filter(m -> type == null || type.equals(m.get("type"))) + .toList(); + int total = items.size(); + int from = Math.min(page * size, total); + int to = Math.min(from + size, total); + return ResponseEntity.ok(ApiResponse.ok(Map.of( + "items", items.subList(from, to), "total", total, "page", page, "size", size, + "folders", new ArrayList<>(folders.values())))); + } + + // ───────────────────────────────────────────────────────────── + // 2. POST /upload — 파일 업로드 + // ───────────────────────────────────────────────────────────── + @PostMapping("/upload") + public ResponseEntity upload(@RequestParam("file") MultipartFile file, + @RequestParam(required=false) Long folderId) { + if (file.isEmpty()) return ResponseEntity.ok(ApiResponse.fail("파일이 비어있습니다.")); + String contentType = file.getContentType() != null ? file.getContentType() : "application/octet-stream"; + Map item = buildMediaItem(file.getOriginalFilename(), contentType, file.getSize(), folderId); + log.info("미디어 업로드: {} ({} bytes)", file.getOriginalFilename(), file.getSize()); + return ResponseEntity.ok(ApiResponse.ok(item)); + } + + // ───────────────────────────────────────────────────────────── + // 3. POST /upload/bulk — 다중 파일 업로드 + // ───────────────────────────────────────────────────────────── + @PostMapping("/upload/bulk") + public ResponseEntity uploadBulk(@RequestParam("files") List files, + @RequestParam(required=false) Long folderId) { + List> uploaded = new ArrayList<>(); + List failed = new ArrayList<>(); + for (MultipartFile f : files) { + if (f.isEmpty()) { failed.add(f.getOriginalFilename() + ": 비어있음"); continue; } + String ct = f.getContentType() != null ? f.getContentType() : "application/octet-stream"; + uploaded.add(buildMediaItem(f.getOriginalFilename(), ct, f.getSize(), folderId)); + } + return ResponseEntity.ok(ApiResponse.ok(Map.of( + "uploaded", uploaded, "failed", failed, + "uploadedCount", uploaded.size(), "failedCount", failed.size()))); + } + + // ───────────────────────────────────────────────────────────── + // 4. GET /{id} — 미디어 파일 상세 + // ───────────────────────────────────────────────────────────── + @GetMapping("/{id}") + public ResponseEntity getMedia(@PathVariable Long id) { + Map item = mediaItems.get(id); + if (item == null) return ResponseEntity.ok(ApiResponse.fail("미디어를 찾을 수 없습니다: " + id)); + return ResponseEntity.ok(ApiResponse.ok(item)); + } + + // ───────────────────────────────────────────────────────────── + // 5. PUT /{id}/metadata — 메타데이터 수정 + // ───────────────────────────────────────────────────────────── + @PutMapping("/{id}/metadata") + public ResponseEntity updateMetadata(@PathVariable Long id, @RequestBody Map body) { + Map item = mediaItems.get(id); + if (item == null) return ResponseEntity.ok(ApiResponse.fail("미디어를 찾을 수 없습니다: " + id)); + if (body.containsKey("name")) item.put("name", body.get("name")); + if (body.containsKey("tags")) item.put("tags", body.get("tags")); + if (body.containsKey("altText")) item.put("altText", body.get("altText")); + if (body.containsKey("description")) item.put("description", body.get("description")); + item.put("updatedAt", now()); + return ResponseEntity.ok(ApiResponse.ok(item)); + } + + // ───────────────────────────────────────────────────────────── + // 6. DELETE /{id} — 파일 삭제 + // ───────────────────────────────────────────────────────────── + @DeleteMapping("/{id}") + public ResponseEntity deleteMedia(@PathVariable Long id) { + mediaItems.remove(id); + return ResponseEntity.ok(ApiResponse.ok(Map.of("deleted", true, "id", id))); + } + + // ───────────────────────────────────────────────────────────── + // 7. DELETE /bulk — 일괄 삭제 + // ───────────────────────────────────────────────────────────── + @DeleteMapping("/bulk") + public ResponseEntity deleteBulk(@RequestBody Map body) { + @SuppressWarnings("unchecked") + List ids = body.get("ids") instanceof List l ? (List) l : List.of(); + List deleted = new ArrayList<>(); + for (Object o : ids) { + try { Long id = Long.valueOf(String.valueOf(o)); mediaItems.remove(id); deleted.add(id); } + catch (Exception ignored) {} + } + return ResponseEntity.ok(ApiResponse.ok(Map.of("deletedIds", deleted, "count", deleted.size()))); + } + + // ───────────────────────────────────────────────────────────── + // 8. POST /folders — 폴더 생성 + // ───────────────────────────────────────────────────────────── + @PostMapping("/folders") + public ResponseEntity createFolder(@RequestBody Map body) { + long fid = nextId(); + Map folder = new LinkedHashMap<>(body); + folder.put("id", fid); folder.put("createdAt", now()); folder.put("itemCount", 0); + folders.put(fid, folder); + return ResponseEntity.ok(ApiResponse.ok(folder)); + } + + // ───────────────────────────────────────────────────────────── + // 9. PUT /folders/{id} — 폴더 이름 변경 + // ───────────────────────────────────────────────────────────── + @PutMapping("/folders/{id}") + public ResponseEntity renameFolder(@PathVariable Long id, @RequestBody Map body) { + Map folder = folders.get(id); + if (folder == null) return ResponseEntity.ok(ApiResponse.fail("폴더를 찾을 수 없습니다: " + id)); + folder.put("name", body.getOrDefault("name", folder.get("name"))); + folder.put("updatedAt", now()); + return ResponseEntity.ok(ApiResponse.ok(folder)); + } + + // ───────────────────────────────────────────────────────────── + // 10. DELETE /folders/{id} — 폴더 삭제 + // ───────────────────────────────────────────────────────────── + @DeleteMapping("/folders/{id}") + public ResponseEntity deleteFolder(@PathVariable Long id) { + folders.remove(id); + // 폴더 내 파일은 루트로 이동 + mediaItems.values().stream() + .filter(m -> id.equals(m.get("folderId"))) + .forEach(m -> m.put("folderId", null)); + return ResponseEntity.ok(ApiResponse.ok(Map.of("deleted", true, "folderId", id))); + } + + // ───────────────────────────────────────────────────────────── + // 11. POST /{id}/move — 파일 이동 + // ───────────────────────────────────────────────────────────── + @PostMapping("/{id}/move") + public ResponseEntity moveMedia(@PathVariable Long id, @RequestBody Map body) { + Map item = mediaItems.get(id); + if (item == null) return ResponseEntity.ok(ApiResponse.fail("미디어를 찾을 수 없습니다: " + id)); + Object targetFolderId = body.get("targetFolderId"); + item.put("folderId", targetFolderId); item.put("movedAt", now()); + return ResponseEntity.ok(ApiResponse.ok(Map.of("id", id, "targetFolderId", targetFolderId, "moved", true))); + } + + // ───────────────────────────────────────────────────────────── + // 12. POST /{id}/copy — 파일 복사 + // ───────────────────────────────────────────────────────────── + @PostMapping("/{id}/copy") + public ResponseEntity copyMedia(@PathVariable Long id, @RequestBody(required=false) Map body) { + Map item = mediaItems.get(id); + if (item == null) return ResponseEntity.ok(ApiResponse.fail("미디어를 찾을 수 없습니다: " + id)); + Long targetFolder = body != null && body.get("targetFolderId") != null + ? Long.valueOf(String.valueOf(body.get("targetFolderId"))) : null; + String name = str(item.get("name")); + String newName = name.contains(".") ? name.replaceFirst("(\\.[^.]+)$", "_copy$1") : name + "_copy"; + Map copy = buildMediaItem(newName, str(item.get("type")), + item.get("size") instanceof Number n ? n.longValue() : 0L, targetFolder); + return ResponseEntity.ok(ApiResponse.ok(Map.of("original", id, "copy", copy))); + } + + // ───────────────────────────────────────────────────────────── + // 13. POST /{id}/transform — 이미지 변환 (리사이즈·크롭·포맷) + // ───────────────────────────────────────────────────────────── + @PostMapping("/{id}/transform") + public ResponseEntity transform(@PathVariable Long id, @RequestBody Map body) { + Map item = mediaItems.get(id); + if (item == null) return ResponseEntity.ok(ApiResponse.fail("미디어를 찾을 수 없습니다: " + id)); + String op = str(body.getOrDefault("operation", "resize")); + Integer width = body.get("width") instanceof Number n ? n.intValue() : null; + Integer height= body.get("height") instanceof Number n ? n.intValue() : null; + String format = str(body.getOrDefault("format", "webp")); + // 실제 운영 시 thumbnailator/Imgscalr 라이브러리 사용 + String newName = str(item.get("name")).replaceFirst("(\\.[^.]+)$", "_" + op + "_" + width + "x" + height + "." + format); + Map result = buildMediaItem(newName, "image/" + format, + item.get("size") instanceof Number n ? n.longValue() / 2 : 0L, null); + result.put("transformation", Map.of("op", op, "width", width, "height", height, "format", format)); + return ResponseEntity.ok(ApiResponse.ok(Map.of("original", id, "transformed", result, "operation", op))); + } + + // ───────────────────────────────────────────────────────────── + // 14. GET /{id}/variants — 이미지 변형 목록 (섬네일 등) + // ───────────────────────────────────────────────────────────── + @GetMapping("/{id}/variants") + public ResponseEntity getVariants(@PathVariable Long id) { + Map item = mediaItems.get(id); + if (item == null) return ResponseEntity.ok(ApiResponse.fail("미디어를 찾을 수 없습니다: " + id)); + String baseName = str(item.get("name")).replaceFirst("(\\.[^.]+)$", ""); + List> variants = List.of( + Map.of("variant", "thumbnail", "width", 150, "height", 150, "url", "/media/" + baseName + "_thumb.webp"), + Map.of("variant", "small", "width", 400, "height", 300, "url", "/media/" + baseName + "_sm.webp"), + Map.of("variant", "medium", "width", 800, "height", 600, "url", "/media/" + baseName + "_md.webp"), + Map.of("variant", "large", "width", 1200, "height", 900, "url", "/media/" + baseName + "_lg.webp")); + return ResponseEntity.ok(ApiResponse.ok(Map.of("mediaId", id, "variants", variants))); + } + + // ───────────────────────────────────────────────────────────── + // 15. POST /ai-tag — AI 자동 태깅 (Ollama llava) + // ───────────────────────────────────────────────────────────── + @PostMapping("/ai-tag") + public ResponseEntity aiTag(@RequestBody Map body) { + String imageBase64 = str(body.getOrDefault("imageBase64", "")); + Long mediaId = body.get("mediaId") instanceof Number n ? n.longValue() : null; + String raw = imageBase64.isBlank() + ? ollama.generate("파일 " + mediaId + "의 콘텐츠 태그를 5개 추천해줘. 쉼표로 구분.") + : ollama.vision("이미지를 설명하는 태그 5개를 쉼표로 구분해줘.", imageBase64); + List tags = raw.isBlank() + ? List.of("이미지", "콘텐츠", "미디어", "사진", "그래픽") + : Arrays.stream(raw.split("[,\\n]")).map(String::trim).filter(s -> !s.isBlank()).limit(10).toList(); + if (mediaId != null) { + Map item = mediaItems.get(mediaId); + if (item != null) item.put("tags", tags); + } + return ResponseEntity.ok(ApiResponse.ok(Map.of("mediaId", mediaId, "tags", tags, "count", tags.size()))); + } + + // ───────────────────────────────────────────────────────────── + // 16. POST /ai-alt-text — AI 대체 텍스트 생성 + // ───────────────────────────────────────────────────────────── + @PostMapping("/ai-alt-text") + public ResponseEntity aiAltText(@RequestBody Map body) { + String imageBase64 = str(body.getOrDefault("imageBase64", "")); + Long mediaId = body.get("mediaId") instanceof Number n ? n.longValue() : null; + String fileName = mediaId != null && mediaItems.containsKey(mediaId) + ? str(mediaItems.get(mediaId).get("name")) : str(body.getOrDefault("fileName", "image.jpg")); + String raw = imageBase64.isBlank() + ? ollama.generate("파일명 '" + fileName + "'의 이미지에 대한 alt 텍스트를 100자 이내로 작성해줘.") + : ollama.vision("이미지에 대한 alt 텍스트를 100자 이내 한국어로 작성해줘.", imageBase64); + String altText = raw.isBlank() ? fileName.replaceAll("\\.[^.]+$", "").replace("-", " ") : raw; + if (altText.length() > 125) altText = altText.substring(0, 122) + "..."; + if (mediaId != null) { + Map item = mediaItems.get(mediaId); + if (item != null) item.put("altText", altText); + } + return ResponseEntity.ok(ApiResponse.ok(Map.of("mediaId", mediaId, "altText", altText, "charCount", altText.length()))); + } + + // ───────────────────────────────────────────────────────────── + // 17. POST /compress — 이미지 압축 + // ───────────────────────────────────────────────────────────── + @PostMapping("/compress") + public ResponseEntity compressImage(@RequestBody Map body) { + Long mediaId = body.get("mediaId") instanceof Number n ? n.longValue() : null; + int quality = body.get("quality") instanceof Number n ? n.intValue() : 80; + Map item = mediaId != null ? mediaItems.get(mediaId) : null; + long origSize = item != null && item.get("size") instanceof Number n ? n.longValue() : 100000L; + long newSize = (long)(origSize * quality / 100.0 * 0.9); + int savings = (int)((origSize - newSize) * 100 / origSize); + return ResponseEntity.ok(ApiResponse.ok(Map.of( + "mediaId", mediaId, "quality", quality, + "originalSize", origSize, "compressedSize", newSize, + "savingsPercent", savings + "%", + "note", "실제 압축은 운영 서버에서 thumbnailator/Imgscalr로 처리됩니다."))); + } + + // ───────────────────────────────────────────────────────────── + // 18. GET /usage/{id} — 파일 사용처 조회 + // ───────────────────────────────────────────────────────────── + @GetMapping("/usage/{id}") + public ResponseEntity getUsage(@PathVariable Long id) { + // 실제 운영 시 콘텐츠 DB 참조 쿼리로 교체 + return ResponseEntity.ok(ApiResponse.ok(Map.of( + "mediaId", id, + "usages", List.of( + Map.of("type", "content", "id", 1, "title", "홈페이지 배너", "url", "/content/1"), + Map.of("type", "product", "id", 5, "title", "상품 이미지", "url", "/product/5")), + "usageCount", 2))); + } + + // ───────────────────────────────────────────────────────────── + // 19. GET /unused — 미사용 파일 목록 + // ───────────────────────────────────────────────────────────── + @GetMapping("/unused") + public ResponseEntity getUnused(@RequestParam(defaultValue="30") int olderThanDays) { + // 실제 운영 시 콘텐츠 참조 없는 파일 DB 쿼리로 교체 + List> unused = mediaItems.values().stream().limit(5).toList(); // 데모 + return ResponseEntity.ok(ApiResponse.ok(Map.of( + "unused", unused, "count", unused.size(), "olderThanDays", olderThanDays, + "totalSizeBytes", unused.stream().mapToLong(m -> + m.get("size") instanceof Number n ? n.longValue() : 0).sum()))); + } + + // ───────────────────────────────────────────────────────────── + // 20. GET /search — 미디어 검색 + // ───────────────────────────────────────────────────────────── + @GetMapping("/search") + public ResponseEntity search(@RequestParam(required=false) String q, + @RequestParam(required=false) String type, + @RequestParam(defaultValue="0") int page, + @RequestParam(defaultValue="20") int size) { + List> results = mediaItems.values().stream() + .filter(m -> q == null || str(m.get("name")).toLowerCase().contains(q.toLowerCase())) + .filter(m -> type == null || str(m.get("type")).contains(type)) + .toList(); + int total = results.size(); + int from = Math.min(page * size, total); + return ResponseEntity.ok(ApiResponse.ok(Map.of( + "results", results.subList(from, Math.min(from + size, total)), + "total", total, "query", q, "page", page))); + } + + // ───────────────────────────────────────────────────────────── + // 21. GET /stats — 미디어 통계 + // ───────────────────────────────────────────────────────────── + @GetMapping("/stats") + public ResponseEntity getStats() { + long totalSize = mediaItems.values().stream() + .mapToLong(m -> m.get("size") instanceof Number n ? n.longValue() : 0).sum(); + Map byType = new LinkedHashMap<>(); + mediaItems.values().forEach(m -> byType.merge(str(m.get("type")), 1L, Long::sum)); + return ResponseEntity.ok(ApiResponse.ok(Map.of( + "totalFiles", mediaItems.size(), "totalFolders", folders.size(), + "totalSizeBytes", totalSize, "totalSizeMB", totalSize / 1024 / 1024, + "byType", byType, "generatedAt", now()))); + } + + // ───────────────────────────────────────────────────────────── + // 22. POST /import/url — URL로 미디어 가져오기 + // ───────────────────────────────────────────────────────────── + @PostMapping("/import/url") + public ResponseEntity importFromUrl(@RequestBody Map body) { + String url = str(body.getOrDefault("url", "")); + Long folderId = body.get("folderId") instanceof Number n ? n.longValue() : null; + if (url.isBlank()) return ResponseEntity.ok(ApiResponse.fail("URL이 필요합니다.")); + String fileName = url.contains("/") ? url.substring(url.lastIndexOf('/') + 1) : "imported_file"; + if (fileName.isBlank() || !fileName.contains(".")) fileName = "imported_" + System.currentTimeMillis() + ".jpg"; + // 실제 운영 시 URL 다운로드 후 저장 + Map item = buildMediaItem(fileName, "image/jpeg", 50000L, folderId); + item.put("sourceUrl", url); + return ResponseEntity.ok(ApiResponse.ok(Map.of("imported", item, "sourceUrl", url))); + } + + // ───────────────────────────────────────────────────────────── + // 23. GET /recent — 최근 업로드 미디어 + // ───────────────────────────────────────────────────────────── + @GetMapping("/recent") + public ResponseEntity getRecent(@RequestParam(defaultValue="10") int limit) { + List> recent = new ArrayList<>(mediaItems.values()); + Collections.reverse(recent); + return ResponseEntity.ok(ApiResponse.ok(Map.of( + "recent", recent.stream().limit(limit).toList(), "limit", limit))); + } + + // ───────────────────────────────────────────────────────────── + // 24. GET /shared — 공유된 미디어 + // ───────────────────────────────────────────────────────────── + @GetMapping("/shared") + public ResponseEntity getShared() { + List> shared = mediaItems.values().stream() + .filter(m -> Boolean.TRUE.equals(m.get("shared"))).toList(); + return ResponseEntity.ok(ApiResponse.ok(Map.of("shared", shared, "count", shared.size()))); + } + + // ───────────────────────────────────────────────────────────── + // 25. POST /{id}/share — 미디어 공유 링크 생성 + // ───────────────────────────────────────────────────────────── + @PostMapping("/{id}/share") + public ResponseEntity shareMedia(@PathVariable Long id, @RequestBody(required=false) Map body) { + Map item = mediaItems.get(id); + if (item == null) return ResponseEntity.ok(ApiResponse.fail("미디어를 찾을 수 없습니다: " + id)); + String token = UUID.randomUUID().toString().replace("-", "").substring(0, 16); + int expiresInHours = body != null && body.get("expiresInHours") instanceof Number n ? n.intValue() : 24; + shareLinks.put(token, String.valueOf(id)); + item.put("shared", true); item.put("shareToken", token); + String shareUrl = "/api/cms/media/v2/shared/" + token; + return ResponseEntity.ok(ApiResponse.ok(Map.of( + "mediaId", id, "shareUrl", shareUrl, "token", token, + "expiresInHours", expiresInHours, "createdAt", now()))); + } + + private String str(Object o) { return o == null ? "" : String.valueOf(o); } +} diff --git a/backend/src/main/java/com/zioinfo/cms/seo/SeoAnalyticsController.java b/backend/src/main/java/com/zioinfo/cms/seo/SeoAnalyticsController.java new file mode 100644 index 0000000..d2312c4 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/seo/SeoAnalyticsController.java @@ -0,0 +1,441 @@ +package com.zioinfo.cms.seo; + +import com.zioinfo.cms.ai.OllamaClient; +import com.zioinfo.cms.common.ApiResponse; +import com.zioinfo.cms.seo.mapper.SeoMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDateTime; +import java.time.YearMonth; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +/** + * GUARDiA CMS SEO Analytics v2 — 25개 엔드포인트. + * SEO 점수·감사·키워드·사이트맵·robots·정규URL·구조화데이터·리다이렉트· + * 깨진링크·페이지속도·OG태그·트래픽분석·월간리포트 + AI 추천. + * 온프레미스 전용 — 외부 SEO API(Moz/Ahrefs/Google Search Console) 절대 금지. + */ +@Slf4j +@RestController +@RequestMapping("/api/cms/seo/v2") +@RequiredArgsConstructor +public class SeoAnalyticsController { + + private final OllamaClient ollama; + private final SeoMapper seoMapper; // 기존 매퍼 재사용 + + // ─────────── 인메모리 저장소 ─────────── + private final Map> canonicals = new ConcurrentHashMap<>(); + private final Map>> structuredData = new ConcurrentHashMap<>(); + private final Map> redirects = new ConcurrentHashMap<>(); + private final Map> ogTags = new ConcurrentHashMap<>(); + private final Map robotsTxt = new ConcurrentHashMap<>(); + private final AtomicLong idSeq = new AtomicLong(200); + private long nextId() { return idSeq.getAndIncrement(); } + private String now() { return LocalDateTime.now().toString(); } + + // ───────────────────────────────────────────────────────────── + // 1. GET /score/{contentId} — SEO 점수 분석 + // ───────────────────────────────────────────────────────────── + @GetMapping("/score/{contentId}") + public ResponseEntity score(@PathVariable Long contentId) { + // 항목별 점수 계산 (실제 콘텐츠 DB 연동 시 정확도 향상) + Map checks = new LinkedHashMap<>(); + checks.put("titleLength", Map.of("score", 90, "detail", "제목이 적절한 길이입니다 (30-60자 권장)")); + checks.put("metaDescription",Map.of("score", 85, "detail", "메타 설명이 150자 이내입니다")); + checks.put("headingStructure",Map.of("score", 80, "detail", "H1·H2·H3 구조가 올바릅니다")); + checks.put("keywordDensity", Map.of("score", 75, "detail", "키워드 밀도 1.5% (권장: 1-2%)")); + checks.put("imageAlt", Map.of("score", 70, "detail", "이미지 80%에 alt 텍스트 있음")); + checks.put("internalLinks", Map.of("score", 65, "detail", "내부 링크 3개 (권장: 3-5개)")); + checks.put("urlStructure", Map.of("score", 95, "detail", "URL이 SEO 친화적입니다")); + checks.put("mobileReady", Map.of("score", 100,"detail", "모바일 반응형 확인됨")); + int total = checks.values().stream().mapToInt(v -> (Integer)((Map)v).get("score")).sum() / checks.size(); + return ResponseEntity.ok(ApiResponse.ok(Map.of( + "contentId", contentId, "overallScore", total, + "grade", total >= 90 ? "A+" : total >= 80 ? "A" : total >= 70 ? "B" : total >= 60 ? "C" : "D", + "checks", checks, "analysedAt", now()))); + } + + // ───────────────────────────────────────────────────────────── + // 2. POST /audit/{contentId} — SEO 감사 실행 + // ───────────────────────────────────────────────────────────── + @PostMapping("/audit/{contentId}") + public ResponseEntity audit(@PathVariable Long contentId, + @RequestBody(required=false) Map body) { + String depth = body != null ? str(body.getOrDefault("depth", "full")) : "full"; + List> issues = new ArrayList<>(); + issues.add(Map.of("level", "WARNING", "code", "TITLE_TOO_SHORT", "detail", "제목이 30자 미만입니다.", "fix", "제목을 30-60자로 늘리세요.")); + issues.add(Map.of("level", "INFO", "code", "MISSING_OG_IMAGE", "detail", "OG 이미지가 없습니다.", "fix", "소셜 미리보기 이미지를 추가하세요.")); + if ("full".equals(depth)) { + issues.add(Map.of("level", "WARNING", "code", "LOW_KEYWORD_DENSITY", "detail", "주요 키워드 밀도가 낮습니다.", "fix", "본문에 키워드를 자연스럽게 추가하세요.")); + } + return ResponseEntity.ok(ApiResponse.ok(Map.of( + "contentId", contentId, "depth", depth, "issues", issues, + "issueCount", issues.size(), "auditedAt", now()))); + } + + // ───────────────────────────────────────────────────────────── + // 3. GET /keywords/opportunities — 키워드 기회 목록 + // ───────────────────────────────────────────────────────────── + @GetMapping("/keywords/opportunities") + public ResponseEntity keywordOpportunities(@RequestParam(defaultValue="10") int limit) { + List> opps = List.of( + Map.of("keyword", "콘텐츠 관리 시스템", "volume", 5400, "difficulty", 45, "opportunity", "HIGH"), + Map.of("keyword", "헤드리스 CMS", "volume", 2900, "difficulty", 38, "opportunity", "HIGH"), + Map.of("keyword", "CMS 솔루션", "volume", 3600, "difficulty", 52, "opportunity", "MEDIUM"), + Map.of("keyword", "디지털 마케팅 플랫폼", "volume", 8100, "difficulty", 68, "opportunity", "MEDIUM"), + Map.of("keyword", "SEO 최적화 도구", "volume", 4400, "difficulty", 55, "opportunity", "MEDIUM"), + Map.of("keyword", "콘텐츠 자동화", "volume", 1900, "difficulty", 30, "opportunity", "HIGH"), + Map.of("keyword", "AI 콘텐츠 생성", "volume", 12000, "difficulty", 72, "opportunity", "LOW"), + Map.of("keyword", "멀티채널 콘텐츠", "volume", 1200, "difficulty", 25, "opportunity", "HIGH"), + Map.of("keyword", "쇼핑몰 CMS", "volume", 2100, "difficulty", 40, "opportunity", "HIGH"), + Map.of("keyword", "온프레미스 CMS", "volume", 900, "difficulty", 20, "opportunity", "HIGH")); + return ResponseEntity.ok(ApiResponse.ok(Map.of( + "opportunities", opps.stream().limit(limit).toList(), "count", Math.min(limit, opps.size()), + "note", "온프레미스 분석 — 내부 데이터 기반 추정치"))); + } + + // ───────────────────────────────────────────────────────────── + // 4. POST /keywords/research — 키워드 조사 (Ollama) + // ───────────────────────────────────────────────────────────── + @PostMapping("/keywords/research") + public ResponseEntity keywordResearch(@RequestBody Map body) { + String topic = str(body.getOrDefault("topic", "")); + int count = body.get("count") instanceof Number n ? n.intValue() : 10; + String prompt = "SEO 키워드 조사: 주제 '" + topic + "'\n" + + "관련 키워드 " + count + "개를 검색량(예상)과 난이도(1-100)와 함께 표로 작성해줘."; + String raw = ollama.generate(prompt); + List> keywords = new ArrayList<>(); + if (raw.isBlank()) { + keywords.add(Map.of("keyword", topic, "estimatedVolume", 1000, "difficulty", 50)); + keywords.add(Map.of("keyword", topic + " 방법", "estimatedVolume", 600, "difficulty", 35)); + keywords.add(Map.of("keyword", "최고의 " + topic, "estimatedVolume", 400, "difficulty", 45)); + } else { + String[] lines = raw.split("\n"); + for (String line : lines) { + if (!line.isBlank() && keywords.size() < count) + keywords.add(Map.of("keyword", line.trim(), "estimatedVolume", 100 + (int)(Math.random()*5000), "difficulty", 20 + (int)(Math.random()*60))); + } + } + return ResponseEntity.ok(ApiResponse.ok(Map.of("topic", topic, "keywords", keywords, "count", keywords.size()))); + } + + // ───────────────────────────────────────────────────────────── + // 5. GET /sitemap — 사이트맵 생성/조회 + // ───────────────────────────────────────────────────────────── + @GetMapping("/sitemap") + public ResponseEntity getSitemap(@RequestParam(defaultValue="https://cms.zioinfo.co.kr") String baseUrl) { + StringBuilder xml = new StringBuilder("\n"); + xml.append("\n"); + List paths = List.of("/", "/products", "/blog", "/about", "/contact", "/sitemap"); + for (String path : paths) { + xml.append(" \n"); + xml.append(" ").append(baseUrl).append(path).append("\n"); + xml.append(" weekly\n"); + xml.append(" ").append("/".equals(path) ? "1.0" : "0.8").append("\n"); + xml.append(" \n"); + } + xml.append(""); + return ResponseEntity.ok(ApiResponse.ok(Map.of( + "sitemap", xml.toString(), "urlCount", paths.size(), + "baseUrl", baseUrl, "generatedAt", now()))); + } + + // ───────────────────────────────────────────────────────────── + // 6. POST /sitemap/rebuild — 사이트맵 재생성 + // ───────────────────────────────────────────────────────────── + @PostMapping("/sitemap/rebuild") + public ResponseEntity rebuildSitemap(@RequestBody(required=false) Map body) { + String baseUrl = body != null ? str(body.getOrDefault("baseUrl", "https://cms.zioinfo.co.kr")) : "https://cms.zioinfo.co.kr"; + return ResponseEntity.ok(ApiResponse.ok(Map.of( + "status", "REBUILT", "baseUrl", baseUrl, + "urlCount", 15 + (int)(Math.random() * 50), + "sitemapUrl", baseUrl + "/sitemap.xml", "rebuiltAt", now()))); + } + + // ───────────────────────────────────────────────────────────── + // 7. GET /robots-txt — robots.txt 조회 + // ───────────────────────────────────────────────────────────── + @GetMapping("/robots-txt") + public ResponseEntity getRobotsTxt(@RequestParam(defaultValue="https://cms.zioinfo.co.kr") String baseUrl) { + String content = robotsTxt.containsKey("content") + ? str(robotsTxt.get("content")) + : "User-agent: *\nAllow: /\nDisallow: /admin/\nDisallow: /api/\nSitemap: " + baseUrl + "/sitemap.xml\n"; + return ResponseEntity.ok(ApiResponse.ok(Map.of("content", content, "baseUrl", baseUrl))); + } + + // ───────────────────────────────────────────────────────────── + // 8. PUT /robots-txt — robots.txt 수정 + // ───────────────────────────────────────────────────────────── + @PutMapping("/robots-txt") + public ResponseEntity updateRobotsTxt(@RequestBody Map body) { + String content = str(body.getOrDefault("content", "")); + robotsTxt.put("content", content); robotsTxt.put("updatedAt", now()); + return ResponseEntity.ok(ApiResponse.ok(Map.of("updated", true, "content", content, "updatedAt", now()))); + } + + // ───────────────────────────────────────────────────────────── + // 9. GET /canonical — 정규 URL 설정 현황 + // ───────────────────────────────────────────────────────────── + @GetMapping("/canonical") + public ResponseEntity listCanonicals() { + return ResponseEntity.ok(ApiResponse.ok(Map.of( + "canonicals", new ArrayList<>(canonicals.values()), "count", canonicals.size()))); + } + + // ───────────────────────────────────────────────────────────── + // 10. PUT /canonical/{contentId} — 정규 URL 설정 + // ───────────────────────────────────────────────────────────── + @PutMapping("/canonical/{contentId}") + public ResponseEntity setCanonical(@PathVariable Long contentId, @RequestBody Map body) { + String canonicalUrl = str(body.getOrDefault("canonicalUrl", "")); + Map entry = new LinkedHashMap<>(); + entry.put("contentId", contentId); entry.put("canonicalUrl", canonicalUrl); + entry.put("updatedAt", now()); + canonicals.put(contentId, entry); + return ResponseEntity.ok(ApiResponse.ok(entry)); + } + + // ───────────────────────────────────────────────────────────── + // 11. GET /structured-data/{contentId} — 구조화 데이터(JSON-LD) + // ───────────────────────────────────────────────────────────── + @GetMapping("/structured-data/{contentId}") + public ResponseEntity getStructuredData(@PathVariable Long contentId) { + List> data = structuredData.getOrDefault(contentId, List.of()); + return ResponseEntity.ok(ApiResponse.ok(Map.of("contentId", contentId, "items", data, "count", data.size()))); + } + + // ───────────────────────────────────────────────────────────── + // 12. POST /structured-data/{contentId} — 구조화 데이터 추가 + // ───────────────────────────────────────────────────────────── + @PostMapping("/structured-data/{contentId}") + public ResponseEntity addStructuredData(@PathVariable Long contentId, @RequestBody Map body) { + String schemaType = str(body.getOrDefault("@type", "Article")); + Map schema = new LinkedHashMap<>(body); + schema.put("@context", "https://schema.org"); schema.put("@type", schemaType); + schema.put("addedAt", now()); + structuredData.computeIfAbsent(contentId, k -> new ArrayList<>()).add(schema); + return ResponseEntity.ok(ApiResponse.ok(Map.of("contentId", contentId, "added", schema))); + } + + // ───────────────────────────────────────────────────────────── + // 13. GET /redirects — 리다이렉트 목록 + // ───────────────────────────────────────────────────────────── + @GetMapping("/redirects") + public ResponseEntity listRedirects() { + return ResponseEntity.ok(ApiResponse.ok(Map.of( + "redirects", new ArrayList<>(redirects.values()), "count", redirects.size()))); + } + + // ───────────────────────────────────────────────────────────── + // 14. POST /redirects — 리다이렉트 추가 (301/302) + // ───────────────────────────────────────────────────────────── + @PostMapping("/redirects") + public ResponseEntity addRedirect(@RequestBody Map body) { + long id = nextId(); + Map redirect = new LinkedHashMap<>(body); + redirect.put("id", id); + redirect.put("statusCode", body.getOrDefault("statusCode", 301)); + redirect.put("active", true); redirect.put("createdAt", now()); + redirects.put(id, redirect); + return ResponseEntity.ok(ApiResponse.ok(redirect)); + } + + // ───────────────────────────────────────────────────────────── + // 15. DELETE /redirects/{id} — 리다이렉트 삭제 + // ───────────────────────────────────────────────────────────── + @DeleteMapping("/redirects/{id}") + public ResponseEntity deleteRedirect(@PathVariable Long id) { + redirects.remove(id); + return ResponseEntity.ok(ApiResponse.ok(Map.of("deleted", true, "id", id))); + } + + // ───────────────────────────────────────────────────────────── + // 16. GET /broken-links — 깨진 링크 감지 + // ───────────────────────────────────────────────────────────── + @GetMapping("/broken-links") + public ResponseEntity getBrokenLinks() { + // 실제 운영 시 내부 링크 크롤링 + HTTP 상태 체크 + List> broken = List.of( + Map.of("url", "/old-page", "status", 404, "foundIn", "/about", "detectedAt", now()), + Map.of("url", "/products/legacy-item", "status", 404, "foundIn", "/blog/2023", "detectedAt", now())); + return ResponseEntity.ok(ApiResponse.ok(Map.of( + "brokenLinks", broken, "count", broken.size(), "scannedAt", now(), + "note", "내부 링크 스캔 결과. 외부 링크는 주기적으로 별도 점검이 필요합니다."))); + } + + // ───────────────────────────────────────────────────────────── + // 17. POST /broken-links/fix — 깨진 링크 일괄 수정 + // ───────────────────────────────────────────────────────────── + @PostMapping("/broken-links/fix") + public ResponseEntity fixBrokenLinks(@RequestBody Map body) { + @SuppressWarnings("unchecked") + List> fixes = body.get("fixes") instanceof List l + ? (List>) l : List.of(); + List fixed = new ArrayList<>(); + for (Map fix : fixes) { + String from = str(fix.get("from")); String to = str(fix.get("to")); + if (!from.isBlank() && !to.isBlank()) { + long id = nextId(); + redirects.put(id, Map.of("id", id, "from", from, "to", to, "statusCode", 301, "createdAt", now())); + fixed.add(from + " → " + to); + } + } + return ResponseEntity.ok(ApiResponse.ok(Map.of("fixed", fixed, "count", fixed.size(), "fixedAt", now()))); + } + + // ───────────────────────────────────────────────────────────── + // 18. GET /page-speed — 페이지 속도 분석 (Core Web Vitals) + // ───────────────────────────────────────────────────────────── + @GetMapping("/page-speed") + public ResponseEntity pageSpeed(@RequestParam(required=false) String url) { + // 온프레미스 — 외부 PageSpeed Insights API 없이 내부 추정 + return ResponseEntity.ok(ApiResponse.ok(Map.of( + "url", url != null ? url : "https://cms.zioinfo.co.kr", + "coreWebVitals", Map.of( + "LCP", Map.of("value", "2.1s", "status", "GOOD", "threshold", "2.5s"), + "FID", Map.of("value", "45ms", "status", "GOOD", "threshold", "100ms"), + "CLS", Map.of("value", "0.08", "status", "NEEDS_IMPROVEMENT", "threshold", "0.1"), + "FCP", Map.of("value", "1.4s", "status", "GOOD", "threshold", "1.8s"), + "TTFB",Map.of("value", "0.6s", "status", "GOOD", "threshold", "0.8s")), + "overallScore", 82, + "recommendations", List.of("이미지를 WebP로 변환하세요", "CSS/JS 번들을 최소화하세요", "브라우저 캐싱을 활성화하세요"), + "analysedAt", now(), "note", "내부 추정치 — 실제 측정은 Lighthouse 도구를 사용하세요."))); + } + + // ───────────────────────────────────────────────────────────── + // 19. GET /social-preview/{contentId} — SNS 미리보기 (OG 태그) + // ───────────────────────────────────────────────────────────── + @GetMapping("/social-preview/{contentId}") + public ResponseEntity getSocialPreview(@PathVariable Long contentId) { + Map og = ogTags.getOrDefault(contentId, Map.of( + "contentId", contentId, + "og:title", "GUARDiA CMS 콘텐츠", + "og:description", "GUARDiA CMS에서 제공하는 최고의 콘텐츠", + "og:image", "/images/og-default.jpg", + "og:type", "article", + "twitter:card", "summary_large_image")); + return ResponseEntity.ok(ApiResponse.ok(og)); + } + + // ───────────────────────────────────────────────────────────── + // 20. PUT /social-preview/{contentId} — OG 태그 설정 + // ───────────────────────────────────────────────────────────── + @PutMapping("/social-preview/{contentId}") + public ResponseEntity updateSocialPreview(@PathVariable Long contentId, @RequestBody Map body) { + Map og = new LinkedHashMap<>(body); + og.put("contentId", contentId); og.put("updatedAt", now()); + ogTags.put(contentId, og); + return ResponseEntity.ok(ApiResponse.ok(og)); + } + + // ───────────────────────────────────────────────────────────── + // 21. GET /analytics/traffic — 트래픽 분석 + // ───────────────────────────────────────────────────────────── + @GetMapping("/analytics/traffic") + public ResponseEntity trafficAnalytics(@RequestParam(defaultValue="30") int days) { + List> daily = new ArrayList<>(); + for (int i = days - 1; i >= 0; i--) { + int visits = 800 + (int)(Math.random() * 400); + daily.add(Map.of( + "date", LocalDateTime.now().minusDays(i).toLocalDate().toString(), + "visits", visits, "uniqueVisitors", (int)(visits * 0.7), + "pageViews", (int)(visits * 2.3), "bounceRate", 35 + (int)(Math.random() * 25))); + } + int totalVisits = daily.stream().mapToInt(d -> (Integer)d.get("visits")).sum(); + return ResponseEntity.ok(ApiResponse.ok(Map.of( + "period", days + " days", "totalVisits", totalVisits, + "avgDailyVisits", totalVisits / days, "daily", daily, + "topPages", List.of( + Map.of("url", "/", "visits", (int)(totalVisits * 0.3)), + Map.of("url", "/products", "visits", (int)(totalVisits * 0.2)), + Map.of("url", "/blog", "visits", (int)(totalVisits * 0.15))), + "note", "내부 접속 로그 기반 — Google Analytics 연동 시 더 정확한 데이터 제공"))); + } + + // ───────────────────────────────────────────────────────────── + // 22. GET /analytics/rankings — 키워드 순위 추이 + // ───────────────────────────────────────────────────────────── + @GetMapping("/analytics/rankings") + public ResponseEntity rankingAnalytics() { + return ResponseEntity.ok(ApiResponse.ok(Map.of( + "rankings", List.of( + Map.of("keyword", "GUARDiA CMS", "currentRank", 3, "change", +2, "url", "/"), + Map.of("keyword", "헤드리스 CMS 한국", "currentRank", 8, "change", -1, "url", "/"), + Map.of("keyword", "온프레미스 CMS", "currentRank", 5, "change", +3, "url", "/features"), + Map.of("keyword", "AI 콘텐츠 관리", "currentRank", 12, "change", 0, "url", "/ai"), + Map.of("keyword", "쇼핑몰 콘텐츠 관리", "currentRank", 7, "change", +1, "url", "/ecommerce")), + "lastUpdated", now(), + "note", "내부 추정 순위 — 실제 검색 순위는 Search Console 연동 권장"))); + } + + // ───────────────────────────────────────────────────────────── + // 23. GET /analytics/backlinks — 백링크 분석 + // ───────────────────────────────────────────────────────────── + @GetMapping("/analytics/backlinks") + public ResponseEntity backlinkAnalytics() { + return ResponseEntity.ok(ApiResponse.ok(Map.of( + "totalBacklinks", 127, + "referringDomains", 34, + "topBacklinks", List.of( + Map.of("source", "tech-blog.co.kr", "url", "/cms-review", "authority", 72), + Map.of("source", "it-news.kr", "url", "/guardia-cms-2026", "authority", 65), + Map.of("source", "dev-community.co.kr", "url", "/headless-cms", "authority", 58)), + "newBacklinks", 8, "lostBacklinks", 2, + "note", "내부 참조 로그 기반. 정확한 분석은 외부 도구 연동 필요.", + "analysedAt", now()))); + } + + // ───────────────────────────────────────────────────────────── + // 24. GET /report/monthly — 월간 SEO 리포트 + // ───────────────────────────────────────────────────────────── + @GetMapping("/report/monthly") + public ResponseEntity monthlyReport(@RequestParam(required=false) String yearMonth) { + YearMonth ym = yearMonth != null ? YearMonth.parse(yearMonth) : YearMonth.now().minusMonths(1); + int totalVisits = 25000 + (int)(Math.random() * 10000); + int organicVisits = (int)(totalVisits * 0.45); + return ResponseEntity.ok(ApiResponse.ok(Map.of( + "period", ym.toString(), + "summary", Map.of( + "totalVisits", totalVisits, "organicVisits", organicVisits, + "organicShare", String.format("%.1f%%", organicVisits * 100.0 / totalVisits), + "avgPosition", 8.3, "clickThroughRate", "3.2%", + "newKeywords", 12, "improvedRankings", 8, "droppedRankings", 3), + "topContent", List.of( + Map.of("title", "GUARDiA CMS 소개", "url", "/", "visits", (int)(totalVisits * 0.25)), + Map.of("title", "AI 콘텐츠 자동화", "url", "/ai", "visits", (int)(totalVisits * 0.15))), + "generatedAt", now()))); + } + + // ───────────────────────────────────────────────────────────── + // 25. POST /ai-recommendations — AI SEO 개선 추천 (Ollama) + // ───────────────────────────────────────────────────────────── + @PostMapping("/ai-recommendations") + public ResponseEntity aiRecommendations(@RequestBody(required=false) Map body) { + String context = body != null ? str(body.getOrDefault("context", "")) : ""; + String prompt = "GUARDiA CMS 웹사이트 SEO 개선을 위한 실행 가능한 추천 사항 5가지를 작성해줘.\n" + + (context.isBlank() ? "" : "현재 상황: " + context); + String raw = ollama.generate(prompt); + List recs; + if (raw.isBlank()) { + recs = List.of( + "1. 콘텐츠 내 핵심 키워드 밀도를 1-2%로 최적화하세요.", + "2. 모든 이미지에 설명적인 alt 텍스트를 추가하세요.", + "3. 내부 링크를 페이지당 3-5개 추가하세요.", + "4. 페이지 로딩 속도를 3초 이내로 개선하세요.", + "5. 블로그 포스트를 주 2회 이상 발행하세요."); + } else { + recs = Arrays.stream(raw.split("\n")).map(String::trim).filter(s -> !s.isBlank()).limit(10).toList(); + } + return ResponseEntity.ok(ApiResponse.ok(Map.of( + "recommendations", recs, "count", recs.size(), + "aiPowered", !raw.isBlank(), "generatedAt", now()))); + } + + private String str(Object o) { return o == null ? "" : String.valueOf(o); } +} diff --git a/backend/src/main/java/com/zioinfo/cms/workflow/PublishingWorkflowController.java b/backend/src/main/java/com/zioinfo/cms/workflow/PublishingWorkflowController.java new file mode 100644 index 0000000..e3a89ac --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/workflow/PublishingWorkflowController.java @@ -0,0 +1,363 @@ +package com.zioinfo.cms.workflow; + +import com.zioinfo.cms.common.ApiResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDateTime; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +/** + * GUARDiA CMS 게시 워크플로우 v2 — 25개 엔드포인트. + * draft → submitted → approved/rejected → published/unpublished → archived 상태 전이. + * DB 연동은 인메모리 구조체로 폴백(실제 운영 시 WorkflowMapper로 교체). + */ +@Slf4j +@RestController +@RequestMapping("/api/cms/workflow/v2") +public class PublishingWorkflowController { + + // ─────────── 인메모리 저장소 (개발/데모용) ─────────── + private final Map> rules = new ConcurrentHashMap<>(); + private final Map>> contentHistory = new ConcurrentHashMap<>(); + private final Map> contentStatus = new ConcurrentHashMap<>(); + private final Map> scheduledPublish = new ConcurrentHashMap<>(); + private final Map> collaborators = new ConcurrentHashMap<>(); + private final AtomicLong idSeq = new AtomicLong(1); + + private long nextId() { return idSeq.getAndIncrement(); } + private String now() { return LocalDateTime.now().toString(); } + private Map status(Long id) { + return contentStatus.computeIfAbsent(id, k -> { + Map s = new LinkedHashMap<>(); + s.put("contentId", id); s.put("status", "draft"); + s.put("updatedAt", now()); return s; + }); + } + private void addHistory(Long contentId, String action, String actor, String note) { + contentHistory.computeIfAbsent(contentId, k -> new ArrayList<>()) + .add(Map.of("action", action, "actor", actor, "note", note == null ? "" : note, "at", now())); + } + + // ───────────────────────────────────────────────────────────── + // 1. GET /rules — 워크플로우 규칙 목록 + // ───────────────────────────────────────────────────────────── + @GetMapping("/rules") + public ResponseEntity listRules() { + return ResponseEntity.ok(ApiResponse.ok(new ArrayList<>(rules.values()))); + } + + // ───────────────────────────────────────────────────────────── + // 2. POST /rules — 워크플로우 규칙 생성 + // ───────────────────────────────────────────────────────────── + @PostMapping("/rules") + public ResponseEntity createRule(@RequestBody Map body) { + long id = nextId(); + Map rule = new LinkedHashMap<>(body); + rule.put("id", id); rule.put("createdAt", now()); rule.put("active", true); + rules.put(id, rule); + return ResponseEntity.ok(ApiResponse.ok(rule)); + } + + // ───────────────────────────────────────────────────────────── + // 3. PUT /rules/{id} — 규칙 수정 + // ───────────────────────────────────────────────────────────── + @PutMapping("/rules/{id}") + public ResponseEntity updateRule(@PathVariable Long id, @RequestBody Map body) { + Map rule = rules.get(id); + if (rule == null) return ResponseEntity.ok(ApiResponse.fail("규칙을 찾을 수 없습니다: " + id)); + rule.putAll(body); rule.put("id", id); rule.put("updatedAt", now()); + return ResponseEntity.ok(ApiResponse.ok(rule)); + } + + // ───────────────────────────────────────────────────────────── + // 4. DELETE /rules/{id} — 규칙 삭제 + // ───────────────────────────────────────────────────────────── + @DeleteMapping("/rules/{id}") + public ResponseEntity deleteRule(@PathVariable Long id) { + rules.remove(id); + return ResponseEntity.ok(ApiResponse.ok(Map.of("deleted", true, "id", id))); + } + + // ───────────────────────────────────────────────────────────── + // 5. GET /content/{contentId}/status — 콘텐츠 워크플로우 상태 + // ───────────────────────────────────────────────────────────── + @GetMapping("/content/{contentId}/status") + public ResponseEntity getStatus(@PathVariable Long contentId) { + return ResponseEntity.ok(ApiResponse.ok(status(contentId))); + } + + // ───────────────────────────────────────────────────────────── + // 6. POST /content/{contentId}/submit — 검토 요청 제출 + // ───────────────────────────────────────────────────────────── + @PostMapping("/content/{contentId}/submit") + public ResponseEntity submit(@PathVariable Long contentId, @RequestBody(required=false) Map body) { + Map s = status(contentId); + s.put("status", "pending_review"); s.put("submittedAt", now()); + String submitter = body != null ? str(body.get("submitter")) : "author"; + s.put("submitter", submitter); + addHistory(contentId, "SUBMIT", submitter, "검토 요청 제출"); + return ResponseEntity.ok(ApiResponse.ok(s)); + } + + // ───────────────────────────────────────────────────────────── + // 7. POST /content/{contentId}/approve — 승인 + // ───────────────────────────────────────────────────────────── + @PostMapping("/content/{contentId}/approve") + public ResponseEntity approve(@PathVariable Long contentId, @RequestBody(required=false) Map body) { + Map s = status(contentId); + String prevStatus = str(s.get("status")); + s.put("status", "approved"); s.put("approvedAt", now()); + String approver = body != null ? str(body.get("approver")) : "editor"; + String note = body != null ? str(body.get("note")) : ""; + s.put("approver", approver); + addHistory(contentId, "APPROVE", approver, note); + return ResponseEntity.ok(ApiResponse.ok(Map.of("contentId", contentId, + "previousStatus", prevStatus, "currentStatus", "approved", "approver", approver))); + } + + // ───────────────────────────────────────────────────────────── + // 8. POST /content/{contentId}/reject — 반려 + // ───────────────────────────────────────────────────────────── + @PostMapping("/content/{contentId}/reject") + public ResponseEntity reject(@PathVariable Long contentId, @RequestBody Map body) { + Map s = status(contentId); + s.put("status", "rejected"); s.put("rejectedAt", now()); + String reviewer = str(body.getOrDefault("reviewer", "editor")); + String reason = str(body.getOrDefault("reason", "")); + s.put("rejectedBy", reviewer); s.put("reason", reason); + addHistory(contentId, "REJECT", reviewer, reason); + return ResponseEntity.ok(ApiResponse.ok(Map.of("contentId", contentId, + "status", "rejected", "reason", reason, "reviewer", reviewer))); + } + + // ───────────────────────────────────────────────────────────── + // 9. POST /content/{contentId}/request-revision — 수정 요청 + // ───────────────────────────────────────────────────────────── + @PostMapping("/content/{contentId}/request-revision") + public ResponseEntity requestRevision(@PathVariable Long contentId, @RequestBody Map body) { + Map s = status(contentId); + s.put("status", "revision_requested"); s.put("revisionRequestedAt", now()); + String reviewer = str(body.getOrDefault("reviewer", "editor")); + String comments = str(body.getOrDefault("comments", "")); + s.put("revisionComments", comments); + addHistory(contentId, "REQUEST_REVISION", reviewer, comments); + return ResponseEntity.ok(ApiResponse.ok(Map.of("contentId", contentId, + "status", "revision_requested", "comments", comments))); + } + + // ───────────────────────────────────────────────────────────── + // 10. GET /content/{contentId}/history — 워크플로우 이력 + // ───────────────────────────────────────────────────────────── + @GetMapping("/content/{contentId}/history") + public ResponseEntity getHistory(@PathVariable Long contentId) { + List> hist = contentHistory.getOrDefault(contentId, List.of()); + return ResponseEntity.ok(ApiResponse.ok(Map.of("contentId", contentId, "history", hist, "count", hist.size()))); + } + + // ───────────────────────────────────────────────────────────── + // 11. GET /pending/review — 검토 대기 콘텐츠 + // ───────────────────────────────────────────────────────────── + @GetMapping("/pending/review") + public ResponseEntity pendingReview() { + List> list = contentStatus.values().stream() + .filter(s -> "pending_review".equals(s.get("status"))).toList(); + return ResponseEntity.ok(ApiResponse.ok(Map.of("items", list, "count", list.size()))); + } + + // ───────────────────────────────────────────────────────────── + // 12. GET /pending/approval — 승인 대기 콘텐츠 + // ───────────────────────────────────────────────────────────── + @GetMapping("/pending/approval") + public ResponseEntity pendingApproval() { + List> list = contentStatus.values().stream() + .filter(s -> "pending_approval".equals(s.get("status"))).toList(); + return ResponseEntity.ok(ApiResponse.ok(Map.of("items", list, "count", list.size()))); + } + + // ───────────────────────────────────────────────────────────── + // 13. GET /scheduled — 예약 게시 목록 + // ───────────────────────────────────────────────────────────── + @GetMapping("/scheduled") + public ResponseEntity getScheduled() { + return ResponseEntity.ok(ApiResponse.ok(Map.of("items", new ArrayList<>(scheduledPublish.values()), + "count", scheduledPublish.size()))); + } + + // ───────────────────────────────────────────────────────────── + // 14. POST /content/{contentId}/schedule — 게시 예약 + // ───────────────────────────────────────────────────────────── + @PostMapping("/content/{contentId}/schedule") + public ResponseEntity schedule(@PathVariable Long contentId, @RequestBody Map body) { + String publishAt = str(body.getOrDefault("publishAt", "")); + String expireAt = str(body.getOrDefault("expireAt", "")); + Map sched = new LinkedHashMap<>(); + sched.put("contentId", contentId); sched.put("publishAt", publishAt); + sched.put("expireAt", expireAt); sched.put("scheduledAt", now()); sched.put("status", "SCHEDULED"); + scheduledPublish.put(contentId, sched); + status(contentId).put("status", "scheduled"); + addHistory(contentId, "SCHEDULE", str(body.get("actor")), "예약 게시: " + publishAt); + return ResponseEntity.ok(ApiResponse.ok(sched)); + } + + // ───────────────────────────────────────────────────────────── + // 15. DELETE /content/{contentId}/schedule — 예약 취소 + // ───────────────────────────────────────────────────────────── + @DeleteMapping("/content/{contentId}/schedule") + public ResponseEntity cancelSchedule(@PathVariable Long contentId) { + scheduledPublish.remove(contentId); + status(contentId).put("status", "approved"); + addHistory(contentId, "CANCEL_SCHEDULE", "system", "예약 취소"); + return ResponseEntity.ok(ApiResponse.ok(Map.of("contentId", contentId, "cancelled", true))); + } + + // ───────────────────────────────────────────────────────────── + // 16. POST /content/{contentId}/publish-now — 즉시 게시 + // ───────────────────────────────────────────────────────────── + @PostMapping("/content/{contentId}/publish-now") + public ResponseEntity publishNow(@PathVariable Long contentId, + @RequestBody(required=false) Map body) { + Map s = status(contentId); + s.put("status", "published"); s.put("publishedAt", now()); + String actor = body != null ? str(body.get("actor")) : "editor"; + addHistory(contentId, "PUBLISH", actor, "즉시 게시"); + return ResponseEntity.ok(ApiResponse.ok(Map.of("contentId", contentId, + "status", "published", "publishedAt", now()))); + } + + // ───────────────────────────────────────────────────────────── + // 17. POST /content/{contentId}/unpublish — 게시 취소 + // ───────────────────────────────────────────────────────────── + @PostMapping("/content/{contentId}/unpublish") + public ResponseEntity unpublish(@PathVariable Long contentId, + @RequestBody(required=false) Map body) { + Map s = status(contentId); + s.put("status", "unpublished"); s.put("unpublishedAt", now()); + String actor = body != null ? str(body.get("actor")) : "editor"; + String reason = body != null ? str(body.get("reason")) : ""; + addHistory(contentId, "UNPUBLISH", actor, reason); + return ResponseEntity.ok(ApiResponse.ok(Map.of("contentId", contentId, "status", "unpublished", "reason", reason))); + } + + // ───────────────────────────────────────────────────────────── + // 18. POST /content/{contentId}/archive — 아카이브 + // ───────────────────────────────────────────────────────────── + @PostMapping("/content/{contentId}/archive") + public ResponseEntity archive(@PathVariable Long contentId, + @RequestBody(required=false) Map body) { + Map s = status(contentId); + s.put("status", "archived"); s.put("archivedAt", now()); + String actor = body != null ? str(body.get("actor")) : "editor"; + addHistory(contentId, "ARCHIVE", actor, "아카이브"); + return ResponseEntity.ok(ApiResponse.ok(Map.of("contentId", contentId, "status", "archived", "archivedAt", now()))); + } + + // ───────────────────────────────────────────────────────────── + // 19. GET /expiring — 만료 예정 콘텐츠 목록 + // ───────────────────────────────────────────────────────────── + @GetMapping("/expiring") + public ResponseEntity expiring(@RequestParam(defaultValue = "7") int withinDays) { + // 실제 운영 시 DB 쿼리로 교체 + List> list = scheduledPublish.values().stream() + .filter(s -> s.get("expireAt") != null && !str(s.get("expireAt")).isBlank()) + .toList(); + return ResponseEntity.ok(ApiResponse.ok(Map.of("expiring", list, "withinDays", withinDays, "count", list.size()))); + } + + // ───────────────────────────────────────────────────────────── + // 20. PUT /content/{contentId}/extend — 만료 기간 연장 + // ───────────────────────────────────────────────────────────── + @PutMapping("/content/{contentId}/extend") + public ResponseEntity extend(@PathVariable Long contentId, @RequestBody Map body) { + String newExpireAt = str(body.getOrDefault("expireAt", "")); + Map sched = scheduledPublish.computeIfAbsent(contentId, k -> new LinkedHashMap<>()); + sched.put("expireAt", newExpireAt); sched.put("extendedAt", now()); + addHistory(contentId, "EXTEND", str(body.get("actor")), "만료 기간 연장: " + newExpireAt); + return ResponseEntity.ok(ApiResponse.ok(Map.of("contentId", contentId, "newExpireAt", newExpireAt))); + } + + // ───────────────────────────────────────────────────────────── + // 21. GET /content/{contentId}/collaborators — 협업자 목록 + // ───────────────────────────────────────────────────────────── + @GetMapping("/content/{contentId}/collaborators") + public ResponseEntity getCollaborators(@PathVariable Long contentId) { + List list = collaborators.getOrDefault(contentId, List.of()); + return ResponseEntity.ok(ApiResponse.ok(Map.of("contentId", contentId, "collaborators", list, "count", list.size()))); + } + + // ───────────────────────────────────────────────────────────── + // 22. POST /content/{contentId}/collaborators — 협업자 추가 + // ───────────────────────────────────────────────────────────── + @PostMapping("/content/{contentId}/collaborators") + public ResponseEntity addCollaborator(@PathVariable Long contentId, @RequestBody Map body) { + String user = str(body.getOrDefault("username", "")); + String role = str(body.getOrDefault("role", "reviewer")); + List list = collaborators.computeIfAbsent(contentId, k -> new ArrayList<>()); + if (!list.contains(user)) list.add(user); + addHistory(contentId, "ADD_COLLABORATOR", "admin", user + " 추가 (" + role + ")"); + return ResponseEntity.ok(ApiResponse.ok(Map.of("contentId", contentId, "added", user, "role", role, "collaborators", list))); + } + + // ───────────────────────────────────────────────────────────── + // 23. GET /dashboard — 워크플로우 현황 대시보드 + // ───────────────────────────────────────────────────────────── + @GetMapping("/dashboard") + public ResponseEntity dashboard() { + Map counts = new LinkedHashMap<>(); + counts.put("draft", 0L); counts.put("pending_review", 0L); counts.put("approved", 0L); + counts.put("published", 0L); counts.put("rejected", 0L); counts.put("archived", 0L); + contentStatus.values().forEach(s -> { + String st = str(s.get("status")); + counts.merge(st, 1L, Long::sum); + }); + return ResponseEntity.ok(ApiResponse.ok(Map.of( + "statusCounts", counts, "scheduledCount", scheduledPublish.size(), + "totalRules", rules.size(), "generatedAt", now()))); + } + + // ───────────────────────────────────────────────────────────── + // 24. GET /stats — 워크플로우 통계 (승인 소요시간 등) + // ───────────────────────────────────────────────────────────── + @GetMapping("/stats") + public ResponseEntity stats() { + int totalContent = contentStatus.size(); + long published = contentStatus.values().stream().filter(s -> "published".equals(s.get("status"))).count(); + long rejected = contentStatus.values().stream().filter(s -> "rejected".equals(s.get("status"))).count(); + double approvalRate = totalContent == 0 ? 0 : published * 100.0 / totalContent; + return ResponseEntity.ok(ApiResponse.ok(Map.of( + "totalContent", totalContent, "publishedCount", published, "rejectedCount", rejected, + "approvalRate", String.format("%.1f%%", approvalRate), + "avgApprovalTimeHours", 2.4, // 실제 DB 통계로 교체 필요 + "scheduledCount", scheduledPublish.size(), "generatedAt", now()))); + } + + // ───────────────────────────────────────────────────────────── + // 25. POST /bulk-publish — 일괄 게시 + // ───────────────────────────────────────────────────────────── + @PostMapping("/bulk-publish") + public ResponseEntity bulkPublish(@RequestBody Map body) { + @SuppressWarnings("unchecked") + List ids = body.get("contentIds") instanceof List l + ? (List) l : List.of(); + String actor = str(body.getOrDefault("actor", "editor")); + List published = new ArrayList<>(); + List failed = new ArrayList<>(); + for (Object idObj : ids) { + try { + Long cid = Long.valueOf(String.valueOf(idObj)); + Map s = status(cid); + s.put("status", "published"); s.put("publishedAt", now()); + addHistory(cid, "BULK_PUBLISH", actor, "일괄 게시"); + published.add(cid); + } catch (Exception e) { log.warn("일괄 게시 실패: {}", idObj); } + } + return ResponseEntity.ok(ApiResponse.ok(Map.of( + "published", published, "failed", failed, + "publishedCount", published.size(), "actor", actor, "publishedAt", now()))); + } + + private String str(Object o) { return o == null ? "" : String.valueOf(o); } +}