feat(cms): GUARDiA CMS v1.0 초기 구축

This commit is contained in:
zio 2026-06-14 10:40:05 +09:00
commit 5f41862c68
151 changed files with 12732 additions and 0 deletions

59
Jenkinsfile vendored Normal file
View File

@ -0,0 +1,59 @@
// GUARDiA CMS — CI/CD Pipeline (Jenkins, 보조)
// 주 배포는 Gitea webhook → deploy_server.py(9999). Jenkins는 검증/롤백 백업 경로.
// 저장소 구조: backend/, frontend/ 가 루트에 위치 (단일 jar — frontend가 backend static으로 번들됨)
pipeline {
agent any
environment {
CMS_HOME = '/opt/guardia-cms'
JAVA_HOME = '/usr/lib/jvm/java-21-openjdk-amd64' // 서버 JDK21 (Java17 타겟 호환 빌드)
PATH = "${JAVA_HOME}/bin:${env.PATH}"
}
stages {
stage('Checkout') { steps { checkout scm } }
// frontend 먼저 빌드 → vite outDir(../backend/src/main/resources/static)에 산출 → jar에 번들
stage('Frontend Build') {
steps {
dir('frontend') {
sh 'npm ci --silent 2>/dev/null || npm install --silent'
sh 'npm run build'
}
}
}
stage('Backend Build & Test') {
steps {
dir('backend') {
sh 'mvn clean package -q' // 테스트 포함(CryptoUtil, ItsmSecuritySanitizer)
}
}
}
stage('Deploy') {
steps {
sh '''
sudo systemctl stop guardia-cms 2>/dev/null || true
sudo mkdir -p ${CMS_HOME}
sudo cp backend/target/guardia-cms-*.jar ${CMS_HOME}/app.jar
sudo systemctl start guardia-cms
'''
}
}
stage('Health Check') {
steps {
retry(5) {
sleep 8
sh 'curl -sf http://localhost:8012/actuator/health | grep -q "UP"'
}
}
}
}
post {
success { echo 'GUARDiA CMS 배포 성공 (포트 8012)' }
failure {
sh 'sudo systemctl stop guardia-cms 2>/dev/null || true'
echo 'GUARDiA CMS 배포 실패 — 서비스 중지(롤백)'
}
}
}

5
backend/.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
target/
*.class
.idea/
*.iml
.DS_Store

82
backend/pom.xml Normal file
View File

@ -0,0 +1,82 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.11</version>
</parent>
<groupId>com.zioinfo</groupId>
<artifactId>guardia-cms</artifactId>
<version>1.0.0</version>
<name>GUARDiA CMS</name>
<description>AI 기반 통합 콘텐츠 관리 시스템 (Shopping CMS) — Ollama 온프레미스 AI + 헤드리스 delivery + GUARDiA 연계</description>
<properties>
<java.version>17</java.version>
<jjwt.version>0.12.6</jjwt.version>
<springdoc.version>2.6.0</springdoc.version>
<mybatis.version>3.0.3</mybatis.version>
<postgresql.version>42.7.7</postgresql.version>
</properties>
<dependencies>
<!-- Spring Boot -->
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-security</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-validation</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-actuator</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-aop</artifactId></dependency>
<!-- MyBatis -->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>${mybatis.version}</version>
</dependency>
<!-- DB Driver -->
<dependency><groupId>org.postgresql</groupId><artifactId>postgresql</artifactId><version>${postgresql.version}</version></dependency>
<!-- JWT -->
<dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-api</artifactId><version>${jjwt.version}</version></dependency>
<dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-impl</artifactId><version>${jjwt.version}</version><scope>runtime</scope></dependency>
<dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-jackson</artifactId><version>${jjwt.version}</version><scope>runtime</scope></dependency>
<!-- OpenAPI -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>${springdoc.version}</version>
</dependency>
<!-- Lombok -->
<dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId><optional>true</optional></dependency>
<!-- HTTP Client (Ollama / 연계 GUARDiA 호출) -->
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-webflux</artifactId></dependency>
<!-- Test -->
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
<dependency><groupId>org.springframework.security</groupId><artifactId>spring-security-test</artifactId><scope>test</scope></dependency>
</dependencies>
<build>
<finalName>guardia-cms-${project.version}</finalName>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId></exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>

View File

@ -0,0 +1,26 @@
package com.zioinfo.cms;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;
/**
* GUARDiA CMS AI 기반 통합 콘텐츠 관리 시스템(Shopping CMS 중심, 범용 헤드리스 CMS).
*
* <p>콘텐츠(페이지/포스트/블록 헤드리스) 게시 워크플로우(draftreviewapprovedpublished)
* 예약/롤백/버전 헤드리스 delivery API(published만) 까지 콘텐츠 생애주기를 관리한다.
* 상품 상세빌더·배너·미디어·메뉴·테마·i18n·SEO··UGC·회원·분석을 포괄한다.
*
* <p>보안 불변 규칙: 외부 AI API 절대 금지(Ollama localhost:11434만 + Java 폴백),
* 회원 PII는 암호화·마스킹, ITSM/연계 응답은 자격증명 새니타이즈,
* 공개 delivery API는 published 상태만 노출(프리뷰는 토큰 검증).
*/
@SpringBootApplication
@EnableScheduling
@EnableAsync
public class CmsApplication {
public static void main(String[] args) {
SpringApplication.run(CmsApplication.class, args);
}
}

View File

@ -0,0 +1,96 @@
package com.zioinfo.cms.admin;
import com.zioinfo.cms.admin.dto.AuditLog;
import com.zioinfo.cms.admin.dto.CmsSetting;
import com.zioinfo.cms.admin.dto.UserDto;
import com.zioinfo.cms.common.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* GUARDiA CMS 관리자 API.
*
* <p>RBAC(SecurityConfig requestMatchers 통제):
* <ul>
* <li>/api/admin/users/** SUPERADMIN</li>
* <li>/api/admin/audit SUPERADMIN, EDITOR</li>
* <li>/api/admin/settings GET: SUPERADMIN/EDITOR, PUT: SUPERADMIN</li>
* </ul>
*/
@RestController
@RequestMapping("/api/admin")
@RequiredArgsConstructor
public class AdminController {
private final AdminUserService userService;
private final AuditService auditService;
private final SettingService settingService;
// ===================== 1. 사용자 관리 (SUPERADMIN) =====================
@GetMapping("/users")
public ApiResponse<List<UserDto>> listUsers() {
return ApiResponse.ok(userService.list());
}
@PostMapping("/users")
public ApiResponse<UserDto> createUser(@RequestBody CreateUserRequest req) {
return ApiResponse.ok(userService.create(req.username(), req.password(), req.displayName(), req.role()));
}
@PutMapping("/users/{id}/role")
public ApiResponse<UserDto> updateRole(@PathVariable Long id, @RequestBody RoleRequest req) {
return ApiResponse.ok(userService.updateRole(id, req.role()));
}
@PutMapping("/users/{id}/active")
public ApiResponse<UserDto> updateActive(@PathVariable Long id, @RequestBody ActiveRequest req) {
return ApiResponse.ok(userService.updateActive(id, req.active()));
}
@PutMapping("/users/{id}/password")
public ApiResponse<UserDto> resetPassword(@PathVariable Long id, @RequestBody PasswordRequest req) {
return ApiResponse.ok(userService.resetPassword(id, req.password()));
}
@DeleteMapping("/users/{id}")
public ApiResponse<Void> deleteUser(@PathVariable Long id, Authentication auth) {
String currentUsername = auth != null ? auth.getName() : null;
userService.delete(id, currentUsername);
return ApiResponse.ok(null);
}
// ===================== 2. 감사 로그 (SUPERADMIN/EDITOR) =====================
@GetMapping("/audit")
public ApiResponse<List<AuditLog>> audit(
@RequestParam(value = "action", required = false) String action,
@RequestParam(value = "actor", required = false) String actor,
@RequestParam(value = "limit", defaultValue = "100") int limit) {
return ApiResponse.ok(auditService.find(action, actor, limit));
}
// ===================== 3. 시스템 설정 =====================
@GetMapping("/settings")
public ApiResponse<List<CmsSetting>> settings() {
return ApiResponse.ok(settingService.list());
}
@PutMapping("/settings/{key}")
public ApiResponse<CmsSetting> updateSetting(@PathVariable String key,
@RequestBody SettingRequest req) {
return ApiResponse.ok(settingService.update(key, req.value()));
}
// ===================== 요청 DTO =====================
record CreateUserRequest(String username, String password, String displayName, String role) {}
record RoleRequest(String role) {}
record ActiveRequest(boolean active) {}
record PasswordRequest(String password) {}
record SettingRequest(String value) {}
}

View File

@ -0,0 +1,118 @@
package com.zioinfo.cms.admin;
import com.zioinfo.cms.admin.dto.UserDto;
import com.zioinfo.cms.admin.mapper.AdminUserMapper;
import com.zioinfo.cms.auth.CmsUser;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Set;
/**
* 관리자 사용자 관리 서비스 (SUPERADMIN 전용).
*
* <p>RBAC 역할: SUPERADMIN / EDITOR / AUTHOR / VIEWER.
* 보안: 응답은 항상 {@link UserDto} 변환하여 password_hash 노출 차단.
*/
@Service
@RequiredArgsConstructor
public class AdminUserService {
private static final Set<String> VALID_ROLES = Set.of("SUPERADMIN", "EDITOR", "AUTHOR", "VIEWER");
private final AdminUserMapper mapper;
private final PasswordEncoder passwordEncoder;
private final AuditService auditService;
public List<UserDto> list() {
return mapper.findAll().stream().map(UserDto::from).toList();
}
public UserDto create(String username, String password, String displayName, String role) {
if (username == null || username.isBlank()) {
throw new IllegalArgumentException("ERR-USR-400: username 필수");
}
if (password == null || password.isBlank()) {
throw new IllegalArgumentException("ERR-USR-400: password 필수");
}
String resolvedRole = normalizeRole(role);
if (mapper.countByUsername(username) > 0) {
throw new RuntimeException("ERR-USR-409: 이미 존재하는 username");
}
CmsUser user = new CmsUser();
user.setUsername(username);
user.setPasswordHash(passwordEncoder.encode(password));
user.setDisplayName(displayName == null || displayName.isBlank() ? username : displayName);
user.setRole(resolvedRole);
user.setActive(true);
mapper.insert(user);
auditService.log("USER_CREATE", username, "role=" + resolvedRole);
return UserDto.from(user);
}
public UserDto updateRole(Long id, String role) {
CmsUser user = require(id);
String newRole = normalizeRole(role);
if ("SUPERADMIN".equals(user.getRole()) && !"SUPERADMIN".equals(newRole)
&& user.isActive() && mapper.countSuperAdmins() <= 1) {
throw new RuntimeException("ERR-USR-423: 마지막 SUPERADMIN 계정의 역할은 변경할 수 없습니다");
}
mapper.updateRole(id, newRole);
auditService.log("USER_ROLE_CHANGE", user.getUsername(), user.getRole() + " -> " + newRole);
return UserDto.from(require(id));
}
public UserDto updateActive(Long id, boolean active) {
CmsUser user = require(id);
if (!active && "SUPERADMIN".equals(user.getRole()) && user.isActive()
&& mapper.countSuperAdmins() <= 1) {
throw new RuntimeException("ERR-USR-423: 마지막 SUPERADMIN 계정은 비활성화할 수 없습니다");
}
mapper.updateActive(id, active);
auditService.log("USER_ACTIVE_TOGGLE", user.getUsername(), "active=" + active);
return UserDto.from(require(id));
}
public UserDto resetPassword(Long id, String newPassword) {
if (newPassword == null || newPassword.isBlank()) {
throw new IllegalArgumentException("ERR-USR-400: password 필수");
}
CmsUser user = require(id);
mapper.updatePassword(id, passwordEncoder.encode(newPassword));
auditService.log("USER_PASSWORD_RESET", user.getUsername(), "password reset");
return UserDto.from(user);
}
public void delete(Long id, String currentUsername) {
CmsUser user = require(id);
if (user.getUsername().equals(currentUsername)) {
throw new RuntimeException("ERR-USR-423: 자기 자신은 삭제할 수 없습니다");
}
if ("SUPERADMIN".equals(user.getRole()) && user.isActive() && mapper.countSuperAdmins() <= 1) {
throw new RuntimeException("ERR-USR-423: 마지막 SUPERADMIN 계정은 삭제할 수 없습니다");
}
mapper.deleteById(id);
auditService.log("USER_DELETE", user.getUsername(), "role=" + user.getRole());
}
private CmsUser require(Long id) {
CmsUser user = mapper.findById(id);
if (user == null) {
throw new RuntimeException("ERR-USR-404: 사용자를 찾을 수 없습니다");
}
return user;
}
private String normalizeRole(String role) {
if (role == null || role.isBlank()) {
return "VIEWER";
}
String upper = role.trim().toUpperCase();
if (!VALID_ROLES.contains(upper)) {
throw new IllegalArgumentException("ERR-USR-400: 유효하지 않은 role (SUPERADMIN/EDITOR/AUTHOR/VIEWER)");
}
return upper;
}
}

View File

@ -0,0 +1,55 @@
package com.zioinfo.cms.admin;
import com.zioinfo.cms.admin.dto.AuditLog;
import com.zioinfo.cms.admin.mapper.AuditLogMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* 감사 로그 서비스 콘텐츠 게시·관리자 작업·UGC 모더레이션 주요 변경을 cms_audit_log 기록.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AuditService {
private final AuditLogMapper mapper;
/** 현재 인증 주체(username)로 감사 로그를 기록한다. */
public void log(String action, String target, String detail) {
log(currentActor(), action, target, detail);
}
public void log(String actor, String action, String target, String detail) {
try {
AuditLog entry = new AuditLog();
entry.setActor(actor);
entry.setAction(action);
entry.setTarget(target);
entry.setDetail(detail);
mapper.insert(entry);
} catch (Exception e) {
// 감사 기록 실패가 업무 흐름을 끊지 않도록 경고만
log.warn("감사 로그 기록 실패 [{}]: {}", action, e.getMessage());
}
}
public List<AuditLog> find(String action, String actor, int limit) {
int safeLimit = (limit <= 0 || limit > 1000) ? 100 : limit;
return mapper.find(action, actor, safeLimit);
}
/** SecurityContext 의 JWT subject(username)를 추출. 없으면 system. */
public static String currentActor() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.getName() != null && !auth.getName().isBlank()) {
return auth.getName();
}
return "system";
}
}

View File

@ -0,0 +1,35 @@
package com.zioinfo.cms.admin;
import com.zioinfo.cms.admin.dto.CmsSetting;
import com.zioinfo.cms.admin.mapper.SettingMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.List;
/** 시스템 설정 서비스. 조회 SUPERADMIN/EDITOR, 변경 SUPERADMIN. */
@Service
@RequiredArgsConstructor
public class SettingService {
private final SettingMapper mapper;
private final AuditService auditService;
public List<CmsSetting> list() {
return mapper.findAll();
}
public CmsSetting update(String key, String value) {
if (key == null || key.isBlank()) {
throw new IllegalArgumentException("ERR-SET-400: key 필수");
}
if (value == null) {
throw new IllegalArgumentException("ERR-SET-400: value 필수");
}
CmsSetting before = mapper.findByKey(key);
mapper.upsert(key, value);
String prev = before == null ? "(none)" : before.getValue();
auditService.log("SETTING_CHANGE", key, prev + " -> " + value);
return mapper.findByKey(key);
}
}

View File

