commit 5f41862c687bf573529c76ac73974569f3c6228d Author: zio Date: Sun Jun 14 10:40:05 2026 +0900 feat(cms): GUARDiA CMS v1.0 초기 구축 diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 0000000..21e107e --- /dev/null +++ b/Jenkinsfile @@ -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 배포 실패 — 서비스 중지(롤백)' + } + } +} diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..cdb0d48 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,5 @@ +target/ +*.class +.idea/ +*.iml +.DS_Store diff --git a/backend/pom.xml b/backend/pom.xml new file mode 100644 index 0000000..fa51181 --- /dev/null +++ b/backend/pom.xml @@ -0,0 +1,82 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.5.11 + + + com.zioinfo + guardia-cms + 1.0.0 + GUARDiA CMS + AI 기반 통합 콘텐츠 관리 시스템 (Shopping CMS) — Ollama 온프레미스 AI + 헤드리스 delivery + GUARDiA 연계 + + + 17 + 0.12.6 + 2.6.0 + 3.0.3 + 42.7.7 + + + + + org.springframework.bootspring-boot-starter-web + org.springframework.bootspring-boot-starter-security + org.springframework.bootspring-boot-starter-validation + org.springframework.bootspring-boot-starter-actuator + org.springframework.bootspring-boot-starter-aop + + + + org.mybatis.spring.boot + mybatis-spring-boot-starter + ${mybatis.version} + + + + org.postgresqlpostgresql${postgresql.version} + + + io.jsonwebtokenjjwt-api${jjwt.version} + io.jsonwebtokenjjwt-impl${jjwt.version}runtime + io.jsonwebtokenjjwt-jackson${jjwt.version}runtime + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + ${springdoc.version} + + + + org.projectlomboklomboktrue + + + org.springframework.bootspring-boot-starter-webflux + + + org.springframework.bootspring-boot-starter-testtest + org.springframework.securityspring-security-testtest + + + + guardia-cms-${project.version} + + + org.springframework.boot + spring-boot-maven-plugin + + + org.projectlomboklombok + + + + + + diff --git a/backend/src/main/java/com/zioinfo/cms/CmsApplication.java b/backend/src/main/java/com/zioinfo/cms/CmsApplication.java new file mode 100644 index 0000000..5e39ac4 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/CmsApplication.java @@ -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). + * + *

콘텐츠(페이지/포스트/블록 헤드리스) → 게시 워크플로우(draft→review→approved→published) + * → 예약/롤백/버전 → 헤드리스 delivery API(published만) 까지 콘텐츠 생애주기를 관리한다. + * 상품 상세빌더·배너·미디어·메뉴·테마·i18n·SEO·폼·UGC·회원·분석을 포괄한다. + * + *

보안 불변 규칙: 외부 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); + } +} diff --git a/backend/src/main/java/com/zioinfo/cms/admin/AdminController.java b/backend/src/main/java/com/zioinfo/cms/admin/AdminController.java new file mode 100644 index 0000000..1b0254e --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/admin/AdminController.java @@ -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. + * + *

RBAC(SecurityConfig requestMatchers 로 통제): + *

+ */ +@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> listUsers() { + return ApiResponse.ok(userService.list()); + } + + @PostMapping("/users") + public ApiResponse createUser(@RequestBody CreateUserRequest req) { + return ApiResponse.ok(userService.create(req.username(), req.password(), req.displayName(), req.role())); + } + + @PutMapping("/users/{id}/role") + public ApiResponse updateRole(@PathVariable Long id, @RequestBody RoleRequest req) { + return ApiResponse.ok(userService.updateRole(id, req.role())); + } + + @PutMapping("/users/{id}/active") + public ApiResponse updateActive(@PathVariable Long id, @RequestBody ActiveRequest req) { + return ApiResponse.ok(userService.updateActive(id, req.active())); + } + + @PutMapping("/users/{id}/password") + public ApiResponse resetPassword(@PathVariable Long id, @RequestBody PasswordRequest req) { + return ApiResponse.ok(userService.resetPassword(id, req.password())); + } + + @DeleteMapping("/users/{id}") + public ApiResponse 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> 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> settings() { + return ApiResponse.ok(settingService.list()); + } + + @PutMapping("/settings/{key}") + public ApiResponse 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) {} +} diff --git a/backend/src/main/java/com/zioinfo/cms/admin/AdminUserService.java b/backend/src/main/java/com/zioinfo/cms/admin/AdminUserService.java new file mode 100644 index 0000000..99bb365 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/admin/AdminUserService.java @@ -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 전용). + * + *

RBAC 역할: SUPERADMIN / EDITOR / AUTHOR / VIEWER. + * 보안: 응답은 항상 {@link UserDto} 로 변환하여 password_hash 노출 차단. + */ +@Service +@RequiredArgsConstructor +public class AdminUserService { + + private static final Set VALID_ROLES = Set.of("SUPERADMIN", "EDITOR", "AUTHOR", "VIEWER"); + + private final AdminUserMapper mapper; + private final PasswordEncoder passwordEncoder; + private final AuditService auditService; + + public List 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; + } +} diff --git a/backend/src/main/java/com/zioinfo/cms/admin/AuditService.java b/backend/src/main/java/com/zioinfo/cms/admin/AuditService.java new file mode 100644 index 0000000..9d3ec0d --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/admin/AuditService.java @@ -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 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"; + } +} diff --git a/backend/src/main/java/com/zioinfo/cms/admin/SettingService.java b/backend/src/main/java/com/zioinfo/cms/admin/SettingService.java new file mode 100644 index 0000000..c05c13a --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/admin/SettingService.java @@ -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 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); + } +} diff --git a/backend/src/main/java/com/zioinfo/cms/admin/dto/AuditLog.java b/backend/src/main/java/com/zioinfo/cms/admin/dto/AuditLog.java new file mode 100644 index 0000000..3ba4b38 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/admin/dto/AuditLog.java @@ -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; +} diff --git a/backend/src/main/java/com/zioinfo/cms/admin/dto/CmsSetting.java b/backend/src/main/java/com/zioinfo/cms/admin/dto/CmsSetting.java new file mode 100644 index 0000000..9722101 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/admin/dto/CmsSetting.java @@ -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; +} diff --git a/backend/src/main/java/com/zioinfo/cms/admin/dto/UserDto.java b/backend/src/main/java/com/zioinfo/cms/admin/dto/UserDto.java new file mode 100644 index 0000000..09feb52 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/admin/dto/UserDto.java @@ -0,0 +1,23 @@ +package com.zioinfo.cms.admin.dto; + +import com.zioinfo.cms.auth.CmsUser; + +import java.time.LocalDateTime; + +/** + * 사용자 응답 DTO. + * + *

보안 불변 규칙: 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()); + } +} diff --git a/backend/src/main/java/com/zioinfo/cms/admin/mapper/AdminUserMapper.java b/backend/src/main/java/com/zioinfo/cms/admin/mapper/AdminUserMapper.java new file mode 100644 index 0000000..fc77e8e --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/admin/mapper/AdminUserMapper.java @@ -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). + * + *

@Mapper 필수 — MyBatisConfig 가 annotationClass=Mapper.class 로 스캔. + */ +@Mapper +public interface AdminUserMapper { + + List 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); +} diff --git a/backend/src/main/java/com/zioinfo/cms/admin/mapper/AuditLogMapper.java b/backend/src/main/java/com/zioinfo/cms/admin/mapper/AuditLogMapper.java new file mode 100644 index 0000000..320acd0 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/admin/mapper/AuditLogMapper.java @@ -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 find(@Param("action") String action, + @Param("actor") String actor, + @Param("limit") int limit); +} diff --git a/backend/src/main/java/com/zioinfo/cms/admin/mapper/SettingMapper.java b/backend/src/main/java/com/zioinfo/cms/admin/mapper/SettingMapper.java new file mode 100644 index 0000000..61c0918 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/admin/mapper/SettingMapper.java @@ -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 findAll(); + + CmsSetting findByKey(@Param("key") String key); + + int upsert(@Param("key") String key, @Param("value") String value); +} diff --git a/backend/src/main/java/com/zioinfo/cms/ai/AiController.java b/backend/src/main/java/com/zioinfo/cms/ai/AiController.java new file mode 100644 index 0000000..ea010a8 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/ai/AiController.java @@ -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 폴백). + * + *

모든 엔드포인트는 Ollama 미가용 시에도 폴백으로 동작한다. + */ +@RestController +@RequestMapping("/api/cms/ai") +@RequiredArgsConstructor +public class AiController { + + private final AiService aiService; + + @GetMapping("/status") + public ApiResponse> status() { + return ApiResponse.ok(Map.of("ollamaAvailable", aiService.ollamaAvailable())); + } + + /** 1. 콘텐츠 초안 생성. */ + @PostMapping("/draft") + public ApiResponse> draft(@RequestBody Map 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> imageTags(@RequestBody Map req) { + return ApiResponse.ok(aiService.imageTags(req.get("imageBase64"), req.get("fileName"))); + } + + /** 3. SEO 제안. */ + @PostMapping("/seo") + public ApiResponse> seo(@RequestBody Map req) { + return ApiResponse.ok(aiService.seoSuggest(req.get("title"), req.get("body"))); + } + + /** 4. 번역. */ + @PostMapping("/translate") + public ApiResponse> translate(@RequestBody Map 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> reviewSummary(@RequestBody Map req) { + @SuppressWarnings("unchecked") + List reviews = (List) req.getOrDefault("reviews", List.of()); + return ApiResponse.ok(aiService.reviewSummary(reviews)); + } + + /** 7. 자연어 검색 키워드. */ + @PostMapping("/search-keywords") + public ApiResponse> searchKeywords(@RequestBody Map req) { + return ApiResponse.ok(Map.of("keywords", aiService.searchKeywords(req.get("query")))); + } + + /** 8. UGC 모더레이션. */ + @PostMapping("/moderate") + public ApiResponse> moderate(@RequestBody Map req) { + return ApiResponse.ok(aiService.moderate(req.get("text"))); + } +} diff --git a/backend/src/main/java/com/zioinfo/cms/ai/AiService.java b/backend/src/main/java/com/zioinfo/cms/ai/AiService.java new file mode 100644 index 0000000..495e1cc --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/ai/AiService.java @@ -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 폴백. + * + *

보안 불변 규칙: 외부 AI API 금지. Ollama 미가용 시 모든 메서드가 규칙기반 폴백으로 동작한다. + * 다른 모듈(ugc 모더레이션·media 대체텍스트·content 초안·seo·delivery 검색)에서 재사용한다. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AiService { + + private final OllamaClient ollama; + + // 욕설/스팸 폴백 사전 (규칙기반) + private static final Set PROFANITY = Set.of( + "욕설", "개새끼", "씨발", "병신", "fuck", "shit", "asshole", "bitch"); + private static final Set 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 imageTags(String imageBase64, String fileName) { + Map 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 seoSuggest(String title, String body) { + Map 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> recommend(String segment, List> 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 reviewSummary(List reviews) { + Map 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 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 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 moderate(String text) { + Map 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 STOPWORDS = Set.of( + "그리고", "또는", "은", "는", "이", "가", "을", "를", "에", "의", "and", "or", "the", "a", "of", "for"); + + private Map fallbackSentiment(List reviews, Map 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 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 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; + } +} diff --git a/backend/src/main/java/com/zioinfo/cms/ai/OllamaClient.java b/backend/src/main/java/com/zioinfo/cms/ai/OllamaClient.java new file mode 100644 index 0000000..ca25db7 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/ai/OllamaClient.java @@ -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 클라이언트. + * + *

보안 불변 규칙: 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 body = Map.of("model", textModel, "prompt", prompt, "stream", false); + Map res = builder.baseUrl(ollamaUrl).build() + .post().uri("/api/generate") + .bodyValue(body) + .retrieve() + .bodyToMono(Map.class) + .timeout(Duration.ofSeconds(30)) + .map(m -> (Map) 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 body = Map.of( + "model", visionModel, + "prompt", prompt, + "images", List.of(imageBase64), + "stream", false); + Map res = builder.baseUrl(ollamaUrl).build() + .post().uri("/api/generate") + .bodyValue(body) + .retrieve() + .bodyToMono(Map.class) + .timeout(Duration.ofSeconds(45)) + .map(m -> (Map) 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; + } + } +} diff --git a/backend/src/main/java/com/zioinfo/cms/analytics/AnalyticsController.java b/backend/src/main/java/com/zioinfo/cms/analytics/AnalyticsController.java new file mode 100644 index 0000000..2271809 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/analytics/AnalyticsController.java @@ -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 피드. + * + *

이벤트 기록(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 event(@RequestBody Map 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> top(@RequestParam(defaultValue = "10") int limit) { + return ApiResponse.ok(mapper.topContent(limit)); + } + + /** 콘텐츠 단건 성과. */ + @GetMapping("/content/{id}") + public ApiResponse byContent(@PathVariable("id") Long contentId) { + return ApiResponse.ok(mapper.findByContent(contentId)); + } + + /** 요약 통계. */ + @GetMapping("/summary") + public ApiResponse> summary() { + Map 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> biFeed() { + Map 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; } + } +} diff --git a/backend/src/main/java/com/zioinfo/cms/analytics/CmsContentStat.java b/backend/src/main/java/com/zioinfo/cms/analytics/CmsContentStat.java new file mode 100644 index 0000000..55de22d --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/analytics/CmsContentStat.java @@ -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; // 파생 +} diff --git a/backend/src/main/java/com/zioinfo/cms/analytics/mapper/AnalyticsMapper.java b/backend/src/main/java/com/zioinfo/cms/analytics/mapper/AnalyticsMapper.java new file mode 100644 index 0000000..f72e8b4 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/analytics/mapper/AnalyticsMapper.java @@ -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 topContent(@Param("limit") int limit); + + CmsContentStat findByContent(@Param("contentId") Long contentId); + + long totalViews(); + long totalConversions(); +} diff --git a/backend/src/main/java/com/zioinfo/cms/auth/AuthController.java b/backend/src/main/java/com/zioinfo/cms/auth/AuthController.java new file mode 100644 index 0000000..6ee58c6 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/auth/AuthController.java @@ -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> 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> me(@RequestHeader("Authorization") String header) { + String token = header.replace("Bearer ", ""); + return ApiResponse.ok(authService.me(token)); + } + + record LoginRequest(String username, String password) {} +} diff --git a/backend/src/main/java/com/zioinfo/cms/auth/AuthService.java b/backend/src/main/java/com/zioinfo/cms/auth/AuthService.java new file mode 100644 index 0000000..16c5ccf --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/auth/AuthService.java @@ -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 me(String token) { + String username = jwtUtil.getUsername(token); + String role = jwtUtil.getRole(token); + CmsUser u = userMapper.findByUsername(username); + Map m = new HashMap<>(); + m.put("username", username); + m.put("role", role); + m.put("displayName", u != null ? u.getDisplayName() : username); + return m; + } +} diff --git a/backend/src/main/java/com/zioinfo/cms/auth/CmsUser.java b/backend/src/main/java/com/zioinfo/cms/auth/CmsUser.java new file mode 100644 index 0000000..e040ce7 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/auth/CmsUser.java @@ -0,0 +1,21 @@ +package com.zioinfo.cms.auth; + +import lombok.Data; +import java.time.LocalDateTime; + +/** + * CMS 관리자/운영자 계정 (cms_user 테이블). + * + *

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; +} diff --git a/backend/src/main/java/com/zioinfo/cms/auth/JwtFilter.java b/backend/src/main/java/com/zioinfo/cms/auth/JwtFilter.java new file mode 100644 index 0000000..f4b1c8c --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/auth/JwtFilter.java @@ -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); + } +} diff --git a/backend/src/main/java/com/zioinfo/cms/auth/JwtUtil.java b/backend/src/main/java/com/zioinfo/cms/auth/JwtUtil.java new file mode 100644 index 0000000..81cd2f9 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/auth/JwtUtil.java @@ -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); + } +} diff --git a/backend/src/main/java/com/zioinfo/cms/auth/mapper/UserMapper.java b/backend/src/main/java/com/zioinfo/cms/auth/mapper/UserMapper.java new file mode 100644 index 0000000..3893a1d --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/auth/mapper/UserMapper.java @@ -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); +} diff --git a/backend/src/main/java/com/zioinfo/cms/banner/BannerController.java b/backend/src/main/java/com/zioinfo/cms/banner/BannerController.java new file mode 100644 index 0000000..46357d4 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/banner/BannerController.java @@ -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(@RequestParam(required = false) String bannerType, + @RequestParam(required = false) String position) { + return ApiResponse.ok(mapper.findAll(bannerType, position)); + } + + /** 현재 노출 대상(active + 스케줄 유효). */ + @GetMapping("/active") + public ApiResponse> active(@RequestParam(required = false) String position) { + return ApiResponse.ok(mapper.findActiveNow(position)); + } + + @GetMapping("/{id}") + public ApiResponse 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 create(@RequestBody Map 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 update(@PathVariable Long id, @RequestBody Map 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 delete(@PathVariable Long id) { + mapper.delete(id); + return ApiResponse.ok(null); + } + + private CmsBanner bind(CmsBanner b, Map 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); } + } +} diff --git a/backend/src/main/java/com/zioinfo/cms/banner/CmsBanner.java b/backend/src/main/java/com/zioinfo/cms/banner/CmsBanner.java new file mode 100644 index 0000000..aba92fc --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/banner/CmsBanner.java @@ -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; +} diff --git a/backend/src/main/java/com/zioinfo/cms/banner/mapper/BannerMapper.java b/backend/src/main/java/com/zioinfo/cms/banner/mapper/BannerMapper.java new file mode 100644 index 0000000..12cfc39 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/banner/mapper/BannerMapper.java @@ -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 findAll(@Param("bannerType") String bannerType, @Param("position") String position); + List 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); +} diff --git a/backend/src/main/java/com/zioinfo/cms/channel/ChannelController.java b/backend/src/main/java/com/zioinfo/cms/channel/ChannelController.java new file mode 100644 index 0000000..a16d964 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/channel/ChannelController.java @@ -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() { + return ApiResponse.ok(mapper.findAll()); + } + + @GetMapping("/{id}") + public ApiResponse 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 create(@RequestBody Map req) { + CmsChannel c = bind(new CmsChannel(), req); + mapper.insert(c); + return ApiResponse.ok(mapper.findById(c.getId())); + } + + @PutMapping("/{id}") + public ApiResponse update(@PathVariable Long id, @RequestBody Map 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 delete(@PathVariable Long id) { + mapper.delete(id); + return ApiResponse.ok(null); + } + + private CmsChannel bind(CmsChannel c, Map 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); } +} diff --git a/backend/src/main/java/com/zioinfo/cms/channel/CmsChannel.java b/backend/src/main/java/com/zioinfo/cms/channel/CmsChannel.java new file mode 100644 index 0000000..c065dbf --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/channel/CmsChannel.java @@ -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; +} diff --git a/backend/src/main/java/com/zioinfo/cms/channel/mapper/ChannelMapper.java b/backend/src/main/java/com/zioinfo/cms/channel/mapper/ChannelMapper.java new file mode 100644 index 0000000..94e4e93 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/channel/mapper/ChannelMapper.java @@ -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 findAll(); + CmsChannel findById(@Param("id") Long id); + int insert(CmsChannel c); + int update(CmsChannel c); + int delete(@Param("id") Long id); +} diff --git a/backend/src/main/java/com/zioinfo/cms/common/ApiResponse.java b/backend/src/main/java/com/zioinfo/cms/common/ApiResponse.java new file mode 100644 index 0000000..8cb3e86 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/common/ApiResponse.java @@ -0,0 +1,20 @@ +package com.zioinfo.cms.common; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class ApiResponse { + private boolean success; + private String message; + private T data; + + public static ApiResponse ok(T data) { + return new ApiResponse<>(true, "OK", data); + } + + public static ApiResponse fail(String message) { + return new ApiResponse<>(false, message, null); + } +} diff --git a/backend/src/main/java/com/zioinfo/cms/common/CryptoUtil.java b/backend/src/main/java/com/zioinfo/cms/common/CryptoUtil.java new file mode 100644 index 0000000..07f9b26 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/common/CryptoUtil.java @@ -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. + * + *

GUARDiA 보안 불변 규칙: 회원 이메일/전화 등 PII와 연계 자격증명은 평문 저장 금지. + * {@code *_enc} 컬럼에 본 유틸로 암호화하여 저장하고, API 응답에는 마스킹/제외한다. + * + *

