feat: 100기능 확장 — AI 고도화 + 자동화 + 분석
This commit is contained in:
parent
55e9b28bf4
commit
82dc942d72
@ -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<String, AtomicLong> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> body) {
|
||||||
|
USAGE.get("tone-adjust").incrementAndGet();
|
||||||
|
String content = str(body.getOrDefault("content", ""));
|
||||||
|
String tone = str(body.getOrDefault("tone", "formal")); // formal/friendly/descriptive
|
||||||
|
Map<String, String> 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<String, Object> 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<String, Object> 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<String, Object> 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<String> 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<String, Object> 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<String, Object> 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<String> 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<String, Object> body) {
|
||||||
|
USAGE.get("category-suggest").incrementAndGet();
|
||||||
|
String content = str(body.getOrDefault("content", ""));
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
List<String> 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<String, Object> 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<String> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<Map<String, String>> 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<String, Object> 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<String, Object> body) {
|
||||||
|
USAGE.get("social-post").incrementAndGet();
|
||||||
|
String content = str(body.getOrDefault("content", ""));
|
||||||
|
String platform = str(body.getOrDefault("platform", "instagram")); // instagram/facebook/twitter/linkedin
|
||||||
|
Map<String, Integer> 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<String, Object> 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<String, Object> 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<String, Long> 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); }
|
||||||
|
}
|
||||||
@ -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<Long, Map<String, Object>> mediaItems = new ConcurrentHashMap<>();
|
||||||
|
private final Map<Long, Map<String, Object>> folders = new ConcurrentHashMap<>();
|
||||||
|
private final Map<String, String> 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<String, Object> buildMediaItem(String name, String type, long size, Long folderId) {
|
||||||
|
long id = nextId();
|
||||||
|
Map<String, Object> 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<Map<String, Object>> 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<String, Object> 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<MultipartFile> files,
|
||||||
|
@RequestParam(required=false) Long folderId) {
|
||||||
|
List<Map<String, Object>> uploaded = new ArrayList<>();
|
||||||
|
List<String> 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<String, Object> 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<String, Object> body) {
|
||||||
|
Map<String, Object> 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<String, Object> body) {
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
List<Object> ids = body.get("ids") instanceof List<?> l ? (List<Object>) l : List.of();
|
||||||
|
List<Long> 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<String, Object> body) {
|
||||||
|
long fid = nextId();
|
||||||
|
Map<String, Object> 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<String, Object> body) {
|
||||||
|
Map<String, Object> 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<String, Object> body) {
|
||||||
|
Map<String, Object> 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<String, Object> body) {
|
||||||
|
Map<String, Object> 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<String, Object> 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<String, Object> body) {
|
||||||
|
Map<String, Object> 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<String, Object> 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<String, Object> item = mediaItems.get(id);
|
||||||
|
if (item == null) return ResponseEntity.ok(ApiResponse.fail("미디어를 찾을 수 없습니다: " + id));
|
||||||
|
String baseName = str(item.get("name")).replaceFirst("(\\.[^.]+)$", "");
|
||||||
|
List<Map<String, Object>> 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<String, Object> 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<String> tags = raw.isBlank()
|
||||||
|
? List.of("이미지", "콘텐츠", "미디어", "사진", "그래픽")
|
||||||
|
: Arrays.stream(raw.split("[,\\n]")).map(String::trim).filter(s -> !s.isBlank()).limit(10).toList();
|
||||||
|
if (mediaId != null) {
|
||||||
|
Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> body) {
|
||||||
|
Long mediaId = body.get("mediaId") instanceof Number n ? n.longValue() : null;
|
||||||
|
int quality = body.get("quality") instanceof Number n ? n.intValue() : 80;
|
||||||
|
Map<String, Object> 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<Map<String, Object>> 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<Map<String, Object>> 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<String, Long> 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<String, Object> 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<String, Object> 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<Map<String, Object>> 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<Map<String, Object>> 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<String, Object> body) {
|
||||||
|
Map<String, Object> 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); }
|
||||||
|
}
|
||||||
@ -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<Long, Map<String, Object>> canonicals = new ConcurrentHashMap<>();
|
||||||
|
private final Map<Long, List<Map<String, Object>>> structuredData = new ConcurrentHashMap<>();
|
||||||
|
private final Map<Long, Map<String, Object>> redirects = new ConcurrentHashMap<>();
|
||||||
|
private final Map<Long, Map<String, Object>> ogTags = new ConcurrentHashMap<>();
|
||||||
|
private final Map<String, Object> 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<String, Object> 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<String, Object> body) {
|
||||||
|
String depth = body != null ? str(body.getOrDefault("depth", "full")) : "full";
|
||||||
|
List<Map<String, Object>> 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<Map<String, Object>> 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<String, Object> 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<Map<String, Object>> 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("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
|
||||||
|
xml.append("<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n");
|
||||||
|
List<String> paths = List.of("/", "/products", "/blog", "/about", "/contact", "/sitemap");
|
||||||
|
for (String path : paths) {
|
||||||
|
xml.append(" <url>\n");
|
||||||
|
xml.append(" <loc>").append(baseUrl).append(path).append("</loc>\n");
|
||||||
|
xml.append(" <changefreq>weekly</changefreq>\n");
|
||||||
|
xml.append(" <priority>").append("/".equals(path) ? "1.0" : "0.8").append("</priority>\n");
|
||||||
|
xml.append(" </url>\n");
|
||||||
|
}
|
||||||
|
xml.append("</urlset>");
|
||||||
|
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<String, Object> 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<String, Object> 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<String, Object> body) {
|
||||||
|
String canonicalUrl = str(body.getOrDefault("canonicalUrl", ""));
|
||||||
|
Map<String, Object> 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<Map<String, Object>> 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<String, Object> body) {
|
||||||
|
String schemaType = str(body.getOrDefault("@type", "Article"));
|
||||||
|
Map<String, Object> 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<String, Object> body) {
|
||||||
|
long id = nextId();
|
||||||
|
Map<String, Object> 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<Map<String, Object>> 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<String, Object> body) {
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
List<Map<String, Object>> fixes = body.get("fixes") instanceof List<?> l
|
||||||
|
? (List<Map<String, Object>>) l : List.of();
|
||||||
|
List<String> fixed = new ArrayList<>();
|
||||||
|
for (Map<String, Object> 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<String, Object> 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<String, Object> body) {
|
||||||
|
Map<String, Object> 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<Map<String, Object>> 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<String, Object> 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<String> 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); }
|
||||||
|
}
|
||||||
@ -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<Long, Map<String, Object>> rules = new ConcurrentHashMap<>();
|
||||||
|
private final Map<Long, List<Map<String, Object>>> contentHistory = new ConcurrentHashMap<>();
|
||||||
|
private final Map<Long, Map<String, Object>> contentStatus = new ConcurrentHashMap<>();
|
||||||
|
private final Map<Long, Map<String, Object>> scheduledPublish = new ConcurrentHashMap<>();
|
||||||
|
private final Map<Long, List<String>> 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<String, Object> status(Long id) {
|
||||||
|
return contentStatus.computeIfAbsent(id, k -> {
|
||||||
|
Map<String, Object> 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<String, Object> body) {
|
||||||
|
long id = nextId();
|
||||||
|
Map<String, Object> 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<String, Object> body) {
|
||||||
|
Map<String, Object> 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<String, Object> body) {
|
||||||
|
Map<String, Object> 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<String, Object> body) {
|
||||||
|
Map<String, Object> 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<String, Object> body) {
|
||||||
|
Map<String, Object> 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<String, Object> body) {
|
||||||
|
Map<String, Object> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<String, Object> body) {
|
||||||
|
String publishAt = str(body.getOrDefault("publishAt", ""));
|
||||||
|
String expireAt = str(body.getOrDefault("expireAt", ""));
|
||||||
|
Map<String, Object> 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<String, Object> body) {
|
||||||
|
Map<String, Object> 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<String, Object> body) {
|
||||||
|
Map<String, Object> 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<String, Object> body) {
|
||||||
|
Map<String, Object> 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<Map<String, Object>> 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<String, Object> body) {
|
||||||
|
String newExpireAt = str(body.getOrDefault("expireAt", ""));
|
||||||
|
Map<String, Object> 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<String> 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<String, Object> body) {
|
||||||
|
String user = str(body.getOrDefault("username", ""));
|
||||||
|
String role = str(body.getOrDefault("role", "reviewer"));
|
||||||
|
List<String> 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<String, Long> 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<String, Object> body) {
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
List<Object> ids = body.get("contentIds") instanceof List<?> l
|
||||||
|
? (List<Object>) l : List.of();
|
||||||
|
String actor = str(body.getOrDefault("actor", "editor"));
|
||||||
|
List<Long> published = new ArrayList<>();
|
||||||
|
List<Long> failed = new ArrayList<>();
|
||||||
|
for (Object idObj : ids) {
|
||||||
|
try {
|
||||||
|
Long cid = Long.valueOf(String.valueOf(idObj));
|
||||||
|
Map<String, Object> 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); }
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user