@ -0,0 +1,16 @@
package com.zioinfo.cms.admin.dto;
import lombok.Data;
import java.time.LocalDateTime;
/** 감사 로그 (cms_audit_log 테이블 매핑). */
@Data
public class AuditLog {
private Long id;
private String actor; // 작업 수행자 (JWT subject = username)
private String action; // CONTENT_PUBLISH, USER_CREATE, UGC_MODERATE
private String target; // 대상 식별자
private String detail; // 부가 설명
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,13 @@
package com.zioinfo.cms.admin.dto;
import lombok.Data;
import java.time.LocalDateTime;
/** 시스템 설정 (cms_setting 테이블 매핑). */
@Data
public class CmsSetting {
private String key;
private String value;
private LocalDateTime updatedAt;
}

View File

@ -0,0 +1,23 @@
package com.zioinfo.cms.admin.dto;
import com.zioinfo.cms.auth.CmsUser;
import java.time.LocalDateTime;
/**
* 사용자 응답 DTO.
*
* <p>보안 불변 규칙: password_hash 어떤 응답에도 포함하지 않는다.
*/
public record UserDto(
Long id,
String username,
String displayName,
String role,
boolean active,
LocalDateTime createdAt
) {
public static UserDto from(CmsUser u) {
return new UserDto(u.getId(), u.getUsername(), u.getDisplayName(), u.getRole(), u.isActive(), u.getCreatedAt());
}
}

View File

@ -0,0 +1,35 @@
package com.zioinfo.cms.admin.mapper;
import com.zioinfo.cms.auth.CmsUser;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 관리자 사용자 관리 매퍼 (cms_user).
*
* <p>@Mapper 필수 MyBatisConfig annotationClass=Mapper.class 스캔.
*/
@Mapper
public interface AdminUserMapper {
List<CmsUser> findAll();
CmsUser findById(@Param("id") Long id);
int insert(CmsUser user);
int updateRole(@Param("id") Long id, @Param("role") String role);
int updateActive(@Param("id") Long id, @Param("active") boolean active);
int updatePassword(@Param("id") Long id, @Param("passwordHash") String passwordHash);
int deleteById(@Param("id") Long id);
/** 활성 SUPERADMIN 계정 수 (마지막 관리자 삭제/강등 방지용). */
int countSuperAdmins();
int countByUsername(@Param("username") String username);
}

View File

@ -0,0 +1,17 @@
package com.zioinfo.cms.admin.mapper;
import com.zioinfo.cms.admin.dto.AuditLog;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface AuditLogMapper {
int insert(AuditLog log);
List<AuditLog> find(@Param("action") String action,
@Param("actor") String actor,
@Param("limit") int limit);
}

View File

@ -0,0 +1,17 @@
package com.zioinfo.cms.admin.mapper;
import com.zioinfo.cms.admin.dto.CmsSetting;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface SettingMapper {
List<CmsSetting> findAll();
CmsSetting findByKey(@Param("key") String key);
int upsert(@Param("key") String key, @Param("value") String value);
}

View File

@ -0,0 +1,72 @@
package com.zioinfo.cms.ai;
import com.zioinfo.cms.common.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/**
* GUARDiA CMS AI 도구 API (Ollama 온프레미스 + Java 폴백).
*
* <p>모든 엔드포인트는 Ollama 미가용 시에도 폴백으로 동작한다.
*/
@RestController
@RequestMapping("/api/cms/ai")
@RequiredArgsConstructor
public class AiController {
private final AiService aiService;
@GetMapping("/status")
public ApiResponse<Map<String, Object>> status() {
return ApiResponse.ok(Map.of("ollamaAvailable", aiService.ollamaAvailable()));
}
/** 1. 콘텐츠 초안 생성. */
@PostMapping("/draft")
public ApiResponse<Map<String, String>> draft(@RequestBody Map<String, String> req) {
String text = aiService.draft(req.get("kind"), req.get("topic"), req.get("tone"));
return ApiResponse.ok(Map.of("draft", text));
}
/** 2. 이미지 태깅/대체텍스트 (base64 이미지). */
@PostMapping("/image-tags")
public ApiResponse<Map<String, Object>> imageTags(@RequestBody Map<String, String> req) {
return ApiResponse.ok(aiService.imageTags(req.get("imageBase64"), req.get("fileName")));
}
/** 3. SEO 제안. */
@PostMapping("/seo")
public ApiResponse<Map<String, Object>> seo(@RequestBody Map<String, String> req) {
return ApiResponse.ok(aiService.seoSuggest(req.get("title"), req.get("body")));
}
/** 4. 번역. */
@PostMapping("/translate")
public ApiResponse<Map<String, String>> translate(@RequestBody Map<String, String> req) {
String out = aiService.translate(req.get("text"), req.getOrDefault("targetLocale", "en"));
return ApiResponse.ok(Map.of("translated", out));
}
/** 6. 리뷰 요약·감성. */
@PostMapping("/review-summary")
public ApiResponse<Map<String, Object>> reviewSummary(@RequestBody Map<String, Object> req) {
@SuppressWarnings("unchecked")
List<String> reviews = (List<String>) req.getOrDefault("reviews", List.of());
return ApiResponse.ok(aiService.reviewSummary(reviews));
}
/** 7. 자연어 검색 키워드. */
@PostMapping("/search-keywords")
public ApiResponse<Map<String, Object>> searchKeywords(@RequestBody Map<String, String> req) {
return ApiResponse.ok(Map.of("keywords", aiService.searchKeywords(req.get("query"))));
}
/** 8. UGC 모더레이션. */
@PostMapping("/moderate")
public ApiResponse<Map<String, Object>> moderate(@RequestBody Map<String, String> req) {
return ApiResponse.ok(aiService.moderate(req.get("text")));
}
}

View File

@ -0,0 +1,299 @@
package com.zioinfo.cms.ai;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
/**
* GUARDiA CMS AI 서비스 8개 기능, 전부 Ollama 온프레미스 + Java 폴백.
*
* <p>보안 불변 규칙: 외부 AI API 금지. Ollama 미가용 모든 메서드가 규칙기반 폴백으로 동작한다.
* 다른 모듈(ugc 모더레이션·media 대체텍스트·content 초안·seo·delivery 검색)에서 재사용한다.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AiService {
private final OllamaClient ollama;
// 욕설/스팸 폴백 사전 (규칙기반)
private static final Set<String> PROFANITY = Set.of(
"욕설", "개새끼", "씨발", "병신", "fuck", "shit", "asshole", "bitch");
private static final Set<String> SPAM_HINTS = Set.of(
"http://", "https://", "카지노", "도박", "비아그라", "viagra", "loan", "대출", "텔레그램", "주식리딩");
public boolean ollamaAvailable() {
return ollama.available();
}
// 1. AI 콘텐츠 초안 생성 (상품설명·블로그·배너카피)
public String draft(String kind, String topic, String tone) {
String prompt = String.format(
"당신은 e-커머스 콘텐츠 작가입니다. 유형: %s, 주제: %s, 톤: %s. "
+ "한국어로 자연스러운 마케팅 초안을 200자 내외로 작성하세요. 설명 없이 본문만 출력.",
kind, topic, tone == null ? "친근함" : tone);
String out = ollama.generate(prompt);
if (out != null && !out.isBlank()) {
return out;
}
// Java 폴백: 템플릿 기반
String t = topic == null ? "신규 상품" : topic;
return switch (kind == null ? "" : kind.toLowerCase()) {
case "product", "상품" -> t + " — 지금 만나보세요. 엄선된 품질과 합리적인 가격으로 일상에 특별함을 더합니다. 한정 수량으로 준비했습니다.";
case "banner", "배너" -> t + " 특별 기획전! 놓치면 후회하는 혜택, 오늘만 이 가격.";
default -> t + "에 대해 소개합니다. 핵심 가치와 활용법을 알기 쉽게 정리했습니다. 자세한 내용은 본문에서 확인하세요.";
};
}
// 2. 이미지 태깅·대체텍스트 자동생성 (llava 비전)
public Map<String, Object> imageTags(String imageBase64, String fileName) {
Map<String, Object> result = new LinkedHashMap<>();
if (imageBase64 != null && !imageBase64.isBlank()) {
String prompt = "이 이미지를 설명하는 한국어 대체텍스트 1문장과, 쉼표로 구분된 태그 5개를 "
+ "'ALT: ...\\nTAGS: a,b,c' 형식으로 출력하세요.";
String out = ollama.vision(prompt, imageBase64);
if (out != null && !out.isBlank()) {
String alt = extractLine(out, "ALT:");
String tags = extractLine(out, "TAGS:");
result.put("altText", alt.isBlank() ? out : alt);
result.put("tags", tags.isBlank() ? List.of() : Arrays.asList(tags.split("\\s*,\\s*")));
result.put("source", "ollama-llava");
return result;
}
}
// Java 폴백: 파일명 기반
String base = fileName == null ? "이미지" : fileName.replaceAll("\\.[^.]+$", "").replaceAll("[_-]", " ");
result.put("altText", base + " 이미지");
result.put("tags", List.of("image", "media"));
result.put("source", "fallback");
return result;
}
// 3. SEO 최적화 제안 (메타·키워드·가독성)
public Map<String, Object> seoSuggest(String title, String body) {
Map<String, Object> result = new LinkedHashMap<>();
String prompt = String.format(
"다음 콘텐츠의 SEO를 개선하세요. 제목: %s\\n본문: %s\\n"
+ "'META: ...\\nKEYWORDS: a,b,c\\nSCORE: 0~100' 형식으로 한국어 출력.",
title, truncate(body, 800));
String out = ollama.generate(prompt);
if (out != null && !out.isBlank()) {
result.put("metaDescription", firstNonBlank(extractLine(out, "META:"), truncate(body, 150)));
String kw = extractLine(out, "KEYWORDS:");
result.put("keywords", kw.isBlank() ? fallbackKeywords(title, body) : Arrays.asList(kw.split("\\s*,\\s*")));
result.put("score", parseScore(extractLine(out, "SCORE:")));
result.put("source", "ollama");
return result;
}
// Java 폴백
result.put("metaDescription", truncate(stripHtml(body), 150));
result.put("keywords", fallbackKeywords(title, body));
result.put("score", readabilityScore(body));
result.put("source", "fallback");
return result;
}
// 4. 다국어 번역
public String translate(String text, String targetLocale) {
if (text == null || text.isBlank()) return text;
String prompt = String.format(
"다음 텍스트를 %s 로케일 언어로 자연스럽게 번역하세요. 번역문만 출력:\\n%s",
targetLocale, truncate(text, 1500));
String out = ollama.generate(prompt);
if (out != null && !out.isBlank()) {
return out;
}
// Java 폴백: 원문 유지 + 로케일 표식(번역 불가 원문 보존이 안전)
return text;
}
// 5. 개인화 콘텐츠/상품추천 (세그먼트별, 폴백=인기순 가정)
public List<Map<String, Object>> recommend(String segment, List<Map<String, Object>> candidates, int topN) {
if (candidates == null || candidates.isEmpty()) return List.of();
int n = Math.min(topN <= 0 ? 5 : topN, candidates.size());
// 폴백: 후보를 그대로 상위 N개 (조회수 desc 정렬은 매퍼에서 수행 가정)
return candidates.subList(0, n);
}
// 6. 리뷰 AI 요약·감성분석
public Map<String, Object> reviewSummary(List<String> reviews) {
Map<String, Object> result = new LinkedHashMap<>();
if (reviews == null || reviews.isEmpty()) {
result.put("summary", "");
result.put("sentiment", "NEUTRAL");
result.put("score", 0.0);
return result;
}
String joined = truncate(String.join("\\n- ", reviews), 1500);
String prompt = "다음 리뷰들을 한 문장으로 요약하고 감성(POSITIVE/NEGATIVE/NEUTRAL)을 판정하세요. "
+ "'SUMMARY: ...\\nSENTIMENT: ...' 형식:\\n- " + joined;
String out = ollama.generate(prompt);
if (out != null && !out.isBlank()) {
result.put("summary", firstNonBlank(extractLine(out, "SUMMARY:"), out));
String s = extractLine(out, "SENTIMENT:").toUpperCase();
result.put("sentiment", s.contains("POSI") ? "POSITIVE" : s.contains("NEGA") ? "NEGATIVE" : "NEUTRAL");
result.put("source", "ollama");
return result;
}
// Java 폴백: 키워드 기반 감성
return fallbackSentiment(reviews, result);
}
/** 단일 텍스트 감성 점수 (폴백 규칙). 0~1 긍정도. */
public double sentimentScore(String text) {
if (text == null) return 0.5;
String t = text.toLowerCase();
int pos = countAny(t, List.of("", "최고", "만족", "추천", "훌륭", "good", "great", "love", "best"));
int neg = countAny(t, List.of("나쁘", "최악", "실망", "별로", "환불", "bad", "worst", "terrible", "hate"));
if (pos + neg == 0) return 0.5;
return (double) pos / (pos + neg);
}
// 7. 자연어 콘텐츠 검색 쿼리 키워드 추출(임베딩 미사용, 키워드 폴백)
public List<String> searchKeywords(String naturalQuery) {
if (naturalQuery == null || naturalQuery.isBlank()) return List.of();
String prompt = "다음 검색 의도에서 핵심 키워드 3~5개를 쉼표로만 출력:\\n" + naturalQuery;
String out = ollama.generate(prompt);
if (out != null && !out.isBlank()) {
String line = out.replaceAll("[\\n\\r]", " ").trim();
List<String> kws = new ArrayList<>();
for (String k : line.split("\\s*,\\s*")) {
String kk = k.trim();
if (!kk.isEmpty() && kk.length() < 30) kws.add(kk);
}
if (!kws.isEmpty()) return kws.subList(0, Math.min(5, kws.size()));
}
// Java 폴백: 불용어 제거 토큰화
return Arrays.stream(naturalQuery.split("[\\s,.]+"))
.map(String::trim)
.filter(w -> w.length() > 1 && !STOPWORDS.contains(w))
.distinct().limit(5).toList();
}
// 8. UGC AI 모더레이션 욕설·스팸·부적절 분류
public Map<String, Object> moderate(String text) {
Map<String, Object> result = new LinkedHashMap<>();
if (text == null || text.isBlank()) {
result.put("decision", "APPROVE");
result.put("risk", 0.0);
result.put("reason", "empty");
return result;
}
String prompt = "다음 UGC가 욕설/스팸/부적절한지 판정하세요. "
+ "'DECISION: APPROVE|REJECT|REVIEW\\nRISK: 0~1\\nREASON: ...' 형식 한국어:\\n" + truncate(text, 800);
String out = ollama.generate(prompt);
if (out != null && !out.isBlank()) {
String d = extractLine(out, "DECISION:").toUpperCase();
String decision = d.contains("REJECT") ? "REJECT" : d.contains("REVIEW") ? "REVIEW" : "APPROVE";
result.put("decision", decision);
result.put("risk", parseRisk(extractLine(out, "RISK:")));
result.put("reason", firstNonBlank(extractLine(out, "REASON:"), "ollama 판정"));
result.put("source", "ollama");
return result;
}
// Java 폴백: 사전 기반
String lower = text.toLowerCase();
boolean hasProfanity = PROFANITY.stream().anyMatch(lower::contains);
long spamHits = SPAM_HINTS.stream().filter(lower::contains).count();
double risk = (hasProfanity ? 0.8 : 0.0) + Math.min(0.6, spamHits * 0.3);
risk = Math.min(1.0, risk);
String decision = risk >= 0.7 ? "REJECT" : risk >= 0.3 ? "REVIEW" : "APPROVE";
result.put("decision", decision);
result.put("risk", round2(risk));
result.put("reason", hasProfanity ? "욕설 감지" : spamHits > 0 ? "스팸 의심" : "정상");
result.put("source", "fallback");
return result;
}
// helpers
private static final Set<String> STOPWORDS = Set.of(
"그리고", "또는", "", "", "", "", "", "", "", "", "and", "or", "the", "a", "of", "for");
private Map<String, Object> fallbackSentiment(List<String> reviews, Map<String, Object> result) {
double sum = 0;
for (String r : reviews) sum += sentimentScore(r);
double avg = sum / reviews.size();
result.put("summary", "리뷰 " + reviews.size() + "건 — 평균 긍정도 " + round2(avg));
result.put("sentiment", avg >= 0.6 ? "POSITIVE" : avg <= 0.4 ? "NEGATIVE" : "NEUTRAL");
result.put("score", round2(avg));
result.put("source", "fallback");
return result;
}
private List<String> fallbackKeywords(String title, String body) {
String text = ((title == null ? "" : title) + " " + stripHtml(body == null ? "" : body));
return Arrays.stream(text.split("[\\s,.]+"))
.map(String::trim)
.filter(w -> w.length() > 1 && !STOPWORDS.contains(w.toLowerCase()))
.distinct().limit(8).toList();
}
private int readabilityScore(String body) {
if (body == null || body.isBlank()) return 30;
int len = stripHtml(body).length();
if (len < 100) return 45;
if (len < 300) return 70;
if (len < 1500) return 85;
return 75;
}
private int countAny(String text, List<String> needles) {
int c = 0;
for (String n : needles) if (text.contains(n)) c++;
return c;
}
private String extractLine(String out, String tag) {
if (out == null) return "";
for (String line : out.split("[\\n\\r]+")) {
String l = line.trim();
if (l.toUpperCase().startsWith(tag.toUpperCase())) {
return l.substring(tag.length()).trim();
}
}
return "";
}
private int parseScore(String s) {
try {
return Math.max(0, Math.min(100, Integer.parseInt(s.replaceAll("[^0-9]", ""))));
} catch (Exception e) {
return 60;
}
}
private double parseRisk(String s) {
try {
double d = Double.parseDouble(s.replaceAll("[^0-9.]", ""));
return round2(Math.max(0, Math.min(1, d)));
} catch (Exception e) {
return 0.5;
}
}
private String firstNonBlank(String a, String b) {
return (a != null && !a.isBlank()) ? a : b;
}
private String stripHtml(String s) {
return s == null ? "" : Pattern.compile("<[^>]+>").matcher(s).replaceAll(" ").trim();
}
private String truncate(String s, int max) {
if (s == null) return "";
return s.length() <= max ? s : s.substring(0, max);
}
private double round2(double d) {
return Math.round(d * 100.0) / 100.0;
}
}

View File

@ -0,0 +1,94 @@
package com.zioinfo.cms.ai;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;
import java.time.Duration;
import java.util.List;
import java.util.Map;
/**
* Ollama 온프레미스 LLM 클라이언트.
*
* <p>보안 불변 규칙: localhost Ollama만 호출. 외부 AI API 절대 금지.
* 오프라인/장애 절대 예외를 던지지 않고 문자열을 반환한다(서비스 계층이 Java 폴백 수행).
*/
@Slf4j
@Component
public class OllamaClient {
private final WebClient.Builder builder;
private final String ollamaUrl;
private final String textModel;
private final String visionModel;
public OllamaClient(WebClient.Builder builder,
@Value("${guardia.ollama-url:http://localhost:11434}") String ollamaUrl,
@Value("${guardia.ollama-text-model:llama3}") String textModel,
@Value("${guardia.ollama-vision-model:llava}") String visionModel) {
this.builder = builder;
this.ollamaUrl = ollamaUrl;
this.textModel = textModel;
this.visionModel = visionModel;
}
/** 프롬프트로 텍스트 생성. 실패 시 빈 문자열 반환(예외 없음). */
@SuppressWarnings("unchecked")
public String generate(String prompt) {
try {
Map<String, Object> body = Map.of("model", textModel, "prompt", prompt, "stream", false);
Map<String, Object> res = builder.baseUrl(ollamaUrl).build()
.post().uri("/api/generate")
.bodyValue(body)
.retrieve()
.bodyToMono(Map.class)
.timeout(Duration.ofSeconds(30))
.map(m -> (Map<String, Object>) m)
.block();
if (res == null) return "";
Object r = res.get("response");
return r == null ? "" : String.valueOf(r).trim();
} catch (Exception e) {
log.warn("Ollama 일시 불가 — Java 폴백 사용: {}", e.getMessage());
return "";
}
}
/** llava 비전 모델로 이미지(base64) 분석. 실패 시 빈 문자열. */
@SuppressWarnings("unchecked")
public String vision(String prompt, String imageBase64) {
try {
Map<String, Object> body = Map.of(
"model", visionModel,
"prompt", prompt,
"images", List.of(imageBase64),
"stream", false);
Map<String, Object> res = builder.baseUrl(ollamaUrl).build()
.post().uri("/api/generate")
.bodyValue(body)
.retrieve()
.bodyToMono(Map.class)
.timeout(Duration.ofSeconds(45))
.map(m -> (Map<String, Object>) m)
.block();
if (res == null) return "";
Object r = res.get("response");
return r == null ? "" : String.valueOf(r).trim();
} catch (Exception e) {
log.warn("Ollama 비전 일시 불가 — Java 폴백 사용: {}", e.getMessage());
return "";
}
}
public boolean available() {
try {
builder.baseUrl(ollamaUrl).build().get().uri("/api/tags")
.retrieve().bodyToMono(String.class).timeout(Duration.ofSeconds(3)).block();
return true;
} catch (Exception e) {
return false;
}
}
}

View File

@ -0,0 +1,75 @@
package com.zioinfo.cms.analytics;
import com.zioinfo.cms.analytics.mapper.AnalyticsMapper;
import com.zioinfo.cms.common.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 콘텐츠 성과 분석 API 조회/체류/전환 집계 + BI 피드.
*
* <p>이벤트 기록(POST /event) 공개 delivery 측에서 호출 가능하도록 작성 권한으로 보호된다.
* BI(8006) /bi-feed 소비해 콘텐츠 전환 분석에 활용한다.
*/
@RestController
@RequestMapping("/api/cms/analytics")
@RequiredArgsConstructor
public class AnalyticsController {
private final AnalyticsMapper mapper;
/** 이벤트 기록 (VIEW/DWELL/CONVERSION). */
@PostMapping("/event")
public ApiResponse<Void> event(@RequestBody Map<String, Object> req) {
Long contentId = longOf(req.get("contentId"));
String type = req.get("eventType") == null ? "VIEW" : String.valueOf(req.get("eventType"));
long amount = longOf(req.getOrDefault("amount", 1L)) == null ? 1L : longOf(req.get("amount"));
if (contentId == null) throw new IllegalArgumentException("ERR-CMS-ANALYTICS-400: contentId 필수");
mapper.recordEvent(contentId, type.toUpperCase(), amount);
return ApiResponse.ok(null);
}
/** 인기 콘텐츠 Top N. */
@GetMapping("/top")
public ApiResponse<List<CmsContentStat>> top(@RequestParam(defaultValue = "10") int limit) {
return ApiResponse.ok(mapper.topContent(limit));
}
/** 콘텐츠 단건 성과. */
@GetMapping("/content/{id}")
public ApiResponse<CmsContentStat> byContent(@PathVariable("id") Long contentId) {
return ApiResponse.ok(mapper.findByContent(contentId));
}
/** 요약 통계. */
@GetMapping("/summary")
public ApiResponse<Map<String, Object>> summary() {
Map<String, Object> m = new LinkedHashMap<>();
long views = mapper.totalViews();
long conv = mapper.totalConversions();
m.put("totalViews", views);
m.put("totalConversions", conv);
m.put("conversionRate", views == 0 ? 0.0 : Math.round((double) conv / views * 10000) / 100.0);
return ApiResponse.ok(m);
}
/** BI 피드 — 콘텐츠 성과를 BI(8006)가 소비. */
@GetMapping("/bi-feed")
public ApiResponse<Map<String, Object>> biFeed() {
Map<String, Object> feed = new LinkedHashMap<>();
feed.put("source", "GUARDiA CMS");
feed.put("totalViews", mapper.totalViews());
feed.put("totalConversions", mapper.totalConversions());
feed.put("topContent", mapper.topContent(20));
return ApiResponse.ok(feed);
}
private Long longOf(Object o) {
if (o == null) return null;
try { return Long.valueOf(String.valueOf(o).trim()); } catch (Exception e) { return null; }
}
}

View File

@ -0,0 +1,15 @@
package com.zioinfo.cms.analytics;
import lombok.Data;
/** 콘텐츠 성과 집계 행 (cms_content_stat). */
@Data
public class CmsContentStat {
private Long contentId;
private String slug;
private String title;
private Long views;
private Long dwellSeconds; // 누적 체류
private Long conversions;
private Double conversionRate; // 파생
}

View File

@ -0,0 +1,22 @@
package com.zioinfo.cms.analytics.mapper;
import com.zioinfo.cms.analytics.CmsContentStat;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface AnalyticsMapper {
/** 이벤트 누적 — upsert. eventType: VIEW / DWELL / CONVERSION. */
int recordEvent(@Param("contentId") Long contentId,
@Param("eventType") String eventType,
@Param("amount") long amount);
List<CmsContentStat> topContent(@Param("limit") int limit);
CmsContentStat findByContent(@Param("contentId") Long contentId);
long totalViews();
long totalConversions();
}

View File

@ -0,0 +1,29 @@
package com.zioinfo.cms.auth;
import com.zioinfo.cms.common.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/api/cms/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
@PostMapping("/login")
public ApiResponse<Map<String, String>> login(@RequestBody LoginRequest req) {
String token = authService.login(req.username(), req.password());
return ApiResponse.ok(Map.of("token", token, "type", "Bearer"));
}
@GetMapping("/me")
public ApiResponse<Map<String, Object>> me(@RequestHeader("Authorization") String header) {
String token = header.replace("Bearer ", "");
return ApiResponse.ok(authService.me(token));
}
record LoginRequest(String username, String password) {}
}

View File

@ -0,0 +1,40 @@
package com.zioinfo.cms.auth;
import com.zioinfo.cms.auth.mapper.UserMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.Map;
@Service
@RequiredArgsConstructor
public class AuthService {
private final UserMapper userMapper;
private final PasswordEncoder passwordEncoder;
private final JwtUtil jwtUtil;
public String login(String username, String password) {
CmsUser user = userMapper.findByUsername(username);
if (user == null || !user.isActive()) {
throw new RuntimeException("ERR-AUTH-001: 존재하지 않거나 비활성 계정");
}
if (!passwordEncoder.matches(password, user.getPasswordHash())) {
throw new RuntimeException("ERR-AUTH-002: 비밀번호 불일치");
}
return jwtUtil.generate(username, user.getRole());
}
public Map<String, Object> me(String token) {
String username = jwtUtil.getUsername(token);
String role = jwtUtil.getRole(token);
CmsUser u = userMapper.findByUsername(username);
Map<String, Object> m = new HashMap<>();
m.put("username", username);
m.put("role", role);
m.put("displayName", u != null ? u.getDisplayName() : username);
return m;
}
}

View File

@ -0,0 +1,21 @@
package com.zioinfo.cms.auth;
import lombok.Data;
import java.time.LocalDateTime;
/**
* CMS 관리자/운영자 계정 (cms_user 테이블).
*
* <p>role: SUPERADMIN / EDITOR / AUTHOR / VIEWER.
* 회원(member)과는 별개 회원은 cms_member, 엔티티는 콘텐츠 운영자.
*/
@Data
public class CmsUser {
private Long id;
private String username;
private String passwordHash;
private String displayName;
private String role;
private boolean active;
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,40 @@
package com.zioinfo.cms.auth;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.List;
@Component
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res,
FilterChain chain) throws ServletException, IOException {
String header = req.getHeader("Authorization");
if (header != null && header.startsWith("Bearer ")) {
String token = header.substring(7);
if (jwtUtil.isValid(token)) {
String username = jwtUtil.getUsername(token);
String role = jwtUtil.getRole(token);
var auth = new UsernamePasswordAuthenticationToken(
username, null, List.of(new SimpleGrantedAuthority("ROLE_" + role))
);
SecurityContextHolder.getContext().setAuthentication(auth);
}
}
chain.doFilter(req, res);
}
}