저장 포맷: 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); + } +} diff --git a/backend/src/main/java/com/zioinfo/cms/common/GlobalExceptionHandler.java b/backend/src/main/java/com/zioinfo/cms/common/GlobalExceptionHandler.java new file mode 100644 index 0000000..67a188c --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/common/GlobalExceptionHandler.java @@ -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; + +/** + * 전역 예외 처리. + * + *

보안 불변 규칙: 스택트레이스를 응답에 절대 노출하지 않는다. + * 에러 코드 + 요약 메시지만 반환한다. + */ +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(MaxUploadSizeExceededException.class) + @ResponseStatus(HttpStatus.PAYLOAD_TOO_LARGE) + public ApiResponse handleMaxSize(MaxUploadSizeExceededException e) { + log.warn("업로드 크기 초과: {}", e.getMessage()); + return ApiResponse.fail("ERR-CMS-413: 파일 크기 초과 (최대 20MB)"); + } + + @ExceptionHandler(AccessDeniedException.class) + @ResponseStatus(HttpStatus.FORBIDDEN) + public ApiResponse handleAccessDenied(AccessDeniedException e) { + log.warn("권한 거부: {}", e.getMessage()); + return ApiResponse.fail("ERR-CMS-403: 권한이 없습니다"); + } + + @ExceptionHandler(IllegalArgumentException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ApiResponse handleIllegalArg(IllegalArgumentException e) { + log.warn("잘못된 요청: {}", e.getMessage()); + return ApiResponse.fail(e.getMessage()); + } + + @ExceptionHandler(RuntimeException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ApiResponse handleRuntime(RuntimeException e) { + // 스택트레이스 미노출 — 에러 코드/요약만 반환 + log.warn("업무 오류: {}", e.getMessage()); + return ApiResponse.fail(e.getMessage()); + } + + @ExceptionHandler(Exception.class) + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + public ApiResponse handleGeneral(Exception e) { + log.error("시스템 오류", e); + return ApiResponse.fail("ERR-SYS-001"); + } +} diff --git a/backend/src/main/java/com/zioinfo/cms/config/CorsConfig.java b/backend/src/main/java/com/zioinfo/cms/config/CorsConfig.java new file mode 100644 index 0000000..3720d03 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/config/CorsConfig.java @@ -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; + } +} diff --git a/backend/src/main/java/com/zioinfo/cms/config/MyBatisConfig.java b/backend/src/main/java/com/zioinfo/cms/config/MyBatisConfig.java new file mode 100644 index 0000000..459a117 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/config/MyBatisConfig.java @@ -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 설정. + * + *

형제 솔루션 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(); + } +} diff --git a/backend/src/main/java/com/zioinfo/cms/config/SecurityConfig.java b/backend/src/main/java/com/zioinfo/cms/config/SecurityConfig.java new file mode 100644 index 0000000..126987c --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/config/SecurityConfig.java @@ -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. + * + *

RBAC 역할(상위 → 하위): SUPERADMIN ⊃ EDITOR ⊃ AUTHOR ⊃ VIEWER. + *

    + *
  • 공개 delivery API(/api/cms/delivery/**) — published 콘텐츠만, 인증 불필요(프리뷰는 토큰 검증)
  • + *
  • auth/health/swagger — permitAll
  • + *
  • 조회(GET /api/cms/**) — 인증 사용자 전체(Viewer+)
  • + *
  • 콘텐츠 작성/수정(POST/PUT/DELETE) — Author 이상
  • + *
  • 게시 승인/발행(workflow approve/publish) — Editor 이상 (서비스/메서드 가드 병행)
  • + *
  • 관리자 API(/api/admin/**) — SuperAdmin 전용(감사로그 조회는 Editor+)
  • + *
+ */ +@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(); + } +} diff --git a/backend/src/main/java/com/zioinfo/cms/config/SpaForwardController.java b/backend/src/main/java/com/zioinfo/cms/config/SpaForwardController.java new file mode 100644 index 0000000..7d5063a --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/config/SpaForwardController.java @@ -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)을 클라이언트 라우팅 경로로 포워드. + * + *

/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"; + } +} diff --git a/backend/src/main/java/com/zioinfo/cms/content/CmsContent.java b/backend/src/main/java/com/zioinfo/cms/content/CmsContent.java new file mode 100644 index 0000000..13cbe3b --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/content/CmsContent.java @@ -0,0 +1,32 @@ +package com.zioinfo.cms.content; + +import lombok.Data; +import java.time.LocalDateTime; + +/** + * 콘텐츠 엔티티 (cms_content) — 페이지/포스트/블록 헤드리스 콘텐츠. + * + *

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; +} diff --git a/backend/src/main/java/com/zioinfo/cms/content/CmsContentVersion.java b/backend/src/main/java/com/zioinfo/cms/content/CmsContentVersion.java new file mode 100644 index 0000000..93f409d --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/content/CmsContentVersion.java @@ -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; +} diff --git a/backend/src/main/java/com/zioinfo/cms/content/ContentController.java b/backend/src/main/java/com/zioinfo/cms/content/ContentController.java new file mode 100644 index 0000000..68b38a2 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/content/ContentController.java @@ -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 (헤드리스 페이지/포스트/블록). + * + *

RBAC: 조회 Viewer+, 작성/수정/삭제 Author+, 워크플로우 승인/발행은 서비스에서 Editor+ 가드. + */ +@RestController +@RequestMapping("/api/cms/content") +@RequiredArgsConstructor +public class ContentController { + + private final ContentService service; + + @GetMapping + public ApiResponse> 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 get(@PathVariable Long id) { + return ApiResponse.ok(service.get(id)); + } + + @GetMapping("/{id}/versions") + public ApiResponse> versions(@PathVariable Long id) { + return ApiResponse.ok(service.versions(id)); + } + + @PostMapping + public ApiResponse create(@RequestBody Map req, Authentication auth) { + return ApiResponse.ok(service.create(req, actor(auth))); + } + + @PutMapping("/{id}") + public ApiResponse update(@PathVariable Long id, @RequestBody Map req, Authentication auth) { + return ApiResponse.ok(service.update(id, req, actor(auth))); + } + + @DeleteMapping("/{id}") + public ApiResponse delete(@PathVariable Long id) { + service.delete(id); + return ApiResponse.ok(null); + } + + /** 워크플로우 전이 (submit/approve/publish/reject/archive/schedule). */ + @PostMapping("/{id}/transition") + public ApiResponse 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 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) {} +} diff --git a/backend/src/main/java/com/zioinfo/cms/content/ContentService.java b/backend/src/main/java/com/zioinfo/cms/content/ContentService.java new file mode 100644 index 0000000..f6e057a --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/content/ContentService.java @@ -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 + 게시 워크플로우 + 버전/롤백 + 예약 게시. + * + *

워크플로우: DRAFT → REVIEW → APPROVED → PUBLISHED (역방향: REJECT→DRAFT, ARCHIVE). + * 게시 승인/발행은 EDITOR 이상만 가능(메서드 가드). 모든 전이는 버전 스냅샷 + 감사로그. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class ContentService { + + private static final Set EDITOR_ROLES = Set.of("EDITOR", "SUPERADMIN"); + private static final Set TYPES = Set.of("PAGE", "POST", "BLOCK", "PRODUCT_DETAIL"); + + private final ContentMapper mapper; + private final AuditService auditService; + + public List 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 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 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 versions(Long id) { + return mapper.findVersions(id); + } + + /** 예약 게시 스케줄러 — 1분마다 예약 시각 도래한 APPROVED 콘텐츠를 PUBLISHED 처리. */ + @Scheduled(fixedDelay = 60000) + public void publishDue() { + try { + List 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 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 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; } + } +} diff --git a/backend/src/main/java/com/zioinfo/cms/content/mapper/ContentMapper.java b/backend/src/main/java/com/zioinfo/cms/content/mapper/ContentMapper.java new file mode 100644 index 0000000..b0656e7 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/content/mapper/ContentMapper.java @@ -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 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 findDueScheduled(); + + // ── 버전 ── + int insertVersion(CmsContentVersion v); + + List findVersions(@Param("contentId") Long contentId); + + CmsContentVersion findVersion(@Param("contentId") Long contentId, @Param("version") Integer version); +} diff --git a/backend/src/main/java/com/zioinfo/cms/dashboard/DashboardController.java b/backend/src/main/java/com/zioinfo/cms/dashboard/DashboardController.java new file mode 100644 index 0000000..622eced --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/dashboard/DashboardController.java @@ -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> summary() { + Map 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>> workflow() { + return ApiResponse.ok(mapper.contentByStatus()); + } + + /** 인기 콘텐츠. */ + @GetMapping("/top-content") + public ApiResponse topContent() { + return ApiResponse.ok(analyticsMapper.topContent(5)); + } +} diff --git a/backend/src/main/java/com/zioinfo/cms/dashboard/mapper/DashboardMapper.java b/backend/src/main/java/com/zioinfo/cms/dashboard/mapper/DashboardMapper.java new file mode 100644 index 0000000..e9ddd07 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/dashboard/mapper/DashboardMapper.java @@ -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> contentByStatus(); +} diff --git a/backend/src/main/java/com/zioinfo/cms/delivery/DeliveryController.java b/backend/src/main/java/com/zioinfo/cms/delivery/DeliveryController.java new file mode 100644 index 0000000..69a98a8 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/delivery/DeliveryController.java @@ -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). + * + *

보안 불변 규칙: 공개(인증 불필요)이되 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( + @RequestParam(required = false) String contentType, + @RequestParam(defaultValue = "ko") String locale) { + return ApiResponse.ok(service.listPublished(contentType, locale)); + } + + /** slug 단건 조회 (published만). */ + @GetMapping("/content/{slug}") + public ApiResponse bySlug(@PathVariable String slug, + @RequestParam(defaultValue = "ko") String locale) { + return ApiResponse.ok(service.getPublished(slug, locale)); + } + + /** 프리뷰 — 토큰 검증 후 미발행 콘텐츠도 조회 허용. */ + @GetMapping("/preview/{id}") + public ApiResponse preview(@PathVariable Long id, @RequestParam String token) { + return ApiResponse.ok(service.preview(id, token)); + } + + /** 프리뷰 토큰 발급 (인증 사용자 전용은 SecurityConfig 미적용 — POST는 작성권한 필요). */ + @PostMapping("/preview-token/{id}") + public ApiResponse> issueToken(@PathVariable Long id) { + return ApiResponse.ok(Map.of("token", service.issuePreviewToken(id), "contentId", String.valueOf(id))); + } +} diff --git a/backend/src/main/java/com/zioinfo/cms/delivery/DeliveryService.java b/backend/src/main/java/com/zioinfo/cms/delivery/DeliveryService.java new file mode 100644 index 0000000..0e25c2d --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/delivery/DeliveryService.java @@ -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 서비스. + * + *

보안 불변 규칙: 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 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; + } + } +} diff --git a/backend/src/main/java/com/zioinfo/cms/form/CmsForm.java b/backend/src/main/java/com/zioinfo/cms/form/CmsForm.java new file mode 100644 index 0000000..2c68983 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/form/CmsForm.java @@ -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; +} diff --git a/backend/src/main/java/com/zioinfo/cms/form/CmsFormSubmission.java b/backend/src/main/java/com/zioinfo/cms/form/CmsFormSubmission.java new file mode 100644 index 0000000..ab931e6 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/form/CmsFormSubmission.java @@ -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; +} diff --git a/backend/src/main/java/com/zioinfo/cms/form/FormController.java b/backend/src/main/java/com/zioinfo/cms/form/FormController.java new file mode 100644 index 0000000..64b6baa --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/form/FormController.java @@ -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() { + return ApiResponse.ok(mapper.findAll()); + } + + @GetMapping("/{id}") + public ApiResponse 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 create(@RequestBody Map 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 update(@PathVariable Long id, @RequestBody Map 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 delete(@PathVariable Long id) { + mapper.delete(id); + return ApiResponse.ok(null); + } + + /** 폼 제출 — AI 스팸 판정 후 수집. */ + @PostMapping("/{id}/submit") + public ApiResponse submit(@PathVariable Long id, @RequestBody Map 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 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> submissions(@PathVariable Long id, + @RequestParam(defaultValue = "false") boolean includeSpam) { + return ApiResponse.ok(mapper.findSubmissions(id, includeSpam)); + } + + private CmsForm bind(CmsForm f, Map 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); } + } +} diff --git a/backend/src/main/java/com/zioinfo/cms/form/mapper/FormMapper.java b/backend/src/main/java/com/zioinfo/cms/form/mapper/FormMapper.java new file mode 100644 index 0000000..4f8e3eb --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/form/mapper/FormMapper.java @@ -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 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 findSubmissions(@Param("formId") Long formId, @Param("includeSpam") boolean includeSpam); +} diff --git a/backend/src/main/java/com/zioinfo/cms/i18n/CmsI18n.java b/backend/src/main/java/com/zioinfo/cms/i18n/CmsI18n.java new file mode 100644 index 0000000..8e38a0e --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/i18n/CmsI18n.java @@ -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; +} diff --git a/backend/src/main/java/com/zioinfo/cms/i18n/I18nController.java b/backend/src/main/java/com/zioinfo/cms/i18n/I18nController.java new file mode 100644 index 0000000..21349c7 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/i18n/I18nController.java @@ -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> locales() { + return ApiResponse.ok(mapper.findLocales()); + } + + @GetMapping("/resources") + public ApiResponse> resources(@RequestParam(defaultValue = "ko") String locale) { + return ApiResponse.ok(mapper.findByLocale(locale)); + } + + @PostMapping("/resources") + public ApiResponse upsert(@RequestBody Map req) { + mapper.upsert(req.getOrDefault("locale", "ko"), req.get("resourceKey"), req.get("value")); + return ApiResponse.ok(null); + } + + @DeleteMapping("/resources/{id}") + public ApiResponse delete(@PathVariable Long id) { + mapper.delete(id); + return ApiResponse.ok(null); + } + + /** AI 번역 — 텍스트를 대상 로케일로. */ + @PostMapping("/translate") + public ApiResponse> translate(@RequestBody Map 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> translateBatch(@RequestBody Map req) { + String target = req.getOrDefault("targetLocale", "en"); + List 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)); + } +} diff --git a/backend/src/main/java/com/zioinfo/cms/i18n/mapper/I18nMapper.java b/backend/src/main/java/com/zioinfo/cms/i18n/mapper/I18nMapper.java new file mode 100644 index 0000000..265bd10 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/i18n/mapper/I18nMapper.java @@ -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 findByLocale(@Param("locale") String locale); + List 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); +} diff --git a/backend/src/main/java/com/zioinfo/cms/integration/CrmClient.java b/backend/src/main/java/com/zioinfo/cms/integration/CrmClient.java new file mode 100644 index 0000000..13c230a --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/integration/CrmClient.java @@ -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 연계 — 회원 = 고객 인사이트. + * + *

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 getCustomerInsight(String email) { + return http.callMap(crmUrl, "/api/crm/customers/insight?email=" + email, HttpMethod.GET, null); + } +} diff --git a/backend/src/main/java/com/zioinfo/cms/integration/GuardiaHttpClient.java b/backend/src/main/java/com/zioinfo/cms/integration/GuardiaHttpClient.java new file mode 100644 index 0000000..07e5db5 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/integration/GuardiaHttpClient.java @@ -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 클라이언트. + * + *

모든 응답은 {@link ItsmSecuritySanitizer#clean(Object)}로 자격증명을 제거한 뒤 반환한다. + * 연계 실패 시 빈 Map/List 반환(스택트레이스 미노출, 요약 로그만) — 폴백을 호출 측이 수행한다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class GuardiaHttpClient { + + private final WebClient.Builder webClientBuilder; + + @SuppressWarnings("unchecked") + public Map 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 result = headersSpec.retrieve() + .bodyToMono(Map.class) + .timeout(Duration.ofSeconds(10)) + .map(m -> (Map) m) + .block(); + if (result == null) { + return Collections.emptyMap(); + } + return (Map) ItsmSecuritySanitizer.clean(result); + } catch (Exception e) { + log.warn("GUARDiA 연계 일시 실패 [{} {}{}]: {}", method, baseUrl, path, e.getMessage()); + return Collections.emptyMap(); + } + } + + @SuppressWarnings("unchecked") + public List 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 result = headersSpec.retrieve() + .bodyToMono(List.class) + .timeout(Duration.ofSeconds(10)) + .map(l -> (List) l) + .block(); + if (result == null) { + return Collections.emptyList(); + } + return (List) ItsmSecuritySanitizer.clean(result); + } catch (Exception e) { + log.warn("GUARDiA 연계 일시 실패 [{} {}{}]: {}", method, baseUrl, path, e.getMessage()); + return Collections.emptyList(); + } + } +} diff --git a/backend/src/main/java/com/zioinfo/cms/integration/IntegrationController.java b/backend/src/main/java/com/zioinfo/cms/integration/IntegrationController.java new file mode 100644 index 0000000..cabb043 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/integration/IntegrationController.java @@ -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. + * + *

모든 응답은 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> status() { + return ApiResponse.ok(Map.of( + "itsm", itsmClient.available(), + "note", "Mall/OCR/CRM 연계는 호출 시 폴백 동작" + )); + } + + /** Mall 상품 카탈로그 (상품-콘텐츠 매핑 보조). */ + @GetMapping("/mall/products") + public ApiResponse> mallProducts() { + return ApiResponse.ok(mallClient.listProducts()); + } + + /** OCR 추출 결과 (콘텐츠 초안 보조). */ + @GetMapping("/ocr/{documentId}") + public ApiResponse> ocrExtraction(@PathVariable String documentId) { + return ApiResponse.ok(ocrClient.getExtraction(documentId)); + } + + /** CRM 고객 인사이트 (개인화 보조). */ + @GetMapping("/crm/insight") + public ApiResponse> crmInsight(@RequestParam String email) { + return ApiResponse.ok(crmClient.getCustomerInsight(email)); + } +} diff --git a/backend/src/main/java/com/zioinfo/cms/integration/ItsmClient.java b/backend/src/main/java/com/zioinfo/cms/integration/ItsmClient.java new file mode 100644 index 0000000..b9b1236 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/integration/ItsmClient.java @@ -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로 자동 생성한다. + * + *

응답은 {@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 payload = Map.of( + "title", title, + "content", content, + "priority", priority == null ? "MEDIUM" : priority, + "source", "CMS" + ); + Map 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 r = http.callMap(itsmUrl, "/actuator/health", HttpMethod.GET, null); + return !r.isEmpty(); + } +} diff --git a/backend/src/main/java/com/zioinfo/cms/integration/ItsmSecuritySanitizer.java b/backend/src/main/java/com/zioinfo/cms/integration/ItsmSecuritySanitizer.java new file mode 100644 index 0000000..5b96615 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/integration/ItsmSecuritySanitizer.java @@ -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 연계 응답 보안 새니타이저. + * + *

