feat(cms): GUARDiA CMS v1.0 초기 구축
This commit is contained in:
commit
5f41862c68
59
Jenkinsfile
vendored
Normal file
59
Jenkinsfile
vendored
Normal 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
5
backend/.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
target/
|
||||
*.class
|
||||
.idea/
|
||||
*.iml
|
||||
.DS_Store
|
||||
82
backend/pom.xml
Normal file
82
backend/pom.xml
Normal 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>
|
||||
26
backend/src/main/java/com/zioinfo/cms/CmsApplication.java
Normal file
26
backend/src/main/java/com/zioinfo/cms/CmsApplication.java
Normal 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>콘텐츠(페이지/포스트/블록 헤드리스) → 게시 워크플로우(draft→review→approved→published)
|
||||
* → 예약/롤백/버전 → 헤드리스 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);
|
||||
}
|
||||
}
|
||||
@ -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) {}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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";
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
23
backend/src/main/java/com/zioinfo/cms/admin/dto/UserDto.java
Normal file
23
backend/src/main/java/com/zioinfo/cms/admin/dto/UserDto.java
Normal 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());
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
72
backend/src/main/java/com/zioinfo/cms/ai/AiController.java
Normal file
72
backend/src/main/java/com/zioinfo/cms/ai/AiController.java
Normal 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")));
|
||||
}
|
||||
}
|
||||
299
backend/src/main/java/com/zioinfo/cms/ai/AiService.java
Normal file
299
backend/src/main/java/com/zioinfo/cms/ai/AiService.java
Normal 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;
|
||||
}
|
||||
}
|
||||
94
backend/src/main/java/com/zioinfo/cms/ai/OllamaClient.java
Normal file
94
backend/src/main/java/com/zioinfo/cms/ai/OllamaClient.java
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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; }
|
||||
}
|
||||
}
|
||||
@ -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; // 파생
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
@ -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) {}
|
||||
}
|
||||
40
backend/src/main/java/com/zioinfo/cms/auth/AuthService.java
Normal file
40
backend/src/main/java/com/zioinfo/cms/auth/AuthService.java
Normal 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;
|
||||
}
|
||||
}
|
||||
21
backend/src/main/java/com/zioinfo/cms/auth/CmsUser.java
Normal file
21
backend/src/main/java/com/zioinfo/cms/auth/CmsUser.java
Normal 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;
|
||||
}
|
||||
40
backend/src/main/java/com/zioinfo/cms/auth/JwtFilter.java
Normal file
40
backend/src/main/java/com/zioinfo/cms/auth/JwtFilter.java
Normal 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);
|
||||
}
|
||||
}
|
||||
59
backend/src/main/java/com/zioinfo/cms/auth/JwtUtil.java
Normal file
59
backend/src/main/java/com/zioinfo/cms/auth/JwtUtil.java
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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); }
|
||||
}
|
||||
}
|
||||
22
backend/src/main/java/com/zioinfo/cms/banner/CmsBanner.java
Normal file
22
backend/src/main/java/com/zioinfo/cms/banner/CmsBanner.java
Normal 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;
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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); }
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
93
backend/src/main/java/com/zioinfo/cms/common/CryptoUtil.java
Normal file
93
backend/src/main/java/com/zioinfo/cms/common/CryptoUtil.java
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
25
backend/src/main/java/com/zioinfo/cms/config/CorsConfig.java
Normal file
25
backend/src/main/java/com/zioinfo/cms/config/CorsConfig.java
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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";
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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) {}
|
||||
}
|
||||
@ -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 (역방향: REJECT→DRAFT, ARCHIVE).
|
||||
* 게시 승인/발행은 EDITOR 이상만 가능(메서드 가드). 모든 전이는 버전 스냅샷 + 감사로그.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ContentService {
|
||||
|
||||
private static final Set<String> EDITOR_ROLES = Set.of("EDITOR", "SUPERADMIN");
|
||||
private static final Set<String> TYPES = Set.of("PAGE", "POST", "BLOCK", "PRODUCT_DETAIL");
|
||||
|
||||
private final ContentMapper mapper;
|
||||
private final AuditService auditService;
|
||||
|
||||
public List<CmsContent> list(String contentType, String status, String locale, String keyword) {
|
||||
return mapper.findAll(contentType, status, locale, keyword);
|
||||
}
|
||||
|
||||
public CmsContent get(Long id) {
|
||||
CmsContent c = mapper.findById(id);
|
||||
if (c == null) {
|
||||
throw new RuntimeException("ERR-CMS-CONTENT-404: 콘텐츠 없음");
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public CmsContent create(Map<String, Object> req, String actor) {
|
||||
CmsContent c = new CmsContent();
|
||||
bind(c, req);
|
||||
if (c.getContentType() == null || !TYPES.contains(c.getContentType())) {
|
||||
c.setContentType("PAGE");
|
||||
}
|
||||
if (c.getSlug() == null || c.getSlug().isBlank()) {
|
||||
throw new IllegalArgumentException("ERR-CMS-CONTENT-400: slug 필수");
|
||||
}
|
||||
if (mapper.countBySlug(c.getSlug(), c.getLocale(), null) > 0) {
|
||||
throw new RuntimeException("ERR-CMS-CONTENT-409: 동일 locale 내 slug 중복");
|
||||
}
|
||||
c.setStatus("DRAFT");
|
||||
c.setVersion(1);
|
||||
c.setCreatedBy(actor);
|
||||
c.setUpdatedBy(actor);
|
||||
mapper.insert(c);
|
||||
snapshot(c, "created");
|
||||
auditService.log("CONTENT_CREATE", c.getSlug(), "type=" + c.getContentType());
|
||||
return mapper.findById(c.getId());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public CmsContent update(Long id, Map<String, Object> req, String actor) {
|
||||
CmsContent c = get(id);
|
||||
if ("PUBLISHED".equals(c.getStatus())) {
|
||||
// 발행본 직접 수정 시 DRAFT로 되돌려 재워크플로우 (콘텐츠 무결성)
|
||||
c.setStatus("DRAFT");
|
||||
}
|
||||
bind(c, req);
|
||||
if (c.getSlug() != null && mapper.countBySlug(c.getSlug(), c.getLocale(), id) > 0) {
|
||||
throw new RuntimeException("ERR-CMS-CONTENT-409: 동일 locale 내 slug 중복");
|
||||
}
|
||||
c.setVersion(c.getVersion() == null ? 1 : c.getVersion() + 1);
|
||||
c.setUpdatedBy(actor);
|
||||
mapper.update(c);
|
||||
snapshot(c, "updated");
|
||||
auditService.log("CONTENT_UPDATE", c.getSlug(), "v" + c.getVersion());
|
||||
return mapper.findById(id);
|
||||
}
|
||||
|
||||
public void delete(Long id) {
|
||||
CmsContent c = get(id);
|
||||
mapper.delete(id);
|
||||
auditService.log("CONTENT_DELETE", c.getSlug(), "type=" + c.getContentType());
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시 워크플로우 전이.
|
||||
*
|
||||
* @param action submit(→REVIEW) / approve(→APPROVED) / publish(→PUBLISHED)
|
||||
* / reject(→DRAFT) / archive(→ARCHIVED) / schedule(예약→APPROVED+scheduledAt)
|
||||
*/
|
||||
@Transactional
|
||||
public CmsContent transition(Long id, String action, String note, LocalDateTime scheduledAt,
|
||||
String actor, String role) {
|
||||
CmsContent c = get(id);
|
||||
String from = c.getStatus();
|
||||
String to = switch (action == null ? "" : action.toLowerCase()) {
|
||||
case "submit" -> requireFrom(from, Set.of("DRAFT"), "REVIEW");
|
||||
case "approve" -> { requireEditor(role); yield requireFrom(from, Set.of("REVIEW"), "APPROVED"); }
|
||||
case "publish" -> { requireEditor(role); yield requireFrom(from, Set.of("APPROVED", "REVIEW"), "PUBLISHED"); }
|
||||
case "reject" -> { requireEditor(role); yield requireFrom(from, Set.of("REVIEW", "APPROVED"), "DRAFT"); }
|
||||
case "archive" -> { requireEditor(role); yield "ARCHIVED"; }
|
||||
case "schedule" -> {
|
||||
requireEditor(role);
|
||||
if (scheduledAt == null) throw new IllegalArgumentException("ERR-CMS-CONTENT-400: scheduledAt 필수");
|
||||
c.setScheduledAt(scheduledAt);
|
||||
yield "APPROVED";
|
||||
}
|
||||
default -> throw new IllegalArgumentException("ERR-CMS-CONTENT-400: 알 수 없는 action");
|
||||
};
|
||||
mapper.updateStatus(id, to, actor);
|
||||
if ("PUBLISHED".equals(to)) {
|
||||
mapper.updatePublished(id, to);
|
||||
}
|
||||
if ("schedule".equalsIgnoreCase(action)) {
|
||||
mapper.update(c); // scheduledAt 반영
|
||||
}
|
||||
CmsContent updated = mapper.findById(id);
|
||||
snapshot(updated, "transition " + from + "->" + to + (note == null ? "" : " : " + note));
|
||||
auditService.log("CONTENT_" + (action == null ? "" : action.toUpperCase()), c.getSlug(), from + " -> " + to);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** 특정 버전으로 롤백 — 해당 스냅샷 본문으로 새 버전 생성, 상태 DRAFT. */
|
||||
@Transactional
|
||||
public CmsContent rollback(Long id, Integer version, String actor) {
|
||||
CmsContent c = get(id);
|
||||
CmsContentVersion v = mapper.findVersion(id, version);
|
||||
if (v == null) {
|
||||
throw new RuntimeException("ERR-CMS-CONTENT-404: 버전 없음");
|
||||
}
|
||||
c.setTitle(v.getTitle());
|
||||
c.setSummary(v.getSummary());
|
||||
c.setBlocks(v.getBlocks());
|
||||
c.setStatus("DRAFT");
|
||||
c.setVersion(c.getVersion() + 1);
|
||||
c.setUpdatedBy(actor);
|
||||
mapper.update(c);
|
||||
snapshot(c, "rollback to v" + version);
|
||||
auditService.log("CONTENT_ROLLBACK", c.getSlug(), "to v" + version);
|
||||
return mapper.findById(id);
|
||||
}
|
||||
|
||||
public List<CmsContentVersion> versions(Long id) {
|
||||
return mapper.findVersions(id);
|
||||
}
|
||||
|
||||
/** 예약 게시 스케줄러 — 1분마다 예약 시각 도래한 APPROVED 콘텐츠를 PUBLISHED 처리. */
|
||||
@Scheduled(fixedDelay = 60000)
|
||||
public void publishDue() {
|
||||
try {
|
||||
List<CmsContent> due = mapper.findDueScheduled();
|
||||
for (CmsContent c : due) {
|
||||
mapper.updatePublished(c.getId(), "PUBLISHED");
|
||||
mapper.updateStatus(c.getId(), "PUBLISHED", "scheduler");
|
||||
auditService.log("scheduler", "CONTENT_SCHEDULED_PUBLISH", c.getSlug(), "scheduledAt 도래");
|
||||
log.info("예약 게시 발행: {}", c.getSlug());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("예약 게시 스케줄러 오류: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ── helpers ──
|
||||
private void requireEditor(String role) {
|
||||
if (role == null || !EDITOR_ROLES.contains(role.toUpperCase())) {
|
||||
throw new RuntimeException("ERR-CMS-403: 게시 승인/발행은 EDITOR 이상만 가능합니다");
|
||||
}
|
||||
}
|
||||
|
||||
private String requireFrom(String from, Set<String> allowed, String to) {
|
||||
if (!allowed.contains(from)) {
|
||||
throw new RuntimeException("ERR-CMS-CONTENT-409: 현재 상태(" + from + ")에서 전이 불가");
|
||||
}
|
||||
return to;
|
||||
}
|
||||
|
||||
private void snapshot(CmsContent c, String note) {
|
||||
CmsContentVersion v = new CmsContentVersion();
|
||||
v.setContentId(c.getId());
|
||||
v.setVersion(c.getVersion());
|
||||
v.setTitle(c.getTitle());
|
||||
v.setSummary(c.getSummary());
|
||||
v.setBlocks(c.getBlocks());
|
||||
v.setStatus(c.getStatus());
|
||||
v.setNote(note);
|
||||
v.setCreatedBy(c.getUpdatedBy() == null ? c.getCreatedBy() : c.getUpdatedBy());
|
||||
mapper.insertVersion(v);
|
||||
}
|
||||
|
||||
private void bind(CmsContent c, Map<String, Object> r) {
|
||||
if (r.containsKey("contentType")) c.setContentType(str(r.get("contentType")));
|
||||
if (r.containsKey("slug")) c.setSlug(str(r.get("slug")));
|
||||
if (r.containsKey("title")) c.setTitle(str(r.get("title")));
|
||||
if (r.containsKey("summary")) c.setSummary(str(r.get("summary")));
|
||||
if (r.containsKey("blocks")) c.setBlocks(jsonStr(r.get("blocks")));
|
||||
if (r.containsKey("locale")) c.setLocale(str(r.get("locale")));
|
||||
if (r.containsKey("menuId")) c.setMenuId(longOf(r.get("menuId")));
|
||||
if (r.containsKey("tags")) c.setTags(str(r.get("tags")));
|
||||
if (r.containsKey("seoMeta")) c.setSeoMeta(jsonStr(r.get("seoMeta")));
|
||||
if (c.getLocale() == null) c.setLocale("ko");
|
||||
}
|
||||
|
||||
private String str(Object o) { return o == null ? null : String.valueOf(o); }
|
||||
|
||||
/** Map/List 는 JSON 문자열로 직렬화(간단 toString — 실제 JSONB 컬럼). */
|
||||
private String jsonStr(Object o) {
|
||||
if (o == null) return null;
|
||||
if (o instanceof String s) return s;
|
||||
try {
|
||||
return new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(o);
|
||||
} catch (Exception e) {
|
||||
return String.valueOf(o);
|
||||
}
|
||||
}
|
||||
|
||||
private Long longOf(Object o) {
|
||||
if (o == null) return null;
|
||||
try { return Long.valueOf(String.valueOf(o).trim()); } catch (Exception e) { return null; }
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
@ -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)));
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
16
backend/src/main/java/com/zioinfo/cms/form/CmsForm.java
Normal file
16
backend/src/main/java/com/zioinfo/cms/form/CmsForm.java
Normal 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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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); }
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
14
backend/src/main/java/com/zioinfo/cms/i18n/CmsI18n.java
Normal file
14
backend/src/main/java/com/zioinfo/cms/i18n/CmsI18n.java
Normal 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;
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
21
backend/src/main/java/com/zioinfo/cms/media/CmsMedia.java
Normal file
21
backend/src/main/java/com/zioinfo/cms/media/CmsMedia.java
Normal 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;
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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; }
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
22
backend/src/main/java/com/zioinfo/cms/member/CmsMember.java
Normal file
22
backend/src/main/java/com/zioinfo/cms/member/CmsMember.java
Normal 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;
|
||||
}
|
||||
@ -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); }
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
24
backend/src/main/java/com/zioinfo/cms/menu/CmsMenu.java
Normal file
24
backend/src/main/java/com/zioinfo/cms/menu/CmsMenu.java
Normal 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<>();
|
||||
}
|
||||
@ -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)));
|
||||
}
|
||||
}
|
||||
107
backend/src/main/java/com/zioinfo/cms/menu/MenuService.java
Normal file
107
backend/src/main/java/com/zioinfo/cms/menu/MenuService.java
Normal 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; } }
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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); }
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
15
backend/src/main/java/com/zioinfo/cms/seo/CmsRedirect.java
Normal file
15
backend/src/main/java/com/zioinfo/cms/seo/CmsRedirect.java
Normal 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;
|
||||
}
|
||||
19
backend/src/main/java/com/zioinfo/cms/seo/CmsSeoMeta.java
Normal file
19
backend/src/main/java/com/zioinfo/cms/seo/CmsSeoMeta.java
Normal 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;
|
||||
}
|
||||
108
backend/src/main/java/com/zioinfo/cms/seo/SeoController.java
Normal file
108
backend/src/main/java/com/zioinfo/cms/seo/SeoController.java
Normal 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; } }
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
16
backend/src/main/java/com/zioinfo/cms/theme/CmsTheme.java
Normal file
16
backend/src/main/java/com/zioinfo/cms/theme/CmsTheme.java
Normal 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;
|
||||
}
|
||||
@ -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); }
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
30
backend/src/main/java/com/zioinfo/cms/ugc/CmsUgc.java
Normal file
30
backend/src/main/java/com/zioinfo/cms/ugc/CmsUgc.java
Normal 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;
|
||||
}
|
||||
64
backend/src/main/java/com/zioinfo/cms/ugc/UgcController.java
Normal file
64
backend/src/main/java/com/zioinfo/cms/ugc/UgcController.java
Normal 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);
|
||||
}
|
||||
}
|
||||
138
backend/src/main/java/com/zioinfo/cms/ugc/UgcService.java
Normal file
138
backend/src/main/java/com/zioinfo/cms/ugc/UgcService.java
Normal 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; } }
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
52
backend/src/main/resources/application.yml
Normal file
52
backend/src/main/resources/application.yml
Normal 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
|
||||
310
backend/src/main/resources/db/schema.sql
Normal file
310
backend/src/main/resources/db/schema.sql
Normal 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 = '기본 테마');
|
||||
56
backend/src/main/resources/mapper/AdminUserMapper.xml
Normal file
56
backend/src/main/resources/mapper/AdminUserMapper.xml
Normal 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>
|
||||
52
backend/src/main/resources/mapper/AnalyticsMapper.xml
Normal file
52
backend/src/main/resources/mapper/AnalyticsMapper.xml
Normal 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>
|
||||
32
backend/src/main/resources/mapper/AuditLogMapper.xml
Normal file
32
backend/src/main/resources/mapper/AuditLogMapper.xml
Normal 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>
|
||||
56
backend/src/main/resources/mapper/BannerMapper.xml
Normal file
56
backend/src/main/resources/mapper/BannerMapper.xml
Normal 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 <= NOW())
|
||||
AND (end_at IS NULL OR end_at >= 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>
|
||||
30
backend/src/main/resources/mapper/ChannelMapper.xml
Normal file
30
backend/src/main/resources/mapper/ChannelMapper.xml
Normal 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>
|
||||
123
backend/src/main/resources/mapper/ContentMapper.xml
Normal file
123
backend/src/main/resources/mapper/ContentMapper.xml
Normal 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 <= 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>
|
||||
19
backend/src/main/resources/mapper/DashboardMapper.xml
Normal file
19
backend/src/main/resources/mapper/DashboardMapper.xml
Normal 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>
|
||||
52
backend/src/main/resources/mapper/FormMapper.xml
Normal file
52
backend/src/main/resources/mapper/FormMapper.xml
Normal 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>
|
||||
32
backend/src/main/resources/mapper/I18nMapper.xml
Normal file
32
backend/src/main/resources/mapper/I18nMapper.xml
Normal 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>
|
||||
44
backend/src/main/resources/mapper/MediaMapper.xml
Normal file
44
backend/src/main/resources/mapper/MediaMapper.xml
Normal 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>
|
||||
43
backend/src/main/resources/mapper/MemberMapper.xml
Normal file
43
backend/src/main/resources/mapper/MemberMapper.xml
Normal 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
Loading…
Reference in New Issue
Block a user