View File

@ -0,0 +1,59 @@
package com.zioinfo.cms.auth;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
@Slf4j
@Component
public class JwtUtil {
@Value("${guardia.jwt.secret:guardia-cms-jwt-secret-2026-minimum-256bit-key-zioinfo}")
private String secret;
@Value("${guardia.jwt.expiration:86400000}")
private long expirationMs;
private SecretKey key() {
return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
}
public String generate(String username, String role) {
return Jwts.builder()
.subject(username)
.claim("role", role)
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + expirationMs))
.signWith(key())
.compact();
}
public Claims parse(String token) {
return Jwts.parser().verifyWith(key()).build()
.parseSignedClaims(token).getPayload();
}
public boolean isValid(String token) {
try {
parse(token);
return true;
} catch (JwtException | IllegalArgumentException e) {
log.debug("JWT 검증 실패: {}", e.getMessage());
return false;
}
}
public String getUsername(String token) {
return parse(token).getSubject();
}
public String getRole(String token) {
return parse(token).get("role", String.class);
}
}

View File

@ -0,0 +1,13 @@
package com.zioinfo.cms.auth.mapper;
import com.zioinfo.cms.auth.CmsUser;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
@Mapper
public interface UserMapper {
CmsUser findByUsername(@Param("username") String username);
int insert(CmsUser user);
}

View File

@ -0,0 +1,88 @@
package com.zioinfo.cms.banner;
import com.zioinfo.cms.banner.mapper.BannerMapper;
import com.zioinfo.cms.common.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
/**
* 배너/프로모션 API. 노출 스케줄(startAt~endAt) + active 기반 현재 노출 조회.
*/
@RestController
@RequestMapping("/api/cms/banner")
@RequiredArgsConstructor
public class BannerController {
private final BannerMapper mapper;
@GetMapping
public ApiResponse<List<CmsBanner>> list(@RequestParam(required = false) String bannerType,
@RequestParam(required = false) String position) {
return ApiResponse.ok(mapper.findAll(bannerType, position));
}
/** 현재 노출 대상(active + 스케줄 유효). */
@GetMapping("/active")
public ApiResponse<List<CmsBanner>> active(@RequestParam(required = false) String position) {
return ApiResponse.ok(mapper.findActiveNow(position));
}
@GetMapping("/{id}")
public ApiResponse<CmsBanner> get(@PathVariable Long id) {
CmsBanner b = mapper.findById(id);
if (b == null) throw new RuntimeException("ERR-CMS-BANNER-404: 배너 없음");
return ApiResponse.ok(b);
}
@PostMapping
public ApiResponse<CmsBanner> create(@RequestBody Map<String, Object> req, Authentication auth) {
CmsBanner b = bind(new CmsBanner(), req);
b.setCreatedBy(auth != null ? auth.getName() : "system");
mapper.insert(b);
return ApiResponse.ok(mapper.findById(b.getId()));
}
@PutMapping("/{id}")
public ApiResponse<CmsBanner> update(@PathVariable Long id, @RequestBody Map<String, Object> req) {
CmsBanner b = mapper.findById(id);
if (b == null) throw new RuntimeException("ERR-CMS-BANNER-404: 배너 없음");
bind(b, req);
mapper.update(b);
return ApiResponse.ok(mapper.findById(id));
}
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@PathVariable Long id) {
mapper.delete(id);
return ApiResponse.ok(null);
}
private CmsBanner bind(CmsBanner b, Map<String, Object> r) {
if (r.containsKey("name")) b.setName(str(r.get("name")));
if (r.containsKey("bannerType")) b.setBannerType(str(r.get("bannerType")));
if (r.containsKey("imageUrl")) b.setImageUrl(str(r.get("imageUrl")));
if (r.containsKey("linkUrl")) b.setLinkUrl(str(r.get("linkUrl")));
if (r.containsKey("position")) b.setPosition(str(r.get("position")));
if (r.containsKey("targetRule")) b.setTargetRule(jsonStr(r.get("targetRule")));
if (r.containsKey("sortOrder")) b.setSortOrder(intOf(r.get("sortOrder")));
if (r.containsKey("active")) b.setActive(Boolean.parseBoolean(String.valueOf(r.get("active"))));
if (r.containsKey("startAt")) b.setStartAt(dt(r.get("startAt")));
if (r.containsKey("endAt")) b.setEndAt(dt(r.get("endAt")));
if (b.getBannerType() == null) b.setBannerType("PROMOTION");
return b;
}
private String str(Object o) { return o == null ? null : String.valueOf(o); }
private Integer intOf(Object o) { if (o == null) return null; try { return Integer.valueOf(String.valueOf(o).trim()); } catch (Exception e) { return null; } }
private LocalDateTime dt(Object o) { if (o == null || String.valueOf(o).isBlank()) return null; try { return LocalDateTime.parse(String.valueOf(o)); } catch (Exception e) { return null; } }
private String jsonStr(Object o) {
if (o == null) return null;
if (o instanceof String s) return s;
try { return new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(o); } catch (Exception e) { return String.valueOf(o); }
}
}

View File

@ -0,0 +1,22 @@
package com.zioinfo.cms.banner;
import lombok.Data;
import java.time.LocalDateTime;
/** 배너/프로모션/기획전/팝업 (cms_banner). */
@Data
public class CmsBanner {
private Long id;
private String name;
private String bannerType; // HERO, PROMOTION, POPUP, CAMPAIGN, EXHIBITION
private String imageUrl;
private String linkUrl;
private String position; // MAIN_TOP, SIDEBAR, FOOTER ...
private String targetRule; // JSONB: 타겟팅 규칙(세그먼트/지역)
private Integer sortOrder;
private boolean active;
private LocalDateTime startAt;
private LocalDateTime endAt;
private String createdBy;
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,17 @@
package com.zioinfo.cms.banner.mapper;
import com.zioinfo.cms.banner.CmsBanner;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface BannerMapper {
List<CmsBanner> findAll(@Param("bannerType") String bannerType, @Param("position") String position);
List<CmsBanner> findActiveNow(@Param("position") String position);
CmsBanner findById(@Param("id") Long id);
int insert(CmsBanner b);
int update(CmsBanner b);
int delete(@Param("id") Long id);
}

View File

@ -0,0 +1,63 @@
package com.zioinfo.cms.channel;
import com.zioinfo.cms.channel.mapper.ChannelMapper;
import com.zioinfo.cms.common.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/** 다채널 배포 타겟 API. */
@RestController
@RequestMapping("/api/cms/channel")
@RequiredArgsConstructor
public class ChannelController {
private final ChannelMapper mapper;
@GetMapping
public ApiResponse<List<CmsChannel>> list() {
return ApiResponse.ok(mapper.findAll());
}
@GetMapping("/{id}")
public ApiResponse<CmsChannel> get(@PathVariable Long id) {
CmsChannel c = mapper.findById(id);
if (c == null) throw new RuntimeException("ERR-CMS-CHANNEL-404: 채널 없음");
return ApiResponse.ok(c);
}
@PostMapping
public ApiResponse<CmsChannel> create(@RequestBody Map<String, Object> req) {
CmsChannel c = bind(new CmsChannel(), req);
mapper.insert(c);
return ApiResponse.ok(mapper.findById(c.getId()));
}
@PutMapping("/{id}")
public ApiResponse<CmsChannel> update(@PathVariable Long id, @RequestBody Map<String, Object> req) {
CmsChannel c = mapper.findById(id);
if (c == null) throw new RuntimeException("ERR-CMS-CHANNEL-404: 채널 없음");
bind(c, req);
mapper.update(c);
return ApiResponse.ok(mapper.findById(id));
}
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@PathVariable Long id) {
mapper.delete(id);
return ApiResponse.ok(null);
}
private CmsChannel bind(CmsChannel c, Map<String, Object> r) {
if (r.containsKey("name")) c.setName(str(r.get("name")));
if (r.containsKey("channelType")) c.setChannelType(str(r.get("channelType")));
if (r.containsKey("endpoint")) c.setEndpoint(str(r.get("endpoint")));
if (r.containsKey("active")) c.setActive(Boolean.parseBoolean(String.valueOf(r.get("active"))));
if (c.getChannelType() == null) c.setChannelType("WEB");
return c;
}
private String str(Object o) { return o == null ? null : String.valueOf(o); }
}

View File

@ -0,0 +1,15 @@
package com.zioinfo.cms.channel;
import lombok.Data;
import java.time.LocalDateTime;
/** 배포 채널 (cms_channel) — 웹/모바일/앱 등 콘텐츠 배포 타겟. */
@Data
public class CmsChannel {
private Long id;
private String name;
private String channelType; // WEB, MOBILE, APP, KIOSK
private String endpoint; // delivery 소비처(선택)
private boolean active;
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,16 @@
package com.zioinfo.cms.channel.mapper;
import com.zioinfo.cms.channel.CmsChannel;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface ChannelMapper {
List<CmsChannel> findAll();
CmsChannel findById(@Param("id") Long id);
int insert(CmsChannel c);
int update(CmsChannel c);
int delete(@Param("id") Long id);
}

View File

@ -0,0 +1,20 @@
package com.zioinfo.cms.common;
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor
public class ApiResponse<T> {
private boolean success;
private String message;
private T data;
public static <T> ApiResponse<T> ok(T data) {
return new ApiResponse<>(true, "OK", data);
}
public static <T> ApiResponse<T> fail(String message) {
return new ApiResponse<>(false, message, null);
}
}

View File

@ -0,0 +1,93 @@
package com.zioinfo.cms.common;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Base64;
/**
* 회원 PII·자격증명 암호화 유틸 AES-256-GCM.
*
* <p>GUARDiA 보안 불변 규칙: 회원 이메일/전화 PII와 연계 자격증명은 평문 저장 금지.
* {@code *_enc} 컬럼에 유틸로 암호화하여 저장하고, API 응답에는 마스킹/제외한다.
*
* <p>저장 포맷: Base64( IV(12B) || ciphertext || GCM tag(16B) ).
*/
@Component
public class CryptoUtil {
private static final int IV_LEN = 12;
private static final int TAG_BITS = 128;
private final SecretKeySpec key;
private final SecureRandom random = new SecureRandom();
public CryptoUtil(@Value("${guardia.crypto.secret:guardia-cms-aes-256-gcm-master-key-2026-zioinfo}") String secret) {
try {
byte[] digest = MessageDigest.getInstance("SHA-256").digest(secret.getBytes(StandardCharsets.UTF_8));
this.key = new SecretKeySpec(digest, "AES");
} catch (Exception e) {
throw new IllegalStateException("암호화 키 초기화 실패", e);
}
}
/** 평문을 AES-256-GCM으로 암호화하여 Base64 문자열로 반환. null/빈 입력은 그대로 반환. */
public String encrypt(String plain) {
if (plain == null || plain.isEmpty()) {
return plain;
}
try {
byte[] iv = new byte[IV_LEN];
random.nextBytes(iv);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(TAG_BITS, iv));
byte[] ct = cipher.doFinal(plain.getBytes(StandardCharsets.UTF_8));
byte[] out = new byte[iv.length + ct.length];
System.arraycopy(iv, 0, out, 0, iv.length);
System.arraycopy(ct, 0, out, iv.length, ct.length);
return Base64.getEncoder().encodeToString(out);
} catch (Exception e) {
throw new RuntimeException("ERR-CMS-CRYPTO-01: 암호화 실패");
}
}
/** Base64 암호문을 복호화. 복호화 실패 시 빈 문자열. */
public String decrypt(String enc) {
if (enc == null || enc.isEmpty()) {
return enc;
}
try {
byte[] all = Base64.getDecoder().decode(enc);
byte[] iv = new byte[IV_LEN];
System.arraycopy(all, 0, iv, 0, IV_LEN);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(TAG_BITS, iv));
byte[] pt = cipher.doFinal(all, IV_LEN, all.length - IV_LEN);
return new String(pt, StandardCharsets.UTF_8);
} catch (Exception e) {
return "";
}
}
/** 이메일/전화 등 PII 마스킹 (응답용). 예: ab***@x.com, 010****5678. */
public static String mask(String value) {
if (value == null || value.isEmpty()) {
return value;
}
int at = value.indexOf('@');
if (at > 0) {
String local = value.substring(0, at);
String shown = local.length() <= 2 ? local.substring(0, 1) : local.substring(0, 2);
return shown + "***" + value.substring(at);
}
if (value.length() <= 4) {
return "****";
}
return value.substring(0, value.length() - 4).replaceAll(".", "*") + value.substring(value.length() - 4);
}
}

View File