보안 불변 규칙: 서버 자격증명(IP/SSH 계정/비밀번호 등)을 API 응답에 절대 노출하지 않는다. + * ITSM/Mall/CRM/OCR/BI 연계 응답을 CMS로 반환하기 전 반드시 {@link #clean(Object)}를 호출한다. + * + *

Map/List 구조를 재귀적으로 순회하며 민감 필드를 제거한다. + */ +public final class ItsmSecuritySanitizer { + + private static final Set 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 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 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()); + } +} diff --git a/backend/src/main/java/com/zioinfo/cms/integration/MallClient.java b/backend/src/main/java/com/zioinfo/cms/integration/MallClient.java new file mode 100644 index 0000000..7cf22e7 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/integration/MallClient.java @@ -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 연계 — 상품 ↔ 콘텐츠 양방향. + * + *

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 listProducts() { + return http.callList(mallUrl, "/api/mall/products", HttpMethod.GET, null); + } + + /** Mall 상품 단건 조회. */ + public Map getProduct(String mallProductId) { + return http.callMap(mallUrl, "/api/mall/products/" + mallProductId, HttpMethod.GET, null); + } +} diff --git a/backend/src/main/java/com/zioinfo/cms/integration/OcrClient.java b/backend/src/main/java/com/zioinfo/cms/integration/OcrClient.java new file mode 100644 index 0000000..a435a27 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/integration/OcrClient.java @@ -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 getExtraction(String documentId) { + return http.callMap(ocrUrl, "/api/ocr/documents/" + documentId, HttpMethod.GET, null); + } +} diff --git a/backend/src/main/java/com/zioinfo/cms/media/CmsMedia.java b/backend/src/main/java/com/zioinfo/cms/media/CmsMedia.java new file mode 100644 index 0000000..6a6cb56 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/media/CmsMedia.java @@ -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; +} diff --git a/backend/src/main/java/com/zioinfo/cms/media/MediaController.java b/backend/src/main/java/com/zioinfo/cms/media/MediaController.java new file mode 100644 index 0000000..73ee4a6 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/media/MediaController.java @@ -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(@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 get(@PathVariable Long id) { + return ApiResponse.ok(service.get(id)); + } + + @PostMapping + public ApiResponse register(@RequestBody Map req, Authentication auth) { + return ApiResponse.ok(service.register(req, auth != null ? auth.getName() : "system")); + } + + /** AI 대체텍스트/태깅 자동생성. */ + @PostMapping("/{id}/auto-tag") + public ApiResponse autoTag(@PathVariable Long id, @RequestBody(required = false) Map req) { + String img = req == null ? null : req.get("imageBase64"); + return ApiResponse.ok(service.autoTag(id, img)); + } + + @PutMapping("/{id}/meta") + public ApiResponse updateMeta(@PathVariable Long id, @RequestBody Map req) { + return ApiResponse.ok(service.updateMeta(id, req.get("altText"), req.get("tags"))); + } + + @DeleteMapping("/{id}") + public ApiResponse delete(@PathVariable Long id) { + service.delete(id); + return ApiResponse.ok(null); + } +} diff --git a/backend/src/main/java/com/zioinfo/cms/media/MediaService.java b/backend/src/main/java/com/zioinfo/cms/media/MediaService.java new file mode 100644 index 0000000..02cb7e7 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/media/MediaService.java @@ -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 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 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 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; } + } +} diff --git a/backend/src/main/java/com/zioinfo/cms/media/mapper/MediaMapper.java b/backend/src/main/java/com/zioinfo/cms/media/mapper/MediaMapper.java new file mode 100644 index 0000000..3dcd141 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/media/mapper/MediaMapper.java @@ -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 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); +} diff --git a/backend/src/main/java/com/zioinfo/cms/member/CmsMember.java b/backend/src/main/java/com/zioinfo/cms/member/CmsMember.java new file mode 100644 index 0000000..c99989a --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/member/CmsMember.java @@ -0,0 +1,22 @@ +package com.zioinfo.cms.member; + +import lombok.Data; +import java.time.LocalDateTime; + +/** + * CMS 회원 (cms_member) — 사이트 방문/구매 회원. + * + *

보안: 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; +} diff --git a/backend/src/main/java/com/zioinfo/cms/member/MemberController.java b/backend/src/main/java/com/zioinfo/cms/member/MemberController.java new file mode 100644 index 0000000..8d3d91a --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/member/MemberController.java @@ -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. + * + *

보안 불변 규칙: 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(@RequestParam(required = false) String segment, + @RequestParam(required = false) String status, + @RequestParam(required = false) String keyword) { + List all = mapper.findAll(segment, status, keyword); + all.forEach(this::mask); + return ApiResponse.ok(all); + } + + @GetMapping("/{id}") + public ApiResponse get(@PathVariable Long id) { + return ApiResponse.ok(mask(require(id))); + } + + @PostMapping + public ApiResponse create(@RequestBody Map 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 update(@PathVariable Long id, @RequestBody Map 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> 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 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); } +} diff --git a/backend/src/main/java/com/zioinfo/cms/member/mapper/MemberMapper.java b/backend/src/main/java/com/zioinfo/cms/member/mapper/MemberMapper.java new file mode 100644 index 0000000..4290fb7 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/member/mapper/MemberMapper.java @@ -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 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); +} diff --git a/backend/src/main/java/com/zioinfo/cms/menu/CmsMenu.java b/backend/src/main/java/com/zioinfo/cms/menu/CmsMenu.java new file mode 100644 index 0000000..3e4042a --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/menu/CmsMenu.java @@ -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 children = new ArrayList<>(); +} diff --git a/backend/src/main/java/com/zioinfo/cms/menu/MenuController.java b/backend/src/main/java/com/zioinfo/cms/menu/MenuController.java new file mode 100644 index 0000000..c2345b9 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/menu/MenuController.java @@ -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(@RequestParam(required = false) String menuType) { + return ApiResponse.ok(service.listFlat(menuType)); + } + + @GetMapping("/tree") + public ApiResponse> tree(@RequestParam(required = false) String menuType) { + return ApiResponse.ok(service.tree(menuType)); + } + + @GetMapping("/{id}") + public ApiResponse get(@PathVariable Long id) { + return ApiResponse.ok(service.get(id)); + } + + @PostMapping + public ApiResponse create(@RequestBody Map req) { + return ApiResponse.ok(service.create(req)); + } + + @PutMapping("/{id}") + public ApiResponse update(@PathVariable Long id, @RequestBody Map req) { + return ApiResponse.ok(service.update(id, req)); + } + + @DeleteMapping("/{id}") + public ApiResponse delete(@PathVariable Long id) { + service.delete(id); + return ApiResponse.ok(null); + } + + /** 사이트맵 XML 생성. */ + @GetMapping("/sitemap") + public ApiResponse> sitemap(@RequestParam(defaultValue = "https://cms.zioinfo.co.kr") String baseUrl) { + return ApiResponse.ok(Map.of("xml", service.sitemapXml(baseUrl))); + } +} diff --git a/backend/src/main/java/com/zioinfo/cms/menu/MenuService.java b/backend/src/main/java/com/zioinfo/cms/menu/MenuService.java new file mode 100644 index 0000000..5938754 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/menu/MenuService.java @@ -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 listFlat(String menuType) { + return mapper.findAll(menuType); + } + + /** 트리 형태로 조립. */ + public List tree(String menuType) { + List all = mapper.findAll(menuType); + Map byId = new LinkedHashMap<>(); + for (CmsMenu m : all) { m.setChildren(new ArrayList<>()); byId.put(m.getId(), m); } + List 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 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 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 published = contentMapper.findAll(null, "PUBLISHED", null, null); + StringBuilder sb = new StringBuilder(); + sb.append("\n"); + sb.append("\n"); + String base = baseUrl == null ? "" : baseUrl.replaceAll("/+$", ""); + for (CmsContent c : published) { + sb.append(" ").append(base).append("/").append(c.getSlug()).append(""); + if (c.getPublishedAt() != null) { + sb.append("").append(c.getPublishedAt().toLocalDate()).append(""); + } + sb.append("\n"); + } + sb.append(""); + return sb.toString(); + } + + private CmsMenu bind(CmsMenu m, Map 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; } } +} diff --git a/backend/src/main/java/com/zioinfo/cms/menu/mapper/MenuMapper.java b/backend/src/main/java/com/zioinfo/cms/menu/mapper/MenuMapper.java new file mode 100644 index 0000000..47634f4 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/menu/mapper/MenuMapper.java @@ -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 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); +} diff --git a/backend/src/main/java/com/zioinfo/cms/product/CmsProductContent.java b/backend/src/main/java/com/zioinfo/cms/product/CmsProductContent.java new file mode 100644 index 0000000..39f3ea2 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/product/CmsProductContent.java @@ -0,0 +1,26 @@ +package com.zioinfo.cms.product; + +import lombok.Data; +import java.time.LocalDateTime; + +/** + * 상품 콘텐츠 (cms_product_content) — 상품 상세페이지 빌더. + * + *

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; +} diff --git a/backend/src/main/java/com/zioinfo/cms/product/ProductController.java b/backend/src/main/java/com/zioinfo/cms/product/ProductController.java new file mode 100644 index 0000000..98f925b --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/product/ProductController.java @@ -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. + * + *

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(@RequestParam(required = false) String status, + @RequestParam(required = false) String keyword) { + return ApiResponse.ok(mapper.findAll(status, keyword)); + } + + @GetMapping("/{id}") + public ApiResponse 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 create(@RequestBody Map 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 update(@PathVariable Long id, @RequestBody Map 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 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> aiDraft(@RequestBody Map req) { + return ApiResponse.ok(Map.of("draft", aiService.draft("product", req.get("topic"), req.get("tone")))); + } + + /** OCR 문서→상품정보 추출 보조. */ + @GetMapping("/ocr-extract/{documentId}") + public ApiResponse> ocrExtract(@PathVariable String documentId) { + return ApiResponse.ok(ocrClient.getExtraction(documentId)); + } + + @DeleteMapping("/{id}") + public ApiResponse delete(@PathVariable Long id) { + mapper.delete(id); + return ApiResponse.ok(null); + } + + private CmsProductContent bind(CmsProductContent p, Map 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); } + } +} diff --git a/backend/src/main/java/com/zioinfo/cms/product/mapper/ProductContentMapper.java b/backend/src/main/java/com/zioinfo/cms/product/mapper/ProductContentMapper.java new file mode 100644 index 0000000..c71f8d3 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/product/mapper/ProductContentMapper.java @@ -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 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); +} diff --git a/backend/src/main/java/com/zioinfo/cms/seo/CmsRedirect.java b/backend/src/main/java/com/zioinfo/cms/seo/CmsRedirect.java new file mode 100644 index 0000000..ae2c8cb --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/seo/CmsRedirect.java @@ -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; +} diff --git a/backend/src/main/java/com/zioinfo/cms/seo/CmsSeoMeta.java b/backend/src/main/java/com/zioinfo/cms/seo/CmsSeoMeta.java new file mode 100644 index 0000000..726152d --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/seo/CmsSeoMeta.java @@ -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; +} diff --git a/backend/src/main/java/com/zioinfo/cms/seo/SeoController.java b/backend/src/main/java/com/zioinfo/cms/seo/SeoController.java new file mode 100644 index 0000000..f6ee9f4 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/seo/SeoController.java @@ -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> listMeta() { + return ApiResponse.ok(mapper.findAllMeta()); + } + + @GetMapping("/meta/by-path") + public ApiResponse byPath(@RequestParam String pathKey, + @RequestParam(defaultValue = "ko") String locale) { + return ApiResponse.ok(mapper.findMetaByPath(pathKey, locale)); + } + + @PostMapping("/meta") + public ApiResponse createMeta(@RequestBody Map req) { + CmsSeoMeta m = bind(new CmsSeoMeta(), req); + mapper.insertMeta(m); + return ApiResponse.ok(mapper.findMetaById(m.getId())); + } + + @PutMapping("/meta/{id}") + public ApiResponse updateMeta(@PathVariable Long id, @RequestBody Map 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 deleteMeta(@PathVariable Long id) { + mapper.deleteMeta(id); + return ApiResponse.ok(null); + } + + // ── 리다이렉트 ── + @GetMapping("/redirect") + public ApiResponse> listRedirects() { + return ApiResponse.ok(mapper.findAllRedirects()); + } + + @PostMapping("/redirect") + public ApiResponse createRedirect(@RequestBody Map 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 deleteRedirect(@PathVariable Long id) { + mapper.deleteRedirect(id); + return ApiResponse.ok(null); + } + + /** robots.txt 생성. */ + @GetMapping("/robots") + public ApiResponse> 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> aiSuggest(@RequestBody Map req) { + return ApiResponse.ok(aiService.seoSuggest(req.get("title"), req.get("body"))); + } + + private CmsSeoMeta bind(CmsSeoMeta m, Map 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; } } +} diff --git a/backend/src/main/java/com/zioinfo/cms/seo/mapper/SeoMapper.java b/backend/src/main/java/com/zioinfo/cms/seo/mapper/SeoMapper.java new file mode 100644 index 0000000..e394da7 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/seo/mapper/SeoMapper.java @@ -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 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 findAllRedirects(); + int insertRedirect(CmsRedirect r); + int deleteRedirect(@Param("id") Long id); +} diff --git a/backend/src/main/java/com/zioinfo/cms/theme/CmsTheme.java b/backend/src/main/java/com/zioinfo/cms/theme/CmsTheme.java new file mode 100644 index 0000000..c6b67e6 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/theme/CmsTheme.java @@ -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; +} diff --git a/backend/src/main/java/com/zioinfo/cms/theme/ThemeController.java b/backend/src/main/java/com/zioinfo/cms/theme/ThemeController.java new file mode 100644 index 0000000..2870ba6 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/theme/ThemeController.java @@ -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() { + return ApiResponse.ok(mapper.findAll()); + } + + @GetMapping("/active") + public ApiResponse active() { + return ApiResponse.ok(mapper.findActive()); + } + + @GetMapping("/{id}") + public ApiResponse 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 create(@RequestBody Map 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 update(@PathVariable Long id, @RequestBody Map 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 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 delete(@PathVariable Long id) { + mapper.delete(id); + return ApiResponse.ok(null); + } + + private CmsTheme bind(CmsTheme t, Map 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); } + } +} diff --git a/backend/src/main/java/com/zioinfo/cms/theme/mapper/ThemeMapper.java b/backend/src/main/java/com/zioinfo/cms/theme/mapper/ThemeMapper.java new file mode 100644 index 0000000..0009f82 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/theme/mapper/ThemeMapper.java @@ -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 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); +} diff --git a/backend/src/main/java/com/zioinfo/cms/ugc/CmsUgc.java b/backend/src/main/java/com/zioinfo/cms/ugc/CmsUgc.java new file mode 100644 index 0000000..6254099 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/ugc/CmsUgc.java @@ -0,0 +1,30 @@ +package com.zioinfo.cms.ugc; + +import lombok.Data; +import java.time.LocalDateTime; + +/** + * UGC 항목 (cms_ugc) — 게시판/리뷰/댓글/문의. + * + *

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; +} diff --git a/backend/src/main/java/com/zioinfo/cms/ugc/UgcController.java b/backend/src/main/java/com/zioinfo/cms/ugc/UgcController.java new file mode 100644 index 0000000..b7b7cb6 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/ugc/UgcController.java @@ -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 — 게시판/리뷰/댓글/문의 + 모더레이션 큐. + * + *

작성(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(@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> queue() { + return ApiResponse.ok(service.queue()); + } + + @GetMapping("/{id}") + public ApiResponse get(@PathVariable Long id) { + return ApiResponse.ok(service.get(id)); + } + + /** UGC 작성 (AI 모더레이션 자동). */ + @PostMapping + public ApiResponse create(@RequestBody Map req) { + return ApiResponse.ok(service.create(req)); + } + + /** 모더레이터 승인/반려. */ + @PutMapping("/{id}/moderate") + public ApiResponse moderate(@PathVariable Long id, @RequestBody Map req) { + return ApiResponse.ok(service.moderate(id, req.get("status"), req.get("reason"))); + } + + /** 리뷰 AI 요약. */ + @GetMapping("/review-summary") + public ApiResponse> reviewSummary(@RequestParam String targetRef) { + return ApiResponse.ok(service.reviewSummary(targetRef)); + } + + @DeleteMapping("/{id}") + public ApiResponse delete(@PathVariable Long id) { + service.delete(id); + return ApiResponse.ok(null); + } +} diff --git a/backend/src/main/java/com/zioinfo/cms/ugc/UgcService.java b/backend/src/main/java/com/zioinfo/cms/ugc/UgcService.java new file mode 100644 index 0000000..0bc96bf --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/ugc/UgcService.java @@ -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 + 리뷰 감성. + * + *

보안: 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 list(String ugcType, String moderationStatus, Long targetContentId) { + List all = mapper.findAll(ugcType, moderationStatus, targetContentId); + all.forEach(this::maskEmail); + return all; + } + + /** 모더레이션 큐 — PENDING 항목. */ + public List queue() { + List 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 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 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 reviewSummary(String targetRef) { + List reviews = mapper.findApprovedReviews(targetRef); + List 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; } } +} diff --git a/backend/src/main/java/com/zioinfo/cms/ugc/mapper/UgcMapper.java b/backend/src/main/java/com/zioinfo/cms/ugc/mapper/UgcMapper.java new file mode 100644 index 0000000..be28eb6 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/ugc/mapper/UgcMapper.java @@ -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 findAll(@Param("ugcType") String ugcType, + @Param("moderationStatus") String moderationStatus, + @Param("targetContentId") Long targetContentId); + CmsUgc findById(@Param("id") Long id); + List 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); +} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml new file mode 100644 index 0000000..9c2e494 --- /dev/null +++ b/backend/src/main/resources/application.yml @@ -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 diff --git a/backend/src/main/resources/db/schema.sql b/backend/src/main/resources/db/schema.sql new file mode 100644 index 0000000..96a14ed --- /dev/null +++ b/backend/src/main/resources/db/schema.sql @@ -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 = '기본 테마'); diff --git a/backend/src/main/resources/mapper/AdminUserMapper.xml b/backend/src/main/resources/mapper/AdminUserMapper.xml new file mode 100644 index 0000000..e2afc31 --- /dev/null +++ b/backend/src/main/resources/mapper/AdminUserMapper.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + INSERT INTO cms_user (username, password_hash, display_name, role, is_active) + VALUES (#{username}, #{passwordHash}, #{displayName}, #{role}, #{active}) + + + + UPDATE cms_user SET role = #{role} WHERE id = #{id} + + + + UPDATE cms_user SET is_active = #{active} WHERE id = #{id} + + + + UPDATE cms_user SET password_hash = #{passwordHash} WHERE id = #{id} + + + + DELETE FROM cms_user WHERE id = #{id} + + + + + + + diff --git a/backend/src/main/resources/mapper/AnalyticsMapper.xml b/backend/src/main/resources/mapper/AnalyticsMapper.xml new file mode 100644 index 0000000..46bd9c2 --- /dev/null +++ b/backend/src/main/resources/mapper/AnalyticsMapper.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + 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() + + + + + + + + + + diff --git a/backend/src/main/resources/mapper/AuditLogMapper.xml b/backend/src/main/resources/mapper/AuditLogMapper.xml new file mode 100644 index 0000000..ff69e3d --- /dev/null +++ b/backend/src/main/resources/mapper/AuditLogMapper.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + INSERT INTO cms_audit_log (actor, action, target, detail) + VALUES (#{actor}, #{action}, #{target}, #{detail}) + + + + + diff --git a/backend/src/main/resources/mapper/BannerMapper.xml b/backend/src/main/resources/mapper/BannerMapper.xml new file mode 100644 index 0000000..bac5450 --- /dev/null +++ b/backend/src/main/resources/mapper/BannerMapper.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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}) + + + + 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} + + + DELETE FROM cms_banner WHERE id = #{id} + + diff --git a/backend/src/main/resources/mapper/ChannelMapper.xml b/backend/src/main/resources/mapper/ChannelMapper.xml new file mode 100644 index 0000000..7a70269 --- /dev/null +++ b/backend/src/main/resources/mapper/ChannelMapper.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + INSERT INTO cms_channel (name, channel_type, endpoint, is_active) + VALUES (#{name}, #{channelType}, #{endpoint}, #{active}) + + + + UPDATE cms_channel SET name = #{name}, channel_type = #{channelType}, endpoint = #{endpoint}, is_active = #{active} + WHERE id = #{id} + + + DELETE FROM cms_channel WHERE id = #{id} + + diff --git a/backend/src/main/resources/mapper/ContentMapper.xml b/backend/src/main/resources/mapper/ContentMapper.xml new file mode 100644 index 0000000..a488403 --- /dev/null +++ b/backend/src/main/resources/mapper/ContentMapper.xml @@ -0,0 +1,123 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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}) + + + + 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 cms_content SET status = #{status}, updated_by = #{updatedBy}, updated_at = NOW() WHERE id = #{id} + + + + UPDATE cms_content SET status = #{status}, published_at = NOW(), scheduled_at = NULL, updated_at = NOW() + WHERE id = #{id} + + + + DELETE FROM cms_content WHERE id = #{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}) + + + + + + + + + + + + + + + + + + + + diff --git a/backend/src/main/resources/mapper/DashboardMapper.xml b/backend/src/main/resources/mapper/DashboardMapper.xml new file mode 100644 index 0000000..2d00671 --- /dev/null +++ b/backend/src/main/resources/mapper/DashboardMapper.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + diff --git a/backend/src/main/resources/mapper/FormMapper.xml b/backend/src/main/resources/mapper/FormMapper.xml new file mode 100644 index 0000000..06162fb --- /dev/null +++ b/backend/src/main/resources/mapper/FormMapper.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO cms_form (name, slug, fields, is_active, created_by) + VALUES (#{name}, #{slug}, #{fields}::jsonb, #{active}, #{createdBy}) + + + + UPDATE cms_form SET name = #{name}, slug = #{slug}, fields = #{fields}::jsonb, is_active = #{active} WHERE id = #{id} + + + DELETE FROM cms_form WHERE id = #{id} + + + INSERT INTO cms_form_submission (form_id, payload, is_spam, spam_risk) + VALUES (#{formId}, #{payload}::jsonb, #{spam}, #{spamRisk}) + + + + + diff --git a/backend/src/main/resources/mapper/I18nMapper.xml b/backend/src/main/resources/mapper/I18nMapper.xml new file mode 100644 index 0000000..22e281f --- /dev/null +++ b/backend/src/main/resources/mapper/I18nMapper.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + 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() + + + DELETE FROM cms_i18n WHERE id = #{id} + + diff --git a/backend/src/main/resources/mapper/MediaMapper.xml b/backend/src/main/resources/mapper/MediaMapper.xml new file mode 100644 index 0000000..64d8f48 --- /dev/null +++ b/backend/src/main/resources/mapper/MediaMapper.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + 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}) + + + + UPDATE cms_media SET alt_text = #{altText}, tags = #{tags} WHERE id = #{id} + + + DELETE FROM cms_media WHERE id = #{id} + + diff --git a/backend/src/main/resources/mapper/MemberMapper.xml b/backend/src/main/resources/mapper/MemberMapper.xml new file mode 100644 index 0000000..0b49e59 --- /dev/null +++ b/backend/src/main/resources/mapper/MemberMapper.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + INSERT INTO cms_member (member_code, name, email_enc, phone_enc, segment, status, crm_customer_id) + VALUES (#{memberCode}, #{name}, #{emailEnc}, #{phoneEnc}, #{segment}, #{status}, #{crmCustomerId}) + + + + UPDATE cms_member SET name = #{name}, email_enc = #{emailEnc}, phone_enc = #{phoneEnc}, + segment = #{segment}, status = #{status}, crm_customer_id = #{crmCustomerId} + WHERE id = #{id} + + + DELETE FROM cms_member WHERE id = #{id} + + diff --git a/backend/src/main/resources/mapper/MenuMapper.xml b/backend/src/main/resources/mapper/MenuMapper.xml new file mode 100644 index 0000000..420b231 --- /dev/null +++ b/backend/src/main/resources/mapper/MenuMapper.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO cms_menu (parent_id, name, menu_type, url, slug, sort_order, depth, is_active) + VALUES (#{parentId}, #{name}, #{menuType}, #{url}, #{slug}, #{sortOrder}, #{depth}, #{active}) + + + + UPDATE cms_menu SET parent_id = #{parentId}, name = #{name}, menu_type = #{menuType}, url = #{url}, + slug = #{slug}, sort_order = #{sortOrder}, is_active = #{active} + WHERE id = #{id} + + + DELETE FROM cms_menu WHERE id = #{id} + + + + diff --git a/backend/src/main/resources/mapper/ProductContentMapper.xml b/backend/src/main/resources/mapper/ProductContentMapper.xml new file mode 100644 index 0000000..7cb2f68 --- /dev/null +++ b/backend/src/main/resources/mapper/ProductContentMapper.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO cms_product_content (mall_product_id, product_name, summary, detail_blocks, specs, locale, status, created_by) + VALUES (#{mallProductId}, #{productName}, #{summary}, #{detailBlocks}::jsonb, #{specs}::jsonb, #{locale}, #{status}, #{createdBy}) + + + + UPDATE cms_product_content SET mall_product_id = #{mallProductId}, product_name = #{productName}, summary = #{summary}, + detail_blocks = #{detailBlocks}::jsonb, specs = #{specs}::jsonb, locale = #{locale}, updated_at = NOW() + WHERE id = #{id} + + + + UPDATE cms_product_content SET status = #{status}, + published_at = CASE WHEN #{status} = 'PUBLISHED' THEN NOW() ELSE published_at END, updated_at = NOW() + WHERE id = #{id} + + + DELETE FROM cms_product_content WHERE id = #{id} + + diff --git a/backend/src/main/resources/mapper/SeoMapper.xml b/backend/src/main/resources/mapper/SeoMapper.xml new file mode 100644 index 0000000..00f0bb5 --- /dev/null +++ b/backend/src/main/resources/mapper/SeoMapper.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO cms_seo_meta (path_key, title, description, keywords, og_title, og_image, canonical, locale) + VALUES (#{pathKey}, #{title}, #{description}, #{keywords}, #{ogTitle}, #{ogImage}, #{canonical}, #{locale}) + + + + UPDATE cms_seo_meta SET path_key = #{pathKey}, title = #{title}, description = #{description}, keywords = #{keywords}, + og_title = #{ogTitle}, og_image = #{ogImage}, canonical = #{canonical}, locale = #{locale}, updated_at = NOW() + WHERE id = #{id} + + + DELETE FROM cms_seo_meta WHERE id = #{id} + + + + + INSERT INTO cms_redirect (from_path, to_path, status_code, is_active) + VALUES (#{fromPath}, #{toPath}, #{statusCode}, #{active}) + + + DELETE FROM cms_redirect WHERE id = #{id} + + diff --git a/backend/src/main/resources/mapper/SettingMapper.xml b/backend/src/main/resources/mapper/SettingMapper.xml new file mode 100644 index 0000000..80df897 --- /dev/null +++ b/backend/src/main/resources/mapper/SettingMapper.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + INSERT INTO cms_setting (key, value, updated_at) + VALUES (#{key}, #{value}, NOW()) + ON CONFLICT (key) DO UPDATE SET value = #{value}, updated_at = NOW() + + + diff --git a/backend/src/main/resources/mapper/ThemeMapper.xml b/backend/src/main/resources/mapper/ThemeMapper.xml new file mode 100644 index 0000000..d6e0e82 --- /dev/null +++ b/backend/src/main/resources/mapper/ThemeMapper.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + INSERT INTO cms_theme (name, layout, tokens, is_active, created_by) + VALUES (#{name}, #{layout}::jsonb, #{tokens}::jsonb, #{active}, #{createdBy}) + + + + UPDATE cms_theme SET name = #{name}, layout = #{layout}::jsonb, tokens = #{tokens}::jsonb WHERE id = #{id} + + + UPDATE cms_theme SET is_active = FALSE + UPDATE cms_theme SET is_active = TRUE WHERE id = #{id} + DELETE FROM cms_theme WHERE id = #{id} + + diff --git a/backend/src/main/resources/mapper/UgcMapper.xml b/backend/src/main/resources/mapper/UgcMapper.xml new file mode 100644 index 0000000..f9a92a3 --- /dev/null +++ b/backend/src/main/resources/mapper/UgcMapper.xml @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO cms_ugc + (ugc_type, target_content_id, target_ref, parent_id, author_name, author_email_enc, body, rating, + moderation_status, ai_risk, ai_reason, sentiment, itsm_sr_id) + VALUES + (#{ugcType}, #{targetContentId}, #{targetRef}, #{parentId}, #{authorName}, #{authorEmail}, #{body}, #{rating}, + #{moderationStatus}, #{aiRisk}, #{aiReason}, #{sentiment}, #{itsmSrId}) + + + + UPDATE cms_ugc SET moderation_status = #{status}, ai_risk = #{aiRisk}, ai_reason = #{aiReason}, moderated_at = NOW() + WHERE id = #{id} + + + + UPDATE cms_ugc SET sentiment = #{sentiment} WHERE id = #{id} + + + + UPDATE cms_ugc SET itsm_sr_id = #{srId} WHERE id = #{id} + + + DELETE FROM cms_ugc WHERE id = #{id} + + diff --git a/backend/src/main/resources/mapper/UserMapper.xml b/backend/src/main/resources/mapper/UserMapper.xml new file mode 100644 index 0000000..ef862ef --- /dev/null +++ b/backend/src/main/resources/mapper/UserMapper.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + INSERT INTO cms_user (username, password_hash, display_name, role, is_active) + VALUES (#{username}, #{passwordHash}, #{displayName}, #{role}, #{active}) + + + diff --git a/backend/src/main/resources/static/assets/index-DzayOpa6.css b/backend/src/main/resources/static/assets/index-DzayOpa6.css new file mode 100644 index 0000000..5852e16 --- /dev/null +++ b/backend/src/main/resources/static/assets/index-DzayOpa6.css @@ -0,0 +1 @@ +*,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji";font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{top:0;right:0;bottom:0;left:0}.left-2\.5{left:.625rem}.top-2\.5{top:.625rem}.z-50{z-index:50}.col-span-1{grid-column:span 1 / span 1}.col-span-2{grid-column:span 2 / span 2}.col-span-3{grid-column:span 3 / span 3}.mb-1{margin-bottom:.25rem}.mb-1\.5{margin-bottom:.375rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-6{margin-bottom:1.5rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-auto{margin-left:auto}.mr-1{margin-right:.25rem}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-1\.5{margin-top:.375rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.line-clamp-2{overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2}.block{display:block}.inline-block{display:inline-block}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.contents{display:contents}.aspect-\[16\/6\]{aspect-ratio:16/6}.aspect-video{aspect-ratio:16 / 9}.h-1\.5{height:.375rem}.h-16{height:4rem}.h-6{height:1.5rem}.h-\[240px\]{height:240px}.h-full{height:100%}.h-screen{height:100vh}.max-h-48{max-height:12rem}.max-h-72{max-height:18rem}.max-h-\[50vh\]{max-height:50vh}.max-h-\[60vh\]{max-height:60vh}.max-h-\[90vh\]{max-height:90vh}.min-h-\[60px\]{min-height:60px}.min-h-screen{min-height:100vh}.w-1\.5{width:.375rem}.w-3\.5{width:.875rem}.w-32{width:8rem}.w-36{width:9rem}.w-40{width:10rem}.w-44{width:11rem}.w-52{width:13rem}.w-56{width:14rem}.w-6{width:1.5rem}.w-60{width:15rem}.w-\[360px\]{width:360px}.w-\[480px\]{width:480px}.w-\[760px\]{width:760px}.w-full{width:100%}.min-w-0{min-width:0px}.max-w-\[280px\]{max-width:280px}.max-w-\[360px\]{max-width:360px}.flex-1{flex:1 1 0%}.shrink-0{flex-shrink:0}.cursor-pointer{cursor:pointer}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-0\.5{gap:.125rem}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.space-y-1\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.375rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.375rem * var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem * var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.75rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem * var(--tw-space-y-reverse))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse: 0;border-top-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px * var(--tw-divide-y-reverse))}.divide-edge\/50>:not([hidden])~:not([hidden]){border-color:#26304a80}.self-start{align-self:flex-start}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-xl{border-radius:.75rem}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-r{border-right-width:1px}.border-r-2{border-right-width:2px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-accent\/30{border-color:#3ddc974d}.border-amber-500\/30{border-color:#f59e0b4d}.border-brand{--tw-border-opacity: 1;border-color:rgb(0 160 200 / var(--tw-border-opacity, 1))}.border-brand\/30{border-color:#00a0c84d}.border-edge{--tw-border-opacity: 1;border-color:rgb(38 48 74 / var(--tw-border-opacity, 1))}.border-edge\/50{border-color:#26304a80}.border-emerald-500\/30{border-color:#10b9814d}.border-rose-500\/30{border-color:#f43f5e4d}.border-rose-500\/40{border-color:#f43f5e66}.border-sky-500\/30{border-color:#0ea5e94d}.border-slate-500\/30{border-color:#64748b4d}.border-slate-600\/30{border-color:#4755694d}.border-violet-500\/30{border-color:#8b5cf64d}.bg-accent{--tw-bg-opacity: 1;background-color:rgb(61 220 151 / var(--tw-bg-opacity, 1))}.bg-accent\/10{background-color:#3ddc971a}.bg-accent\/15{background-color:#3ddc9726}.bg-amber-500\/10{background-color:#f59e0b1a}.bg-amber-500\/15{background-color:#f59e0b26}.bg-black\/50{background-color:#00000080}.bg-brand{--tw-bg-opacity: 1;background-color:rgb(0 160 200 / var(--tw-bg-opacity, 1))}.bg-brand\/10{background-color:#00a0c81a}.bg-brand\/15{background-color:#00a0c826}.bg-card{--tw-bg-opacity: 1;background-color:rgb(26 34 52 / var(--tw-bg-opacity, 1))}.bg-emerald-500\/15{background-color:#10b98126}.bg-ink{--tw-bg-opacity: 1;background-color:rgb(11 15 23 / var(--tw-bg-opacity, 1))}.bg-panel{--tw-bg-opacity: 1;background-color:rgb(19 25 39 / var(--tw-bg-opacity, 1))}.bg-rose-500\/10{background-color:#f43f5e1a}.bg-rose-500\/15{background-color:#f43f5e26}.bg-rose-500\/90{background-color:#f43f5ee6}.bg-sky-500\/15{background-color:#0ea5e926}.bg-slate-500{--tw-bg-opacity: 1;background-color:rgb(100 116 139 / var(--tw-bg-opacity, 1))}.bg-slate-500\/15{background-color:#64748b26}.bg-slate-600\/15{background-color:#47556926}.bg-violet-500\/15{background-color:#8b5cf626}.object-cover{-o-object-fit:cover;object-fit:cover}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-10{padding-top:2.5rem;padding-bottom:2.5rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-6{padding-top:1.5rem;padding-bottom:1.5rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-1\.5{padding-bottom:.375rem}.pl-8{padding-left:2rem}.pt-4{padding-top:1rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.align-top{vertical-align:top}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.font-sans{font-family:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji"}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-5xl{font-size:3rem;line-height:1}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.leading-tight{line-height:1.25}.tracking-wider{letter-spacing:.05em}.text-accent{--tw-text-opacity: 1;color:rgb(61 220 151 / var(--tw-text-opacity, 1))}.text-amber-300{--tw-text-opacity: 1;color:rgb(252 211 77 / var(--tw-text-opacity, 1))}.text-amber-400{--tw-text-opacity: 1;color:rgb(251 191 36 / var(--tw-text-opacity, 1))}.text-amber-400\/80{color:#fbbf24cc}.text-brand{--tw-text-opacity: 1;color:rgb(0 160 200 / var(--tw-text-opacity, 1))}.text-emerald-400{--tw-text-opacity: 1;color:rgb(52 211 153 / var(--tw-text-opacity, 1))}.text-ink{--tw-text-opacity: 1;color:rgb(11 15 23 / var(--tw-text-opacity, 1))}.text-rose-300{--tw-text-opacity: 1;color:rgb(253 164 175 / var(--tw-text-opacity, 1))}.text-rose-400{--tw-text-opacity: 1;color:rgb(251 113 133 / var(--tw-text-opacity, 1))}.text-sky-400{--tw-text-opacity: 1;color:rgb(56 189 248 / var(--tw-text-opacity, 1))}.text-slate-200{--tw-text-opacity: 1;color:rgb(226 232 240 / var(--tw-text-opacity, 1))}.text-slate-300{--tw-text-opacity: 1;color:rgb(203 213 225 / var(--tw-text-opacity, 1))}.text-slate-400{--tw-text-opacity: 1;color:rgb(148 163 184 / var(--tw-text-opacity, 1))}.text-slate-500{--tw-text-opacity: 1;color:rgb(100 116 139 / var(--tw-text-opacity, 1))}.text-slate-600{--tw-text-opacity: 1;color:rgb(71 85 105 / var(--tw-text-opacity, 1))}.text-violet-400{--tw-text-opacity: 1;color:rgb(167 139 250 / var(--tw-text-opacity, 1))}.text-white{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.outline-none{outline:2px solid transparent;outline-offset:2px}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}:root{color-scheme:dark}body{margin:0;font-family:Pretendard,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;background:#0b0f17;color:#e6edf6}*{box-sizing:border-box}.inp{width:100%;padding:8px 12px;border-radius:8px;background:#0b1220;border:1px solid #26304a;font-size:13px;color:#e2e8f0;outline:none}.inp:focus{border-color:#00a0c8}textarea.inp{resize:vertical;min-height:80px;font-family:inherit}.hover\:border-brand:hover{--tw-border-opacity: 1;border-color:rgb(0 160 200 / var(--tw-border-opacity, 1))}.hover\:bg-brand\/90:hover{background-color:#00a0c8e6}.hover\:bg-card\/60:hover{background-color:#1a223499}.hover\:bg-panel\/40:hover{background-color:#13192766}.hover\:bg-rose-500:hover{--tw-bg-opacity: 1;background-color:rgb(244 63 94 / var(--tw-bg-opacity, 1))}.hover\:text-accent\/80:hover{color:#3ddc97cc}.hover\:text-brand:hover{--tw-text-opacity: 1;color:rgb(0 160 200 / var(--tw-text-opacity, 1))}.hover\:text-emerald-300:hover{--tw-text-opacity: 1;color:rgb(110 231 183 / var(--tw-text-opacity, 1))}.hover\:text-rose-300:hover{--tw-text-opacity: 1;color:rgb(253 164 175 / var(--tw-text-opacity, 1))}.hover\:text-slate-200:hover{--tw-text-opacity: 1;color:rgb(226 232 240 / var(--tw-text-opacity, 1))}.hover\:underline:hover{text-decoration-line:underline}.focus\:border-brand:focus{--tw-border-opacity: 1;border-color:rgb(0 160 200 / var(--tw-border-opacity, 1))}.disabled\:opacity-50:disabled{opacity:.5}.disabled\:opacity-60:disabled{opacity:.6}@media (min-width: 768px){.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@media (min-width: 1024px){.lg\:col-span-1{grid-column:span 1 / span 1}.lg\:col-span-2{grid-column:span 2 / span 2}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}} diff --git a/backend/src/main/resources/static/assets/index-qhjCbNFY.js b/backend/src/main/resources/static/assets/index-qhjCbNFY.js new file mode 100644 index 0000000..3676def --- /dev/null +++ b/backend/src/main/resources/static/assets/index-qhjCbNFY.js @@ -0,0 +1,487 @@ +var e1=e=>{throw TypeError(e)};var hm=(e,t,n)=>t.has(e)||e1("Cannot "+n);var U=(e,t,n)=>(hm(e,t,"read from private field"),n?n.call(e):t.get(e)),we=(e,t,n)=>t.has(e)?e1("Cannot add the same private member more than once"):t instanceof WeakSet?t.add(e):t.set(e,n),he=(e,t,n,r)=>(hm(e,t,"write to private field"),r?r.call(e,n):t.set(e,n),n),Rt=(e,t,n)=>(hm(e,t,"access private method"),n);var Zc=(e,t,n,r)=>({set _(a){he(e,t,a,n)},get _(){return U(e,t,r)}});(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const a of document.querySelectorAll('link[rel="modulepreload"]'))r(a);new MutationObserver(a=>{for(const i of a)if(i.type==="childList")for(const o of i.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&r(o)}).observe(document,{childList:!0,subtree:!0});function n(a){const i={};return a.integrity&&(i.integrity=a.integrity),a.referrerPolicy&&(i.referrerPolicy=a.referrerPolicy),a.crossOrigin==="use-credentials"?i.credentials="include":a.crossOrigin==="anonymous"?i.credentials="omit":i.credentials="same-origin",i}function r(a){if(a.ep)return;a.ep=!0;const i=n(a);fetch(a.href,i)}})();var Jc=typeof globalThis<"u"?globalThis:typeof window<"u"?window:typeof global<"u"?global:typeof self<"u"?self:{};function Ne(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var A2={exports:{}},qh={};/** + * @license React + * react-jsx-runtime.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var zP=Symbol.for("react.transitional.element"),LP=Symbol.for("react.fragment");function E2(e,t,n){var r=null;if(n!==void 0&&(r=""+n),t.key!==void 0&&(r=""+t.key),"key"in t){n={};for(var a in t)a!=="key"&&(n[a]=t[a])}else n=t;return t=n.ref,{$$typeof:zP,type:e,key:r,ref:t!==void 0?t:null,props:n}}qh.Fragment=LP;qh.jsx=E2;qh.jsxs=E2;A2.exports=qh;var c=A2.exports,_2={exports:{}},ye={};/** + * @license React + * react.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var Sb=Symbol.for("react.transitional.element"),BP=Symbol.for("react.portal"),UP=Symbol.for("react.fragment"),IP=Symbol.for("react.strict_mode"),HP=Symbol.for("react.profiler"),qP=Symbol.for("react.consumer"),FP=Symbol.for("react.context"),GP=Symbol.for("react.forward_ref"),VP=Symbol.for("react.suspense"),KP=Symbol.for("react.memo"),T2=Symbol.for("react.lazy"),YP=Symbol.for("react.activity"),t1=Symbol.iterator;function XP(e){return e===null||typeof e!="object"?null:(e=t1&&e[t1]||e["@@iterator"],typeof e=="function"?e:null)}var N2={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},C2=Object.assign,M2={};function ns(e,t,n){this.props=e,this.context=t,this.refs=M2,this.updater=n||N2}ns.prototype.isReactComponent={};ns.prototype.setState=function(e,t){if(typeof e!="object"&&typeof e!="function"&&e!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,e,t,"setState")};ns.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,"forceUpdate")};function $2(){}$2.prototype=ns.prototype;function wb(e,t,n){this.props=e,this.context=t,this.refs=M2,this.updater=n||N2}var Ob=wb.prototype=new $2;Ob.constructor=wb;C2(Ob,ns.prototype);Ob.isPureReactComponent=!0;var n1=Array.isArray;function Ry(){}var Ye={H:null,A:null,T:null,S:null},P2=Object.prototype.hasOwnProperty;function jb(e,t,n){var r=n.ref;return{$$typeof:Sb,type:e,key:t,ref:r!==void 0?r:null,props:n}}function WP(e,t){return jb(e.type,t,e.props)}function Ab(e){return typeof e=="object"&&e!==null&&e.$$typeof===Sb}function QP(e){var t={"=":"=0",":":"=2"};return"$"+e.replace(/[=:]/g,function(n){return t[n]})}var r1=/\/+/g;function pm(e,t){return typeof e=="object"&&e!==null&&e.key!=null?QP(""+e.key):t.toString(36)}function ZP(e){switch(e.status){case"fulfilled":return e.value;case"rejected":throw e.reason;default:switch(typeof e.status=="string"?e.then(Ry,Ry):(e.status="pending",e.then(function(t){e.status==="pending"&&(e.status="fulfilled",e.value=t)},function(t){e.status==="pending"&&(e.status="rejected",e.reason=t)})),e.status){case"fulfilled":return e.value;case"rejected":throw e.reason}}throw e}function Po(e,t,n,r,a){var i=typeof e;(i==="undefined"||i==="boolean")&&(e=null);var o=!1;if(e===null)o=!0;else switch(i){case"bigint":case"string":case"number":o=!0;break;case"object":switch(e.$$typeof){case Sb:case BP:o=!0;break;case T2:return o=e._init,Po(o(e._payload),t,n,r,a)}}if(o)return a=a(e),o=r===""?"."+pm(e,0):r,n1(a)?(n="",o!=null&&(n=o.replace(r1,"$&/")+"/"),Po(a,t,n,"",function(u){return u})):a!=null&&(Ab(a)&&(a=WP(a,n+(a.key==null||e&&e.key===a.key?"":(""+a.key).replace(r1,"$&/")+"/")+o)),t.push(a)),1;o=0;var l=r===""?".":r+":";if(n1(e))for(var s=0;s>>1,V=M[F];if(0>>1;Fa(ie,B))Da(W,ie)?(M[F]=W,M[D]=B,F=D):(M[F]=ie,M[X]=B,F=X);else if(Da(W,B))M[F]=W,M[D]=B,F=D;else break e}}return z}function a(M,z){var B=M.sortIndex-z.sortIndex;return B!==0?B:M.id-z.id}if(e.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var i=performance;e.unstable_now=function(){return i.now()}}else{var o=Date,l=o.now();e.unstable_now=function(){return o.now()-l}}var s=[],u=[],d=1,f=null,h=3,p=!1,y=!1,m=!1,b=!1,g=typeof setTimeout=="function"?setTimeout:null,S=typeof clearTimeout=="function"?clearTimeout:null,x=typeof setImmediate<"u"?setImmediate:null;function v(M){for(var z=n(u);z!==null;){if(z.callback===null)r(u);else if(z.startTime<=M)r(u),z.sortIndex=z.expirationTime,t(s,z);else break;z=n(u)}}function w(M){if(m=!1,v(M),!y)if(n(s)!==null)y=!0,O||(O=!0,C());else{var z=n(u);z!==null&&R(w,z.startTime-M)}}var O=!1,j=-1,_=5,E=-1;function N(){return b?!0:!(e.unstable_now()-E<_)}function $(){if(b=!1,O){var M=e.unstable_now();E=M;var z=!0;try{e:{y=!1,m&&(m=!1,S(j),j=-1),p=!0;var B=h;try{t:{for(v(M),f=n(s);f!==null&&!(f.expirationTime>M&&N());){var F=f.callback;if(typeof F=="function"){f.callback=null,h=f.priorityLevel;var V=F(f.expirationTime<=M);if(M=e.unstable_now(),typeof V=="function"){f.callback=V,v(M),z=!0;break t}f===n(s)&&r(s),v(M)}else r(s);f=n(s)}if(f!==null)z=!0;else{var ee=n(u);ee!==null&&R(w,ee.startTime-M),z=!1}}break e}finally{f=null,h=B,p=!1}z=void 0}}finally{z?C():O=!1}}}var C;if(typeof x=="function")C=function(){x($)};else if(typeof MessageChannel<"u"){var L=new MessageChannel,k=L.port2;L.port1.onmessage=$,C=function(){k.postMessage(null)}}else C=function(){g($,0)};function R(M,z){j=g(function(){M(e.unstable_now())},z)}e.unstable_IdlePriority=5,e.unstable_ImmediatePriority=1,e.unstable_LowPriority=4,e.unstable_NormalPriority=3,e.unstable_Profiling=null,e.unstable_UserBlockingPriority=2,e.unstable_cancelCallback=function(M){M.callback=null},e.unstable_forceFrameRate=function(M){0>M||125F?(M.sortIndex=B,t(u,M),n(s)===null&&M===n(u)&&(m?(S(j),j=-1):m=!0,R(w,B-F))):(M.sortIndex=V,t(s,M),y||p||(y=!0,O||(O=!0,C()))),M},e.unstable_shouldYield=N,e.unstable_wrapCallback=function(M){var z=h;return function(){var B=h;h=z;try{return M.apply(this,arguments)}finally{h=B}}}})(k2);D2.exports=k2;var tR=D2.exports,z2={exports:{}},tn={};/** + * @license React + * react-dom.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var nR=A;function L2(e){var t="https://react.dev/errors/"+e;if(1"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(B2)}catch(e){console.error(e)}}B2(),z2.exports=tn;var iR=z2.exports;/** + * @license React + * react-dom-client.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var At=tR,U2=A,oR=iR;function H(e){var t="https://react.dev/errors/"+e;if(1zo||(e.current=Uy[zo],Uy[zo]=null,zo--)}function Ie(e,t){zo++,Uy[zo]=e.current,e.current=t}var _r=$r(null),Su=$r(null),Ga=$r(null),id=$r(null);function od(e,t){switch(Ie(Ga,t),Ie(Su,e),Ie(_r,null),t.nodeType){case 9:case 11:e=(e=t.documentElement)&&(e=e.namespaceURI)?dS(e):0;break;default:if(e=t.tagName,t=t.namespaceURI)t=dS(t),e=dT(t,e);else switch(e){case"svg":e=1;break;case"math":e=2;break;default:e=0}}$t(_r),Ie(_r,e)}function gl(){$t(_r),$t(Su),$t(Ga)}function Iy(e){e.memoizedState!==null&&Ie(id,e);var t=_r.current,n=dT(t,e.type);t!==n&&(Ie(Su,e),Ie(_r,n))}function ld(e){Su.current===e&&($t(_r),$t(Su)),id.current===e&&($t(id),$u._currentValue=Hi)}var mm,l1;function wi(e){if(mm===void 0)try{throw Error()}catch(n){var t=n.stack.trim().match(/\n( *(at )?)/);mm=t&&t[1]||"",l1=-1)":-1a||s[r]!==u[a]){var d=` +`+s[r].replace(" at new "," at ");return e.displayName&&d.includes("")&&(d=d.replace("",e.displayName)),d}while(1<=r&&0<=a);break}}}finally{ym=!1,Error.prepareStackTrace=n}return(n=e?e.displayName||e.name:"")?wi(n):""}function fR(e,t){switch(e.tag){case 26:case 27:case 5:return wi(e.type);case 16:return wi("Lazy");case 13:return e.child!==t&&t!==null?wi("Suspense Fallback"):wi("Suspense");case 19:return wi("SuspenseList");case 0:case 15:return vm(e.type,!1);case 11:return vm(e.type.render,!1);case 1:return vm(e.type,!0);case 31:return wi("Activity");default:return""}}function s1(e){try{var t="",n=null;do t+=fR(e,n),n=e,e=e.return;while(e);return t}catch(r){return` +Error generating stack: `+r.message+` +`+r.stack}}var Hy=Object.prototype.hasOwnProperty,Tb=At.unstable_scheduleCallback,gm=At.unstable_cancelCallback,dR=At.unstable_shouldYield,hR=At.unstable_requestPaint,jn=At.unstable_now,pR=At.unstable_getCurrentPriorityLevel,K2=At.unstable_ImmediatePriority,Y2=At.unstable_UserBlockingPriority,sd=At.unstable_NormalPriority,mR=At.unstable_LowPriority,X2=At.unstable_IdlePriority,yR=At.log,vR=At.unstable_setDisableYieldValue,Oc=null,An=null;function La(e){if(typeof yR=="function"&&vR(e),An&&typeof An.setStrictMode=="function")try{An.setStrictMode(Oc,e)}catch{}}var En=Math.clz32?Math.clz32:xR,gR=Math.log,bR=Math.LN2;function xR(e){return e>>>=0,e===0?32:31-(gR(e)/bR|0)|0}var nf=256,rf=262144,af=4194304;function Oi(e){var t=e&42;if(t!==0)return t;switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return e&261888;case 262144:case 524288:case 1048576:case 2097152:return e&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return e&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return e}}function Vh(e,t,n){var r=e.pendingLanes;if(r===0)return 0;var a=0,i=e.suspendedLanes,o=e.pingedLanes;e=e.warmLanes;var l=r&134217727;return l!==0?(r=l&~i,r!==0?a=Oi(r):(o&=l,o!==0?a=Oi(o):n||(n=l&~e,n!==0&&(a=Oi(n))))):(l=r&~i,l!==0?a=Oi(l):o!==0?a=Oi(o):n||(n=r&~e,n!==0&&(a=Oi(n)))),a===0?0:t!==0&&t!==a&&!(t&i)&&(i=a&-a,n=t&-t,i>=n||i===32&&(n&4194048)!==0)?t:a}function jc(e,t){return(e.pendingLanes&~(e.suspendedLanes&~e.pingedLanes)&t)===0}function SR(e,t){switch(e){case 1:case 2:case 4:case 8:case 64:return t+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return t+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function W2(){var e=af;return af<<=1,!(af&62914560)&&(af=4194304),e}function bm(e){for(var t=[],n=0;31>n;n++)t.push(e);return t}function Ac(e,t){e.pendingLanes|=t,t!==268435456&&(e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0)}function wR(e,t,n,r,a,i){var o=e.pendingLanes;e.pendingLanes=n,e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0,e.expiredLanes&=n,e.entangledLanes&=n,e.errorRecoveryDisabledLanes&=n,e.shellSuspendCounter=0;var l=e.entanglements,s=e.expirationTimes,u=e.hiddenUpdates;for(n=o&~n;0"u")return null;try{return e.activeElement||e.body}catch{return e.body}}var TR=/[\n"\\]/g;function Fn(e){return e.replace(TR,function(t){return"\\"+t.charCodeAt(0).toString(16)+" "})}function Gy(e,t,n,r,a,i,o,l){e.name="",o!=null&&typeof o!="function"&&typeof o!="symbol"&&typeof o!="boolean"?e.type=o:e.removeAttribute("type"),t!=null?o==="number"?(t===0&&e.value===""||e.value!=t)&&(e.value=""+In(t)):e.value!==""+In(t)&&(e.value=""+In(t)):o!=="submit"&&o!=="reset"||e.removeAttribute("value"),t!=null?Vy(e,o,In(t)):n!=null?Vy(e,o,In(n)):r!=null&&e.removeAttribute("value"),a==null&&i!=null&&(e.defaultChecked=!!i),a!=null&&(e.checked=a&&typeof a!="function"&&typeof a!="symbol"),l!=null&&typeof l!="function"&&typeof l!="symbol"&&typeof l!="boolean"?e.name=""+In(l):e.removeAttribute("name")}function iE(e,t,n,r,a,i,o,l){if(i!=null&&typeof i!="function"&&typeof i!="symbol"&&typeof i!="boolean"&&(e.type=i),t!=null||n!=null){if(!(i!=="submit"&&i!=="reset"||t!=null)){Fy(e);return}n=n!=null?""+In(n):"",t=t!=null?""+In(t):n,l||t===e.value||(e.value=t),e.defaultValue=t}r=r??a,r=typeof r!="function"&&typeof r!="symbol"&&!!r,e.checked=l?e.checked:!!r,e.defaultChecked=!!r,o!=null&&typeof o!="function"&&typeof o!="symbol"&&typeof o!="boolean"&&(e.name=o),Fy(e)}function Vy(e,t,n){t==="number"&&ud(e.ownerDocument)===e||e.defaultValue===""+n||(e.defaultValue=""+n)}function el(e,t,n,r){if(e=e.options,t){t={};for(var a=0;a"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),Yy=!1;if(oa)try{var Cs={};Object.defineProperty(Cs,"passive",{get:function(){Yy=!0}}),window.addEventListener("test",Cs,Cs),window.removeEventListener("test",Cs,Cs)}catch{Yy=!1}var Ba=null,Rb=null,Bf=null;function cE(){if(Bf)return Bf;var e,t=Rb,n=t.length,r,a="value"in Ba?Ba.value:Ba.textContent,i=a.length;for(e=0;e=tu),b1=" ",x1=!1;function dE(e,t){switch(e){case"keyup":return nD.indexOf(t.keyCode)!==-1;case"keydown":return t.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function hE(e){return e=e.detail,typeof e=="object"&&"data"in e?e.data:null}var Uo=!1;function aD(e,t){switch(e){case"compositionend":return hE(t);case"keypress":return t.which!==32?null:(x1=!0,b1);case"textInput":return e=t.data,e===b1&&x1?null:e;default:return null}}function iD(e,t){if(Uo)return e==="compositionend"||!kb&&dE(e,t)?(e=cE(),Bf=Rb=Ba=null,Uo=!1,e):null;switch(e){case"paste":return null;case"keypress":if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=r}e:{for(;n;){if(n.nextSibling){n=n.nextSibling;break e}n=n.parentNode}n=void 0}n=A1(n)}}function vE(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?vE(e,t.parentNode):"contains"in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function gE(e){e=e!=null&&e.ownerDocument!=null&&e.ownerDocument.defaultView!=null?e.ownerDocument.defaultView:window;for(var t=ud(e.document);t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href=="string"}catch{n=!1}if(n)e=t.contentWindow;else break;t=ud(e.document)}return t}function zb(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t==="input"&&(e.type==="text"||e.type==="search"||e.type==="tel"||e.type==="url"||e.type==="password")||t==="textarea"||e.contentEditable==="true")}var hD=oa&&"documentMode"in document&&11>=document.documentMode,Io=null,Xy=null,ru=null,Wy=!1;function _1(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;Wy||Io==null||Io!==ud(r)||(r=Io,"selectionStart"in r&&zb(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),ru&&ju(ru,r)||(ru=r,r=_d(Xy,"onSelect"),0>=o,a-=o,Sr=1<<32-En(t)+a|n<_?(E=j,j=null):E=j.sibling;var N=h(g,j,x[_],v);if(N===null){j===null&&(j=E);break}e&&j&&N.alternate===null&&t(g,j),S=i(N,S,_),O===null?w=N:O.sibling=N,O=N,j=E}if(_===x.length)return n(g,j),je&&Gr(g,_),w;if(j===null){for(;__?(E=j,j=null):E=j.sibling;var $=h(g,j,N.value,v);if($===null){j===null&&(j=E);break}e&&j&&$.alternate===null&&t(g,j),S=i($,S,_),O===null?w=$:O.sibling=$,O=$,j=E}if(N.done)return n(g,j),je&&Gr(g,_),w;if(j===null){for(;!N.done;_++,N=x.next())N=f(g,N.value,v),N!==null&&(S=i(N,S,_),O===null?w=N:O.sibling=N,O=N);return je&&Gr(g,_),w}for(j=r(j);!N.done;_++,N=x.next())N=p(j,g,_,N.value,v),N!==null&&(e&&N.alternate!==null&&j.delete(N.key===null?_:N.key),S=i(N,S,_),O===null?w=N:O.sibling=N,O=N);return e&&j.forEach(function(C){return t(g,C)}),je&&Gr(g,_),w}function b(g,S,x,v){if(typeof x=="object"&&x!==null&&x.type===ko&&x.key===null&&(x=x.props.children),typeof x=="object"&&x!==null){switch(x.$$typeof){case tf:e:{for(var w=x.key;S!==null;){if(S.key===w){if(w=x.type,w===ko){if(S.tag===7){n(g,S.sibling),v=a(S,x.props.children),v.return=g,g=v;break e}}else if(S.elementType===w||typeof w=="object"&&w!==null&&w.$$typeof===ja&&ji(w)===S.type){n(g,S.sibling),v=a(S,x.props),$s(v,x),v.return=g,g=v;break e}n(g,S);break}else t(g,S);S=S.sibling}x.type===ko?(v=qi(x.props.children,g.mode,v,x.key),v.return=g,g=v):(v=If(x.type,x.key,x.props,null,g.mode,v),$s(v,x),v.return=g,g=v)}return o(g);case Ys:e:{for(w=x.key;S!==null;){if(S.key===w)if(S.tag===4&&S.stateNode.containerInfo===x.containerInfo&&S.stateNode.implementation===x.implementation){n(g,S.sibling),v=a(S,x.children||[]),v.return=g,g=v;break e}else{n(g,S);break}else t(g,S);S=S.sibling}v=Tm(x,g.mode,v),v.return=g,g=v}return o(g);case ja:return x=ji(x),b(g,S,x,v)}if(Xs(x))return y(g,S,x,v);if(Ns(x)){if(w=Ns(x),typeof w!="function")throw Error(H(150));return x=w.call(x),m(g,S,x,v)}if(typeof x.then=="function")return b(g,S,uf(x),v);if(x.$$typeof===Yr)return b(g,S,sf(g,x),v);cf(g,x)}return typeof x=="string"&&x!==""||typeof x=="number"||typeof x=="bigint"?(x=""+x,S!==null&&S.tag===6?(n(g,S.sibling),v=a(S,x),v.return=g,g=v):(n(g,S),v=_m(x,g.mode,v),v.return=g,g=v),o(g)):n(g,S)}return function(g,S,x,v){try{_u=0;var w=b(g,S,x,v);return rl=null,w}catch(j){if(j===os||j===Zh)throw j;var O=Sn(29,j,null,g.mode);return O.lanes=v,O.return=g,O}finally{}}}var Ji=PE(!0),RE=PE(!1),Aa=!1;function Vb(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function rv(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,callbacks:null})}function Ka(e){return{lane:e,tag:0,payload:null,callback:null,next:null}}function Ya(e,t,n){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,Ee&2){var a=r.pending;return a===null?t.next=t:(t.next=a.next,a.next=t),r.pending=t,t=fd(e),AE(e,null,n),t}return Qh(e,r,t,n),fd(e)}function iu(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,(n&4194048)!==0)){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,Z2(e,n)}}function Cm(e,t){var n=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,n===r)){var a=null,i=null;if(n=n.firstBaseUpdate,n!==null){do{var o={lane:n.lane,tag:n.tag,payload:n.payload,callback:null,next:null};i===null?a=i=o:i=i.next=o,n=n.next}while(n!==null);i===null?a=i=t:i=i.next=t}else a=i=t;n={baseState:r.baseState,firstBaseUpdate:a,lastBaseUpdate:i,shared:r.shared,callbacks:r.callbacks},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}var av=!1;function ou(){if(av){var e=nl;if(e!==null)throw e}}function lu(e,t,n,r){av=!1;var a=e.updateQueue;Aa=!1;var i=a.firstBaseUpdate,o=a.lastBaseUpdate,l=a.shared.pending;if(l!==null){a.shared.pending=null;var s=l,u=s.next;s.next=null,o===null?i=u:o.next=u,o=s;var d=e.alternate;d!==null&&(d=d.updateQueue,l=d.lastBaseUpdate,l!==o&&(l===null?d.firstBaseUpdate=u:l.next=u,d.lastBaseUpdate=s))}if(i!==null){var f=a.baseState;o=0,d=u=s=null,l=i;do{var h=l.lane&-536870913,p=h!==l.lane;if(p?(Oe&h)===h:(r&h)===h){h!==0&&h===Sl&&(av=!0),d!==null&&(d=d.next={lane:0,tag:l.tag,payload:l.payload,callback:null,next:null});e:{var y=e,m=l;h=t;var b=n;switch(m.tag){case 1:if(y=m.payload,typeof y=="function"){f=y.call(b,f,h);break e}f=y;break e;case 3:y.flags=y.flags&-65537|128;case 0:if(y=m.payload,h=typeof y=="function"?y.call(b,f,h):y,h==null)break e;f=Xe({},f,h);break e;case 2:Aa=!0}}h=l.callback,h!==null&&(e.flags|=64,p&&(e.flags|=8192),p=a.callbacks,p===null?a.callbacks=[h]:p.push(h))}else p={lane:h,tag:l.tag,payload:l.payload,callback:l.callback,next:null},d===null?(u=d=p,s=f):d=d.next=p,o|=h;if(l=l.next,l===null){if(l=a.shared.pending,l===null)break;p=l,l=p.next,p.next=null,a.lastBaseUpdate=p,a.shared.pending=null}}while(!0);d===null&&(s=f),a.baseState=s,a.firstBaseUpdate=u,a.lastBaseUpdate=d,i===null&&(a.shared.lanes=0),oi|=o,e.lanes=o,e.memoizedState=f}}function DE(e,t){if(typeof e!="function")throw Error(H(191,e));e.call(t)}function kE(e,t){var n=e.callbacks;if(n!==null)for(e.callbacks=null,e=0;ei?i:8;var o=de.T,l={};de.T=l,i0(e,!1,t,n);try{var s=a(),u=de.S;if(u!==null&&u(l,s),s!==null&&typeof s=="object"&&typeof s.then=="function"){var d=wD(s,r);su(e,t,d,_n(e))}else su(e,t,r,_n(e))}catch(f){su(e,t,{then:function(){},status:"rejected",reason:f},_n())}finally{_e.p=i,o!==null&&l.types!==null&&(o.types=l.types),de.T=o}}function TD(){}function uv(e,t,n,r){if(e.tag!==5)throw Error(H(476));var a=l_(e).queue;o_(e,a,t,Hi,n===null?TD:function(){return s_(e),n(r)})}function l_(e){var t=e.memoizedState;if(t!==null)return t;t={memoizedState:Hi,baseState:Hi,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:sa,lastRenderedState:Hi},next:null};var n={};return t.next={memoizedState:n,baseState:n,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:sa,lastRenderedState:n},next:null},e.memoizedState=t,e=e.alternate,e!==null&&(e.memoizedState=t),t}function s_(e){var t=l_(e);t.next===null&&(t=e.alternate.memoizedState),su(e,t.next.queue,{},_n())}function a0(){return Ut($u)}function u_(){return ut().memoizedState}function c_(){return ut().memoizedState}function ND(e){for(var t=e.return;t!==null;){switch(t.tag){case 24:case 3:var n=_n();e=Ka(n);var r=Ya(t,e,n);r!==null&&(un(r,t,n),iu(r,t,n)),t={cache:qb()},e.payload=t;return}t=t.return}}function CD(e,t,n){var r=_n();n={lane:r,revertLane:0,gesture:null,action:n,hasEagerState:!1,eagerState:null,next:null},np(e)?d_(t,n):(n=Bb(e,t,n,r),n!==null&&(un(n,e,r),h_(n,t,r)))}function f_(e,t,n){var r=_n();su(e,t,n,r)}function su(e,t,n,r){var a={lane:r,revertLane:0,gesture:null,action:n,hasEagerState:!1,eagerState:null,next:null};if(np(e))d_(t,a);else{var i=e.alternate;if(e.lanes===0&&(i===null||i.lanes===0)&&(i=t.lastRenderedReducer,i!==null))try{var o=t.lastRenderedState,l=i(o,n);if(a.hasEagerState=!0,a.eagerState=l,Mn(l,o))return Qh(e,t,a,0),Le===null&&Wh(),!1}catch{}finally{}if(n=Bb(e,t,a,r),n!==null)return un(n,e,r),h_(n,t,r),!0}return!1}function i0(e,t,n,r){if(r={lane:2,revertLane:p0(),gesture:null,action:r,hasEagerState:!1,eagerState:null,next:null},np(e)){if(t)throw Error(H(479))}else t=Bb(e,n,r,2),t!==null&&un(t,e,2)}function np(e){var t=e.alternate;return e===ve||t!==null&&t===ve}function d_(e,t){al=vd=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function h_(e,t,n){if(n&4194048){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,Z2(e,n)}}var Nu={readContext:Ut,use:ep,useCallback:nt,useContext:nt,useEffect:nt,useImperativeHandle:nt,useLayoutEffect:nt,useInsertionEffect:nt,useMemo:nt,useReducer:nt,useRef:nt,useState:nt,useDebugValue:nt,useDeferredValue:nt,useTransition:nt,useSyncExternalStore:nt,useId:nt,useHostTransitionStatus:nt,useFormState:nt,useActionState:nt,useOptimistic:nt,useMemoCache:nt,useCacheRefresh:nt};Nu.useEffectEvent=nt;var p_={readContext:Ut,use:ep,useCallback:function(e,t){return Kt().memoizedState=[e,t===void 0?null:t],e},useContext:Ut,useEffect:H1,useImperativeHandle:function(e,t,n){n=n!=null?n.concat([e]):null,Ff(4194308,4,t_.bind(null,t,e),n)},useLayoutEffect:function(e,t){return Ff(4194308,4,e,t)},useInsertionEffect:function(e,t){Ff(4,2,e,t)},useMemo:function(e,t){var n=Kt();t=t===void 0?null:t;var r=e();if(eo){La(!0);try{e()}finally{La(!1)}}return n.memoizedState=[r,t],r},useReducer:function(e,t,n){var r=Kt();if(n!==void 0){var a=n(t);if(eo){La(!0);try{n(t)}finally{La(!1)}}}else a=t;return r.memoizedState=r.baseState=a,e={pending:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:a},r.queue=e,e=e.dispatch=CD.bind(null,ve,e),[r.memoizedState,e]},useRef:function(e){var t=Kt();return e={current:e},t.memoizedState=e},useState:function(e){e=lv(e);var t=e.queue,n=f_.bind(null,ve,t);return t.dispatch=n,[e.memoizedState,n]},useDebugValue:n0,useDeferredValue:function(e,t){var n=Kt();return r0(n,e,t)},useTransition:function(){var e=lv(!1);return e=o_.bind(null,ve,e.queue,!0,!1),Kt().memoizedState=e,[!1,e]},useSyncExternalStore:function(e,t,n){var r=ve,a=Kt();if(je){if(n===void 0)throw Error(H(407));n=n()}else{if(n=t(),Le===null)throw Error(H(349));Oe&127||IE(r,t,n)}a.memoizedState=n;var i={value:n,getSnapshot:t};return a.queue=i,H1(qE.bind(null,r,i,e),[e]),r.flags|=2048,Ol(9,{destroy:void 0},HE.bind(null,r,i,n,t),null),n},useId:function(){var e=Kt(),t=Le.identifierPrefix;if(je){var n=wr,r=Sr;n=(r&~(1<<32-En(r)-1)).toString(32)+n,t="_"+t+"R_"+n,n=gd++,0<\/script>",i=i.removeChild(i.firstChild);break;case"select":i=typeof r.is=="string"?o.createElement("select",{is:r.is}):o.createElement("select"),r.multiple?i.multiple=!0:r.size&&(i.size=r.size);break;default:i=typeof r.is=="string"?o.createElement(a,{is:r.is}):o.createElement(a)}}i[zt]=t,i[fn]=r;e:for(o=t.child;o!==null;){if(o.tag===5||o.tag===6)i.appendChild(o.stateNode);else if(o.tag!==4&&o.tag!==27&&o.child!==null){o.child.return=o,o=o.child;continue}if(o===t)break e;for(;o.sibling===null;){if(o.return===null||o.return===t)break e;o=o.return}o.sibling.return=o.return,o=o.sibling}t.stateNode=i;e:switch(It(i,a,r),a){case"button":case"input":case"select":case"textarea":r=!!r.autoFocus;break e;case"img":r=!0;break e;default:r=!1}r&&Lr(t)}}return Fe(t),Lm(t,t.type,e===null?null:e.memoizedProps,t.pendingProps,n),null;case 6:if(e&&t.stateNode!=null)e.memoizedProps!==r&&Lr(t);else{if(typeof r!="string"&&t.stateNode===null)throw Error(H(166));if(e=Ga.current,Ao(t)){if(e=t.stateNode,n=t.memoizedProps,r=null,a=Lt,a!==null)switch(a.tag){case 27:case 5:r=a.memoizedProps}e[zt]=t,e=!!(e.nodeValue===n||r!==null&&r.suppressHydrationWarning===!0||fT(e.nodeValue,n)),e||ai(t,!0)}else e=Td(e).createTextNode(r),e[zt]=t,t.stateNode=e}return Fe(t),null;case 31:if(n=t.memoizedState,e===null||e.memoizedState!==null){if(r=Ao(t),n!==null){if(e===null){if(!r)throw Error(H(318));if(e=t.memoizedState,e=e!==null?e.dehydrated:null,!e)throw Error(H(557));e[zt]=t}else Qi(),!(t.flags&128)&&(t.memoizedState=null),t.flags|=4;Fe(t),e=!1}else n=Nm(),e!==null&&e.memoizedState!==null&&(e.memoizedState.hydrationErrors=n),e=!0;if(!e)return t.flags&256?(xn(t),t):(xn(t),null);if(t.flags&128)throw Error(H(558))}return Fe(t),null;case 13:if(r=t.memoizedState,e===null||e.memoizedState!==null&&e.memoizedState.dehydrated!==null){if(a=Ao(t),r!==null&&r.dehydrated!==null){if(e===null){if(!a)throw Error(H(318));if(a=t.memoizedState,a=a!==null?a.dehydrated:null,!a)throw Error(H(317));a[zt]=t}else Qi(),!(t.flags&128)&&(t.memoizedState=null),t.flags|=4;Fe(t),a=!1}else a=Nm(),e!==null&&e.memoizedState!==null&&(e.memoizedState.hydrationErrors=a),a=!0;if(!a)return t.flags&256?(xn(t),t):(xn(t),null)}return xn(t),t.flags&128?(t.lanes=n,t):(n=r!==null,e=e!==null&&e.memoizedState!==null,n&&(r=t.child,a=null,r.alternate!==null&&r.alternate.memoizedState!==null&&r.alternate.memoizedState.cachePool!==null&&(a=r.alternate.memoizedState.cachePool.pool),i=null,r.memoizedState!==null&&r.memoizedState.cachePool!==null&&(i=r.memoizedState.cachePool.pool),i!==a&&(r.flags|=2048)),n!==e&&n&&(t.child.flags|=8192),ff(t,t.updateQueue),Fe(t),null);case 4:return gl(),e===null&&m0(t.stateNode.containerInfo),Fe(t),null;case 10:return na(t.type),Fe(t),null;case 19:if($t(lt),r=t.memoizedState,r===null)return Fe(t),null;if(a=(t.flags&128)!==0,i=r.rendering,i===null)if(a)Ps(r,!1);else{if(it!==0||e!==null&&e.flags&128)for(e=t.child;e!==null;){if(i=yd(e),i!==null){for(t.flags|=128,Ps(r,!1),e=i.updateQueue,t.updateQueue=e,ff(t,e),t.subtreeFlags=0,e=n,n=t.child;n!==null;)EE(n,e),n=n.sibling;return Ie(lt,lt.current&1|2),je&&Gr(t,r.treeForkCount),t.child}e=e.sibling}r.tail!==null&&jn()>wd&&(t.flags|=128,a=!0,Ps(r,!1),t.lanes=4194304)}else{if(!a)if(e=yd(i),e!==null){if(t.flags|=128,a=!0,e=e.updateQueue,t.updateQueue=e,ff(t,e),Ps(r,!0),r.tail===null&&r.tailMode==="hidden"&&!i.alternate&&!je)return Fe(t),null}else 2*jn()-r.renderingStartTime>wd&&n!==536870912&&(t.flags|=128,a=!0,Ps(r,!1),t.lanes=4194304);r.isBackwards?(i.sibling=t.child,t.child=i):(e=r.last,e!==null?e.sibling=i:t.child=i,r.last=i)}return r.tail!==null?(e=r.tail,r.rendering=e,r.tail=e.sibling,r.renderingStartTime=jn(),e.sibling=null,n=lt.current,Ie(lt,a?n&1|2:n&1),je&&Gr(t,r.treeForkCount),e):(Fe(t),null);case 22:case 23:return xn(t),Kb(),r=t.memoizedState!==null,e!==null?e.memoizedState!==null!==r&&(t.flags|=8192):r&&(t.flags|=8192),r?n&536870912&&!(t.flags&128)&&(Fe(t),t.subtreeFlags&6&&(t.flags|=8192)):Fe(t),n=t.updateQueue,n!==null&&ff(t,n.retryQueue),n=null,e!==null&&e.memoizedState!==null&&e.memoizedState.cachePool!==null&&(n=e.memoizedState.cachePool.pool),r=null,t.memoizedState!==null&&t.memoizedState.cachePool!==null&&(r=t.memoizedState.cachePool.pool),r!==n&&(t.flags|=2048),e!==null&&$t(Fi),null;case 24:return n=null,e!==null&&(n=e.memoizedState.cache),t.memoizedState.cache!==n&&(t.flags|=2048),na(mt),Fe(t),null;case 25:return null;case 30:return null}throw Error(H(156,t.tag))}function DD(e,t){switch(Hb(t),t.tag){case 1:return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return na(mt),gl(),e=t.flags,e&65536&&!(e&128)?(t.flags=e&-65537|128,t):null;case 26:case 27:case 5:return ld(t),null;case 31:if(t.memoizedState!==null){if(xn(t),t.alternate===null)throw Error(H(340));Qi()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 13:if(xn(t),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(H(340));Qi()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return $t(lt),null;case 4:return gl(),null;case 10:return na(t.type),null;case 22:case 23:return xn(t),Kb(),e!==null&&$t(Fi),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 24:return na(mt),null;case 25:return null;default:return null}}function E_(e,t){switch(Hb(t),t.tag){case 3:na(mt),gl();break;case 26:case 27:case 5:ld(t);break;case 4:gl();break;case 31:t.memoizedState!==null&&xn(t);break;case 13:xn(t);break;case 19:$t(lt);break;case 10:na(t.type);break;case 22:case 23:xn(t),Kb(),e!==null&&$t(Fi);break;case 24:na(mt)}}function Cc(e,t){try{var n=t.updateQueue,r=n!==null?n.lastEffect:null;if(r!==null){var a=r.next;n=a;do{if((n.tag&e)===e){r=void 0;var i=n.create,o=n.inst;r=i(),o.destroy=r}n=n.next}while(n!==a)}}catch(l){$e(t,t.return,l)}}function ii(e,t,n){try{var r=t.updateQueue,a=r!==null?r.lastEffect:null;if(a!==null){var i=a.next;r=i;do{if((r.tag&e)===e){var o=r.inst,l=o.destroy;if(l!==void 0){o.destroy=void 0,a=t;var s=n,u=l;try{u()}catch(d){$e(a,s,d)}}}r=r.next}while(r!==i)}}catch(d){$e(t,t.return,d)}}function __(e){var t=e.updateQueue;if(t!==null){var n=e.stateNode;try{kE(t,n)}catch(r){$e(e,e.return,r)}}}function T_(e,t,n){n.props=to(e.type,e.memoizedProps),n.state=e.memoizedState;try{n.componentWillUnmount()}catch(r){$e(e,t,r)}}function uu(e,t){try{var n=e.ref;if(n!==null){switch(e.tag){case 26:case 27:case 5:var r=e.stateNode;break;case 30:r=e.stateNode;break;default:r=e.stateNode}typeof n=="function"?e.refCleanup=n(r):n.current=r}}catch(a){$e(e,t,a)}}function Or(e,t){var n=e.ref,r=e.refCleanup;if(n!==null)if(typeof r=="function")try{r()}catch(a){$e(e,t,a)}finally{e.refCleanup=null,e=e.alternate,e!=null&&(e.refCleanup=null)}else if(typeof n=="function")try{n(null)}catch(a){$e(e,t,a)}else n.current=null}function N_(e){var t=e.type,n=e.memoizedProps,r=e.stateNode;try{e:switch(t){case"button":case"input":case"select":case"textarea":n.autoFocus&&r.focus();break e;case"img":n.src?r.src=n.src:n.srcSet&&(r.srcset=n.srcSet)}}catch(a){$e(e,e.return,a)}}function Bm(e,t,n){try{var r=e.stateNode;r3(r,e.type,n,t),r[fn]=t}catch(a){$e(e,e.return,a)}}function C_(e){return e.tag===5||e.tag===3||e.tag===26||e.tag===27&&ui(e.type)||e.tag===4}function Um(e){e:for(;;){for(;e.sibling===null;){if(e.return===null||C_(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.tag===27&&ui(e.type)||e.flags&2||e.child===null||e.tag===4)continue e;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function pv(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?(n.nodeType===9?n.body:n.nodeName==="HTML"?n.ownerDocument.body:n).insertBefore(e,t):(t=n.nodeType===9?n.body:n.nodeName==="HTML"?n.ownerDocument.body:n,t.appendChild(e),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=Xr));else if(r!==4&&(r===27&&ui(e.type)&&(n=e.stateNode,t=null),e=e.child,e!==null))for(pv(e,t,n),e=e.sibling;e!==null;)pv(e,t,n),e=e.sibling}function Sd(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(r!==4&&(r===27&&ui(e.type)&&(n=e.stateNode),e=e.child,e!==null))for(Sd(e,t,n),e=e.sibling;e!==null;)Sd(e,t,n),e=e.sibling}function M_(e){var t=e.stateNode,n=e.memoizedProps;try{for(var r=e.type,a=t.attributes;a.length;)t.removeAttributeNode(a[0]);It(t,r,n),t[zt]=e,t[fn]=n}catch(i){$e(e,e.return,i)}}var Kr=!1,pt=!1,Im=!1,tS=typeof WeakSet=="function"?WeakSet:Set,Nt=null;function kD(e,t){if(e=e.containerInfo,Sv=$d,e=gE(e),zb(e)){if("selectionStart"in e)var n={start:e.selectionStart,end:e.selectionEnd};else e:{n=(n=e.ownerDocument)&&n.defaultView||window;var r=n.getSelection&&n.getSelection();if(r&&r.rangeCount!==0){n=r.anchorNode;var a=r.anchorOffset,i=r.focusNode;r=r.focusOffset;try{n.nodeType,i.nodeType}catch{n=null;break e}var o=0,l=-1,s=-1,u=0,d=0,f=e,h=null;t:for(;;){for(var p;f!==n||a!==0&&f.nodeType!==3||(l=o+a),f!==i||r!==0&&f.nodeType!==3||(s=o+r),f.nodeType===3&&(o+=f.nodeValue.length),(p=f.firstChild)!==null;)h=f,f=p;for(;;){if(f===e)break t;if(h===n&&++u===a&&(l=o),h===i&&++d===r&&(s=o),(p=f.nextSibling)!==null)break;f=h,h=f.parentNode}f=p}n=l===-1||s===-1?null:{start:l,end:s}}else n=null}n=n||{start:0,end:0}}else n=null;for(wv={focusedElem:e,selectionRange:n},$d=!1,Nt=t;Nt!==null;)if(t=Nt,e=t.child,(t.subtreeFlags&1028)!==0&&e!==null)e.return=t,Nt=e;else for(;Nt!==null;){switch(t=Nt,i=t.alternate,e=t.flags,t.tag){case 0:if(e&4&&(e=t.updateQueue,e=e!==null?e.events:null,e!==null))for(n=0;n title"))),It(i,r,n),i[zt]=e,Ct(i),r=i;break e;case"link":var o=SS("link","href",a).get(r+(n.href||""));if(o){for(var l=0;lb&&(o=b,b=m,m=o);var g=E1(l,m),S=E1(l,b);if(g&&S&&(p.rangeCount!==1||p.anchorNode!==g.node||p.anchorOffset!==g.offset||p.focusNode!==S.node||p.focusOffset!==S.offset)){var x=f.createRange();x.setStart(g.node,g.offset),p.removeAllRanges(),m>b?(p.addRange(x),p.extend(S.node,S.offset)):(x.setEnd(S.node,S.offset),p.addRange(x))}}}}for(f=[],p=l;p=p.parentNode;)p.nodeType===1&&f.push({element:p,left:p.scrollLeft,top:p.scrollTop});for(typeof l.focus=="function"&&l.focus(),l=0;ln?32:n,de.T=null,n=vv,vv=null;var i=Wa,o=ra;if(Ot=0,Al=Wa=null,ra=0,Ee&6)throw Error(H(331));var l=Ee;if(Ee|=4,H_(i.current),B_(i,i.current,o,n),Ee=l,Mc(0,!1),An&&typeof An.onPostCommitFiberRoot=="function")try{An.onPostCommitFiberRoot(Oc,i)}catch{}return!0}finally{_e.p=a,de.T=r,rT(e,t)}}function iS(e,t,n){t=Gn(n,t),t=fv(e.stateNode,t,2),e=Ya(e,t,2),e!==null&&(Ac(e,2),Pr(e))}function $e(e,t,n){if(e.tag===3)iS(e,e,n);else for(;t!==null;){if(t.tag===3){iS(t,e,n);break}else if(t.tag===1){var r=t.stateNode;if(typeof t.type.getDerivedStateFromError=="function"||typeof r.componentDidCatch=="function"&&(Xa===null||!Xa.has(r))){e=Gn(n,e),n=b_(2),r=Ya(t,n,2),r!==null&&(x_(n,r,t,e),Ac(r,2),Pr(r));break}}t=t.return}}function qm(e,t,n){var r=e.pingCache;if(r===null){r=e.pingCache=new BD;var a=new Set;r.set(t,a)}else a=r.get(t),a===void 0&&(a=new Set,r.set(t,a));a.has(n)||(f0=!0,a.add(n),e=FD.bind(null,e,t,n),t.then(e,e))}function FD(e,t,n){var r=e.pingCache;r!==null&&r.delete(t),e.pingedLanes|=e.suspendedLanes&n,e.warmLanes&=~n,Le===e&&(Oe&n)===n&&(it===4||it===3&&(Oe&62914560)===Oe&&300>jn()-rp?!(Ee&2)&&El(e,0):d0|=n,jl===Oe&&(jl=0)),Pr(e)}function iT(e,t){t===0&&(t=W2()),e=mo(e,t),e!==null&&(Ac(e,t),Pr(e))}function GD(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),iT(e,n)}function VD(e,t){var n=0;switch(e.tag){case 31:case 13:var r=e.stateNode,a=e.memoizedState;a!==null&&(n=a.retryLane);break;case 19:r=e.stateNode;break;case 22:r=e.stateNode._retryCache;break;default:throw Error(H(314))}r!==null&&r.delete(t),iT(e,n)}function KD(e,t){return Tb(e,t)}var Ad=null,Do=null,bv=!1,Ed=!1,Fm=!1,Ha=0;function Pr(e){e!==Do&&e.next===null&&(Do===null?Ad=Do=e:Do=Do.next=e),Ed=!0,bv||(bv=!0,XD())}function Mc(e,t){if(!Fm&&Ed){Fm=!0;do for(var n=!1,r=Ad;r!==null;){if(e!==0){var a=r.pendingLanes;if(a===0)var i=0;else{var o=r.suspendedLanes,l=r.pingedLanes;i=(1<<31-En(42|e)+1)-1,i&=a&~(o&~l),i=i&201326741?i&201326741|1:i?i|2:0}i!==0&&(n=!0,oS(r,i))}else i=Oe,i=Vh(r,r===Le?i:0,r.cancelPendingCommit!==null||r.timeoutHandle!==-1),!(i&3)||jc(r,i)||(n=!0,oS(r,i));r=r.next}while(n);Fm=!1}}function YD(){oT()}function oT(){Ed=bv=!1;var e=0;Ha!==0&&i3()&&(e=Ha);for(var t=jn(),n=null,r=Ad;r!==null;){var a=r.next,i=lT(r,t);i===0?(r.next=null,n===null?Ad=a:n.next=a,a===null&&(Do=n)):(n=r,(e!==0||i&3)&&(Ed=!0)),r=a}Ot!==0&&Ot!==5||Mc(e),Ha!==0&&(Ha=0)}function lT(e,t){for(var n=e.suspendedLanes,r=e.pingedLanes,a=e.expirationTimes,i=e.pendingLanes&-62914561;0l)break;var d=s.transferSize,f=s.initiatorType;d&&fS(f)&&(s=s.responseEnd,o+=d*(s"u"?null:document;function yT(e,t,n){var r=ss;if(r&&typeof t=="string"&&t){var a=Fn(t);a='link[rel="'+e+'"][href="'+a+'"]',typeof n=="string"&&(a+='[crossorigin="'+n+'"]'),gS.has(a)||(gS.add(a),e={rel:e,crossOrigin:n,href:t},r.querySelector(a)===null&&(t=r.createElement("link"),It(t,"link",e),Ct(t),r.head.appendChild(t)))}}function p3(e){ma.D(e),yT("dns-prefetch",e,null)}function m3(e,t){ma.C(e,t),yT("preconnect",e,t)}function y3(e,t,n){ma.L(e,t,n);var r=ss;if(r&&e&&t){var a='link[rel="preload"][as="'+Fn(t)+'"]';t==="image"&&n&&n.imageSrcSet?(a+='[imagesrcset="'+Fn(n.imageSrcSet)+'"]',typeof n.imageSizes=="string"&&(a+='[imagesizes="'+Fn(n.imageSizes)+'"]')):a+='[href="'+Fn(e)+'"]';var i=a;switch(t){case"style":i=_l(e);break;case"script":i=us(e)}Qn.has(i)||(e=Xe({rel:"preload",href:t==="image"&&n&&n.imageSrcSet?void 0:e,as:t},n),Qn.set(i,e),r.querySelector(a)!==null||t==="style"&&r.querySelector($c(i))||t==="script"&&r.querySelector(Pc(i))||(t=r.createElement("link"),It(t,"link",e),Ct(t),r.head.appendChild(t)))}}function v3(e,t){ma.m(e,t);var n=ss;if(n&&e){var r=t&&typeof t.as=="string"?t.as:"script",a='link[rel="modulepreload"][as="'+Fn(r)+'"][href="'+Fn(e)+'"]',i=a;switch(r){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":i=us(e)}if(!Qn.has(i)&&(e=Xe({rel:"modulepreload",href:e},t),Qn.set(i,e),n.querySelector(a)===null)){switch(r){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":if(n.querySelector(Pc(i)))return}r=n.createElement("link"),It(r,"link",e),Ct(r),n.head.appendChild(r)}}}function g3(e,t,n){ma.S(e,t,n);var r=ss;if(r&&e){var a=Jo(r).hoistableStyles,i=_l(e);t=t||"default";var o=a.get(i);if(!o){var l={loading:0,preload:null};if(o=r.querySelector($c(i)))l.loading=5;else{e=Xe({rel:"stylesheet",href:e,"data-precedence":t},n),(n=Qn.get(i))&&y0(e,n);var s=o=r.createElement("link");Ct(s),It(s,"link",e),s._p=new Promise(function(u,d){s.onload=u,s.onerror=d}),s.addEventListener("load",function(){l.loading|=1}),s.addEventListener("error",function(){l.loading|=2}),l.loading|=4,Yf(o,t,r)}o={type:"stylesheet",instance:o,count:1,state:l},a.set(i,o)}}}function b3(e,t){ma.X(e,t);var n=ss;if(n&&e){var r=Jo(n).hoistableScripts,a=us(e),i=r.get(a);i||(i=n.querySelector(Pc(a)),i||(e=Xe({src:e,async:!0},t),(t=Qn.get(a))&&v0(e,t),i=n.createElement("script"),Ct(i),It(i,"link",e),n.head.appendChild(i)),i={type:"script",instance:i,count:1,state:null},r.set(a,i))}}function x3(e,t){ma.M(e,t);var n=ss;if(n&&e){var r=Jo(n).hoistableScripts,a=us(e),i=r.get(a);i||(i=n.querySelector(Pc(a)),i||(e=Xe({src:e,async:!0,type:"module"},t),(t=Qn.get(a))&&v0(e,t),i=n.createElement("script"),Ct(i),It(i,"link",e),n.head.appendChild(i)),i={type:"script",instance:i,count:1,state:null},r.set(a,i))}}function bS(e,t,n,r){var a=(a=Ga.current)?Nd(a):null;if(!a)throw Error(H(446));switch(e){case"meta":case"title":return null;case"style":return typeof n.precedence=="string"&&typeof n.href=="string"?(t=_l(n.href),n=Jo(a).hoistableStyles,r=n.get(t),r||(r={type:"style",instance:null,count:0,state:null},n.set(t,r)),r):{type:"void",instance:null,count:0,state:null};case"link":if(n.rel==="stylesheet"&&typeof n.href=="string"&&typeof n.precedence=="string"){e=_l(n.href);var i=Jo(a).hoistableStyles,o=i.get(e);if(o||(a=a.ownerDocument||a,o={type:"stylesheet",instance:null,count:0,state:{loading:0,preload:null}},i.set(e,o),(i=a.querySelector($c(e)))&&!i._p&&(o.instance=i,o.state.loading=5),Qn.has(e)||(n={rel:"preload",as:"style",href:n.href,crossOrigin:n.crossOrigin,integrity:n.integrity,media:n.media,hrefLang:n.hrefLang,referrerPolicy:n.referrerPolicy},Qn.set(e,n),i||S3(a,e,n,o.state))),t&&r===null)throw Error(H(528,""));return o}if(t&&r!==null)throw Error(H(529,""));return null;case"script":return t=n.async,n=n.src,typeof n=="string"&&t&&typeof t!="function"&&typeof t!="symbol"?(t=us(n),n=Jo(a).hoistableScripts,r=n.get(t),r||(r={type:"script",instance:null,count:0,state:null},n.set(t,r)),r):{type:"void",instance:null,count:0,state:null};default:throw Error(H(444,e))}}function _l(e){return'href="'+Fn(e)+'"'}function $c(e){return'link[rel="stylesheet"]['+e+"]"}function vT(e){return Xe({},e,{"data-precedence":e.precedence,precedence:null})}function S3(e,t,n,r){e.querySelector('link[rel="preload"][as="style"]['+t+"]")?r.loading=1:(t=e.createElement("link"),r.preload=t,t.addEventListener("load",function(){return r.loading|=1}),t.addEventListener("error",function(){return r.loading|=2}),It(t,"link",n),Ct(t),e.head.appendChild(t))}function us(e){return'[src="'+Fn(e)+'"]'}function Pc(e){return"script[async]"+e}function xS(e,t,n){if(t.count++,t.instance===null)switch(t.type){case"style":var r=e.querySelector('style[data-href~="'+Fn(n.href)+'"]');if(r)return t.instance=r,Ct(r),r;var a=Xe({},n,{"data-href":n.href,"data-precedence":n.precedence,href:null,precedence:null});return r=(e.ownerDocument||e).createElement("style"),Ct(r),It(r,"style",a),Yf(r,n.precedence,e),t.instance=r;case"stylesheet":a=_l(n.href);var i=e.querySelector($c(a));if(i)return t.state.loading|=4,t.instance=i,Ct(i),i;r=vT(n),(a=Qn.get(a))&&y0(r,a),i=(e.ownerDocument||e).createElement("link"),Ct(i);var o=i;return o._p=new Promise(function(l,s){o.onload=l,o.onerror=s}),It(i,"link",r),t.state.loading|=4,Yf(i,n.precedence,e),t.instance=i;case"script":return i=us(n.src),(a=e.querySelector(Pc(i)))?(t.instance=a,Ct(a),a):(r=n,(a=Qn.get(i))&&(r=Xe({},n),v0(r,a)),e=e.ownerDocument||e,a=e.createElement("script"),Ct(a),It(a,"link",r),e.head.appendChild(a),t.instance=a);case"void":return null;default:throw Error(H(443,t.type))}else t.type==="stylesheet"&&!(t.state.loading&4)&&(r=t.instance,t.state.loading|=4,Yf(r,n.precedence,e));return t.instance}function Yf(e,t,n){for(var r=n.querySelectorAll('link[rel="stylesheet"][data-precedence],style[data-precedence]'),a=r.length?r[r.length-1]:null,i=a,o=0;o title"):null)}function w3(e,t,n){if(n===1||t.itemProp!=null)return!1;switch(e){case"meta":case"title":return!0;case"style":if(typeof t.precedence!="string"||typeof t.href!="string"||t.href==="")break;return!0;case"link":if(typeof t.rel!="string"||typeof t.href!="string"||t.href===""||t.onLoad||t.onError)break;switch(t.rel){case"stylesheet":return e=t.disabled,typeof t.precedence=="string"&&e==null;default:return!0}case"script":if(t.async&&typeof t.async!="function"&&typeof t.async!="symbol"&&!t.onLoad&&!t.onError&&t.src&&typeof t.src=="string")return!0}return!1}function gT(e){return!(e.type==="stylesheet"&&!(e.state.loading&3))}function O3(e,t,n,r){if(n.type==="stylesheet"&&(typeof r.media!="string"||matchMedia(r.media).matches!==!1)&&!(n.state.loading&4)){if(n.instance===null){var a=_l(r.href),i=t.querySelector($c(a));if(i){t=i._p,t!==null&&typeof t=="object"&&typeof t.then=="function"&&(e.count++,e=Cd.bind(e),t.then(e,e)),n.state.loading|=4,n.instance=i,Ct(i);return}i=t.ownerDocument||t,r=vT(r),(a=Qn.get(a))&&y0(r,a),i=i.createElement("link"),Ct(i);var o=i;o._p=new Promise(function(l,s){o.onload=l,o.onerror=s}),It(i,"link",r),n.instance=i}e.stylesheets===null&&(e.stylesheets=new Map),e.stylesheets.set(n,t),(t=n.state.preload)&&!(n.state.loading&3)&&(e.count++,n=Cd.bind(e),t.addEventListener("load",n),t.addEventListener("error",n))}}var Wm=0;function j3(e,t){return e.stylesheets&&e.count===0&&Wf(e,e.stylesheets),0Wm?50:800)+t);return e.unsuspend=n,function(){e.unsuspend=null,clearTimeout(r),clearTimeout(a)}}:null}function Cd(){if(this.count--,this.count===0&&(this.imgCount===0||!this.waitingForImages)){if(this.stylesheets)Wf(this,this.stylesheets);else if(this.unsuspend){var e=this.unsuspend;this.unsuspend=null,e()}}}var Md=null;function Wf(e,t){e.stylesheets=null,e.unsuspend!==null&&(e.count++,Md=new Map,t.forEach(A3,e),Md=null,Cd.call(e))}function A3(e,t){if(!(t.state.loading&4)){var n=Md.get(e);if(n)var r=n.get(null);else{n=new Map,Md.set(e,n);for(var a=e.querySelectorAll("link[data-precedence],style[data-precedence]"),i=0;i"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(ET)}catch(e){console.error(e)}}ET(),R2.exports=Fh;var P3=R2.exports;const R3=Ne(P3);/** + * react-router v7.17.0 + * + * Copyright (c) Remix Software Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE.md file in the root directory of this source tree. + * + * @license MIT + */var NS="popstate";function CS(e){return typeof e=="object"&&e!=null&&"pathname"in e&&"search"in e&&"hash"in e&&"state"in e&&"key"in e}function D3(e={}){function t(r,a){var u;let i=(u=a.state)==null?void 0:u.masked,{pathname:o,search:l,hash:s}=i||r.location;return Cv("",{pathname:o,search:l,hash:s},a.state&&a.state.usr||null,a.state&&a.state.key||"default",i?{pathname:r.location.pathname,search:r.location.search,hash:r.location.hash}:void 0)}function n(r,a){return typeof a=="string"?a:Du(a)}return z3(t,n,null,e)}function Je(e,t){if(e===!1||e===null||typeof e>"u")throw new Error(t)}function dr(e,t){if(!e){typeof console<"u"&&console.warn(t);try{throw new Error(t)}catch{}}}function k3(){return Math.random().toString(36).substring(2,10)}function MS(e,t){return{usr:e.state,key:e.key,idx:t,masked:e.mask?{pathname:e.pathname,search:e.search,hash:e.hash}:void 0}}function Cv(e,t,n=null,r,a){return{pathname:typeof e=="string"?e:e.pathname,search:"",hash:"",...typeof t=="string"?cs(t):t,state:n,key:t&&t.key||r||k3(),mask:a}}function Du({pathname:e="/",search:t="",hash:n=""}){return t&&t!=="?"&&(e+=t.charAt(0)==="?"?t:"?"+t),n&&n!=="#"&&(e+=n.charAt(0)==="#"?n:"#"+n),e}function cs(e){let t={};if(e){let n=e.indexOf("#");n>=0&&(t.hash=e.substring(n),e=e.substring(0,n));let r=e.indexOf("?");r>=0&&(t.search=e.substring(r),e=e.substring(0,r)),e&&(t.pathname=e)}return t}function z3(e,t,n,r={}){let{window:a=document.defaultView,v5Compat:i=!1}=r,o=a.history,l="POP",s=null,u=d();u==null&&(u=0,o.replaceState({...o.state,idx:u},""));function d(){return(o.state||{idx:null}).idx}function f(){l="POP";let b=d(),g=b==null?null:b-u;u=b,s&&s({action:l,location:m.location,delta:g})}function h(b,g){l="PUSH";let S=CS(b)?b:Cv(m.location,b,g);u=d()+1;let x=MS(S,u),v=m.createHref(S.mask||S);try{o.pushState(x,"",v)}catch(w){if(w instanceof DOMException&&w.name==="DataCloneError")throw w;a.location.assign(v)}i&&s&&s({action:l,location:m.location,delta:1})}function p(b,g){l="REPLACE";let S=CS(b)?b:Cv(m.location,b,g);u=d();let x=MS(S,u),v=m.createHref(S.mask||S);o.replaceState(x,"",v),i&&s&&s({action:l,location:m.location,delta:0})}function y(b){return L3(a,b)}let m={get action(){return l},get location(){return e(a,o)},listen(b){if(s)throw new Error("A history only accepts one active listener");return a.addEventListener(NS,f),s=b,()=>{a.removeEventListener(NS,f),s=null}},createHref(b){return t(a,b)},createURL:y,encodeLocation(b){let g=y(b);return{pathname:g.pathname,search:g.search,hash:g.hash}},push:h,replace:p,go(b){return o.go(b)}};return m}function L3(e,t,n=!1){let r="http://localhost";e&&(r=e.location.origin!=="null"?e.location.origin:e.location.href),Je(r,"No window.location.(origin|href) available to create URL");let a=typeof t=="string"?t:Du(t);return a=a.replace(/ $/,"%20"),!n&&a.startsWith("//")&&(a=r+a),new URL(a,r)}function _T(e,t,n="/"){return B3(e,t,n,!1)}function B3(e,t,n,r,a){let i=typeof t=="string"?cs(t):t,o=fa(i.pathname||"/",n);if(o==null)return null;let l=U3(e),s=null,u=Z3(o);for(let d=0;s==null&&d{let d={relativePath:u===void 0?o.path||"":u,caseSensitive:o.caseSensitive===!0,childrenIndex:l,route:o};if(d.relativePath.startsWith("/")){if(!d.relativePath.startsWith(r)&&s)return;Je(d.relativePath.startsWith(r),`Absolute route path "${d.relativePath}" nested under path "${r}" is not valid. An absolute child route path must start with the combined path of all its parent routes.`),d.relativePath=d.relativePath.slice(r.length)}let f=cr([r,d.relativePath]),h=n.concat(d);o.children&&o.children.length>0&&(Je(o.index!==!0,`Index routes must not have child routes. Please remove all child routes from route path "${f}".`),TT(o.children,t,h,f,s)),!(o.path==null&&!o.index)&&t.push({path:f,score:Y3(f,o.index),routesMeta:h})};return e.forEach((o,l)=>{var s;if(o.path===""||!((s=o.path)!=null&&s.includes("?")))i(o,l);else for(let u of NT(o.path))i(o,l,!0,u)}),t}function NT(e){let t=e.split("/");if(t.length===0)return[];let[n,...r]=t,a=n.endsWith("?"),i=n.replace(/\?$/,"");if(r.length===0)return a?[i,""]:[i];let o=NT(r.join("/")),l=[];return l.push(...o.map(s=>s===""?i:[i,s].join("/"))),a&&l.push(...o),l.map(s=>e.startsWith("/")&&s===""?"/":s)}function I3(e){e.sort((t,n)=>t.score!==n.score?n.score-t.score:X3(t.routesMeta.map(r=>r.childrenIndex),n.routesMeta.map(r=>r.childrenIndex)))}var H3=/^:[\w-]+$/,q3=3,F3=2,G3=1,V3=10,K3=-2,$S=e=>e==="*";function Y3(e,t){let n=e.split("/"),r=n.length;return n.some($S)&&(r+=K3),t&&(r+=F3),n.filter(a=>!$S(a)).reduce((a,i)=>a+(H3.test(i)?q3:i===""?G3:V3),r)}function X3(e,t){return e.length===t.length&&e.slice(0,-1).every((r,a)=>r===t[a])?e[e.length-1]-t[t.length-1]:0}function W3(e,t,n=!1){let{routesMeta:r}=e,a={},i="/",o=[];for(let l=0;l{if(d==="*"){let y=l[h]||"";o=i.slice(0,i.length-y.length).replace(/(.)\/+$/,"$1")}const p=l[h];return f&&!p?u[d]=void 0:u[d]=(p||"").replace(/%2F/g,"/"),u},{}),pathname:i,pathnameBase:o,pattern:e}}function Q3(e,t=!1,n=!0){dr(e==="*"||!e.endsWith("*")||e.endsWith("/*"),`Route path "${e}" will be treated as if it were "${e.replace(/\*$/,"/*")}" because the \`*\` character must always follow a \`/\` in the pattern. To get rid of this warning, please change the route path to "${e.replace(/\*$/,"/*")}".`);let r=[],a="^"+e.replace(/\/*\*?$/,"").replace(/^\/*/,"/").replace(/[\\.*+^${}|()[\]]/g,"\\$&").replace(/\/:([\w-]+)(\?)?/g,(o,l,s,u,d)=>{if(r.push({paramName:l,isOptional:s!=null}),s){let f=d.charAt(u+o.length);return f&&f!=="/"?"/([^\\/]*)":"(?:/([^\\/]*))?"}return"/([^\\/]+)"}).replace(/\/([\w-]+)\?(\/|$)/g,"(/$1)?$2");return e.endsWith("*")?(r.push({paramName:"*"}),a+=e==="*"||e==="/*"?"(.*)$":"(?:\\/(.+)|\\/*)$"):n?a+="\\/*$":e!==""&&e!=="/"&&(a+="(?:(?=\\/|$))"),[new RegExp(a,t?void 0:"i"),r]}function Z3(e){try{return e.split("/").map(t=>decodeURIComponent(t).replace(/\//g,"%2F")).join("/")}catch(t){return dr(!1,`The URL path "${e}" could not be decoded because it is a malformed URL segment. This is probably due to a bad percent encoding (${t}).`),e}}function fa(e,t){if(t==="/")return e;if(!e.toLowerCase().startsWith(t.toLowerCase()))return null;let n=t.endsWith("/")?t.length-1:t.length,r=e.charAt(n);return r&&r!=="/"?null:e.slice(n)||"/"}var J3=/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i;function ek(e,t="/"){let{pathname:n,search:r="",hash:a=""}=typeof e=="string"?cs(e):e,i;return n?(n=CT(n),n.startsWith("/")?i=PS(n.substring(1),"/"):i=PS(n,t)):i=t,{pathname:i,search:rk(r),hash:ak(a)}}function PS(e,t){let n=Dd(t).split("/");return e.split("/").forEach(a=>{a===".."?n.length>1&&n.pop():a!=="."&&n.push(a)}),n.length>1?n.join("/"):"/"}function Qm(e,t,n,r){return`Cannot include a '${e}' character in a manually specified \`to.${t}\` field [${JSON.stringify(r)}]. Please separate it out to the \`to.${n}\` field. Alternatively you may provide the full path as a string in and the router will parse it for you.`}function tk(e){return e.filter((t,n)=>n===0||t.route.path&&t.route.path.length>0)}function w0(e){let t=tk(e);return t.map((n,r)=>r===t.length-1?n.pathname:n.pathnameBase)}function sp(e,t,n,r=!1){let a;typeof e=="string"?a=cs(e):(a={...e},Je(!a.pathname||!a.pathname.includes("?"),Qm("?","pathname","search",a)),Je(!a.pathname||!a.pathname.includes("#"),Qm("#","pathname","hash",a)),Je(!a.search||!a.search.includes("#"),Qm("#","search","hash",a)));let i=e===""||a.pathname==="",o=i?"/":a.pathname,l;if(o==null)l=n;else{let f=t.length-1;if(!r&&o.startsWith("..")){let h=o.split("/");for(;h[0]==="..";)h.shift(),f-=1;a.pathname=h.join("/")}l=f>=0?t[f]:"/"}let s=ek(a,l),u=o&&o!=="/"&&o.endsWith("/"),d=(i||o===".")&&n.endsWith("/");return!s.pathname.endsWith("/")&&(u||d)&&(s.pathname+="/"),s}var CT=e=>e.replace(/\/\/+/g,"/"),cr=e=>CT(e.join("/")),Dd=e=>e.replace(/\/+$/,""),nk=e=>Dd(e).replace(/^\/*/,"/"),rk=e=>!e||e==="?"?"":e.startsWith("?")?e:"?"+e,ak=e=>!e||e==="#"?"":e.startsWith("#")?e:"#"+e,ik=class{constructor(e,t,n,r=!1){this.status=e,this.statusText=t||"",this.internal=r,n instanceof Error?(this.data=n.toString(),this.error=n):this.data=n}};function ok(e){return e!=null&&typeof e.status=="number"&&typeof e.statusText=="string"&&typeof e.internal=="boolean"&&"data"in e}function lk(e){let t=e.map(n=>n.route.path).filter(Boolean);return cr(t)||"/"}var MT=typeof window<"u"&&typeof window.document<"u"&&typeof window.document.createElement<"u";function $T(e,t){let n=e;if(typeof n!="string"||!J3.test(n))return{absoluteURL:void 0,isExternal:!1,to:n};let r=n,a=!1;if(MT)try{let i=new URL(window.location.href),o=n.startsWith("//")?new URL(i.protocol+n):new URL(n),l=fa(o.pathname,t);o.origin===i.origin&&l!=null?n=l+o.search+o.hash:a=!0}catch{dr(!1,` contains an invalid URL which will probably break when clicked - please update to a valid URL path.`)}return{absoluteURL:r,isExternal:a,to:n}}Object.getOwnPropertyNames(Object.prototype).sort().join("\0");var PT=["POST","PUT","PATCH","DELETE"];new Set(PT);var sk=["GET",...PT];new Set(sk);var fs=A.createContext(null);fs.displayName="DataRouter";var up=A.createContext(null);up.displayName="DataRouterState";var RT=A.createContext(!1);function uk(){return A.useContext(RT)}var DT=A.createContext({isTransitioning:!1});DT.displayName="ViewTransition";var ck=A.createContext(new Map);ck.displayName="Fetchers";var fk=A.createContext(null);fk.displayName="Await";var Pn=A.createContext(null);Pn.displayName="Navigation";var Rc=A.createContext(null);Rc.displayName="Location";var Jn=A.createContext({outlet:null,matches:[],isDataRoute:!1});Jn.displayName="Route";var O0=A.createContext(null);O0.displayName="RouteError";var kT="REACT_ROUTER_ERROR",dk="REDIRECT",hk="ROUTE_ERROR_RESPONSE";function pk(e){if(e.startsWith(`${kT}:${dk}:{`))try{let t=JSON.parse(e.slice(28));if(typeof t=="object"&&t&&typeof t.status=="number"&&typeof t.statusText=="string"&&typeof t.location=="string"&&typeof t.reloadDocument=="boolean"&&typeof t.replace=="boolean")return t}catch{}}function mk(e){if(e.startsWith(`${kT}:${hk}:{`))try{let t=JSON.parse(e.slice(40));if(typeof t=="object"&&t&&typeof t.status=="number"&&typeof t.statusText=="string")return new ik(t.status,t.statusText,t.data)}catch{}}function yk(e,{relative:t}={}){Je(ds(),"useHref() may be used only in the context of a component.");let{basename:n,navigator:r}=A.useContext(Pn),{hash:a,pathname:i,search:o}=Dc(e,{relative:t}),l=i;return n!=="/"&&(l=i==="/"?n:cr([n,i])),r.createHref({pathname:l,search:o,hash:a})}function ds(){return A.useContext(Rc)!=null}function Rr(){return Je(ds(),"useLocation() may be used only in the context of a component."),A.useContext(Rc).location}var zT="You should call navigate() in a React.useEffect(), not when your component is first rendered.";function LT(e){A.useContext(Pn).static||A.useLayoutEffect(e)}function hs(){let{isDataRoute:e}=A.useContext(Jn);return e?$k():vk()}function vk(){Je(ds(),"useNavigate() may be used only in the context of a component.");let e=A.useContext(fs),{basename:t,navigator:n}=A.useContext(Pn),{matches:r}=A.useContext(Jn),{pathname:a}=Rr(),i=JSON.stringify(w0(r)),o=A.useRef(!1);return LT(()=>{o.current=!0}),A.useCallback((s,u={})=>{if(dr(o.current,zT),!o.current)return;if(typeof s=="number"){n.go(s);return}let d=sp(s,JSON.parse(i),a,u.relative==="path");e==null&&t!=="/"&&(d.pathname=d.pathname==="/"?t:cr([t,d.pathname])),(u.replace?n.replace:n.push)(d,u.state,u)},[t,n,i,a,e])}var gk=A.createContext(null);function bk(e){let t=A.useContext(Jn).outlet;return A.useMemo(()=>t&&A.createElement(gk.Provider,{value:e},t),[t,e])}function xk(){let{matches:e}=A.useContext(Jn),t=e[e.length-1];return(t==null?void 0:t.params)??{}}function Dc(e,{relative:t}={}){let{matches:n}=A.useContext(Jn),{pathname:r}=Rr(),a=JSON.stringify(w0(n));return A.useMemo(()=>sp(e,JSON.parse(a),r,t==="path"),[e,a,r,t])}function Sk(e,t){return BT(e,t)}function BT(e,t,n){var b;Je(ds(),"useRoutes() may be used only in the context of a component.");let{navigator:r}=A.useContext(Pn),{matches:a}=A.useContext(Jn),i=a[a.length-1],o=i?i.params:{},l=i?i.pathname:"/",s=i?i.pathnameBase:"/",u=i&&i.route;{let g=u&&u.path||"";IT(l,!u||g.endsWith("*")||g.endsWith("*?"),`You rendered descendant (or called \`useRoutes()\`) at "${l}" (under ) but the parent route path has no trailing "*". This means if you navigate deeper, the parent won't match anymore and therefore the child routes will never render. + +Please change the parent to .`)}let d=Rr(),f;if(t){let g=typeof t=="string"?cs(t):t;Je(s==="/"||((b=g.pathname)==null?void 0:b.startsWith(s)),`When overriding the location using \`\` or \`useRoutes(routes, location)\`, the location pathname must begin with the portion of the URL pathname that was matched by all parent routes. The current pathname base is "${s}" but pathname "${g.pathname}" was given in the \`location\` prop.`),f=g}else f=d;let h=f.pathname||"/",p=h;if(s!=="/"){let g=s.replace(/^\//,"").split("/");p="/"+h.replace(/^\//,"").split("/").slice(g.length).join("/")}let y=n&&n.state.matches.length?n.state.matches.map(g=>Object.assign(g,{route:n.manifest[g.route.id]||g.route})):_T(e,{pathname:p});dr(u||y!=null,`No routes matched location "${f.pathname}${f.search}${f.hash}" `),dr(y==null||y[y.length-1].route.element!==void 0||y[y.length-1].route.Component!==void 0||y[y.length-1].route.lazy!==void 0,`Matched leaf route at location "${f.pathname}${f.search}${f.hash}" does not have an element or Component. This means it will render an with a null value by default resulting in an "empty" page.`);let m=Ek(y&&y.map(g=>Object.assign({},g,{params:Object.assign({},o,g.params),pathname:cr([s,r.encodeLocation?r.encodeLocation(g.pathname.replace(/%/g,"%25").replace(/\?/g,"%3F").replace(/#/g,"%23")).pathname:g.pathname]),pathnameBase:g.pathnameBase==="/"?s:cr([s,r.encodeLocation?r.encodeLocation(g.pathnameBase.replace(/%/g,"%25").replace(/\?/g,"%3F").replace(/#/g,"%23")).pathname:g.pathnameBase])})),a,n);return t&&m?A.createElement(Rc.Provider,{value:{location:{pathname:"/",search:"",hash:"",state:null,key:"default",mask:void 0,...f},navigationType:"POP"}},m):m}function wk(){let e=Mk(),t=ok(e)?`${e.status} ${e.statusText}`:e instanceof Error?e.message:JSON.stringify(e),n=e instanceof Error?e.stack:null,r="rgba(200,200,200, 0.5)",a={padding:"0.5rem",backgroundColor:r},i={padding:"2px 4px",backgroundColor:r},o=null;return console.error("Error handled by React Router default ErrorBoundary:",e),o=A.createElement(A.Fragment,null,A.createElement("p",null,"💿 Hey developer 👋"),A.createElement("p",null,"You can provide a way better UX than this when your app throws errors by providing your own ",A.createElement("code",{style:i},"ErrorBoundary")," or"," ",A.createElement("code",{style:i},"errorElement")," prop on your route.")),A.createElement(A.Fragment,null,A.createElement("h2",null,"Unexpected Application Error!"),A.createElement("h3",{style:{fontStyle:"italic"}},t),n?A.createElement("pre",{style:a},n):null,o)}var Ok=A.createElement(wk,null),UT=class extends A.Component{constructor(e){super(e),this.state={location:e.location,revalidation:e.revalidation,error:e.error}}static getDerivedStateFromError(e){return{error:e}}static getDerivedStateFromProps(e,t){return t.location!==e.location||t.revalidation!=="idle"&&e.revalidation==="idle"?{error:e.error,location:e.location,revalidation:e.revalidation}:{error:e.error!==void 0?e.error:t.error,location:t.location,revalidation:e.revalidation||t.revalidation}}componentDidCatch(e,t){this.props.onError?this.props.onError(e,t):console.error("React Router caught the following error during render",e)}render(){let e=this.state.error;if(this.context&&typeof e=="object"&&e&&"digest"in e&&typeof e.digest=="string"){const n=mk(e.digest);n&&(e=n)}let t=e!==void 0?A.createElement(Jn.Provider,{value:this.props.routeContext},A.createElement(O0.Provider,{value:e,children:this.props.component})):this.props.children;return this.context?A.createElement(jk,{error:e},t):t}};UT.contextType=RT;var Zm=new WeakMap;function jk({children:e,error:t}){let{basename:n}=A.useContext(Pn);if(typeof t=="object"&&t&&"digest"in t&&typeof t.digest=="string"){let r=pk(t.digest);if(r){let a=Zm.get(t);if(a)throw a;let i=$T(r.location,n);if(MT&&!Zm.get(t))if(i.isExternal||r.reloadDocument)window.location.href=i.absoluteURL||i.to;else{const o=Promise.resolve().then(()=>window.__reactRouterDataRouter.navigate(i.to,{replace:r.replace}));throw Zm.set(t,o),o}return A.createElement("meta",{httpEquiv:"refresh",content:`0;url=${i.absoluteURL||i.to}`})}}return e}function Ak({routeContext:e,match:t,children:n}){let r=A.useContext(fs);return r&&r.static&&r.staticContext&&(t.route.errorElement||t.route.ErrorBoundary)&&(r.staticContext._deepestRenderedBoundaryId=t.route.id),A.createElement(Jn.Provider,{value:e},n)}function Ek(e,t=[],n){let r=n==null?void 0:n.state;if(e==null){if(!r)return null;if(r.errors)e=r.matches;else if(t.length===0&&!r.initialized&&r.matches.length>0)e=r.matches;else return null}let a=e,i=r==null?void 0:r.errors;if(i!=null){let d=a.findIndex(f=>f.route.id&&(i==null?void 0:i[f.route.id])!==void 0);Je(d>=0,`Could not find a matching route for errors on route IDs: ${Object.keys(i).join(",")}`),a=a.slice(0,Math.min(a.length,d+1))}let o=!1,l=-1;if(n&&r){o=r.renderFallback;for(let d=0;d=0?a=a.slice(0,l+1):a=[a[0]];break}}}}let s=n==null?void 0:n.onError,u=r&&s?(d,f)=>{var h,p;s(d,{location:r.location,params:((p=(h=r.matches)==null?void 0:h[0])==null?void 0:p.params)??{},pattern:lk(r.matches),errorInfo:f})}:void 0;return a.reduceRight((d,f,h)=>{let p,y=!1,m=null,b=null;r&&(p=i&&f.route.id?i[f.route.id]:void 0,m=f.route.errorElement||Ok,o&&(l<0&&h===0?(IT("route-fallback",!1,"No `HydrateFallback` element provided to render during initial hydration"),y=!0,b=null):l===h&&(y=!0,b=f.route.hydrateFallbackElement||null)));let g=t.concat(a.slice(0,h+1)),S=()=>{let x;return p?x=m:y?x=b:f.route.Component?x=A.createElement(f.route.Component,null):f.route.element?x=f.route.element:x=d,A.createElement(Ak,{match:f,routeContext:{outlet:d,matches:g,isDataRoute:r!=null},children:x})};return r&&(f.route.ErrorBoundary||f.route.errorElement||h===0)?A.createElement(UT,{location:r.location,revalidation:r.revalidation,component:m,error:p,children:S(),routeContext:{outlet:null,matches:g,isDataRoute:!0},onError:u}):S()},null)}function j0(e){return`${e} must be used within a data router. See https://reactrouter.com/en/main/routers/picking-a-router.`}function _k(e){let t=A.useContext(fs);return Je(t,j0(e)),t}function Tk(e){let t=A.useContext(up);return Je(t,j0(e)),t}function Nk(e){let t=A.useContext(Jn);return Je(t,j0(e)),t}function A0(e){let t=Nk(e),n=t.matches[t.matches.length-1];return Je(n.route.id,`${e} can only be used on routes that contain a unique "id"`),n.route.id}function Ck(){return A0("useRouteId")}function Mk(){var r;let e=A.useContext(O0),t=Tk("useRouteError"),n=A0("useRouteError");return e!==void 0?e:(r=t.errors)==null?void 0:r[n]}function $k(){let{router:e}=_k("useNavigate"),t=A0("useNavigate"),n=A.useRef(!1);return LT(()=>{n.current=!0}),A.useCallback(async(a,i={})=>{dr(n.current,zT),n.current&&(typeof a=="number"?await e.navigate(a):await e.navigate(a,{fromRouteId:t,...i}))},[e,t])}var RS={};function IT(e,t,n){!t&&!RS[e]&&(RS[e]=!0,dr(!1,n))}A.memo(Pk);function Pk({routes:e,manifest:t,future:n,state:r,isStatic:a,onError:i}){return BT(e,void 0,{manifest:t,state:r,isStatic:a,onError:i})}function HT({to:e,replace:t,state:n,relative:r}){Je(ds()," may be used only in the context of a component.");let{static:a}=A.useContext(Pn);dr(!a," must not be used on the initial render in a . This is a no-op, but you should modify your code so the is only ever rendered in response to some user interaction or state change.");let{matches:i}=A.useContext(Jn),{pathname:o}=Rr(),l=hs(),s=sp(e,w0(i),o,r==="path"),u=JSON.stringify(s);return A.useEffect(()=>{l(JSON.parse(u),{replace:t,state:n,relative:r})},[l,u,r,t,n]),null}function Rk(e){return bk(e.context)}function We(e){Je(!1,"A is only ever to be used as the child of element, never rendered directly. Please wrap your in a .")}function Dk({basename:e="/",children:t=null,location:n,navigationType:r="POP",navigator:a,static:i=!1,useTransitions:o}){Je(!ds(),"You cannot render a inside another . You should never have more than one in your app.");let l=e.replace(/^\/*/,"/"),s=A.useMemo(()=>({basename:l,navigator:a,static:i,useTransitions:o,future:{}}),[l,a,i,o]);typeof n=="string"&&(n=cs(n));let{pathname:u="/",search:d="",hash:f="",state:h=null,key:p="default",mask:y}=n,m=A.useMemo(()=>{let b=fa(u,l);return b==null?null:{location:{pathname:b,search:d,hash:f,state:h,key:p,mask:y},navigationType:r}},[l,u,d,f,h,p,r,y]);return dr(m!=null,` is not able to match the URL "${u}${d}${f}" because it does not start with the basename, so the won't render anything.`),m==null?null:A.createElement(Pn.Provider,{value:s},A.createElement(Rc.Provider,{children:t,value:m}))}function kk({children:e,location:t}){return Sk(Mv(e),t)}function Mv(e,t=[]){let n=[];return A.Children.forEach(e,(r,a)=>{if(!A.isValidElement(r))return;let i=[...t,a];if(r.type===A.Fragment){n.push.apply(n,Mv(r.props.children,i));return}Je(r.type===We,`[${typeof r.type=="string"?r.type:r.type.name}] is not a component. All component children of must be a or `),Je(!r.props.index||!r.props.children,"An index route cannot have child routes.");let o={id:r.props.id||i.join("-"),caseSensitive:r.props.caseSensitive,element:r.props.element,Component:r.props.Component,index:r.props.index,path:r.props.path,middleware:r.props.middleware,loader:r.props.loader,action:r.props.action,hydrateFallbackElement:r.props.hydrateFallbackElement,HydrateFallback:r.props.HydrateFallback,errorElement:r.props.errorElement,ErrorBoundary:r.props.ErrorBoundary,hasErrorBoundary:r.props.hasErrorBoundary===!0||r.props.ErrorBoundary!=null||r.props.errorElement!=null,shouldRevalidate:r.props.shouldRevalidate,handle:r.props.handle,lazy:r.props.lazy};r.props.children&&(o.children=Mv(r.props.children,i)),n.push(o)}),n}var Zf="get",Jf="application/x-www-form-urlencoded";function cp(e){return typeof HTMLElement<"u"&&e instanceof HTMLElement}function zk(e){return cp(e)&&e.tagName.toLowerCase()==="button"}function Lk(e){return cp(e)&&e.tagName.toLowerCase()==="form"}function Bk(e){return cp(e)&&e.tagName.toLowerCase()==="input"}function Uk(e){return!!(e.metaKey||e.altKey||e.ctrlKey||e.shiftKey)}function Ik(e,t){return e.button===0&&(!t||t==="_self")&&!Uk(e)}var vf=null;function Hk(){if(vf===null)try{new FormData(document.createElement("form"),0),vf=!1}catch{vf=!0}return vf}var qk=new Set(["application/x-www-form-urlencoded","multipart/form-data","text/plain"]);function Jm(e){return e!=null&&!qk.has(e)?(dr(!1,`"${e}" is not a valid \`encType\` for \`

\`/\`\` and will default to "${Jf}"`),null):e}function Fk(e,t){let n,r,a,i,o;if(Lk(e)){let l=e.getAttribute("action");r=l?fa(l,t):null,n=e.getAttribute("method")||Zf,a=Jm(e.getAttribute("enctype"))||Jf,i=new FormData(e)}else if(zk(e)||Bk(e)&&(e.type==="submit"||e.type==="image")){let l=e.form;if(l==null)throw new Error('Cannot submit a