@ -0,0 +1,56 @@
package com.zioinfo.cms.common;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.multipart.MaxUploadSizeExceededException;
/**
* 전역 예외 처리.
*
* <p>보안 불변 규칙: 스택트레이스를 응답에 절대 노출하지 않는다.
* 에러 코드 + 요약 메시지만 반환한다.
*/
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MaxUploadSizeExceededException.class)
@ResponseStatus(HttpStatus.PAYLOAD_TOO_LARGE)
public ApiResponse<Void> handleMaxSize(MaxUploadSizeExceededException e) {
log.warn("업로드 크기 초과: {}", e.getMessage());
return ApiResponse.fail("ERR-CMS-413: 파일 크기 초과 (최대 20MB)");
}
@ExceptionHandler(AccessDeniedException.class)
@ResponseStatus(HttpStatus.FORBIDDEN)
public ApiResponse<Void> handleAccessDenied(AccessDeniedException e) {
log.warn("권한 거부: {}", e.getMessage());
return ApiResponse.fail("ERR-CMS-403: 권한이 없습니다");
}
@ExceptionHandler(IllegalArgumentException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ApiResponse<Void> handleIllegalArg(IllegalArgumentException e) {
log.warn("잘못된 요청: {}", e.getMessage());
return ApiResponse.fail(e.getMessage());
}
@ExceptionHandler(RuntimeException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ApiResponse<Void> handleRuntime(RuntimeException e) {
// 스택트레이스 미노출 에러 코드/요약만 반환
log.warn("업무 오류: {}", e.getMessage());
return ApiResponse.fail(e.getMessage());
}
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ApiResponse<Void> handleGeneral(Exception e) {
log.error("시스템 오류", e);
return ApiResponse.fail("ERR-SYS-001");
}
}

View File

@ -0,0 +1,25 @@
package com.zioinfo.cms.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.List;
@Configuration
public class CorsConfig {
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOriginPatterns(List.of("*"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
config.setAllowedHeaders(List.of("*"));
config.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
}

View File

@ -0,0 +1,36 @@
package com.zioinfo.cms.config;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.session.SqlSessionFactory;
import org.mybatis.spring.SqlSessionFactoryBean;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import javax.sql.DataSource;
/**
* MyBatis 설정.
*
* <p>형제 솔루션 TemplateMapper 빈누락 크래시 함정 준수:
* 전체 베이스 패키지에서 {@code @Mapper} 인터페이스만 등록(annotationClass=Mapper.class).
* 모든 매퍼 인터페이스에 {@code @Mapper} 필수, XML namespace=FQN.
*/
@Configuration
@MapperScan(basePackages = "com.zioinfo.cms", annotationClass = Mapper.class)
public class MyBatisConfig {
@Bean
public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception {
SqlSessionFactoryBean factory = new SqlSessionFactoryBean();
factory.setDataSource(dataSource);
factory.setMapperLocations(
new PathMatchingResourcePatternResolver().getResources("classpath:mapper/**/*.xml")
);
org.apache.ibatis.session.Configuration config = new org.apache.ibatis.session.Configuration();
config.setMapUnderscoreToCamelCase(true);
factory.setConfiguration(config);
return factory.getObject();
}
}

View File

@ -0,0 +1,88 @@
package com.zioinfo.cms.config;
import com.zioinfo.cms.auth.JwtFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
/**
* GUARDiA CMS 보안 설정 JWT 무상태 인증 + RBAC.
*
* <p>RBAC 역할(상위 하위): SUPERADMIN EDITOR AUTHOR VIEWER.
* <ul>
* <li>공개 delivery API(/api/cms/delivery/**) published 콘텐츠만, 인증 불필요(프리뷰는 토큰 검증)</li>
* <li>auth/health/swagger permitAll</li>
* <li>조회(GET /api/cms/**) 인증 사용자 전체(Viewer+)</li>
* <li>콘텐츠 작성/수정(POST/PUT/DELETE) Author 이상</li>
* <li>게시 승인/발행(workflow approve/publish) Editor 이상 (서비스/메서드 가드 병행)</li>
* <li>관리자 API(/api/admin/**) SuperAdmin 전용(감사로그 조회는 Editor+)</li>
* </ul>
*/
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtFilter jwtFilter;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.cors(cors -> {})
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/cms/auth/**").permitAll()
.requestMatchers("/actuator/health").permitAll()
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/api/cms/docs/**", "/api/cms/swagger/**").permitAll()
// 공개 헤드리스 delivery API GET(published 콘텐츠 조회·토큰 프리뷰) 무인증.
// POST(프리뷰 토큰 발급) 아래 POST /api/cms/** 규칙으로 폴백 Author+ 강제(H-1 우회 차단)
.requestMatchers(HttpMethod.GET, "/api/cms/delivery/**").permitAll()
// 정적 프론트 번들
.requestMatchers("/", "/index.html", "/assets/**", "/favicon.ico").permitAll()
// 관리자 사용자/설정 관리는 SUPERADMIN 전용
.requestMatchers("/api/admin/users/**").hasRole("SUPERADMIN")
.requestMatchers(HttpMethod.GET, "/api/admin/settings").hasAnyRole("SUPERADMIN", "EDITOR")
.requestMatchers("/api/admin/settings/**").hasRole("SUPERADMIN")
// 감사 로그 조회 SUPERADMIN/EDITOR
.requestMatchers("/api/admin/audit").hasAnyRole("SUPERADMIN", "EDITOR")
.requestMatchers("/api/admin/**").hasRole("SUPERADMIN")
// 콘텐츠/리소스 작성·수정·삭제 AUTHOR 이상 (게시 승인/발행은 서비스 메서드에서 EDITOR+ 추가 가드)
.requestMatchers(HttpMethod.POST, "/api/cms/**").hasAnyRole("SUPERADMIN", "EDITOR", "AUTHOR")
.requestMatchers(HttpMethod.PUT, "/api/cms/**").hasAnyRole("SUPERADMIN", "EDITOR", "AUTHOR")
.requestMatchers(HttpMethod.PATCH, "/api/cms/**").hasAnyRole("SUPERADMIN", "EDITOR", "AUTHOR")
.requestMatchers(HttpMethod.DELETE, "/api/cms/**").hasAnyRole("SUPERADMIN", "EDITOR", "AUTHOR")
// 조회 인증 사용자 전체(Viewer+)
.requestMatchers(HttpMethod.GET, "/api/cms/**").hasAnyRole("SUPERADMIN", "EDITOR", "AUTHOR", "VIEWER")
.anyRequest().authenticated()
)
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
}

View File

@ -0,0 +1,26 @@
package com.zioinfo.cms.config;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.stereotype.Controller;
/**
* SPA 포워딩 vite 정적 번들(index.html) 클라이언트 라우팅 경로로 포워드.
*
* <p>/api, /actuator, /swagger 등은 제외하고 -정적 경로를 index.html 포워딩한다.
* (형제 솔루션 단일 jar 패턴: frontend backend static)
*/
@Controller
public class SpaForwardController {
@RequestMapping(value = {"/admin", "/admin/**"})
public String adminSpa() {
return "forward:/index.html";
}
@GetMapping("/health-lite")
@org.springframework.web.bind.annotation.ResponseBody
public String healthLite() {
return "GUARDiA CMS UP";
}
}

View File

@ -0,0 +1,32 @@
package com.zioinfo.cms.content;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 콘텐츠 엔티티 (cms_content) 페이지/포스트/블록 헤드리스 콘텐츠.
*
* <p>blocks 헤드리스 블록 배열(JSONB) Java String 매핑, INSERT {@code #{blocks}::jsonb}.
* status 게시 워크플로우 단계: DRAFT REVIEW APPROVED PUBLISHED (+ARCHIVED).
*/
@Data
public class CmsContent {
private Long id;
private String contentType; // PAGE, POST, BLOCK, PRODUCT_DETAIL
private String slug; // URL 식별자(고유)
private String title;
private String summary;
private String blocks; // JSONB: 헤드리스 블록 배열
private String locale; // ko, en ...
private String status; // DRAFT, REVIEW, APPROVED, PUBLISHED, ARCHIVED
private Integer version; // 현재 버전 번호
private Long menuId; // 소속 메뉴/카테고리(선택)
private String tags; // 쉼표구분
private String seoMeta; // JSONB: SEO 메타(선택)
private LocalDateTime scheduledAt; // 예약 게시 시각
private LocalDateTime publishedAt;
private String createdBy;
private String updatedBy;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@ -0,0 +1,19 @@
package com.zioinfo.cms.content;
import lombok.Data;
import java.time.LocalDateTime;
/** 콘텐츠 버전 스냅샷 (cms_content_version) — 롤백용. */
@Data
public class CmsContentVersion {
private Long id;
private Long contentId;
private Integer version;
private String title;
private String summary;
private String blocks; // JSONB 스냅샷
private String status; // 스냅샷 시점 상태
private String note; // 변경 메모(워크플로우 전이 사유)
private String createdBy;
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,87 @@
package com.zioinfo.cms.content;
import com.zioinfo.cms.common.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
/**
* 콘텐츠 관리 API (헤드리스 페이지/포스트/블록).
*
* <p>RBAC: 조회 Viewer+, 작성/수정/삭제 Author+, 워크플로우 승인/발행은 서비스에서 Editor+ 가드.
*/
@RestController
@RequestMapping("/api/cms/content")
@RequiredArgsConstructor
public class ContentController {
private final ContentService service;
@GetMapping
public ApiResponse<List<CmsContent>> list(
@RequestParam(required = false) String contentType,
@RequestParam(required = false) String status,
@RequestParam(required = false) String locale,
@RequestParam(required = false) String keyword) {
return ApiResponse.ok(service.list(contentType, status, locale, keyword));
}
@GetMapping("/{id}")
public ApiResponse<CmsContent> get(@PathVariable Long id) {
return ApiResponse.ok(service.get(id));
}
@GetMapping("/{id}/versions")
public ApiResponse<List<CmsContentVersion>> versions(@PathVariable Long id) {
return ApiResponse.ok(service.versions(id));
}
@PostMapping
public ApiResponse<CmsContent> create(@RequestBody Map<String, Object> req, Authentication auth) {
return ApiResponse.ok(service.create(req, actor(auth)));
}
@PutMapping("/{id}")
public ApiResponse<CmsContent> update(@PathVariable Long id, @RequestBody Map<String, Object> req, Authentication auth) {
return ApiResponse.ok(service.update(id, req, actor(auth)));
}
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@PathVariable Long id) {
service.delete(id);
return ApiResponse.ok(null);
}
/** 워크플로우 전이 (submit/approve/publish/reject/archive/schedule). */
@PostMapping("/{id}/transition")
public ApiResponse<CmsContent> transition(@PathVariable Long id,
@RequestBody TransitionRequest req,
Authentication auth) {
LocalDateTime scheduledAt = (req.scheduledAt() == null || req.scheduledAt().isBlank())
? null : LocalDateTime.parse(req.scheduledAt());
return ApiResponse.ok(service.transition(id, req.action(), req.note(), scheduledAt, actor(auth), role(auth)));
}
/** 버전 롤백. */
@PostMapping("/{id}/rollback")
public ApiResponse<CmsContent> rollback(@PathVariable Long id, @RequestBody RollbackRequest req, Authentication auth) {
return ApiResponse.ok(service.rollback(id, req.version(), actor(auth)));
}
private String actor(Authentication a) {
return a != null ? a.getName() : "system";
}
private String role(Authentication a) {
if (a == null || a.getAuthorities().isEmpty()) return "VIEWER";
String r = a.getAuthorities().iterator().next().getAuthority();
return r.startsWith("ROLE_") ? r.substring(5) : r;
}
record TransitionRequest(String action, String note, String scheduledAt) {}
record RollbackRequest(Integer version) {}
}

View File

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

View File

@ -0,0 +1,43 @@
package com.zioinfo.cms.content.mapper;
import com.zioinfo.cms.content.CmsContent;
import com.zioinfo.cms.content.CmsContentVersion;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface ContentMapper {
List<CmsContent> findAll(@Param("contentType") String contentType,
@Param("status") String status,
@Param("locale") String locale,
@Param("keyword") String keyword);
CmsContent findById(@Param("id") Long id);
CmsContent findPublishedBySlug(@Param("slug") String slug, @Param("locale") String locale);
int countBySlug(@Param("slug") String slug, @Param("locale") String locale, @Param("excludeId") Long excludeId);
int insert(CmsContent c);
int update(CmsContent c);
int updateStatus(@Param("id") Long id, @Param("status") String status, @Param("updatedBy") String updatedBy);
int updatePublished(@Param("id") Long id, @Param("status") String status);
int delete(@Param("id") Long id);
// 예약 게시 예약 시각 도래한 APPROVED 콘텐츠
List<CmsContent> findDueScheduled();
// 버전
int insertVersion(CmsContentVersion v);
List<CmsContentVersion> findVersions(@Param("contentId") Long contentId);
CmsContentVersion findVersion(@Param("contentId") Long contentId, @Param("version") Integer version);
}

View File

@ -0,0 +1,50 @@
package com.zioinfo.cms.dashboard;
import com.zioinfo.cms.analytics.mapper.AnalyticsMapper;
import com.zioinfo.cms.common.ApiResponse;
import com.zioinfo.cms.dashboard.mapper.DashboardMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/** CMS 대시보드 — 콘텐츠/미디어/UGC/회원/성과 요약. */
@RestController
@RequestMapping("/api/cms/dashboard")
@RequiredArgsConstructor
public class DashboardController {
private final DashboardMapper mapper;
private final AnalyticsMapper analyticsMapper;
@GetMapping("/summary")
public ApiResponse<Map<String, Object>> summary() {
Map<String, Object> m = new LinkedHashMap<>();
m.put("totalContent", mapper.countTotalContent());
m.put("publishedContent", mapper.countContentByStatus("PUBLISHED"));
m.put("draftContent", mapper.countContentByStatus("DRAFT"));
m.put("reviewContent", mapper.countContentByStatus("REVIEW"));
m.put("mediaCount", mapper.countMedia());
m.put("pendingUgc", mapper.countPendingUgc());
m.put("memberCount", mapper.countMembers());
m.put("totalViews", analyticsMapper.totalViews());
m.put("totalConversions", analyticsMapper.totalConversions());
return ApiResponse.ok(m);
}
/** 워크플로우 파이프라인(상태별 콘텐츠 수). */
@GetMapping("/workflow")
public ApiResponse<List<Map<String, Object>>> workflow() {
return ApiResponse.ok(mapper.contentByStatus());
}
/** 인기 콘텐츠. */
@GetMapping("/top-content")
public ApiResponse<?> topContent() {
return ApiResponse.ok(analyticsMapper.topContent(5));
}
}

View File

@ -0,0 +1,18 @@
package com.zioinfo.cms.dashboard.mapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
import java.util.Map;
@Mapper
public interface DashboardMapper {
int countContentByStatus(@Param("status") String status);
int countTotalContent();
int countMedia();
int countPendingUgc();
int countMembers();
/** 워크플로우 단계별 콘텐츠 수. */
List<Map<String, Object>> contentByStatus();
}

View File

@ -0,0 +1,51 @@
package com.zioinfo.cms.delivery;
import com.zioinfo.cms.common.ApiResponse;
import com.zioinfo.cms.content.CmsContent;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/**
* 헤드리스 공개 콘텐츠 전송 API (delivery).
*
* <p>보안 불변 규칙: 공개(인증 불필요)이되 PUBLISHED 상태 콘텐츠만 노출한다.
* 미발행(DRAFT/REVIEW/APPROVED) 콘텐츠 프리뷰는 유효한 preview token 검증 시에만 허용한다.
* Mall/홈페이지 외부 채널이 API를 소비한다.
*/
@RestController
@RequestMapping("/api/cms/delivery")
@RequiredArgsConstructor
public class DeliveryController {
private final DeliveryService service;
/** 발행 콘텐츠 목록 (published만). */
@GetMapping("/content")
public ApiResponse<List<CmsContent>> list(
@RequestParam(required = false) String contentType,
@RequestParam(defaultValue = "ko") String locale) {
return ApiResponse.ok(service.listPublished(contentType, locale));
}
/** slug 단건 조회 (published만). */
@GetMapping("/content/{slug}")
public ApiResponse<CmsContent> bySlug(@PathVariable String slug,
@RequestParam(defaultValue = "ko") String locale) {
return ApiResponse.ok(service.getPublished(slug, locale));
}
/** 프리뷰 — 토큰 검증 후 미발행 콘텐츠도 조회 허용. */
@GetMapping("/preview/{id}")
public ApiResponse<CmsContent> preview(@PathVariable Long id, @RequestParam String token) {
return ApiResponse.ok(service.preview(id, token));
}
/** 프리뷰 토큰 발급 (인증 사용자 전용은 SecurityConfig 미적용 — POST는 작성권한 필요). */
@PostMapping("/preview-token/{id}")
public ApiResponse<Map<String, String>> issueToken(@PathVariable Long id) {
return ApiResponse.ok(Map.of("token", service.issuePreviewToken(id), "contentId", String.valueOf(id)));
}
}

View File

@ -0,0 +1,70 @@
package com.zioinfo.cms.delivery;
import com.zioinfo.cms.content.CmsContent;
import com.zioinfo.cms.content.mapper.ContentMapper;
import com.zioinfo.cms.common.CryptoUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* 헤드리스 delivery 서비스.
*
* <p>보안 불변 규칙: published 상태만 공개. 프리뷰는 HMAC 기반 토큰(contentId+만료) 검증.
* 토큰은 CryptoUtil(AES-GCM) 봉인 "contentId:expiryEpoch" 평문을 암호화한 .
*/
@Service
@RequiredArgsConstructor
public class DeliveryService {
private static final long PREVIEW_TTL_MS = 3600_000; // 1시간
private final ContentMapper contentMapper;
private final CryptoUtil crypto;
public List<CmsContent> listPublished(String contentType, String locale) {
return contentMapper.findAll(contentType, "PUBLISHED", locale, null);
}
public CmsContent getPublished(String slug, String locale) {
CmsContent c = contentMapper.findPublishedBySlug(slug, locale);
if (c == null) {
throw new RuntimeException("ERR-CMS-DELIVERY-404: 발행된 콘텐츠 없음");
}
return c;
}
public CmsContent preview(Long id, String token) {
if (!verifyToken(id, token)) {
throw new RuntimeException("ERR-CMS-DELIVERY-403: 유효하지 않거나 만료된 프리뷰 토큰");
}
CmsContent c = contentMapper.findById(id);
if (c == null) {
throw new RuntimeException("ERR-CMS-DELIVERY-404: 콘텐츠 없음");
}
return c;
}
public String issuePreviewToken(Long id) {
if (contentMapper.findById(id) == null) {
throw new RuntimeException("ERR-CMS-DELIVERY-404: 콘텐츠 없음");
}
long expiry = System.currentTimeMillis() + PREVIEW_TTL_MS;
return crypto.encrypt(id + ":" + expiry);
}
private boolean verifyToken(Long id, String token) {
try {
String plain = crypto.decrypt(token);
if (plain == null || plain.isBlank()) return false;
String[] parts = plain.split(":");
if (parts.length != 2) return false;
long tokenId = Long.parseLong(parts[0]);
long expiry = Long.parseLong(parts[1]);
return tokenId == id && expiry >= System.currentTimeMillis();
} catch (Exception e) {
return false;
}
}
}

View File

@ -0,0 +1,16 @@
package com.zioinfo.cms.form;
import lombok.Data;
import java.time.LocalDateTime;
/** 폼 정의 (cms_form) — 폼빌더. fields 는 필드 정의 배열(JSONB). */
@Data
public class CmsForm {
private Long id;
private String name;
private String slug;
private String fields; // JSONB: 필드 정의 배열
private boolean active;
private String createdBy;
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,15 @@
package com.zioinfo.cms.form;
import lombok.Data;
import java.time.LocalDateTime;
/** 폼 제출 (cms_form_submission). payload 는 제출 값(JSONB). */
@Data
public class CmsFormSubmission {
private Long id;
private Long formId;
private String payload; // JSONB: 제출 데이터
private boolean spam; // AI 스팸 판정
private Double spamRisk;
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,96 @@
package com.zioinfo.cms.form;
import com.zioinfo.cms.ai.AiService;
import com.zioinfo.cms.common.ApiResponse;
import com.zioinfo.cms.form.mapper.FormMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/**
* 폼빌더 API 정의 CRUD + 제출 수집(AI 스팸 필터).
*/
@RestController
@RequestMapping("/api/cms/form")
@RequiredArgsConstructor
public class FormController {
private final FormMapper mapper;
private final AiService aiService;
@GetMapping
public ApiResponse<List<CmsForm>> list() {
return ApiResponse.ok(mapper.findAll());
}
@GetMapping("/{id}")
public ApiResponse<CmsForm> get(@PathVariable Long id) {
CmsForm f = mapper.findById(id);
if (f == null) throw new RuntimeException("ERR-CMS-FORM-404: 폼 없음");
return ApiResponse.ok(f);
}
@PostMapping
public ApiResponse<CmsForm> create(@RequestBody Map<String, Object> req, Authentication auth) {
CmsForm f = bind(new CmsForm(), req);
f.setCreatedBy(auth != null ? auth.getName() : "system");
mapper.insert(f);
return ApiResponse.ok(mapper.findById(f.getId()));
}
@PutMapping("/{id}")
public ApiResponse<CmsForm> update(@PathVariable Long id, @RequestBody Map<String, Object> req) {
CmsForm f = mapper.findById(id);
if (f == null) throw new RuntimeException("ERR-CMS-FORM-404: 폼 없음");
bind(f, req);
mapper.update(f);
return ApiResponse.ok(mapper.findById(id));
}
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@PathVariable Long id) {
mapper.delete(id);
return ApiResponse.ok(null);
}
/** 폼 제출 — AI 스팸 판정 후 수집. */
@PostMapping("/{id}/submit")
public ApiResponse<CmsFormSubmission> submit(@PathVariable Long id, @RequestBody Map<String, Object> payload) {
if (mapper.findById(id) == null) throw new RuntimeException("ERR-CMS-FORM-404: 폼 없음");
CmsFormSubmission s = new CmsFormSubmission();
s.setFormId(id);
s.setPayload(jsonStr(payload));
Map<String, Object> mod = aiService.moderate(jsonStr(payload));
double risk = toDouble(mod.get("risk"));
s.setSpamRisk(risk);
s.setSpam(risk >= 0.7);
mapper.insertSubmission(s);
return ApiResponse.ok(s);
}
/** 제출 목록 조회 (기본 스팸 제외). */
@GetMapping("/{id}/submissions")
public ApiResponse<List<CmsFormSubmission>> submissions(@PathVariable Long id,
@RequestParam(defaultValue = "false") boolean includeSpam) {
return ApiResponse.ok(mapper.findSubmissions(id, includeSpam));
}
private CmsForm bind(CmsForm f, Map<String, Object> r) {
if (r.containsKey("name")) f.setName(str(r.get("name")));
if (r.containsKey("slug")) f.setSlug(str(r.get("slug")));
if (r.containsKey("fields")) f.setFields(jsonStr(r.get("fields")));
if (r.containsKey("active")) f.setActive(Boolean.parseBoolean(String.valueOf(r.get("active"))));
return f;
}
private String str(Object o) { return o == null ? null : String.valueOf(o); }
private double toDouble(Object o) { if (o == null) return 0; try { return Double.parseDouble(String.valueOf(o)); } catch (Exception e) { return 0; } }
private String jsonStr(Object o) {
if (o == null) return null;
if (o instanceof String s) return s;
try { return new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(o); } catch (Exception e) { return String.valueOf(o); }
}
}

View File

@ -0,0 +1,21 @@
package com.zioinfo.cms.form.mapper;
import com.zioinfo.cms.form.CmsForm;
import com.zioinfo.cms.form.CmsFormSubmission;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface FormMapper {
List<CmsForm> findAll();
CmsForm findById(@Param("id") Long id);
CmsForm findBySlug(@Param("slug") String slug);
int insert(CmsForm f);
int update(CmsForm f);
int delete(@Param("id") Long id);
int insertSubmission(CmsFormSubmission s);
List<CmsFormSubmission> findSubmissions(@Param("formId") Long formId, @Param("includeSpam") boolean includeSpam);
}

View File

@ -0,0 +1,14 @@
package com.zioinfo.cms.i18n;
import lombok.Data;
import java.time.LocalDateTime;
/** 번역 리소스 (cms_i18n) — locale/key/value. */
@Data
public class CmsI18n {
private Long id;
private String locale; // ko, en, ja ...
private String resourceKey;
private String value;
private LocalDateTime updatedAt;
}

View File

@ -0,0 +1,63 @@
package com.zioinfo.cms.i18n;
import com.zioinfo.cms.ai.AiService;
import com.zioinfo.cms.common.ApiResponse;
import com.zioinfo.cms.i18n.mapper.I18nMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/** 다국어/로케일 API — 번역 리소스 CRUD + AI 번역. */
@RestController
@RequestMapping("/api/cms/i18n")
@RequiredArgsConstructor
public class I18nController {
private final I18nMapper mapper;
private final AiService aiService;
@GetMapping("/locales")
public ApiResponse<List<String>> locales() {
return ApiResponse.ok(mapper.findLocales());
}
@GetMapping("/resources")
public ApiResponse<List<CmsI18n>> resources(@RequestParam(defaultValue = "ko") String locale) {
return ApiResponse.ok(mapper.findByLocale(locale));
}
@PostMapping("/resources")
public ApiResponse<Void> upsert(@RequestBody Map<String, String> req) {
mapper.upsert(req.getOrDefault("locale", "ko"), req.get("resourceKey"), req.get("value"));
return ApiResponse.ok(null);
}
@DeleteMapping("/resources/{id}")
public ApiResponse<Void> delete(@PathVariable Long id) {
mapper.delete(id);
return ApiResponse.ok(null);
}
/** AI 번역 — 텍스트를 대상 로케일로. */
@PostMapping("/translate")
public ApiResponse<Map<String, String>> translate(@RequestBody Map<String, String> req) {
String out = aiService.translate(req.get("text"), req.getOrDefault("targetLocale", "en"));
return ApiResponse.ok(Map.of("translated", out));
}
/** ko 리소스를 대상 로케일로 일괄 AI 번역 후 저장. */
@PostMapping("/translate-batch")
public ApiResponse<Map<String, Object>> translateBatch(@RequestBody Map<String, String> req) {
String target = req.getOrDefault("targetLocale", "en");
List<CmsI18n> source = mapper.findByLocale(req.getOrDefault("sourceLocale", "ko"));
int count = 0;
for (CmsI18n s : source) {
String translated = aiService.translate(s.getValue(), target);
mapper.upsert(target, s.getResourceKey(), translated);
count++;
}
return ApiResponse.ok(Map.of("translated", count, "targetLocale", target));
}
}

View File

@ -0,0 +1,16 @@
package com.zioinfo.cms.i18n.mapper;
import com.zioinfo.cms.i18n.CmsI18n;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface I18nMapper {
List<CmsI18n> findByLocale(@Param("locale") String locale);
List<String> findLocales();
CmsI18n findById(@Param("id") Long id);
int upsert(@Param("locale") String locale, @Param("resourceKey") String resourceKey, @Param("value") String value);
int delete(@Param("id") Long id);
}

View File

@ -0,0 +1,28 @@
package com.zioinfo.cms.integration;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpMethod;
import org.springframework.stereotype.Component;
import java.util.Map;
/**
* GUARDiA CRM 연계 회원 = 고객 인사이트.
*
* <p>CMS 회원의 CRM 고객 프로필을 조회해 개인화 콘텐츠/추천 세그먼트 보조에 활용한다.
*/
@Component
@RequiredArgsConstructor
public class CrmClient {
@Value("${guardia.crm-url:http://localhost:8004}")
private String crmUrl;
private final GuardiaHttpClient http;
/** CRM 고객 인사이트 조회 (이메일 키). 실패 시 빈 Map. */
public Map<String, Object> getCustomerInsight(String email) {
return http.callMap(crmUrl, "/api/crm/customers/insight?email=" + email, HttpMethod.GET, null);
}
}

View File

@ -0,0 +1,68 @@
package com.zioinfo.cms.integration;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpMethod;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;
import java.time.Duration;
import java.util.Collections;
import java.util.List;
import java.util.Map;
/**
* GUARDiA 연계 솔루션(ITSM/Mall/CRM/OCR/BI) 공용 HTTP 클라이언트.
*
* <p>모든 응답은 {@link ItsmSecuritySanitizer#clean(Object)} 자격증명을 제거한 반환한다.
* 연계 실패 Map/List 반환(스택트레이스 미노출, 요약 로그만) 폴백을 호출 측이 수행한다.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class GuardiaHttpClient {
private final WebClient.Builder webClientBuilder;
@SuppressWarnings("unchecked")
public Map<String, Object> callMap(String baseUrl, String path, HttpMethod method, Object body) {
try {
WebClient.RequestBodySpec spec = webClientBuilder.baseUrl(baseUrl).build().method(method).uri(path);
WebClient.RequestHeadersSpec<?> headersSpec =
(body != null && method != HttpMethod.GET) ? spec.bodyValue(body) : spec;
Map<String, Object> result = headersSpec.retrieve()
.bodyToMono(Map.class)
.timeout(Duration.ofSeconds(10))
.map(m -> (Map<String, Object>) m)
.block();
if (result == null) {
return Collections.emptyMap();
}
return (Map<String, Object>) ItsmSecuritySanitizer.clean(result);
} catch (Exception e) {
log.warn("GUARDiA 연계 일시 실패 [{} {}{}]: {}", method, baseUrl, path, e.getMessage());
return Collections.emptyMap();
}
}
@SuppressWarnings("unchecked")
public List<Object> callList(String baseUrl, String path, HttpMethod method, Object body) {
try {
WebClient.RequestBodySpec spec = webClientBuilder.baseUrl(baseUrl).build().method(method).uri(path);
WebClient.RequestHeadersSpec<?> headersSpec =
(body != null && method != HttpMethod.GET) ? spec.bodyValue(body) : spec;
List<Object> result = headersSpec.retrieve()
.bodyToMono(List.class)
.timeout(Duration.ofSeconds(10))
.map(l -> (List<Object>) l)
.block();
if (result == null) {
return Collections.emptyList();
}
return (List<Object>) ItsmSecuritySanitizer.clean(result);
} catch (Exception e) {
log.warn("GUARDiA 연계 일시 실패 [{} {}{}]: {}", method, baseUrl, path, e.getMessage());
return Collections.emptyList();
}
}
}

View File

@ -0,0 +1,52 @@
package com.zioinfo.cms.integration;
import com.zioinfo.cms.common.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/**
* GUARDiA 연계 조회 API.
*
* <p>모든 응답은 GuardiaHttpClient가 자격증명을 새니타이즈한 결과다.
* 연계 미가용 결과를 반환(폴백) 호출 UI는 비어있는 상태로 정상 동작.
*/
@RestController
@RequestMapping("/api/cms/integration")
@RequiredArgsConstructor
public class IntegrationController {
private final ItsmClient itsmClient;
private final MallClient mallClient;
private final OcrClient ocrClient;
private final CrmClient crmClient;
/** 연계 솔루션 헬스 상태 요약. */
@GetMapping("/status")
public ApiResponse<Map<String, Object>> status() {
return ApiResponse.ok(Map.of(
"itsm", itsmClient.available(),
"note", "Mall/OCR/CRM 연계는 호출 시 폴백 동작"
));
}
/** Mall 상품 카탈로그 (상품-콘텐츠 매핑 보조). */
@GetMapping("/mall/products")
public ApiResponse<List<Object>> mallProducts() {
return ApiResponse.ok(mallClient.listProducts());
}
/** OCR 추출 결과 (콘텐츠 초안 보조). */
@GetMapping("/ocr/{documentId}")
public ApiResponse<Map<String, Object>> ocrExtraction(@PathVariable String documentId) {
return ApiResponse.ok(ocrClient.getExtraction(documentId));
}
/** CRM 고객 인사이트 (개인화 보조). */
@GetMapping("/crm/insight")
public ApiResponse<Map<String, Object>> crmInsight(@RequestParam String email) {
return ApiResponse.ok(crmClient.getCustomerInsight(email));
}
}

View File

@ -0,0 +1,45 @@
package com.zioinfo.cms.integration;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpMethod;
import org.springframework.stereotype.Component;
import java.util.Map;
/**
* GUARDiA ITSM 연계 UGC 문의(inquiry) ITSM SR로 자동 생성한다.
*
* <p>응답은 {@link GuardiaHttpClient} 자격증명을 새니타이즈한다.
*/
@Component
@RequiredArgsConstructor
public class ItsmClient {
@Value("${guardia.itsm-url:http://localhost:9001}")
private String itsmUrl;
private final GuardiaHttpClient http;
/**
* UGC 문의 ITSM SR 생성.
*
* @return 생성된 SR ID(연계 실패 null)
*/
public String createSr(String title, String content, String priority) {
Map<String, Object> payload = Map.of(
"title", title,
"content", content,
"priority", priority == null ? "MEDIUM" : priority,
"source", "CMS"
);
Map<String, Object> result = http.callMap(itsmUrl, "/api/tasks", HttpMethod.POST, payload);
Object id = result.getOrDefault("sr_id", result.get("id"));
return id != null ? String.valueOf(id) : null;
}
public boolean available() {
Map<String, Object> r = http.callMap(itsmUrl, "/actuator/health", HttpMethod.GET, null);
return !r.isEmpty();
}
}

View File

@ -0,0 +1,62 @@
package com.zioinfo.cms.integration;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* GUARDiA 연계 응답 보안 새니타이저.
*
* <p>보안 불변 규칙: 서버 자격증명(IP/SSH 계정/비밀번호 ) API 응답에 절대 노출하지 않는다.
* ITSM/Mall/CRM/OCR/BI 연계 응답을 CMS로 반환하기 반드시 {@link #clean(Object)} 호출한다.
*
* <p>Map/List 구조를 재귀적으로 순회하며 민감 필드를 제거한다.
*/
public final class ItsmSecuritySanitizer {
private static final Set<String> SENSITIVE_KEYS = Set.of(
"ip_addr", "ipaddr", "ssh_user", "sshuser", "os_pw_enc", "ospwenc",
"password", "password_enc", "passwordenc", "ssh_key", "sshkey",
"secret", "token", "private_key", "credential"
);
private ItsmSecuritySanitizer() {
}
/**
* 응답 데이터에서 민감 필드를 재귀 제거한다.
*
* @param data Map(필터링), List( 원소 적용), (그대로 반환)
* @return 민감 필드가 제거된 데이터
*/
public static Object clean(Object data) {
if (data instanceof Map<?, ?> map) {
Map<String, Object> cleaned = new LinkedHashMap<>();
for (Map.Entry<?, ?> entry : map.entrySet()) {
String key = String.valueOf(entry.getKey());
if (isSensitive(key)) {
continue;
}
cleaned.put(key, clean(entry.getValue()));
}
return cleaned;
}
if (data instanceof List<?> list) {
List<Object> cleaned = new ArrayList<>(list.size());
for (Object item : list) {
cleaned.add(clean(item));
}
return cleaned;
}
return data;
}
private static boolean isSensitive(String key) {
if (key == null) {
return false;
}
return SENSITIVE_KEYS.contains(key.toLowerCase());
}
}

View File

@ -0,0 +1,35 @@
package com.zioinfo.cms.integration;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpMethod;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Map;
/**
* GUARDiA Mall 연계 상품 콘텐츠 양방향.
*
* <p>CMS가 상품 상세 콘텐츠를 관리하고, Mall은 CMS delivery API를 소비한다.
* 클라이언트는 Mall 상품 카탈로그를 조회해 상품-콘텐츠 매핑 보조에 사용한다.
*/
@Component
@RequiredArgsConstructor
public class MallClient {
@Value("${guardia.mall-url:http://localhost:8011}")
private String mallUrl;
private final GuardiaHttpClient http;
/** Mall 상품 목록 조회 (자격증명 제거 후 반환). 실패 시 빈 List. */
public List<Object> listProducts() {
return http.callList(mallUrl, "/api/mall/products", HttpMethod.GET, null);
}
/** Mall 상품 단건 조회. */
public Map<String, Object> getProduct(String mallProductId) {
return http.callMap(mallUrl, "/api/mall/products/" + mallProductId, HttpMethod.GET, null);
}
}

View File

@ -0,0 +1,30 @@
package com.zioinfo.cms.integration;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpMethod;
import org.springframework.stereotype.Component;
import java.util.Map;
/**
* GUARDiA OCR 연계 이미지/문서 상품정보·콘텐츠 자동 추출 보조.
*/
@Component
@RequiredArgsConstructor
public class OcrClient {
@Value("${guardia.ocr-url:http://localhost:8005}")
private String ocrUrl;
private final GuardiaHttpClient http;
/**
* 문서 ID로 OCR 추출 결과를 조회한다(상품 콘텐츠 초안 작성 보조).
*
* @return 추출 텍스트/필드 Map, 실패 Map
*/
public Map<String, Object> getExtraction(String documentId) {
return http.callMap(ocrUrl, "/api/ocr/documents/" + documentId, HttpMethod.GET, null);
}
}

View File

@ -0,0 +1,21 @@
package com.zioinfo.cms.media;
import lombok.Data;
import java.time.LocalDateTime;
/** 미디어 라이브러리 자산 (cms_media). */
@Data
public class CmsMedia {
private Long id;
private String fileName;
private String mediaType; // IMAGE, VIDEO, FILE
private String url; // 저장 경로/URL
private String folder; // 폴더 분류
private String mimeType;
private Long sizeBytes;
private String altText; // 대체텍스트 (AI 자동생성 가능)
private String tags; // 쉼표구분 (AI 태깅 가능)
private Integer refCount; // 참조 추적
private String createdBy;
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,52 @@
package com.zioinfo.cms.media;
import com.zioinfo.cms.common.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/cms/media")
@RequiredArgsConstructor
public class MediaController {
private final MediaService service;
@GetMapping
public ApiResponse<List<CmsMedia>> list(@RequestParam(required = false) String folder,
@RequestParam(required = false) String mediaType,
@RequestParam(required = false) String keyword) {
return ApiResponse.ok(service.list(folder, mediaType, keyword));
}
@GetMapping("/{id}")
public ApiResponse<CmsMedia> get(@PathVariable Long id) {
return ApiResponse.ok(service.get(id));
}
@PostMapping
public ApiResponse<CmsMedia> register(@RequestBody Map<String, Object> req, Authentication auth) {
return ApiResponse.ok(service.register(req, auth != null ? auth.getName() : "system"));
}
/** AI 대체텍스트/태깅 자동생성. */
@PostMapping("/{id}/auto-tag")
public ApiResponse<CmsMedia> autoTag(@PathVariable Long id, @RequestBody(required = false) Map<String, String> req) {
String img = req == null ? null : req.get("imageBase64");
return ApiResponse.ok(service.autoTag(id, img));
}
@PutMapping("/{id}/meta")
public ApiResponse<CmsMedia> updateMeta(@PathVariable Long id, @RequestBody Map<String, String> req) {
return ApiResponse.ok(service.updateMeta(id, req.get("altText"), req.get("tags")));
}
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@PathVariable Long id) {
service.delete(id);
return ApiResponse.ok(null);
}
}

View File

@ -0,0 +1,81 @@
package com.zioinfo.cms.media;
import com.zioinfo.cms.admin.AuditService;
import com.zioinfo.cms.ai.AiService;
import com.zioinfo.cms.media.mapper.MediaMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
/**
* 미디어 라이브러리 서비스 메타 등록 + AI 대체텍스트/태깅(llava 폴백).
*/
@Service
@RequiredArgsConstructor
public class MediaService {
private final MediaMapper mapper;
private final AiService aiService;
private final AuditService auditService;
public List<CmsMedia> list(String folder, String mediaType, String keyword) {
return mapper.findAll(folder, mediaType, keyword);
}
public CmsMedia get(Long id) {
CmsMedia m = mapper.findById(id);
if (m == null) throw new RuntimeException("ERR-CMS-MEDIA-404: 미디어 없음");
return m;
}
/** 미디어 메타 등록 (업로드 자체는 url 전달 — 실 파일저장은 프론트/스토리지 책임). */
public CmsMedia register(Map<String, Object> req, String actor) {
CmsMedia m = new CmsMedia();
m.setFileName(str(req.get("fileName")));
m.setMediaType(req.get("mediaType") == null ? "IMAGE" : str(req.get("mediaType")));
m.setUrl(str(req.get("url")));
m.setFolder(req.get("folder") == null ? "default" : str(req.get("folder")));
m.setMimeType(str(req.get("mimeType")));
m.setSizeBytes(longOf(req.get("sizeBytes")));
m.setAltText(str(req.get("altText")));
m.setTags(str(req.get("tags")));
m.setRefCount(0);
m.setCreatedBy(actor);
mapper.insert(m);
auditService.log("MEDIA_REGISTER", m.getFileName(), m.getMediaType());
return mapper.findById(m.getId());
}
/** AI 대체텍스트/태깅 (base64 이미지 또는 파일명 폴백). */
public CmsMedia autoTag(Long id, String imageBase64) {
CmsMedia m = get(id);
Map<String, Object> ai = aiService.imageTags(imageBase64, m.getFileName());
String alt = String.valueOf(ai.getOrDefault("altText", m.getAltText()));
Object tagsObj = ai.get("tags");
String tags = tagsObj instanceof List<?> l ? String.join(",", l.stream().map(String::valueOf).toList())
: String.valueOf(tagsObj);
mapper.updateMeta(id, alt, tags);
auditService.log("MEDIA_AUTOTAG", m.getFileName(), "source=" + ai.getOrDefault("source", "?"));
return mapper.findById(id);
}
public CmsMedia updateMeta(Long id, String altText, String tags) {
get(id);
mapper.updateMeta(id, altText, tags);
return mapper.findById(id);
}
public void delete(Long id) {
CmsMedia m = get(id);
mapper.delete(id);
auditService.log("MEDIA_DELETE", m.getFileName(), null);
}
private String str(Object o) { return o == null ? null : String.valueOf(o); }
private Long longOf(Object o) {
if (o == null) return null;
try { return Long.valueOf(String.valueOf(o).trim()); } catch (Exception e) { return null; }
}
}

View File

@ -0,0 +1,17 @@
package com.zioinfo.cms.media.mapper;
import com.zioinfo.cms.media.CmsMedia;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface MediaMapper {
List<CmsMedia> findAll(@Param("folder") String folder, @Param("mediaType") String mediaType,
@Param("keyword") String keyword);
CmsMedia findById(@Param("id") Long id);
int insert(CmsMedia m);
int updateMeta(@Param("id") Long id, @Param("altText") String altText, @Param("tags") String tags);
int delete(@Param("id") Long id);
}

View File

@ -0,0 +1,22 @@
package com.zioinfo.cms.member;
import lombok.Data;
import java.time.LocalDateTime;
/**
* CMS 회원 (cms_member) 사이트 방문/구매 회원.
*
* <p>보안: email/phone *_enc 컬럼에 AES 암호화 저장, 응답은 마스킹.
*/
@Data
public class CmsMember {
private Long id;
private String memberCode;
private String name;
private String emailEnc; // 암호화 저장 응답 마스킹
private String phoneEnc; // 암호화 저장 응답 마스킹
private String segment; // VIP, REGULAR, NEW ...
private String status; // ACTIVE, DORMANT, WITHDRAWN
private String crmCustomerId; // CRM 연계 ID
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,105 @@
package com.zioinfo.cms.member;
import com.zioinfo.cms.common.ApiResponse;
import com.zioinfo.cms.common.CryptoUtil;
import com.zioinfo.cms.integration.CrmClient;
import com.zioinfo.cms.member.mapper.MemberMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/**
* 회원 관리 API.
*
* <p>보안 불변 규칙: email/phone PII는 CryptoUtil 암호화 저장, 모든 응답은 마스킹.
* CRM 연계로 고객 인사이트 조회.
*/
@RestController
@RequestMapping("/api/cms/member")
@RequiredArgsConstructor
public class MemberController {
private final MemberMapper mapper;
private final CryptoUtil crypto;
private final CrmClient crmClient;
@GetMapping
public ApiResponse<List<CmsMember>> list(@RequestParam(required = false) String segment,
@RequestParam(required = false) String status,
@RequestParam(required = false) String keyword) {
List<CmsMember> all = mapper.findAll(segment, status, keyword);
all.forEach(this::mask);
return ApiResponse.ok(all);
}
@GetMapping("/{id}")
public ApiResponse<CmsMember> get(@PathVariable Long id) {
return ApiResponse.ok(mask(require(id)));
}
@PostMapping
public ApiResponse<CmsMember> create(@RequestBody Map<String, Object> req) {
CmsMember m = new CmsMember();
m.setMemberCode(str(req.get("memberCode")));
m.setName(str(req.get("name")));
m.setEmailEnc(encOrNull(str(req.get("email"))));
m.setPhoneEnc(encOrNull(str(req.get("phone"))));
m.setSegment(req.get("segment") == null ? "NEW" : str(req.get("segment")));
m.setStatus(req.get("status") == null ? "ACTIVE" : str(req.get("status")));
m.setCrmCustomerId(str(req.get("crmCustomerId")));
mapper.insert(m);
return ApiResponse.ok(mask(mapper.findById(m.getId())));
}
@PutMapping("/{id}")
public ApiResponse<CmsMember> update(@PathVariable Long id, @RequestBody Map<String, Object> req) {
CmsMember m = require(id);
if (req.containsKey("name")) m.setName(str(req.get("name")));
if (req.containsKey("email")) m.setEmailEnc(encOrNull(str(req.get("email"))));
if (req.containsKey("phone")) m.setPhoneEnc(encOrNull(str(req.get("phone"))));
if (req.containsKey("segment")) m.setSegment(str(req.get("segment")));
if (req.containsKey("status")) m.setStatus(str(req.get("status")));
if (req.containsKey("crmCustomerId")) m.setCrmCustomerId(str(req.get("crmCustomerId")));
mapper.update(m);
return ApiResponse.ok(mask(mapper.findById(id)));
}
/** CRM 고객 인사이트 (이메일 평문은 서버 내부에서만 사용, 응답 노출 없음). */
@GetMapping("/{id}/crm-insight")
public ApiResponse<Map<String, Object>> crmInsight(@PathVariable Long id) {
CmsMember m = require(id);
String email = m.getEmailEnc() == null ? null : crypto.decrypt(m.getEmailEnc());
if (email == null || email.isBlank()) {
return ApiResponse.ok(Map.of("note", "이메일 미등록 — 인사이트 조회 불가"));
}
return ApiResponse.ok(crmClient.getCustomerInsight(email));
}
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@PathVariable Long id) {
mapper.delete(id);
return ApiResponse.ok(null);
}
// helpers
private CmsMember require(Long id) {
CmsMember m = mapper.findById(id);
if (m == null) throw new RuntimeException("ERR-CMS-MEMBER-404: 회원 없음");
return m;
}
/** 응답용 마스킹 — emailEnc/phoneEnc 를 복호화→마스킹 후 같은 필드에 담아 노출. */
private CmsMember mask(CmsMember m) {
if (m.getEmailEnc() != null) m.setEmailEnc(CryptoUtil.mask(crypto.decrypt(m.getEmailEnc())));
if (m.getPhoneEnc() != null) m.setPhoneEnc(CryptoUtil.mask(crypto.decrypt(m.getPhoneEnc())));
return m;
}
private String encOrNull(String plain) {
return (plain == null || plain.isBlank()) ? null : crypto.encrypt(plain);
}
private String str(Object o) { return o == null ? null : String.valueOf(o); }
}

View File

@ -0,0 +1,17 @@
package com.zioinfo.cms.member.mapper;
import com.zioinfo.cms.member.CmsMember;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface MemberMapper {
List<CmsMember> findAll(@Param("segment") String segment, @Param("status") String status,
@Param("keyword") String keyword);
CmsMember findById(@Param("id") Long id);
int insert(CmsMember m);
int update(CmsMember m);
int delete(@Param("id") Long id);
}

View File

@ -0,0 +1,24 @@
package com.zioinfo.cms.menu;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/** 메뉴/카테고리 (cms_menu) — 트리 구조. */
@Data
public class CmsMenu {
private Long id;
private Long parentId;
private String name;
private String menuType; // MENU, CATEGORY
private String url; // 링크(선택)
private String slug;
private Integer sortOrder;
private Integer depth;
private boolean active;
private LocalDateTime createdAt;
// 트리 조립용 (DB 매핑 아님)
private transient List<CmsMenu> children = new ArrayList<>();
}

View File

@ -0,0 +1,53 @@
package com.zioinfo.cms.menu;
import com.zioinfo.cms.common.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/cms/menu")
@RequiredArgsConstructor
public class MenuController {
private final MenuService service;
@GetMapping
public ApiResponse<List<CmsMenu>> list(@RequestParam(required = false) String menuType) {
return ApiResponse.ok(service.listFlat(menuType));
}
@GetMapping("/tree")
public ApiResponse<List<CmsMenu>> tree(@RequestParam(required = false) String menuType) {
return ApiResponse.ok(service.tree(menuType));
}
@GetMapping("/{id}")
public ApiResponse<CmsMenu> get(@PathVariable Long id) {
return ApiResponse.ok(service.get(id));
}
@PostMapping
public ApiResponse<CmsMenu> create(@RequestBody Map<String, Object> req) {
return ApiResponse.ok(service.create(req));
}
@PutMapping("/{id}")
public ApiResponse<CmsMenu> update(@PathVariable Long id, @RequestBody Map<String, Object> req) {
return ApiResponse.ok(service.update(id, req));
}
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@PathVariable Long id) {
service.delete(id);
return ApiResponse.ok(null);
}
/** 사이트맵 XML 생성. */
@GetMapping("/sitemap")
public ApiResponse<Map<String, String>> sitemap(@RequestParam(defaultValue = "https://cms.zioinfo.co.kr") String baseUrl) {
return ApiResponse.ok(Map.of("xml", service.sitemapXml(baseUrl)));
}
}

View File

@ -0,0 +1,107 @@
package com.zioinfo.cms.menu;
import com.zioinfo.cms.content.CmsContent;
import com.zioinfo.cms.content.mapper.ContentMapper;
import com.zioinfo.cms.menu.mapper.MenuMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/** 메뉴/카테고리 서비스 — 트리 조립 + 사이트맵 생성. */
@Service
@RequiredArgsConstructor
public class MenuService {
private final MenuMapper mapper;
private final ContentMapper contentMapper;
public List<CmsMenu> listFlat(String menuType) {
return mapper.findAll(menuType);
}
/** 트리 형태로 조립. */
public List<CmsMenu> tree(String menuType) {
List<CmsMenu> all = mapper.findAll(menuType);
Map<Long, CmsMenu> byId = new LinkedHashMap<>();
for (CmsMenu m : all) { m.setChildren(new ArrayList<>()); byId.put(m.getId(), m); }
List<CmsMenu> roots = new ArrayList<>();
for (CmsMenu m : all) {
if (m.getParentId() != null && byId.containsKey(m.getParentId())) {
byId.get(m.getParentId()).getChildren().add(m);
} else {
roots.add(m);
}
}
return roots;
}
public CmsMenu get(Long id) {
CmsMenu m = mapper.findById(id);
if (m == null) throw new RuntimeException("ERR-CMS-MENU-404: 메뉴 없음");
return m;
}
public CmsMenu create(Map<String, Object> req) {
CmsMenu m = bind(new CmsMenu(), req);
if (m.getParentId() != null) {
CmsMenu parent = mapper.findById(m.getParentId());
m.setDepth(parent == null ? 0 : (parent.getDepth() == null ? 0 : parent.getDepth()) + 1);
} else {
m.setDepth(0);
}
mapper.insert(m);
return mapper.findById(m.getId());
}
public CmsMenu update(Long id, Map<String, Object> req) {
CmsMenu m = get(id);
bind(m, req);
mapper.update(m);
return mapper.findById(id);
}
public void delete(Long id) {
if (mapper.countChildren(id) > 0) {
throw new RuntimeException("ERR-CMS-MENU-409: 하위 메뉴가 있어 삭제 불가");
}
mapper.delete(id);
}
/** 사이트맵 생성 — 발행 콘텐츠 slug 기반 XML. */
public String sitemapXml(String baseUrl) {
List<CmsContent> published = contentMapper.findAll(null, "PUBLISHED", null, null);
StringBuilder sb = new StringBuilder();
sb.append("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
sb.append("<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n");
String base = baseUrl == null ? "" : baseUrl.replaceAll("/+$", "");
for (CmsContent c : published) {
sb.append(" <url><loc>").append(base).append("/").append(c.getSlug()).append("</loc>");
if (c.getPublishedAt() != null) {
sb.append("<lastmod>").append(c.getPublishedAt().toLocalDate()).append("</lastmod>");
}
sb.append("</url>\n");
}
sb.append("</urlset>");
return sb.toString();
}
private CmsMenu bind(CmsMenu m, Map<String, Object> r) {
if (r.containsKey("parentId")) m.setParentId(longOf(r.get("parentId")));
if (r.containsKey("name")) m.setName(str(r.get("name")));
if (r.containsKey("menuType")) m.setMenuType(str(r.get("menuType")));
if (r.containsKey("url")) m.setUrl(str(r.get("url")));
if (r.containsKey("slug")) m.setSlug(str(r.get("slug")));
if (r.containsKey("sortOrder")) m.setSortOrder(intOf(r.get("sortOrder")));
if (r.containsKey("active")) m.setActive(Boolean.parseBoolean(String.valueOf(r.get("active"))));
if (m.getMenuType() == null) m.setMenuType("MENU");
return m;
}
private String str(Object o) { return o == null ? null : String.valueOf(o); }
private Long longOf(Object o) { if (o == null || String.valueOf(o).isBlank()) return null; try { return Long.valueOf(String.valueOf(o).trim()); } catch (Exception e) { return null; } }
private Integer intOf(Object o) { if (o == null) return null; try { return Integer.valueOf(String.valueOf(o).trim()); } catch (Exception e) { return null; } }
}

View File

@ -0,0 +1,17 @@
package com.zioinfo.cms.menu.mapper;
import com.zioinfo.cms.menu.CmsMenu;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface MenuMapper {
List<CmsMenu> findAll(@Param("menuType") String menuType);
CmsMenu findById(@Param("id") Long id);
int insert(CmsMenu m);
int update(CmsMenu m);
int delete(@Param("id") Long id);
int countChildren(@Param("parentId") Long parentId);
}

View File

@ -0,0 +1,26 @@
package com.zioinfo.cms.product;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 상품 콘텐츠 (cms_product_content) 상품 상세페이지 빌더.
*
* <p>mallProductId Mall 상품과 매핑. detailBlocks 상세 빌더 블록(JSONB).
* Mall은 delivery API로 published 상품 상세를 소비한다.
*/
@Data
public class CmsProductContent {
private Long id;
private String mallProductId; // Mall 상품 ID 매핑
private String productName;
private String summary;
private String detailBlocks; // JSONB: 상세 빌더 블록
private String specs; // JSONB: 사양
private String locale;
private String status; // DRAFT, PUBLISHED
private String createdBy;
private LocalDateTime publishedAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@ -0,0 +1,107 @@
package com.zioinfo.cms.product;
import com.zioinfo.cms.admin.AuditService;
import com.zioinfo.cms.ai.AiService;
import com.zioinfo.cms.common.ApiResponse;
import com.zioinfo.cms.integration.OcrClient;
import com.zioinfo.cms.product.mapper.ProductContentMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
/**
* 상품 콘텐츠/상세빌더 API.
*
* <p>AI 상품설명 초안 + OCR 문서상품정보 추출 보조. Mall은 delivery로 published 소비.
*/
@RestController
@RequestMapping("/api/cms/product")
@RequiredArgsConstructor
public class ProductController {
private final ProductContentMapper mapper;
private final AiService aiService;
private final OcrClient ocrClient;
private final AuditService auditService;
@GetMapping
public ApiResponse<List<CmsProductContent>> list(@RequestParam(required = false) String status,
@RequestParam(required = false) String keyword) {
return ApiResponse.ok(mapper.findAll(status, keyword));
}
@GetMapping("/{id}")
public ApiResponse<CmsProductContent> get(@PathVariable Long id) {
CmsProductContent p = mapper.findById(id);
if (p == null) throw new RuntimeException("ERR-CMS-PRODUCT-404: 상품 콘텐츠 없음");
return ApiResponse.ok(p);
}
@PostMapping
public ApiResponse<CmsProductContent> create(@RequestBody Map<String, Object> req, Authentication auth) {
CmsProductContent p = bind(new CmsProductContent(), req);
p.setStatus("DRAFT");
p.setCreatedBy(auth != null ? auth.getName() : "system");
mapper.insert(p);
auditService.log("PRODUCT_CREATE", p.getProductName(), "mallId=" + p.getMallProductId());
return ApiResponse.ok(mapper.findById(p.getId()));
}
@PutMapping("/{id}")
public ApiResponse<CmsProductContent> update(@PathVariable Long id, @RequestBody Map<String, Object> req) {
CmsProductContent p = mapper.findById(id);
if (p == null) throw new RuntimeException("ERR-CMS-PRODUCT-404: 상품 콘텐츠 없음");
bind(p, req);
mapper.update(p);
return ApiResponse.ok(mapper.findById(id));
}
/** 발행. */
@PostMapping("/{id}/publish")
public ApiResponse<CmsProductContent> publish(@PathVariable Long id) {
if (mapper.findById(id) == null) throw new RuntimeException("ERR-CMS-PRODUCT-404: 상품 콘텐츠 없음");
mapper.updateStatus(id, "PUBLISHED");
auditService.log("PRODUCT_PUBLISH", String.valueOf(id), null);
return ApiResponse.ok(mapper.findById(id));
}
/** AI 상품 설명 초안. */
@PostMapping("/ai-draft")
public ApiResponse<Map<String, String>> aiDraft(@RequestBody Map<String, String> req) {
return ApiResponse.ok(Map.of("draft", aiService.draft("product", req.get("topic"), req.get("tone"))));
}
/** OCR 문서→상품정보 추출 보조. */
@GetMapping("/ocr-extract/{documentId}")
public ApiResponse<Map<String, Object>> ocrExtract(@PathVariable String documentId) {
return ApiResponse.ok(ocrClient.getExtraction(documentId));
}
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@PathVariable Long id) {
mapper.delete(id);
return ApiResponse.ok(null);
}
private CmsProductContent bind(CmsProductContent p, Map<String, Object> r) {
if (r.containsKey("mallProductId")) p.setMallProductId(str(r.get("mallProductId")));
if (r.containsKey("productName")) p.setProductName(str(r.get("productName")));
if (r.containsKey("summary")) p.setSummary(str(r.get("summary")));
if (r.containsKey("detailBlocks")) p.setDetailBlocks(jsonStr(r.get("detailBlocks")));
if (r.containsKey("specs")) p.setSpecs(jsonStr(r.get("specs")));
if (r.containsKey("locale")) p.setLocale(str(r.get("locale")));
if (p.getLocale() == null) p.setLocale("ko");
return p;
}
private String str(Object o) { return o == null ? null : String.valueOf(o); }
private String jsonStr(Object o) {
if (o == null) return null;
if (o instanceof String s) return s;
try { return new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(o); } catch (Exception e) { return String.valueOf(o); }
}
}

View File

@ -0,0 +1,18 @@
package com.zioinfo.cms.product.mapper;
import com.zioinfo.cms.product.CmsProductContent;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface ProductContentMapper {
List<CmsProductContent> findAll(@Param("status") String status, @Param("keyword") String keyword);
CmsProductContent findById(@Param("id") Long id);
CmsProductContent findPublishedByMallId(@Param("mallProductId") String mallProductId, @Param("locale") String locale);
int insert(CmsProductContent p);
int update(CmsProductContent p);
int updateStatus(@Param("id") Long id, @Param("status") String status);
int delete(@Param("id") Long id);
}

View File

@ -0,0 +1,15 @@
package com.zioinfo.cms.seo;
import lombok.Data;
import java.time.LocalDateTime;
/** 리다이렉트 규칙 (cms_redirect). */
@Data
public class CmsRedirect {
private Long id;
private String fromPath;
private String toPath;
private Integer statusCode; // 301, 302
private boolean active;
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,19 @@
package com.zioinfo.cms.seo;
import lombok.Data;
import java.time.LocalDateTime;
/** SEO 메타데이터 (cms_seo_meta) — 경로별 메타/OG/canonical. */
@Data
public class CmsSeoMeta {
private Long id;
private String pathKey; // 적용 경로(slug 또는 URL path)
private String title;
private String description;
private String keywords;
private String ogTitle;
private String ogImage;
private String canonical;
private String locale;
private LocalDateTime updatedAt;
}

View File

@ -0,0 +1,108 @@
package com.zioinfo.cms.seo;
import com.zioinfo.cms.ai.AiService;
import com.zioinfo.cms.common.ApiResponse;
import com.zioinfo.cms.seo.mapper.SeoMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/**
* SEO API 메타데이터/OG/canonical, 리다이렉트, robots.txt, AI SEO 제안.
*/
@RestController
@RequestMapping("/api/cms/seo")
@RequiredArgsConstructor
public class SeoController {
private final SeoMapper mapper;
private final AiService aiService;
// 메타데이터
@GetMapping("/meta")
public ApiResponse<List<CmsSeoMeta>> listMeta() {
return ApiResponse.ok(mapper.findAllMeta());
}
@GetMapping("/meta/by-path")
public ApiResponse<CmsSeoMeta> byPath(@RequestParam String pathKey,
@RequestParam(defaultValue = "ko") String locale) {
return ApiResponse.ok(mapper.findMetaByPath(pathKey, locale));
}
@PostMapping("/meta")
public ApiResponse<CmsSeoMeta> createMeta(@RequestBody Map<String, Object> req) {
CmsSeoMeta m = bind(new CmsSeoMeta(), req);
mapper.insertMeta(m);
return ApiResponse.ok(mapper.findMetaById(m.getId()));
}
@PutMapping("/meta/{id}")
public ApiResponse<CmsSeoMeta> updateMeta(@PathVariable Long id, @RequestBody Map<String, Object> req) {
CmsSeoMeta m = mapper.findMetaById(id);
if (m == null) throw new RuntimeException("ERR-CMS-SEO-404: 메타 없음");
bind(m, req);
mapper.updateMeta(m);
return ApiResponse.ok(mapper.findMetaById(id));
}
@DeleteMapping("/meta/{id}")
public ApiResponse<Void> deleteMeta(@PathVariable Long id) {
mapper.deleteMeta(id);
return ApiResponse.ok(null);
}
// 리다이렉트
@GetMapping("/redirect")
public ApiResponse<List<CmsRedirect>> listRedirects() {
return ApiResponse.ok(mapper.findAllRedirects());
}
@PostMapping("/redirect")
public ApiResponse<CmsRedirect> createRedirect(@RequestBody Map<String, Object> req) {
CmsRedirect r = new CmsRedirect();
r.setFromPath(str(req.get("fromPath")));
r.setToPath(str(req.get("toPath")));
r.setStatusCode(req.get("statusCode") == null ? 301 : intOf(req.get("statusCode")));
r.setActive(req.get("active") == null || Boolean.parseBoolean(String.valueOf(req.get("active"))));
mapper.insertRedirect(r);
return ApiResponse.ok(r);
}
@DeleteMapping("/redirect/{id}")
public ApiResponse<Void> deleteRedirect(@PathVariable Long id) {
mapper.deleteRedirect(id);
return ApiResponse.ok(null);
}
/** robots.txt 생성. */
@GetMapping("/robots")
public ApiResponse<Map<String, String>> robots(@RequestParam(defaultValue = "https://cms.zioinfo.co.kr") String baseUrl) {
String txt = "User-agent: *\nAllow: /\nSitemap: " + baseUrl.replaceAll("/+$", "") + "/sitemap.xml\n";
return ApiResponse.ok(Map.of("robots", txt));
}
/** AI SEO 제안 (메타·키워드·가독성). */
@PostMapping("/ai-suggest")
public ApiResponse<Map<String, Object>> aiSuggest(@RequestBody Map<String, String> req) {
return ApiResponse.ok(aiService.seoSuggest(req.get("title"), req.get("body")));
}
private CmsSeoMeta bind(CmsSeoMeta m, Map<String, Object> r) {
if (r.containsKey("pathKey")) m.setPathKey(str(r.get("pathKey")));
if (r.containsKey("title")) m.setTitle(str(r.get("title")));
if (r.containsKey("description")) m.setDescription(str(r.get("description")));
if (r.containsKey("keywords")) m.setKeywords(str(r.get("keywords")));
if (r.containsKey("ogTitle")) m.setOgTitle(str(r.get("ogTitle")));
if (r.containsKey("ogImage")) m.setOgImage(str(r.get("ogImage")));
if (r.containsKey("canonical")) m.setCanonical(str(r.get("canonical")));
if (r.containsKey("locale")) m.setLocale(str(r.get("locale")));
if (m.getLocale() == null) m.setLocale("ko");
return m;
}
private String str(Object o) { return o == null ? null : String.valueOf(o); }
private Integer intOf(Object o) { if (o == null) return null; try { return Integer.valueOf(String.valueOf(o).trim()); } catch (Exception e) { return 301; } }
}

View File

@ -0,0 +1,22 @@
package com.zioinfo.cms.seo.mapper;
import com.zioinfo.cms.seo.CmsRedirect;
import com.zioinfo.cms.seo.CmsSeoMeta;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface SeoMapper {
List<CmsSeoMeta> findAllMeta();
CmsSeoMeta findMetaByPath(@Param("pathKey") String pathKey, @Param("locale") String locale);
CmsSeoMeta findMetaById(@Param("id") Long id);
int insertMeta(CmsSeoMeta m);
int updateMeta(CmsSeoMeta m);
int deleteMeta(@Param("id") Long id);
List<CmsRedirect> findAllRedirects();
int insertRedirect(CmsRedirect r);
int deleteRedirect(@Param("id") Long id);
}

View File

@ -0,0 +1,16 @@
package com.zioinfo.cms.theme;
import lombok.Data;
import java.time.LocalDateTime;
/** 테마/템플릿 (cms_theme) — 레이아웃·컴포넌트 슬롯. layout 은 JSONB. */
@Data
public class CmsTheme {
private Long id;
private String name;
private String layout; // JSONB: 레이아웃/슬롯 정의
private String tokens; // JSONB: 디자인 토큰(색상/폰트)
private boolean active; // 적용 테마
private String createdBy;
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,85 @@
package com.zioinfo.cms.theme;
import com.zioinfo.cms.common.ApiResponse;
import com.zioinfo.cms.theme.mapper.ThemeMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/** 테마/템플릿 API. 단일 활성 테마 보장. */
@RestController
@RequestMapping("/api/cms/theme")
@RequiredArgsConstructor
public class ThemeController {
private final ThemeMapper mapper;
@GetMapping
public ApiResponse<List<CmsTheme>> list() {
return ApiResponse.ok(mapper.findAll());
}
@GetMapping("/active")
public ApiResponse<CmsTheme> active() {
return ApiResponse.ok(mapper.findActive());
}
@GetMapping("/{id}")
public ApiResponse<CmsTheme> get(@PathVariable Long id) {
CmsTheme t = mapper.findById(id);
if (t == null) throw new RuntimeException("ERR-CMS-THEME-404: 테마 없음");
return ApiResponse.ok(t);
}
@PostMapping
public ApiResponse<CmsTheme> create(@RequestBody Map<String, Object> req, Authentication auth) {
CmsTheme t = bind(new CmsTheme(), req);
t.setCreatedBy(auth != null ? auth.getName() : "system");
mapper.insert(t);
return ApiResponse.ok(mapper.findById(t.getId()));
}
@PutMapping("/{id}")
public ApiResponse<CmsTheme> update(@PathVariable Long id, @RequestBody Map<String, Object> req) {
CmsTheme t = mapper.findById(id);
if (t == null) throw new RuntimeException("ERR-CMS-THEME-404: 테마 없음");
bind(t, req);
mapper.update(t);
return ApiResponse.ok(mapper.findById(id));
}
/** 테마 활성화 (단일 활성 보장). */
@PostMapping("/{id}/activate")
@Transactional
public ApiResponse<CmsTheme> activate(@PathVariable Long id) {
if (mapper.findById(id) == null) throw new RuntimeException("ERR-CMS-THEME-404: 테마 없음");
mapper.deactivateAll();
mapper.activate(id);
return ApiResponse.ok(mapper.findById(id));
}
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@PathVariable Long id) {
mapper.delete(id);
return ApiResponse.ok(null);
}
private CmsTheme bind(CmsTheme t, Map<String, Object> r) {
if (r.containsKey("name")) t.setName(str(r.get("name")));
if (r.containsKey("layout")) t.setLayout(jsonStr(r.get("layout")));
if (r.containsKey("tokens")) t.setTokens(jsonStr(r.get("tokens")));
if (r.containsKey("active")) t.setActive(Boolean.parseBoolean(String.valueOf(r.get("active"))));
return t;
}
private String str(Object o) { return o == null ? null : String.valueOf(o); }
private String jsonStr(Object o) {
if (o == null) return null;
if (o instanceof String s) return s;
try { return new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(o); } catch (Exception e) { return String.valueOf(o); }
}
}

View File

@ -0,0 +1,19 @@
package com.zioinfo.cms.theme.mapper;
import com.zioinfo.cms.theme.CmsTheme;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface ThemeMapper {
List<CmsTheme> findAll();
CmsTheme findById(@Param("id") Long id);
CmsTheme findActive();
int insert(CmsTheme t);
int update(CmsTheme t);
int deactivateAll();
int activate(@Param("id") Long id);
int delete(@Param("id") Long id);
}

View File

@ -0,0 +1,30 @@
package com.zioinfo.cms.ugc;
import lombok.Data;
import java.time.LocalDateTime;
/**
* UGC 항목 (cms_ugc) 게시판/리뷰/댓글/문의.
*
* <p>moderationStatus: PENDING APPROVED/REJECTED (AI 위험도 기반 ).
* inquiry 유형이 SR로 전환되면 itsmSrId 기록.
*/
@Data
public class CmsUgc {
private Long id;
private String ugcType; // BOARD, REVIEW, COMMENT, INQUIRY
private Long targetContentId; // 대상 콘텐츠/상품
private String targetRef; // 자유 참조(상품코드 )
private Long parentId; // 댓글 부모
private String authorName;
private String authorEmail; // PII 응답 마스킹
private String body;
private Integer rating; // 리뷰 평점 1~5
private String moderationStatus; // PENDING, APPROVED, REJECTED
private Double aiRisk; // AI 모더레이션 위험도 0~1
private String aiReason;
private String sentiment; // POSITIVE/NEGATIVE/NEUTRAL (리뷰)
private String itsmSrId; // 문의ITSM SR 연계 ID
private LocalDateTime createdAt;
private LocalDateTime moderatedAt;
}

View File

@ -0,0 +1,64 @@
package com.zioinfo.cms.ugc;
import com.zioinfo.cms.common.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/**
* UGC API 게시판/리뷰/댓글/문의 + 모더레이션 .
*
* <p>작성(POST /public) 공개 delivery 경로가 아니므로 Author+ 권한 필요.
* 운영 화면용 /모더레이션은 Editor+ 권장(SecurityConfig POST/PUT 가드).
*/
@RestController
@RequestMapping("/api/cms/ugc")
@RequiredArgsConstructor
public class UgcController {
private final UgcService service;
@GetMapping
public ApiResponse<List<CmsUgc>> list(@RequestParam(required = false) String ugcType,
@RequestParam(required = false) String moderationStatus,
@RequestParam(required = false) Long targetContentId) {
return ApiResponse.ok(service.list(ugcType, moderationStatus, targetContentId));
}
/** 모더레이션 큐 (PENDING). */
@GetMapping("/queue")
public ApiResponse<List<CmsUgc>> queue() {
return ApiResponse.ok(service.queue());
}
@GetMapping("/{id}")
public ApiResponse<CmsUgc> get(@PathVariable Long id) {
return ApiResponse.ok(service.get(id));
}
/** UGC 작성 (AI 모더레이션 자동). */
@PostMapping
public ApiResponse<CmsUgc> create(@RequestBody Map<String, Object> req) {
return ApiResponse.ok(service.create(req));
}
/** 모더레이터 승인/반려. */
@PutMapping("/{id}/moderate")
public ApiResponse<CmsUgc> moderate(@PathVariable Long id, @RequestBody Map<String, String> req) {
return ApiResponse.ok(service.moderate(id, req.get("status"), req.get("reason")));
}
/** 리뷰 AI 요약. */
@GetMapping("/review-summary")
public ApiResponse<Map<String, Object>> reviewSummary(@RequestParam String targetRef) {
return ApiResponse.ok(service.reviewSummary(targetRef));
}
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@PathVariable Long id) {
service.delete(id);
return ApiResponse.ok(null);
}
}

View File

@ -0,0 +1,138 @@
package com.zioinfo.cms.ugc;
import com.zioinfo.cms.admin.AuditService;
import com.zioinfo.cms.ai.AiService;
import com.zioinfo.cms.common.CryptoUtil;
import com.zioinfo.cms.integration.ItsmClient;
import com.zioinfo.cms.ugc.mapper.UgcMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
/**
* UGC 서비스 작성 AI 모더레이션 자동 분류 + 모더레이션 + 문의ITSM SR + 리뷰 감성.
*
* <p>보안: authorEmail(PII) 암호화 저장, 응답은 마스킹. 위험도 0.7은 자동 REJECT,
* 0.3은 REVIEW(), APPROVE.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class UgcService {
private final UgcMapper mapper;
private final AiService aiService;
private final ItsmClient itsmClient;
private final CryptoUtil crypto;
private final AuditService auditService;
public List<CmsUgc> list(String ugcType, String moderationStatus, Long targetContentId) {
List<CmsUgc> all = mapper.findAll(ugcType, moderationStatus, targetContentId);
all.forEach(this::maskEmail);
return all;
}
/** 모더레이션 큐 — PENDING 항목. */
public List<CmsUgc> queue() {
List<CmsUgc> all = mapper.findAll(null, "PENDING", null);
all.forEach(this::maskEmail);
return all;
}
public CmsUgc get(Long id) {
CmsUgc u = mapper.findById(id);
if (u == null) throw new RuntimeException("ERR-CMS-UGC-404: UGC 없음");
return maskEmail(u);
}
/** 공개 작성 (인증 불필요 경로에서도 호출 가능). AI 모더레이션 자동 수행. */
public CmsUgc create(Map<String, Object> req) {
CmsUgc u = new CmsUgc();
u.setUgcType(req.get("ugcType") == null ? "COMMENT" : str(req.get("ugcType")));
u.setTargetContentId(longOf(req.get("targetContentId")));
u.setTargetRef(str(req.get("targetRef")));
u.setParentId(longOf(req.get("parentId")));
u.setAuthorName(req.get("authorName") == null ? "익명" : str(req.get("authorName")));
// PII 암호화 저장
String email = str(req.get("authorEmail"));
u.setAuthorEmail(email == null ? null : crypto.encrypt(email));
u.setBody(str(req.get("body")));
u.setRating(intOf(req.get("rating")));
// AI 모더레이션
Map<String, Object> mod = aiService.moderate(u.getBody());
String decision = String.valueOf(mod.get("decision"));
double risk = toDouble(mod.get("risk"));
u.setAiRisk(risk);
u.setAiReason(String.valueOf(mod.getOrDefault("reason", "")));
u.setModerationStatus(switch (decision) {
case "REJECT" -> "REJECTED";
case "APPROVE" -> "APPROVED";
default -> "PENDING";
});
// 리뷰 감성
if ("REVIEW".equalsIgnoreCase(u.getUgcType()) && u.getBody() != null) {
double s = aiService.sentimentScore(u.getBody());
u.setSentiment(s >= 0.6 ? "POSITIVE" : s <= 0.4 ? "NEGATIVE" : "NEUTRAL");
}
mapper.insert(u);
// 문의 ITSM SR 자동 생성
if ("INQUIRY".equalsIgnoreCase(u.getUgcType())) {
String srId = itsmClient.createSr(
"[CMS 문의] " + (u.getAuthorName()),
u.getBody(),
"MEDIUM");
if (srId != null) {
mapper.updateSrId(u.getId(), srId);
u.setItsmSrId(srId);
}
}
auditService.log("system", "UGC_CREATE", u.getUgcType(), "moderation=" + u.getModerationStatus());
return maskEmail(mapper.findById(u.getId()));
}
/** 모더레이터 승인/반려. */
public CmsUgc moderate(Long id, String status, String reason) {
CmsUgc u = mapper.findById(id);
if (u == null) throw new RuntimeException("ERR-CMS-UGC-404: UGC 없음");
String s = status == null ? "" : status.toUpperCase();
if (!s.equals("APPROVED") && !s.equals("REJECTED")) {
throw new IllegalArgumentException("ERR-CMS-UGC-400: status는 APPROVED/REJECTED");
}
mapper.updateModeration(id, s, u.getAiRisk(), reason == null ? u.getAiReason() : reason);
auditService.log("UGC_MODERATE", String.valueOf(id), u.getModerationStatus() + " -> " + s);
return maskEmail(mapper.findById(id));
}
/** 리뷰 AI 요약 — 대상의 승인 리뷰들 요약/감성. */
public Map<String, Object> reviewSummary(String targetRef) {
List<CmsUgc> reviews = mapper.findApprovedReviews(targetRef);
List<String> bodies = reviews.stream().map(CmsUgc::getBody).filter(b -> b != null).toList();
return aiService.reviewSummary(bodies);
}
public void delete(Long id) {
get(id);
mapper.delete(id);
auditService.log("UGC_DELETE", String.valueOf(id), null);
}
// helpers
private CmsUgc maskEmail(CmsUgc u) {
if (u.getAuthorEmail() != null) {
String dec = crypto.decrypt(u.getAuthorEmail());
u.setAuthorEmail(CryptoUtil.mask(dec));
}
return u;
}
private String str(Object o) { return o == null ? null : String.valueOf(o); }
private Long longOf(Object o) { if (o == null) return null; try { return Long.valueOf(String.valueOf(o).trim()); } catch (Exception e) { return null; } }
private Integer intOf(Object o) { if (o == null) return null; try { return Integer.valueOf(String.valueOf(o).trim()); } catch (Exception e) { return null; } }
private double toDouble(Object o) { if (o == null) return 0; try { return Double.parseDouble(String.valueOf(o)); } catch (Exception e) { return 0; } }
}

View File

@ -0,0 +1,22 @@
package com.zioinfo.cms.ugc.mapper;
import com.zioinfo.cms.ugc.CmsUgc;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface UgcMapper {
List<CmsUgc> findAll(@Param("ugcType") String ugcType,
@Param("moderationStatus") String moderationStatus,
@Param("targetContentId") Long targetContentId);
CmsUgc findById(@Param("id") Long id);
List<CmsUgc> findApprovedReviews(@Param("targetRef") String targetRef);
int insert(CmsUgc u);
int updateModeration(@Param("id") Long id, @Param("status") String status,
@Param("aiRisk") Double aiRisk, @Param("aiReason") String aiReason);
int updateSentiment(@Param("id") Long id, @Param("sentiment") String sentiment);
int updateSrId(@Param("id") Long id, @Param("srId") String srId);
int delete(@Param("id") Long id);
}

View File

@ -0,0 +1,52 @@
server:
port: 8012
spring:
application:
name: guardia-cms
datasource:
url: ${DB_URL:jdbc:postgresql://localhost:5432/cms_db}
username: ${DB_USER:cms_user}
password: ${DB_PASS:cms_pass2026}
driver-class-name: org.postgresql.Driver
sql:
init:
mode: ${SQL_INIT_MODE:never}
schema-locations: classpath:db/schema.sql
servlet:
multipart:
max-file-size: 20MB
max-request-size: 20MB
mybatis:
mapper-locations: classpath:mapper/**/*.xml
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl
springdoc:
api-docs:
path: /api/cms/docs
swagger-ui:
path: /api/cms/swagger
guardia:
itsm-url: ${ITSM_URL:http://localhost:9001}
mall-url: ${MALL_URL:http://localhost:8011}
crm-url: ${CRM_URL:http://localhost:8004}
ocr-url: ${OCR_URL:http://localhost:8005}
bi-url: ${BI_URL:http://localhost:8006}
# 보안 불변 규칙: Ollama 온프레미스만 허용. 외부 AI API 절대 금지.
ollama-url: ${OLLAMA_URL:http://localhost:11434}
ollama-text-model: ${OLLAMA_TEXT_MODEL:llama3}
ollama-vision-model: ${OLLAMA_VISION_MODEL:llava}
crypto:
secret: ${CMS_CRYPTO_SECRET:guardia-cms-aes-256-gcm-master-key-2026-zioinfo}
jwt:
secret: ${JWT_SECRET:guardia-cms-jwt-secret-2026-minimum-256bit-key-zioinfo}
expiration: 86400000
logging:
level:
com.zioinfo.cms: DEBUG
org.mybatis: WARN

View File

@ -0,0 +1,310 @@
-- ============================================================================
-- GUARDiA CMS — PostgreSQL 스키마 (cms_db)
-- AI 기반 통합 콘텐츠 관리 시스템 (Shopping CMS)
-- 보안 불변: PII/자격증명 *_enc AES 저장·응답 마스킹, delivery published만
-- ============================================================================
-- ── 운영자 계정 (auth/admin) — RBAC: SUPERADMIN/EDITOR/AUTHOR/VIEWER ──────────
CREATE TABLE IF NOT EXISTS cms_user (
id BIGSERIAL PRIMARY KEY,
username VARCHAR(200) UNIQUE NOT NULL,
password_hash VARCHAR(500) NOT NULL,
display_name VARCHAR(200),
role VARCHAR(50) DEFAULT 'VIEWER',
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT NOW()
);
-- 기본 관리자 (비밀번호: admin123 / BCrypt $2a). 운영 배포 시 즉시 변경.
INSERT INTO cms_user (username, password_hash, display_name, role)
VALUES ('admin', '$2a$10$Uo4GFiJ2TZy9KlGRALfDt.gcE0e7..57rXBRaEv1mJG2XqANG3OI2', '최고관리자', 'SUPERADMIN')
ON CONFLICT (username) DO NOTHING;
INSERT INTO cms_user (username, password_hash, display_name, role)
VALUES ('editor', '$2a$10$Uo4GFiJ2TZy9KlGRALfDt.gcE0e7..57rXBRaEv1mJG2XqANG3OI2', '편집자', 'EDITOR')
ON CONFLICT (username) DO NOTHING;
INSERT INTO cms_user (username, password_hash, display_name, role)
VALUES ('author', '$2a$10$Uo4GFiJ2TZy9KlGRALfDt.gcE0e7..57rXBRaEv1mJG2XqANG3OI2', '작성자', 'AUTHOR')
ON CONFLICT (username) DO NOTHING;
CREATE TABLE IF NOT EXISTS cms_audit_log (
id BIGSERIAL PRIMARY KEY,
actor VARCHAR(200),
action VARCHAR(100),
target VARCHAR(300),
detail TEXT,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_cms_audit_action ON cms_audit_log(action);
CREATE INDEX IF NOT EXISTS idx_cms_audit_actor ON cms_audit_log(actor);
CREATE TABLE IF NOT EXISTS cms_setting (
key VARCHAR(200) PRIMARY KEY,
value TEXT,
updated_at TIMESTAMP DEFAULT NOW()
);
INSERT INTO cms_setting (key, value) VALUES
('ollama_text_model', 'llama3'),
('ollama_vision_model', 'llava'),
('default_locale', 'ko'),
('moderation_auto_reject_threshold', '0.7'),
('itsm_inquiry_to_sr', 'true')
ON CONFLICT (key) DO NOTHING;
-- ── 콘텐츠 (content) — 헤드리스 페이지/포스트/블록 + 워크플로우 ────────────────
CREATE TABLE IF NOT EXISTS cms_content (
id BIGSERIAL PRIMARY KEY,
content_type VARCHAR(50) DEFAULT 'PAGE', -- PAGE, POST, BLOCK, PRODUCT_DETAIL
slug VARCHAR(300) NOT NULL,
title VARCHAR(500),
summary TEXT,
blocks JSONB, -- 헤드리스 블록 배열
locale VARCHAR(20) DEFAULT 'ko',
status VARCHAR(30) DEFAULT 'DRAFT', -- DRAFT, REVIEW, APPROVED, PUBLISHED, ARCHIVED
version INTEGER DEFAULT 1,
menu_id BIGINT,
tags VARCHAR(500),
seo_meta JSONB,
scheduled_at TIMESTAMP,
published_at TIMESTAMP,
created_by VARCHAR(200),
updated_by VARCHAR(200),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_cms_content_status ON cms_content(status);
CREATE INDEX IF NOT EXISTS idx_cms_content_slug ON cms_content(slug, locale);
CREATE TABLE IF NOT EXISTS cms_content_version (
id BIGSERIAL PRIMARY KEY,
content_id BIGINT REFERENCES cms_content(id) ON DELETE CASCADE,
version INTEGER,
title VARCHAR(500),
summary TEXT,
blocks JSONB,
status VARCHAR(30),
note TEXT,
created_by VARCHAR(200),
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_cms_cversion_content ON cms_content_version(content_id);
-- ── 상품 콘텐츠 (product) — 상세빌더 + Mall 매핑 ─────────────────────────────
CREATE TABLE IF NOT EXISTS cms_product_content (
id BIGSERIAL PRIMARY KEY,
mall_product_id VARCHAR(200),
product_name VARCHAR(500),
summary TEXT,
detail_blocks JSONB,
specs JSONB,
locale VARCHAR(20) DEFAULT 'ko',
status VARCHAR(30) DEFAULT 'DRAFT', -- DRAFT, PUBLISHED
created_by VARCHAR(200),
published_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_cms_product_mall ON cms_product_content(mall_product_id);
-- ── 배너/프로모션 (banner) ──────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS cms_banner (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(300),
banner_type VARCHAR(50) DEFAULT 'PROMOTION', -- HERO, PROMOTION, POPUP, CAMPAIGN, EXHIBITION
image_url VARCHAR(1000),
link_url VARCHAR(1000),
position VARCHAR(100),
target_rule JSONB,
sort_order INTEGER,
is_active BOOLEAN DEFAULT TRUE,
start_at TIMESTAMP,
end_at TIMESTAMP,
created_by VARCHAR(200),
created_at TIMESTAMP DEFAULT NOW()
);
-- ── 미디어 (media) ──────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS cms_media (
id BIGSERIAL PRIMARY KEY,
file_name VARCHAR(500),
media_type VARCHAR(30) DEFAULT 'IMAGE', -- IMAGE, VIDEO, FILE
url VARCHAR(1000),
folder VARCHAR(200) DEFAULT 'default',
mime_type VARCHAR(100),
size_bytes BIGINT,
alt_text VARCHAR(1000),
tags VARCHAR(500),
ref_count INTEGER DEFAULT 0,
created_by VARCHAR(200),
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_cms_media_folder ON cms_media(folder);
-- ── 메뉴/카테고리 (menu) — 트리 ─────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS cms_menu (
id BIGSERIAL PRIMARY KEY,
parent_id BIGINT,
name VARCHAR(300),
menu_type VARCHAR(30) DEFAULT 'MENU', -- MENU, CATEGORY
url VARCHAR(1000),
slug VARCHAR(300),
sort_order INTEGER,
depth INTEGER DEFAULT 0,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT NOW()
);
-- ── 테마 (theme) ────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS cms_theme (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(300),
layout JSONB,
tokens JSONB,
is_active BOOLEAN DEFAULT FALSE,
created_by VARCHAR(200),
created_at TIMESTAMP DEFAULT NOW()
);
-- ── 다국어 (i18n) ───────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS cms_i18n (
id BIGSERIAL PRIMARY KEY,
locale VARCHAR(20),
resource_key VARCHAR(300),
value TEXT,
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE (locale, resource_key)
);
-- ── SEO (seo) ───────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS cms_seo_meta (
id BIGSERIAL PRIMARY KEY,
path_key VARCHAR(500),
title VARCHAR(500),
description TEXT,
keywords VARCHAR(1000),
og_title VARCHAR(500),
og_image VARCHAR(1000),
canonical VARCHAR(1000),
locale VARCHAR(20) DEFAULT 'ko',
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS cms_redirect (
id BIGSERIAL PRIMARY KEY,
from_path VARCHAR(1000),
to_path VARCHAR(1000),
status_code INTEGER DEFAULT 301,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT NOW()
);
-- ── 폼빌더 (form) ───────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS cms_form (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(300),
slug VARCHAR(300),
fields JSONB,
is_active BOOLEAN DEFAULT TRUE,
created_by VARCHAR(200),
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS cms_form_submission (
id BIGSERIAL PRIMARY KEY,
form_id BIGINT REFERENCES cms_form(id) ON DELETE CASCADE,
payload JSONB,
is_spam BOOLEAN DEFAULT FALSE,
spam_risk DOUBLE PRECISION,
created_at TIMESTAMP DEFAULT NOW()
);
-- ── 채널 (channel) ──────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS cms_channel (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(300),
channel_type VARCHAR(30) DEFAULT 'WEB', -- WEB, MOBILE, APP, KIOSK
endpoint VARCHAR(1000),
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT NOW()
);
-- ── UGC (ugc) — 게시판/리뷰/댓글/문의 + 모더레이션 ──────────────────────────
CREATE TABLE IF NOT EXISTS cms_ugc (
id BIGSERIAL PRIMARY KEY,
ugc_type VARCHAR(30) DEFAULT 'COMMENT', -- BOARD, REVIEW, COMMENT, INQUIRY
target_content_id BIGINT,
target_ref VARCHAR(300),
parent_id BIGINT,
author_name VARCHAR(200),
author_email_enc TEXT, -- PII AES 암호화 (응답 마스킹)
body TEXT,
rating INTEGER,
moderation_status VARCHAR(30) DEFAULT 'PENDING', -- PENDING, APPROVED, REJECTED
ai_risk DOUBLE PRECISION,
ai_reason VARCHAR(500),
sentiment VARCHAR(20),
itsm_sr_id VARCHAR(100),
created_at TIMESTAMP DEFAULT NOW(),
moderated_at TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_cms_ugc_moderation ON cms_ugc(moderation_status);
CREATE INDEX IF NOT EXISTS idx_cms_ugc_type ON cms_ugc(ugc_type);
-- ── 회원 (member) — PII 암호화 ──────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS cms_member (
id BIGSERIAL PRIMARY KEY,
member_code VARCHAR(100),
name VARCHAR(200),
email_enc TEXT, -- AES 암호화 (응답 마스킹)
phone_enc TEXT, -- AES 암호화 (응답 마스킹)
segment VARCHAR(50) DEFAULT 'NEW',
status VARCHAR(30) DEFAULT 'ACTIVE',
crm_customer_id VARCHAR(100),
created_at TIMESTAMP DEFAULT NOW()
);
-- ── 콘텐츠 성과 (analytics) ─────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS cms_content_stat (
content_id BIGINT PRIMARY KEY,
views BIGINT DEFAULT 0,
dwell_seconds BIGINT DEFAULT 0,
conversions BIGINT DEFAULT 0,
updated_at TIMESTAMP DEFAULT NOW()
);
-- ============================================================================
-- 데모 시드 콘텐츠
-- ============================================================================
INSERT INTO cms_content (content_type, slug, title, summary, blocks, locale, status, version, published_at, created_by, updated_by)
SELECT 'PAGE', 'welcome', 'GUARDiA CMS에 오신 것을 환영합니다',
'AI 기반 통합 콘텐츠 관리 시스템 데모 페이지',
'[{"type":"hero","title":"Shopping CMS","text":"헤드리스 + AI"},{"type":"text","value":"콘텐츠를 작성하고 게시 워크플로우로 발행하세요."}]'::jsonb,
'ko', 'PUBLISHED', 1, NOW(), 'system', 'system'
WHERE NOT EXISTS (SELECT 1 FROM cms_content WHERE slug = 'welcome' AND locale = 'ko');
INSERT INTO cms_content (content_type, slug, title, summary, blocks, locale, status, version, created_by, updated_by)
SELECT 'POST', 'spring-promotion-draft', '봄맞이 기획전 (작성중)',
'게시 워크플로우 데모용 초안',
'[{"type":"text","value":"봄맞이 신상품을 소개합니다."}]'::jsonb,
'ko', 'DRAFT', 1, 'author', 'author'
WHERE NOT EXISTS (SELECT 1 FROM cms_content WHERE slug = 'spring-promotion-draft' AND locale = 'ko');
INSERT INTO cms_menu (parent_id, name, menu_type, slug, sort_order, depth, is_active)
SELECT NULL, '전체 카테고리', 'CATEGORY', 'all', 0, 0, TRUE
WHERE NOT EXISTS (SELECT 1 FROM cms_menu WHERE slug = 'all');
INSERT INTO cms_banner (name, banner_type, image_url, link_url, position, sort_order, is_active)
SELECT '메인 히어로 배너', 'HERO', '/assets/demo-hero.jpg', '/welcome', 'MAIN_TOP', 0, TRUE
WHERE NOT EXISTS (SELECT 1 FROM cms_banner WHERE name = '메인 히어로 배너');
INSERT INTO cms_channel (name, channel_type, endpoint, is_active)
SELECT '', 'WEB', 'https://cms.zioinfo.co.kr', TRUE
WHERE NOT EXISTS (SELECT 1 FROM cms_channel WHERE name = '');
INSERT INTO cms_i18n (locale, resource_key, value) VALUES
('ko', 'common.welcome', '환영합니다'),
('ko', 'common.search', '검색'),
('en', 'common.welcome', 'Welcome'),
('en', 'common.search', 'Search')
ON CONFLICT (locale, resource_key) DO NOTHING;
INSERT INTO cms_theme (name, layout, tokens, is_active, created_by)
SELECT '기본 테마', '{"header":true,"footer":true}'::jsonb,
'{"primary":"#1e3a8a","font":"Pretendard"}'::jsonb, TRUE, 'system'
WHERE NOT EXISTS (SELECT 1 FROM cms_theme WHERE name = '기본 테마');

View File

@ -0,0 +1,56 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.zioinfo.cms.admin.mapper.AdminUserMapper">
<resultMap id="userMap" type="com.zioinfo.cms.auth.CmsUser">
<id property="id" column="id"/>
<result property="username" column="username"/>
<result property="passwordHash" column="password_hash"/>
<result property="displayName" column="display_name"/>
<result property="role" column="role"/>
<result property="active" column="is_active"/>
<result property="createdAt" column="created_at"/>
</resultMap>
<select id="findAll" resultMap="userMap">
SELECT id, username, password_hash, display_name, role, is_active, created_at
FROM cms_user ORDER BY id ASC
</select>
<select id="findById" resultMap="userMap">
SELECT id, username, password_hash, display_name, role, is_active, created_at
FROM cms_user WHERE id = #{id}
</select>
<insert id="insert" parameterType="com.zioinfo.cms.auth.CmsUser"
useGeneratedKeys="true" keyProperty="id">
INSERT INTO cms_user (username, password_hash, display_name, role, is_active)
VALUES (#{username}, #{passwordHash}, #{displayName}, #{role}, #{active})
</insert>
<update id="updateRole">
UPDATE cms_user SET role = #{role} WHERE id = #{id}
</update>
<update id="updateActive">
UPDATE cms_user SET is_active = #{active} WHERE id = #{id}
</update>
<update id="updatePassword">
UPDATE cms_user SET password_hash = #{passwordHash} WHERE id = #{id}
</update>
<delete id="deleteById">
DELETE FROM cms_user WHERE id = #{id}
</delete>
<select id="countSuperAdmins" resultType="int">
SELECT COUNT(*) FROM cms_user WHERE role = 'SUPERADMIN' AND is_active = TRUE
</select>
<select id="countByUsername" resultType="int">
SELECT COUNT(*) FROM cms_user WHERE username = #{username}
</select>
</mapper>

View File

@ -0,0 +1,52 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.zioinfo.cms.analytics.mapper.AnalyticsMapper">
<resultMap id="statMap" type="com.zioinfo.cms.analytics.CmsContentStat">
<id property="contentId" column="content_id"/>
<result property="slug" column="slug"/>
<result property="title" column="title"/>
<result property="views" column="views"/>
<result property="dwellSeconds" column="dwell_seconds"/>
<result property="conversions" column="conversions"/>
<result property="conversionRate" column="conversion_rate"/>
</resultMap>
<update id="recordEvent">
INSERT INTO cms_content_stat (content_id, views, dwell_seconds, conversions, updated_at)
VALUES (
#{contentId},
CASE WHEN #{eventType} = 'VIEW' THEN #{amount} ELSE 0 END,
CASE WHEN #{eventType} = 'DWELL' THEN #{amount} ELSE 0 END,
CASE WHEN #{eventType} = 'CONVERSION' THEN #{amount} ELSE 0 END,
NOW()
)
ON CONFLICT (content_id) DO UPDATE SET
views = cms_content_stat.views + (CASE WHEN #{eventType} = 'VIEW' THEN #{amount} ELSE 0 END),
dwell_seconds = cms_content_stat.dwell_seconds + (CASE WHEN #{eventType} = 'DWELL' THEN #{amount} ELSE 0 END),
conversions = cms_content_stat.conversions + (CASE WHEN #{eventType} = 'CONVERSION' THEN #{amount} ELSE 0 END),
updated_at = NOW()
</update>
<select id="topContent" resultMap="statMap">
SELECT s.content_id, c.slug, c.title, s.views, s.dwell_seconds, s.conversions,
CASE WHEN s.views = 0 THEN 0 ELSE ROUND(s.conversions::numeric / s.views * 100, 2) END AS conversion_rate
FROM cms_content_stat s
LEFT JOIN cms_content c ON c.id = s.content_id
ORDER BY s.views DESC
LIMIT #{limit}
</select>
<select id="findByContent" resultMap="statMap">
SELECT s.content_id, c.slug, c.title, s.views, s.dwell_seconds, s.conversions,
CASE WHEN s.views = 0 THEN 0 ELSE ROUND(s.conversions::numeric / s.views * 100, 2) END AS conversion_rate
FROM cms_content_stat s
LEFT JOIN cms_content c ON c.id = s.content_id
WHERE s.content_id = #{contentId}
</select>
<select id="totalViews" resultType="long">SELECT COALESCE(SUM(views),0) FROM cms_content_stat</select>
<select id="totalConversions" resultType="long">SELECT COALESCE(SUM(conversions),0) FROM cms_content_stat</select>
</mapper>

View File

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.zioinfo.cms.admin.mapper.AuditLogMapper">
<resultMap id="auditMap" type="com.zioinfo.cms.admin.dto.AuditLog">
<id property="id" column="id"/>
<result property="actor" column="actor"/>
<result property="action" column="action"/>
<result property="target" column="target"/>
<result property="detail" column="detail"/>
<result property="createdAt" column="created_at"/>
</resultMap>
<insert id="insert" parameterType="com.zioinfo.cms.admin.dto.AuditLog"
useGeneratedKeys="true" keyProperty="id">
INSERT INTO cms_audit_log (actor, action, target, detail)
VALUES (#{actor}, #{action}, #{target}, #{detail})
</insert>
<select id="find" resultMap="auditMap">
SELECT id, actor, action, target, detail, created_at
FROM cms_audit_log
<where>
<if test="action != null and action != ''">AND action = #{action}</if>
<if test="actor != null and actor != ''">AND actor = #{actor}</if>
</where>
ORDER BY id DESC
LIMIT #{limit}
</select>
</mapper>

View File

@ -0,0 +1,56 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.zioinfo.cms.banner.mapper.BannerMapper">
<resultMap id="bannerMap" type="com.zioinfo.cms.banner.CmsBanner">
<id property="id" column="id"/>
<result property="name" column="name"/>
<result property="bannerType" column="banner_type"/>
<result property="imageUrl" column="image_url"/>
<result property="linkUrl" column="link_url"/>
<result property="position" column="position"/>
<result property="targetRule" column="target_rule"/>
<result property="sortOrder" column="sort_order"/>
<result property="active" column="is_active"/>
<result property="startAt" column="start_at"/>
<result property="endAt" column="end_at"/>
<result property="createdBy" column="created_by"/>
<result property="createdAt" column="created_at"/>
</resultMap>
<select id="findAll" resultMap="bannerMap">
SELECT * FROM cms_banner
<where>
<if test="bannerType != null and bannerType != ''">AND banner_type = #{bannerType}</if>
<if test="position != null and position != ''">AND position = #{position}</if>
</where>
ORDER BY sort_order ASC NULLS LAST, id DESC
</select>
<select id="findActiveNow" resultMap="bannerMap">
SELECT * FROM cms_banner
WHERE is_active = TRUE
AND (start_at IS NULL OR start_at &lt;= NOW())
AND (end_at IS NULL OR end_at &gt;= NOW())
<if test="position != null and position != ''">AND position = #{position}</if>
ORDER BY sort_order ASC NULLS LAST, id DESC
</select>
<select id="findById" resultMap="bannerMap">SELECT * FROM cms_banner WHERE id = #{id}</select>
<insert id="insert" parameterType="com.zioinfo.cms.banner.CmsBanner" useGeneratedKeys="true" keyProperty="id">
INSERT INTO cms_banner (name, banner_type, image_url, link_url, position, target_rule, sort_order, is_active, start_at, end_at, created_by)
VALUES (#{name}, #{bannerType}, #{imageUrl}, #{linkUrl}, #{position}, #{targetRule}::jsonb, #{sortOrder}, #{active}, #{startAt}, #{endAt}, #{createdBy})
</insert>
<update id="update" parameterType="com.zioinfo.cms.banner.CmsBanner">
UPDATE cms_banner SET name = #{name}, banner_type = #{bannerType}, image_url = #{imageUrl}, link_url = #{linkUrl},
position = #{position}, target_rule = #{targetRule}::jsonb, sort_order = #{sortOrder}, is_active = #{active},
start_at = #{startAt}, end_at = #{endAt}
WHERE id = #{id}
</update>
<delete id="delete">DELETE FROM cms_banner WHERE id = #{id}</delete>
</mapper>

View File

@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.zioinfo.cms.channel.mapper.ChannelMapper">
<resultMap id="channelMap" type="com.zioinfo.cms.channel.CmsChannel">
<id property="id" column="id"/>
<result property="name" column="name"/>
<result property="channelType" column="channel_type"/>
<result property="endpoint" column="endpoint"/>
<result property="active" column="is_active"/>
<result property="createdAt" column="created_at"/>
</resultMap>
<select id="findAll" resultMap="channelMap">SELECT * FROM cms_channel ORDER BY id ASC</select>
<select id="findById" resultMap="channelMap">SELECT * FROM cms_channel WHERE id = #{id}</select>
<insert id="insert" parameterType="com.zioinfo.cms.channel.CmsChannel" useGeneratedKeys="true" keyProperty="id">
INSERT INTO cms_channel (name, channel_type, endpoint, is_active)
VALUES (#{name}, #{channelType}, #{endpoint}, #{active})
</insert>
<update id="update" parameterType="com.zioinfo.cms.channel.CmsChannel">
UPDATE cms_channel SET name = #{name}, channel_type = #{channelType}, endpoint = #{endpoint}, is_active = #{active}
WHERE id = #{id}
</update>
<delete id="delete">DELETE FROM cms_channel WHERE id = #{id}</delete>
</mapper>

View File

@ -0,0 +1,123 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.zioinfo.cms.content.mapper.ContentMapper">
<resultMap id="contentMap" type="com.zioinfo.cms.content.CmsContent">
<id property="id" column="id"/>
<result property="contentType" column="content_type"/>
<result property="slug" column="slug"/>
<result property="title" column="title"/>
<result property="summary" column="summary"/>
<result property="blocks" column="blocks"/>
<result property="locale" column="locale"/>
<result property="status" column="status"/>
<result property="version" column="version"/>
<result property="menuId" column="menu_id"/>
<result property="tags" column="tags"/>
<result property="seoMeta" column="seo_meta"/>
<result property="scheduledAt" column="scheduled_at"/>
<result property="publishedAt" column="published_at"/>
<result property="createdBy" column="created_by"/>
<result property="updatedBy" column="updated_by"/>
<result property="createdAt" column="created_at"/>
<result property="updatedAt" column="updated_at"/>
</resultMap>
<select id="findAll" resultMap="contentMap">
SELECT * FROM cms_content
<where>
<if test="contentType != null and contentType != ''">AND content_type = #{contentType}</if>
<if test="status != null and status != ''">AND status = #{status}</if>
<if test="locale != null and locale != ''">AND locale = #{locale}</if>
<if test="keyword != null and keyword != ''">
AND (title ILIKE '%' || #{keyword} || '%' OR slug ILIKE '%' || #{keyword} || '%')
</if>
</where>
ORDER BY id DESC
</select>
<select id="findById" resultMap="contentMap">
SELECT * FROM cms_content WHERE id = #{id}
</select>
<select id="findPublishedBySlug" resultMap="contentMap">
SELECT * FROM cms_content
WHERE slug = #{slug} AND status = 'PUBLISHED'
<if test="locale != null and locale != ''">AND locale = #{locale}</if>
ORDER BY published_at DESC NULLS LAST LIMIT 1
</select>
<select id="countBySlug" resultType="int">
SELECT COUNT(*) FROM cms_content
WHERE slug = #{slug}
<if test="locale != null and locale != ''">AND locale = #{locale}</if>
<if test="excludeId != null">AND id != #{excludeId}</if>
</select>
<insert id="insert" parameterType="com.zioinfo.cms.content.CmsContent"
useGeneratedKeys="true" keyProperty="id">
INSERT INTO cms_content
(content_type, slug, title, summary, blocks, locale, status, version, menu_id, tags, seo_meta,
scheduled_at, created_by, updated_by)
VALUES
(#{contentType}, #{slug}, #{title}, #{summary}, #{blocks}::jsonb, #{locale}, #{status}, #{version},
#{menuId}, #{tags}, #{seoMeta}::jsonb, #{scheduledAt}, #{createdBy}, #{updatedBy})
</insert>
<update id="update" parameterType="com.zioinfo.cms.content.CmsContent">
UPDATE cms_content SET
content_type = #{contentType}, slug = #{slug}, title = #{title}, summary = #{summary},
blocks = #{blocks}::jsonb, locale = #{locale}, status = #{status}, version = #{version},
menu_id = #{menuId}, tags = #{tags}, seo_meta = #{seoMeta}::jsonb,
scheduled_at = #{scheduledAt}, updated_by = #{updatedBy}, updated_at = NOW()
WHERE id = #{id}
</update>
<update id="updateStatus">
UPDATE cms_content SET status = #{status}, updated_by = #{updatedBy}, updated_at = NOW() WHERE id = #{id}
</update>
<update id="updatePublished">
UPDATE cms_content SET status = #{status}, published_at = NOW(), scheduled_at = NULL, updated_at = NOW()
WHERE id = #{id}
</update>
<delete id="delete">
DELETE FROM cms_content WHERE id = #{id}
</delete>
<select id="findDueScheduled" resultMap="contentMap">
SELECT * FROM cms_content
WHERE status = 'APPROVED' AND scheduled_at IS NOT NULL AND scheduled_at &lt;= NOW()
</select>
<!-- ── 버전 ── -->
<insert id="insertVersion" parameterType="com.zioinfo.cms.content.CmsContentVersion"
useGeneratedKeys="true" keyProperty="id">
INSERT INTO cms_content_version (content_id, version, title, summary, blocks, status, note, created_by)
VALUES (#{contentId}, #{version}, #{title}, #{summary}, #{blocks}::jsonb, #{status}, #{note}, #{createdBy})
</insert>
<select id="findVersions" resultMap="versionMap">
SELECT * FROM cms_content_version WHERE content_id = #{contentId} ORDER BY version DESC
</select>
<select id="findVersion" resultMap="versionMap">
SELECT * FROM cms_content_version WHERE content_id = #{contentId} AND version = #{version} LIMIT 1
</select>
<resultMap id="versionMap" type="com.zioinfo.cms.content.CmsContentVersion">
<id property="id" column="id"/>
<result property="contentId" column="content_id"/>
<result property="version" column="version"/>
<result property="title" column="title"/>
<result property="summary" column="summary"/>
<result property="blocks" column="blocks"/>
<result property="status" column="status"/>
<result property="note" column="note"/>
<result property="createdBy" column="created_by"/>
<result property="createdAt" column="created_at"/>
</resultMap>
</mapper>

View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.zioinfo.cms.dashboard.mapper.DashboardMapper">
<select id="countContentByStatus" resultType="int">
SELECT COUNT(*) FROM cms_content WHERE status = #{status}
</select>
<select id="countTotalContent" resultType="int">SELECT COUNT(*) FROM cms_content</select>
<select id="countMedia" resultType="int">SELECT COUNT(*) FROM cms_media</select>
<select id="countPendingUgc" resultType="int">SELECT COUNT(*) FROM cms_ugc WHERE moderation_status = 'PENDING'</select>
<select id="countMembers" resultType="int">SELECT COUNT(*) FROM cms_member</select>
<select id="contentByStatus" resultType="map">
SELECT status, COUNT(*) AS count FROM cms_content GROUP BY status ORDER BY status
</select>
</mapper>

View File

@ -0,0 +1,52 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.zioinfo.cms.form.mapper.FormMapper">
<resultMap id="formMap" type="com.zioinfo.cms.form.CmsForm">
<id property="id" column="id"/>
<result property="name" column="name"/>
<result property="slug" column="slug"/>
<result property="fields" column="fields"/>
<result property="active" column="is_active"/>
<result property="createdBy" column="created_by"/>
<result property="createdAt" column="created_at"/>
</resultMap>
<resultMap id="subMap" type="com.zioinfo.cms.form.CmsFormSubmission">
<id property="id" column="id"/>
<result property="formId" column="form_id"/>
<result property="payload" column="payload"/>
<result property="spam" column="is_spam"/>
<result property="spamRisk" column="spam_risk"/>
<result property="createdAt" column="created_at"/>
</resultMap>
<select id="findAll" resultMap="formMap">SELECT * FROM cms_form ORDER BY id DESC</select>
<select id="findById" resultMap="formMap">SELECT * FROM cms_form WHERE id = #{id}</select>
<select id="findBySlug" resultMap="formMap">SELECT * FROM cms_form WHERE slug = #{slug}</select>
<insert id="insert" parameterType="com.zioinfo.cms.form.CmsForm" useGeneratedKeys="true" keyProperty="id">
INSERT INTO cms_form (name, slug, fields, is_active, created_by)
VALUES (#{name}, #{slug}, #{fields}::jsonb, #{active}, #{createdBy})
</insert>
<update id="update" parameterType="com.zioinfo.cms.form.CmsForm">
UPDATE cms_form SET name = #{name}, slug = #{slug}, fields = #{fields}::jsonb, is_active = #{active} WHERE id = #{id}
</update>
<delete id="delete">DELETE FROM cms_form WHERE id = #{id}</delete>
<insert id="insertSubmission" parameterType="com.zioinfo.cms.form.CmsFormSubmission" useGeneratedKeys="true" keyProperty="id">
INSERT INTO cms_form_submission (form_id, payload, is_spam, spam_risk)
VALUES (#{formId}, #{payload}::jsonb, #{spam}, #{spamRisk})
</insert>
<select id="findSubmissions" resultMap="subMap">
SELECT * FROM cms_form_submission
WHERE form_id = #{formId}
<if test="!includeSpam">AND is_spam = FALSE</if>
ORDER BY id DESC LIMIT 500
</select>
</mapper>

View File

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.zioinfo.cms.i18n.mapper.I18nMapper">
<resultMap id="i18nMap" type="com.zioinfo.cms.i18n.CmsI18n">
<id property="id" column="id"/>
<result property="locale" column="locale"/>
<result property="resourceKey" column="resource_key"/>
<result property="value" column="value"/>
<result property="updatedAt" column="updated_at"/>
</resultMap>
<select id="findByLocale" resultMap="i18nMap">
SELECT * FROM cms_i18n WHERE locale = #{locale} ORDER BY resource_key ASC
</select>
<select id="findLocales" resultType="string">
SELECT DISTINCT locale FROM cms_i18n ORDER BY locale ASC
</select>
<select id="findById" resultMap="i18nMap">SELECT * FROM cms_i18n WHERE id = #{id}</select>
<update id="upsert">
INSERT INTO cms_i18n (locale, resource_key, value, updated_at)
VALUES (#{locale}, #{resourceKey}, #{value}, NOW())
ON CONFLICT (locale, resource_key) DO UPDATE SET value = #{value}, updated_at = NOW()
</update>
<delete id="delete">DELETE FROM cms_i18n WHERE id = #{id}</delete>
</mapper>

View File

@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.zioinfo.cms.media.mapper.MediaMapper">
<resultMap id="mediaMap" type="com.zioinfo.cms.media.CmsMedia">
<id property="id" column="id"/>
<result property="fileName" column="file_name"/>
<result property="mediaType" column="media_type"/>
<result property="url" column="url"/>
<result property="folder" column="folder"/>
<result property="mimeType" column="mime_type"/>
<result property="sizeBytes" column="size_bytes"/>
<result property="altText" column="alt_text"/>
<result property="tags" column="tags"/>
<result property="refCount" column="ref_count"/>
<result property="createdBy" column="created_by"/>
<result property="createdAt" column="created_at"/>
</resultMap>
<select id="findAll" resultMap="mediaMap">
SELECT * FROM cms_media
<where>
<if test="folder != null and folder != ''">AND folder = #{folder}</if>
<if test="mediaType != null and mediaType != ''">AND media_type = #{mediaType}</if>
<if test="keyword != null and keyword != ''">AND (file_name ILIKE '%'||#{keyword}||'%' OR tags ILIKE '%'||#{keyword}||'%')</if>
</where>
ORDER BY id DESC
</select>
<select id="findById" resultMap="mediaMap">SELECT * FROM cms_media WHERE id = #{id}</select>
<insert id="insert" parameterType="com.zioinfo.cms.media.CmsMedia" useGeneratedKeys="true" keyProperty="id">
INSERT INTO cms_media (file_name, media_type, url, folder, mime_type, size_bytes, alt_text, tags, ref_count, created_by)
VALUES (#{fileName}, #{mediaType}, #{url}, #{folder}, #{mimeType}, #{sizeBytes}, #{altText}, #{tags}, #{refCount}, #{createdBy})
</insert>
<update id="updateMeta">
UPDATE cms_media SET alt_text = #{altText}, tags = #{tags} WHERE id = #{id}
</update>
<delete id="delete">DELETE FROM cms_media WHERE id = #{id}</delete>
</mapper>

View File

@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.zioinfo.cms.member.mapper.MemberMapper">
<resultMap id="memberMap" type="com.zioinfo.cms.member.CmsMember">
<id property="id" column="id"/>
<result property="memberCode" column="member_code"/>
<result property="name" column="name"/>
<result property="emailEnc" column="email_enc"/>
<result property="phoneEnc" column="phone_enc"/>
<result property="segment" column="segment"/>
<result property="status" column="status"/>
<result property="crmCustomerId" column="crm_customer_id"/>
<result property="createdAt" column="created_at"/>
</resultMap>
<select id="findAll" resultMap="memberMap">
SELECT * FROM cms_member
<where>
<if test="segment != null and segment != ''">AND segment = #{segment}</if>
<if test="status != null and status != ''">AND status = #{status}</if>
<if test="keyword != null and keyword != ''">AND (name ILIKE '%'||#{keyword}||'%' OR member_code ILIKE '%'||#{keyword}||'%')</if>
</where>
ORDER BY id DESC
</select>
<select id="findById" resultMap="memberMap">SELECT * FROM cms_member WHERE id = #{id}</select>
<insert id="insert" parameterType="com.zioinfo.cms.member.CmsMember" useGeneratedKeys="true" keyProperty="id">
INSERT INTO cms_member (member_code, name, email_enc, phone_enc, segment, status, crm_customer_id)
VALUES (#{memberCode}, #{name}, #{emailEnc}, #{phoneEnc}, #{segment}, #{status}, #{crmCustomerId})
</insert>
<update id="update" parameterType="com.zioinfo.cms.member.CmsMember">
UPDATE cms_member SET name = #{name}, email_enc = #{emailEnc}, phone_enc = #{phoneEnc},
segment = #{segment}, status = #{status}, crm_customer_id = #{crmCustomerId}
WHERE id = #{id}
</update>
<delete id="delete">DELETE FROM cms_member WHERE id = #{id}</delete>
</mapper>

Some files were not shown because too many files have changed in this diff Show More