feat: admin system
16
.gitignore
vendored
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
# Backend
|
||||||
|
backend/target/
|
||||||
|
backend/data/
|
||||||
|
backend/*.db
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
frontend/node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
frontend/.vite/
|
||||||
|
|
||||||
|
# Secrets / OS
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
97
Jenkinsfile
vendored
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
pipeline {
|
||||||
|
agent any
|
||||||
|
|
||||||
|
environment {
|
||||||
|
DEPLOY_DIR = '/var/www/zioinfo'
|
||||||
|
APP_DIR = '/opt/zioinfo/app'
|
||||||
|
JAVA_HOME = '/usr/lib/jvm/java-21-openjdk-amd64'
|
||||||
|
MVN = '/usr/bin/mvn'
|
||||||
|
NODE_HOME = '/usr/bin'
|
||||||
|
}
|
||||||
|
|
||||||
|
options {
|
||||||
|
buildDiscarder(logRotator(numToKeepStr: '5'))
|
||||||
|
timeout(time: 20, unit: 'MINUTES')
|
||||||
|
}
|
||||||
|
|
||||||
|
stages {
|
||||||
|
stage('Checkout') {
|
||||||
|
steps {
|
||||||
|
echo "브랜치: ${env.GIT_BRANCH ?: 'main'} | 커밋: ${env.GIT_COMMIT?.take(7) ?: '-'}"
|
||||||
|
checkout scm
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Frontend Build') {
|
||||||
|
steps {
|
||||||
|
dir('frontend') {
|
||||||
|
sh '''
|
||||||
|
echo "=== [1/3] React 빌드 ==="
|
||||||
|
npm ci --legacy-peer-deps --prefer-offline 2>/dev/null || npm install --legacy-peer-deps
|
||||||
|
npm run build
|
||||||
|
echo "빌드 결과: $(ls ../backend/src/main/resources/static/assets/ | wc -l) 파일"
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Backend Build') {
|
||||||
|
steps {
|
||||||
|
dir('backend') {
|
||||||
|
sh '''
|
||||||
|
echo "=== [2/3] Spring Boot 빌드 ==="
|
||||||
|
${MVN} clean package -DskipTests -q
|
||||||
|
JAR=$(find target -name "*.jar" ! -name "*sources*" | head -1)
|
||||||
|
echo "JAR: $JAR ($(du -sh $JAR | cut -f1))"
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Deploy') {
|
||||||
|
steps {
|
||||||
|
sh '''
|
||||||
|
echo "=== [3/3] 배포 ==="
|
||||||
|
JAR=$(find backend/target -name "*.jar" ! -name "*sources*" | head -1)
|
||||||
|
|
||||||
|
# 앱 디렉터리 확인
|
||||||
|
mkdir -p ${APP_DIR} ${DEPLOY_DIR}
|
||||||
|
|
||||||
|
# JAR 배포
|
||||||
|
cp "$JAR" ${APP_DIR}/app.jar
|
||||||
|
|
||||||
|
# React 정적 파일 배포
|
||||||
|
cp -r backend/src/main/resources/static/. ${DEPLOY_DIR}/
|
||||||
|
|
||||||
|
# Spring Boot 서비스 재시작
|
||||||
|
systemctl restart zioinfo || true
|
||||||
|
sleep 4
|
||||||
|
|
||||||
|
# 헬스체크
|
||||||
|
for i in 1 2 3 4 5; do
|
||||||
|
HTTP=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/api/company 2>/dev/null)
|
||||||
|
if [ "$HTTP" = "200" ]; then
|
||||||
|
echo "배포 성공 (Spring Boot HTTP $HTTP)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "헬스체크 ${i}/5 대기중 (HTTP: $HTTP)..."
|
||||||
|
sleep 3
|
||||||
|
done
|
||||||
|
echo "경고: Spring Boot 응답 없음 — 서비스 상태 확인 필요"
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
post {
|
||||||
|
success {
|
||||||
|
echo "✅ 배포 완료: ${currentBuild.displayName} (${currentBuild.durationString})"
|
||||||
|
}
|
||||||
|
failure {
|
||||||
|
echo "❌ 배포 실패: ${currentBuild.displayName} — 로그 확인 필요"
|
||||||
|
}
|
||||||
|
always {
|
||||||
|
cleanWs(cleanWhenNotBuilt: false, cleanWhenSuccess: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2
backend/.mvn/wrapper/maven-wrapper.properties
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.6/apache-maven-3.9.6-bin.zip
|
||||||
|
wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar
|
||||||
126
backend/pom.xml
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<parent>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-parent</artifactId>
|
||||||
|
<version>3.2.5</version>
|
||||||
|
<relativePath/>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<groupId>kr.co.zioinfo</groupId>
|
||||||
|
<artifactId>zioinfo-web</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<packaging>jar</packaging>
|
||||||
|
<name>ZioInfo Web</name>
|
||||||
|
<description>(주)지오정보기술 기업 홈페이지</description>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<java.version>17</java.version>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<!-- Spring Boot Web -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-web</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Spring Data JPA -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- SQLite (기본 DB — 파일 기반, 설치 없음) -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.xerial</groupId>
|
||||||
|
<artifactId>sqlite-jdbc</artifactId>
|
||||||
|
<version>3.45.3.0</version>
|
||||||
|
</dependency>
|
||||||
|
<!-- SQLite Hibernate Dialect (hibernate-community-dialects) -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.hibernate.orm</groupId>
|
||||||
|
<artifactId>hibernate-community-dialects</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- MySQL (운영 환경 전환 시 사용) -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.mysql</groupId>
|
||||||
|
<artifactId>mysql-connector-j</artifactId>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
<optional>true</optional>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Validation -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-validation</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Mail (문의 이메일 발송) -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-mail</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Spring Security -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-security</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- JWT -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
|
<artifactId>jjwt-api</artifactId>
|
||||||
|
<version>0.12.3</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
|
<artifactId>jjwt-impl</artifactId>
|
||||||
|
<version>0.12.3</version>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
|
<artifactId>jjwt-jackson</artifactId>
|
||||||
|
<version>0.12.3</version>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Lombok -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
<optional>true</optional>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Test -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
|
<configuration>
|
||||||
|
<excludes>
|
||||||
|
<exclude>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
</exclude>
|
||||||
|
</excludes>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</project>
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
package kr.co.zioinfo.web;
|
||||||
|
|
||||||
|
import org.springframework.boot.SpringApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
|
||||||
|
|
||||||
|
@SpringBootApplication
|
||||||
|
@EnableJpaAuditing
|
||||||
|
public class ZioinfoWebApplication {
|
||||||
|
public static void main(String[] args) {
|
||||||
|
SpringApplication.run(ZioinfoWebApplication.class, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,97 @@
|
|||||||
|
package kr.co.zioinfo.web.config;
|
||||||
|
|
||||||
|
import kr.co.zioinfo.web.model.*;
|
||||||
|
import kr.co.zioinfo.web.repository.*;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.boot.CommandLineRunner;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class DataInitializer implements CommandLineRunner {
|
||||||
|
|
||||||
|
private final NewsRepository newsRepo;
|
||||||
|
private final AdminUserRepository adminUserRepo;
|
||||||
|
private final RecruitRepository recruitRepo;
|
||||||
|
private final PasswordEncoder passwordEncoder;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void run(String... args) {
|
||||||
|
initAdmin();
|
||||||
|
initNews();
|
||||||
|
initRecruits();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void initAdmin() {
|
||||||
|
if (adminUserRepo.existsByUsername("admin")) return;
|
||||||
|
adminUserRepo.save(AdminUser.builder()
|
||||||
|
.username("admin")
|
||||||
|
.password(passwordEncoder.encode("Admin@2026!"))
|
||||||
|
.displayName("관리자")
|
||||||
|
.email("admin@zioinfo.co.kr")
|
||||||
|
.enabled(true)
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void initNews() {
|
||||||
|
if (newsRepo.count() > 0) return;
|
||||||
|
|
||||||
|
newsRepo.save(News.builder()
|
||||||
|
.title("GUARDiA ITSM 2.0 정식 출시 — AI 기반 인프라 자율 운영 플랫폼")
|
||||||
|
.category("보도자료")
|
||||||
|
.summary("(주)지오정보기술이 공공기관 레거시 인프라 자동화를 위한 GUARDiA ITSM 2.0을 정식 출시했습니다.")
|
||||||
|
.content("GUARDiA ITSM 2.0은 메신저 한 줄 명령으로 에이전트리스 SSH/SFTP 배포·운영을 자동화하는 온프레미스 플랫폼입니다.")
|
||||||
|
.visible(true).viewCount(128).build());
|
||||||
|
|
||||||
|
newsRepo.save(News.builder()
|
||||||
|
.title("2026 공공기관 AI 인프라 혁신 박람회 참가")
|
||||||
|
.category("공지사항")
|
||||||
|
.summary("지오정보기술이 2026 공공기관 AI 인프라 혁신 박람회에 참가하여 GUARDiA 솔루션을 선보입니다.")
|
||||||
|
.content("박람회 기간: 2026년 6월 15일~17일 / 장소: 코엑스 A홀 / 부스: A-215")
|
||||||
|
.visible(true).viewCount(87).build());
|
||||||
|
|
||||||
|
newsRepo.save(News.builder()
|
||||||
|
.title("행정안전부 공공SW 우수제품 선정")
|
||||||
|
.category("보도자료")
|
||||||
|
.summary("GUARDiA ITSM이 행정안전부 2026년 공공SW 우수제품으로 선정되었습니다.")
|
||||||
|
.content("행정안전부는 공공기관 정보화 사업에 적합한 소프트웨어를 심사하여 우수제품을 선정합니다.")
|
||||||
|
.visible(true).viewCount(214).build());
|
||||||
|
|
||||||
|
newsRepo.save(News.builder()
|
||||||
|
.title("특허 등록 — 에이전트리스 레거시 인프라 자동화 방법")
|
||||||
|
.category("공지사항")
|
||||||
|
.summary("에이전트 설치 없이 SSH/SFTP 프로토콜만으로 레거시 WAS를 자동화하는 방법에 대한 특허가 등록되었습니다.")
|
||||||
|
.content("특허명: 에이전트리스 레거시 인프라 자동화 시스템 및 방법\n등록번호: 10-2026-XXXXXXX")
|
||||||
|
.visible(true).viewCount(41).build());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void initRecruits() {
|
||||||
|
if (recruitRepo.count() > 0) return;
|
||||||
|
|
||||||
|
recruitRepo.save(Recruit.builder()
|
||||||
|
.title("백엔드 개발자 (Java/Spring Boot)")
|
||||||
|
.department("개발팀").jobType("정규직")
|
||||||
|
.description("- GUARDiA ITSM 백엔드 API 개발\n- 성능 최적화 및 코드 리뷰\n- 공공기관 SI 프로젝트 참여")
|
||||||
|
.requirements("- Spring Boot 실무 경력 3년 이상\n- JPA/Hibernate 경험\n- RESTful API 설계 능력")
|
||||||
|
.preferred("- 공공기관 프로젝트 경험\n- MSA 아키텍처 이해\n- 보안 코딩 경험")
|
||||||
|
.deadline(LocalDate.of(2026, 6, 30)).headcount(2).active(true).build());
|
||||||
|
|
||||||
|
recruitRepo.save(Recruit.builder()
|
||||||
|
.title("프론트엔드 개발자 (React)")
|
||||||
|
.department("개발팀").jobType("정규직")
|
||||||
|
.description("- React SPA 개발 및 유지보수\n- 홈페이지 및 관리자 페이지 개발\n- UI/UX 개선")
|
||||||
|
.requirements("- React 실무 경력 2년 이상\n- JavaScript/TypeScript 능숙\n- CSS 레이아웃 및 반응형 웹 경험")
|
||||||
|
.preferred("- 공공기관 프로젝트 경험\n- 데이터 시각화 경험 (Chart.js 등)")
|
||||||
|
.deadline(LocalDate.of(2026, 6, 30)).headcount(1).active(true).build());
|
||||||
|
|
||||||
|
recruitRepo.save(Recruit.builder()
|
||||||
|
.title("인프라 엔지니어 (Linux/DevOps)")
|
||||||
|
.department("인프라팀").jobType("정규직")
|
||||||
|
.description("- 공공기관 서버 인프라 구축 및 운영\n- CI/CD 파이프라인 관리\n- 보안 취약점 점검")
|
||||||
|
.requirements("- Linux 서버 운영 경력 3년 이상\n- Ansible/Terraform 경험\n- 네트워크 기초 지식")
|
||||||
|
.preferred("- 공공기관 정보보호 인증 자격증 (정보처리기사 등)\n- Kubernetes 경험")
|
||||||
|
.deadline(LocalDate.of(2026, 7, 31)).headcount(1).active(true).build());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,61 @@
|
|||||||
|
package kr.co.zioinfo.web.config;
|
||||||
|
|
||||||
|
import kr.co.zioinfo.web.security.JwtAuthFilter;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.http.HttpMethod;
|
||||||
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||||
|
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||||
|
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||||
|
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;
|
||||||
|
import org.springframework.web.cors.CorsConfiguration;
|
||||||
|
import org.springframework.web.cors.CorsConfigurationSource;
|
||||||
|
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@EnableWebSecurity
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class SecurityConfig {
|
||||||
|
|
||||||
|
private final JwtAuthFilter jwtAuthFilter;
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||||
|
return http
|
||||||
|
.csrf(AbstractHttpConfigurer::disable)
|
||||||
|
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
||||||
|
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||||
|
.authorizeHttpRequests(auth -> auth
|
||||||
|
.requestMatchers(HttpMethod.POST, "/api/admin/login").permitAll()
|
||||||
|
.requestMatchers("/api/admin/**").hasRole("ADMIN")
|
||||||
|
.requestMatchers(HttpMethod.GET, "/api/**").permitAll()
|
||||||
|
.requestMatchers(HttpMethod.POST, "/api/inquiry").permitAll()
|
||||||
|
.requestMatchers("/", "/**").permitAll()
|
||||||
|
.anyRequest().authenticated())
|
||||||
|
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public PasswordEncoder passwordEncoder() {
|
||||||
|
return new BCryptPasswordEncoder();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public CorsConfigurationSource corsConfigurationSource() {
|
||||||
|
CorsConfiguration cfg = new CorsConfiguration();
|
||||||
|
cfg.setAllowedOriginPatterns(List.of("*"));
|
||||||
|
cfg.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
|
||||||
|
cfg.setAllowedHeaders(List.of("*"));
|
||||||
|
cfg.setAllowCredentials(true);
|
||||||
|
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||||
|
source.registerCorsConfiguration("/**", cfg);
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,182 @@
|
|||||||
|
package kr.co.zioinfo.web.controller;
|
||||||
|
|
||||||
|
import kr.co.zioinfo.web.model.*;
|
||||||
|
import kr.co.zioinfo.web.repository.*;
|
||||||
|
import kr.co.zioinfo.web.security.JwtUtil;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.data.domain.*;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/admin")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AdminController {
|
||||||
|
|
||||||
|
private final AdminUserRepository adminUserRepo;
|
||||||
|
private final NewsRepository newsRepo;
|
||||||
|
private final InquiryRepository inquiryRepo;
|
||||||
|
private final RecruitRepository recruitRepo;
|
||||||
|
private final JwtUtil jwtUtil;
|
||||||
|
private final PasswordEncoder passwordEncoder;
|
||||||
|
|
||||||
|
// ── 로그인 ────────────────────────────────────────────────
|
||||||
|
@PostMapping("/login")
|
||||||
|
public ResponseEntity<?> login(@RequestBody Map<String, String> body) {
|
||||||
|
String username = body.get("username");
|
||||||
|
String password = body.get("password");
|
||||||
|
|
||||||
|
return adminUserRepo.findByUsername(username)
|
||||||
|
.filter(u -> u.isEnabled() && passwordEncoder.matches(password, u.getPassword()))
|
||||||
|
.map(u -> ResponseEntity.ok(Map.of(
|
||||||
|
"token", jwtUtil.generate(u.getUsername()),
|
||||||
|
"username", u.getUsername(),
|
||||||
|
"displayName", Optional.ofNullable(u.getDisplayName()).orElse(u.getUsername()))))
|
||||||
|
.orElse(ResponseEntity.status(401).body(Map.of("message", "아이디 또는 비밀번호가 올바르지 않습니다.")));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 대시보드 통계 ────────────────────────────────────────
|
||||||
|
@GetMapping("/dashboard")
|
||||||
|
public ResponseEntity<Map<String, Object>> dashboard() {
|
||||||
|
Map<String, Object> stats = new LinkedHashMap<>();
|
||||||
|
stats.put("totalNews", newsRepo.count());
|
||||||
|
stats.put("visibleNews", newsRepo.countByVisibleTrue());
|
||||||
|
stats.put("totalInquiries", inquiryRepo.count());
|
||||||
|
stats.put("pendingInquiries", inquiryRepo.countByStatus("PENDING"));
|
||||||
|
stats.put("totalRecruits", recruitRepo.count());
|
||||||
|
stats.put("activeRecruits", recruitRepo.countByActiveTrue());
|
||||||
|
stats.put("recentInquiries", inquiryRepo.findTop5ByOrderByCreatedAtDesc());
|
||||||
|
stats.put("recentNews", newsRepo.findTop5ByOrderByCreatedAtDesc());
|
||||||
|
return ResponseEntity.ok(stats);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 뉴스 관리 ────────────────────────────────────────────
|
||||||
|
@GetMapping("/news")
|
||||||
|
public ResponseEntity<Page<News>> adminNews(
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "10") int size) {
|
||||||
|
return ResponseEntity.ok(newsRepo.findAll(
|
||||||
|
PageRequest.of(page, size, Sort.by("createdAt").descending())));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/news")
|
||||||
|
public ResponseEntity<News> createNews(@RequestBody News news) {
|
||||||
|
news.setId(null);
|
||||||
|
news.setCreatedAt(null);
|
||||||
|
return ResponseEntity.ok(newsRepo.save(news));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/news/{id}")
|
||||||
|
public ResponseEntity<?> updateNews(@PathVariable Long id, @RequestBody News body) {
|
||||||
|
return newsRepo.findById(id).map(n -> {
|
||||||
|
n.setTitle(body.getTitle());
|
||||||
|
n.setCategory(body.getCategory());
|
||||||
|
n.setContent(body.getContent());
|
||||||
|
n.setSummary(body.getSummary());
|
||||||
|
n.setThumbnailUrl(body.getThumbnailUrl());
|
||||||
|
n.setVisible(body.isVisible());
|
||||||
|
return ResponseEntity.ok(newsRepo.save(n));
|
||||||
|
}).orElse(ResponseEntity.notFound().build());
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/news/{id}")
|
||||||
|
public ResponseEntity<Void> deleteNews(@PathVariable Long id) {
|
||||||
|
if (!newsRepo.existsById(id)) return ResponseEntity.notFound().build();
|
||||||
|
newsRepo.deleteById(id);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PatchMapping("/news/{id}/visibility")
|
||||||
|
public ResponseEntity<?> toggleVisibility(@PathVariable Long id) {
|
||||||
|
return newsRepo.findById(id).map(n -> {
|
||||||
|
n.setVisible(!n.isVisible());
|
||||||
|
return ResponseEntity.ok(newsRepo.save(n));
|
||||||
|
}).orElse(ResponseEntity.notFound().build());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 문의 관리 ────────────────────────────────────────────
|
||||||
|
@GetMapping("/inquiries")
|
||||||
|
public ResponseEntity<Page<Inquiry>> adminInquiries(
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "10") int size,
|
||||||
|
@RequestParam(required = false) String status) {
|
||||||
|
Pageable pageable = PageRequest.of(page, size, Sort.by("createdAt").descending());
|
||||||
|
Page<Inquiry> result = (status != null && !status.isBlank())
|
||||||
|
? inquiryRepo.findByStatus(status, pageable)
|
||||||
|
: inquiryRepo.findAll(pageable);
|
||||||
|
return ResponseEntity.ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/inquiries/{id}")
|
||||||
|
public ResponseEntity<Inquiry> getInquiry(@PathVariable Long id) {
|
||||||
|
return inquiryRepo.findById(id)
|
||||||
|
.map(ResponseEntity::ok)
|
||||||
|
.orElse(ResponseEntity.notFound().build());
|
||||||
|
}
|
||||||
|
|
||||||
|
@PatchMapping("/inquiries/{id}/status")
|
||||||
|
public ResponseEntity<?> updateInquiryStatus(
|
||||||
|
@PathVariable Long id, @RequestBody Map<String, String> body) {
|
||||||
|
return inquiryRepo.findById(id).map(i -> {
|
||||||
|
i.setStatus(body.getOrDefault("status", i.getStatus()));
|
||||||
|
return ResponseEntity.ok(inquiryRepo.save(i));
|
||||||
|
}).orElse(ResponseEntity.notFound().build());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 채용공고 관리 ────────────────────────────────────────
|
||||||
|
@GetMapping("/recruits")
|
||||||
|
public ResponseEntity<Page<Recruit>> adminRecruits(
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "10") int size) {
|
||||||
|
return ResponseEntity.ok(recruitRepo.findAllByOrderByCreatedAtDesc(
|
||||||
|
PageRequest.of(page, size)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/recruits")
|
||||||
|
public ResponseEntity<Recruit> createRecruit(@RequestBody Recruit recruit) {
|
||||||
|
recruit.setId(null);
|
||||||
|
recruit.setCreatedAt(null);
|
||||||
|
return ResponseEntity.ok(recruitRepo.save(recruit));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/recruits/{id}")
|
||||||
|
public ResponseEntity<?> updateRecruit(@PathVariable Long id, @RequestBody Recruit body) {
|
||||||
|
return recruitRepo.findById(id).map(r -> {
|
||||||
|
r.setTitle(body.getTitle());
|
||||||
|
r.setDepartment(body.getDepartment());
|
||||||
|
r.setJobType(body.getJobType());
|
||||||
|
r.setDescription(body.getDescription());
|
||||||
|
r.setRequirements(body.getRequirements());
|
||||||
|
r.setPreferred(body.getPreferred());
|
||||||
|
r.setDeadline(body.getDeadline());
|
||||||
|
r.setHeadcount(body.getHeadcount());
|
||||||
|
r.setActive(body.isActive());
|
||||||
|
return ResponseEntity.ok(recruitRepo.save(r));
|
||||||
|
}).orElse(ResponseEntity.notFound().build());
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/recruits/{id}")
|
||||||
|
public ResponseEntity<Void> deleteRecruit(@PathVariable Long id) {
|
||||||
|
if (!recruitRepo.existsById(id)) return ResponseEntity.notFound().build();
|
||||||
|
recruitRepo.deleteById(id);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 비밀번호 변경 ────────────────────────────────────────
|
||||||
|
@PutMapping("/password")
|
||||||
|
public ResponseEntity<?> changePassword(
|
||||||
|
@RequestBody Map<String, String> body,
|
||||||
|
jakarta.servlet.http.HttpServletRequest req) {
|
||||||
|
String authHeader = req.getHeader("Authorization");
|
||||||
|
String username = jwtUtil.extractUsername(authHeader.substring(7));
|
||||||
|
return adminUserRepo.findByUsername(username).map(u -> {
|
||||||
|
if (!passwordEncoder.matches(body.get("currentPassword"), u.getPassword()))
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("message", "현재 비밀번호가 올바르지 않습니다."));
|
||||||
|
u.setPassword(passwordEncoder.encode(body.get("newPassword")));
|
||||||
|
adminUserRepo.save(u);
|
||||||
|
return ResponseEntity.ok(Map.of("message", "비밀번호가 변경되었습니다."));
|
||||||
|
}).orElse(ResponseEntity.notFound().build());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,190 @@
|
|||||||
|
package kr.co.zioinfo.web.controller;
|
||||||
|
|
||||||
|
import kr.co.zioinfo.web.model.Inquiry;
|
||||||
|
import kr.co.zioinfo.web.model.News;
|
||||||
|
import kr.co.zioinfo.web.repository.RecruitRepository;
|
||||||
|
import kr.co.zioinfo.web.service.InquiryService;
|
||||||
|
import kr.co.zioinfo.web.service.NewsService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.data.domain.*;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ApiController {
|
||||||
|
|
||||||
|
private final NewsService newsService;
|
||||||
|
private final InquiryService inquiryService;
|
||||||
|
private final RecruitRepository recruitRepo;
|
||||||
|
|
||||||
|
// ── 회사 정보 ────────────────────────────────────────────────
|
||||||
|
@GetMapping("/company")
|
||||||
|
public ResponseEntity<Map<String, Object>> getCompanyInfo() {
|
||||||
|
Map<String, Object> info = new LinkedHashMap<>();
|
||||||
|
info.put("name", "(주)지오정보기술");
|
||||||
|
info.put("ceo", "대표이사");
|
||||||
|
info.put("founded", "2000년");
|
||||||
|
info.put("address", "서울특별시");
|
||||||
|
info.put("phone", "02-000-0000");
|
||||||
|
info.put("email", "info@zioinfo.co.kr");
|
||||||
|
info.put("business", Arrays.asList(
|
||||||
|
Map.of("name", "GUARDiA ITSM", "desc", "AI 기반 레거시 인프라 자율 운영 플랫폼"),
|
||||||
|
Map.of("name", "ERP 솔루션", "desc", "기업 자원관리 시스템"),
|
||||||
|
Map.of("name", "SI 구축", "desc", "정보화사업 시스템 통합"),
|
||||||
|
Map.of("name", "IT 인프라", "desc", "인프라 구축 및 운영")
|
||||||
|
));
|
||||||
|
return ResponseEntity.ok(info);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 연혁 ─────────────────────────────────────────────────────
|
||||||
|
@GetMapping("/history")
|
||||||
|
public ResponseEntity<List<Map<String, Object>>> getHistory() {
|
||||||
|
List<Map<String, Object>> history = new ArrayList<>();
|
||||||
|
history.add(Map.of("year", "2026", "events", List.of(
|
||||||
|
"GUARDiA ITSM v2.0 출시 (AI 자율 운영 플랫폼)",
|
||||||
|
"공공기관 AI 인프라 자동화 사업 수주"
|
||||||
|
)));
|
||||||
|
history.add(Map.of("year", "2024", "events", List.of(
|
||||||
|
"GUARDiA ITSM v1.0 개발 완료",
|
||||||
|
"관공서 레거시 인프라 자동화 특허 출원"
|
||||||
|
)));
|
||||||
|
history.add(Map.of("year", "2022", "events", List.of(
|
||||||
|
"AI 기반 ChatOps 플랫폼 연구 개발 착수",
|
||||||
|
"행정기관 SI 사업 10건 수주"
|
||||||
|
)));
|
||||||
|
history.add(Map.of("year", "2020", "events", List.of(
|
||||||
|
"창립 20주년",
|
||||||
|
"클라우드 전환 컨설팅 사업 진출"
|
||||||
|
)));
|
||||||
|
history.add(Map.of("year", "2010", "events", List.of(
|
||||||
|
"ERP·CRM 솔루션 공급 100개사 달성"
|
||||||
|
)));
|
||||||
|
history.add(Map.of("year", "2000", "events", List.of(
|
||||||
|
"(주)지오정보기술 설립"
|
||||||
|
)));
|
||||||
|
return ResponseEntity.ok(history);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GUARDiA 솔루션 정보 ───────────────────────────────────────
|
||||||
|
@GetMapping("/solutions/guardia")
|
||||||
|
public ResponseEntity<Map<String, Object>> getGuardiaInfo() {
|
||||||
|
Map<String, Object> g = new LinkedHashMap<>();
|
||||||
|
g.put("name", "GUARDiA ITSM");
|
||||||
|
g.put("tagline", "AI 기반 레거시 인프라 자율 운영 플랫폼");
|
||||||
|
g.put("description",
|
||||||
|
"1,000개 이상 관공서 레거시 인프라를 대상으로 하는 AI 기반 통합 ChatOps 오케스트레이션 플랫폼. " +
|
||||||
|
"메신저 한 줄 명령으로 에이전트리스 배포·운영을 자동화합니다.");
|
||||||
|
g.put("keyFeatures", Arrays.asList(
|
||||||
|
Map.of("icon", "🤖", "title", "AI 에이전트 자동화",
|
||||||
|
"desc", "Ollama 온프레미스 sLLM 기반 자연어 명령 → 자동 배포·운영"),
|
||||||
|
Map.of("icon", "🔧", "title", "에이전트리스 아키텍처",
|
||||||
|
"desc", "대상 서버에 소프트웨어 설치 없이 SSH/SFTP만으로 관리"),
|
||||||
|
Map.of("icon", "💬", "title", "ChatOps 메신저 통합",
|
||||||
|
"desc", "카카오워크·네이버웍스·슬랙 등 메신저에서 직접 인프라 제어"),
|
||||||
|
Map.of("icon", "📊", "title", "통합 ITSM 대시보드",
|
||||||
|
"desc", "SR·인시던트·변경관리·SLA·CMDB 전체를 하나의 플랫폼에서"),
|
||||||
|
Map.of("icon", "🔒", "title", "엔터프라이즈 보안",
|
||||||
|
"desc", "AES-256-GCM 암호화, MFA, PAM, 불변 감사로그, Zero Trust"),
|
||||||
|
Map.of("icon", "🏗️", "title", "PMS (프로젝트 관리)",
|
||||||
|
"desc", "WBS·산출물·일주월 보고서 자동 생성, 이슈·위험 관리")
|
||||||
|
));
|
||||||
|
g.put("editions", Arrays.asList(
|
||||||
|
Map.of("name", "COMMUNITY", "price", "무료", "target", "소규모 기관",
|
||||||
|
"features", List.of("기본 SR 관리", "CMDB", "대시보드")),
|
||||||
|
Map.of("name", "STANDARD", "price", "협의", "target", "중형 기관",
|
||||||
|
"features", List.of("전체 ITSM", "AI 에이전트", "LDAP/MFA", "SLA")),
|
||||||
|
Map.of("name", "ENTERPRISE", "price", "협의", "target", "대형 관공서",
|
||||||
|
"features", List.of("무제한", "취약점 스캔", "Scouter APM", "FinOps", "전담 지원"))
|
||||||
|
));
|
||||||
|
g.put("techStack", Map.of(
|
||||||
|
"backend", "Python 3.11 / FastAPI",
|
||||||
|
"ai", "Ollama (온프레미스 sLLM, 외부 API 완전 금지)",
|
||||||
|
"infra", "paramiko SSH/SFTP (에이전트리스)",
|
||||||
|
"db", "PostgreSQL / SQLite",
|
||||||
|
"frontend", "React.js / PWA"
|
||||||
|
));
|
||||||
|
return ResponseEntity.ok(g);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 소식 목록 ────────────────────────────────────────────────
|
||||||
|
@GetMapping("/news")
|
||||||
|
public ResponseEntity<Page<News>> getNews(
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "6") int size,
|
||||||
|
@RequestParam(required = false) String category) {
|
||||||
|
Pageable pageable = PageRequest.of(page, size, Sort.by("createdAt").descending());
|
||||||
|
return ResponseEntity.ok(newsService.findAll(category, pageable));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/news/{id}")
|
||||||
|
public ResponseEntity<News> getNewsDetail(@PathVariable Long id) {
|
||||||
|
return newsService.findById(id)
|
||||||
|
.map(ResponseEntity::ok)
|
||||||
|
.orElse(ResponseEntity.notFound().build());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 문의 접수 ────────────────────────────────────────────────
|
||||||
|
@PostMapping("/inquiry")
|
||||||
|
public ResponseEntity<Map<String, String>> submitInquiry(@Valid @RequestBody Inquiry inquiry) {
|
||||||
|
inquiryService.save(inquiry);
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"message", "문의가 접수되었습니다. 빠른 시일 내에 연락드리겠습니다.",
|
||||||
|
"status", "SUCCESS"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 채용공고 (공개) ───────────────────────────────────────────
|
||||||
|
@GetMapping("/recruits")
|
||||||
|
public ResponseEntity<?> getRecruits() {
|
||||||
|
return ResponseEntity.ok(recruitRepo.findByActiveTrueOrderByCreatedAtDesc());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 메뉴 구조 ────────────────────────────────────────────────
|
||||||
|
@GetMapping("/menu")
|
||||||
|
public ResponseEntity<List<Map<String, Object>>> getMenu() {
|
||||||
|
return ResponseEntity.ok(List.of(
|
||||||
|
Map.of("id", "company", "label", "회사소개",
|
||||||
|
"children", List.of(
|
||||||
|
Map.of("id", "greeting", "label", "CEO 인사말", "path", "/company/greeting"),
|
||||||
|
Map.of("id", "history", "label", "연혁", "path", "/company/history"),
|
||||||
|
Map.of("id", "organization", "label", "조직도", "path", "/company/organization"),
|
||||||
|
Map.of("id", "ci", "label", "CI 소개", "path", "/company/ci"),
|
||||||
|
Map.of("id", "location", "label", "오시는 길", "path", "/company/location")
|
||||||
|
)),
|
||||||
|
Map.of("id", "solution", "label", "솔루션",
|
||||||
|
"children", List.of(
|
||||||
|
Map.of("id", "guardia", "label", "GUARDiA ITSM", "path", "/solution/guardia", "badge", "NEW"),
|
||||||
|
Map.of("id", "erp", "label", "ERP", "path", "/solution/erp"),
|
||||||
|
Map.of("id", "crm", "label", "CRM", "path", "/solution/crm"),
|
||||||
|
Map.of("id", "bi", "label", "BI", "path", "/solution/bi")
|
||||||
|
)),
|
||||||
|
Map.of("id", "business", "label", "사업실적",
|
||||||
|
"children", List.of(
|
||||||
|
Map.of("id", "reference", "label", "구축 레퍼런스", "path", "/business/reference"),
|
||||||
|
Map.of("id", "partner", "label", "파트너", "path", "/business/partner")
|
||||||
|
)),
|
||||||
|
Map.of("id", "support", "label", "고객지원",
|
||||||
|
"children", List.of(
|
||||||
|
Map.of("id", "notice", "label", "공지사항", "path", "/support/notice"),
|
||||||
|
Map.of("id", "faq", "label", "FAQ", "path", "/support/faq"),
|
||||||
|
Map.of("id", "catalog", "label", "카탈로그", "path", "/support/catalog"),
|
||||||
|
Map.of("id", "contact", "label", "문의하기", "path", "/support/contact")
|
||||||
|
)),
|
||||||
|
Map.of("id", "recruit", "label", "채용",
|
||||||
|
"children", List.of(
|
||||||
|
Map.of("id", "jobs", "label", "채용공고", "path", "/recruit/jobs"),
|
||||||
|
Map.of("id", "welfare", "label", "복리후생", "path", "/recruit/welfare"),
|
||||||
|
Map.of("id", "apply", "label", "지원하기", "path", "/recruit/apply")
|
||||||
|
)),
|
||||||
|
Map.of("id", "news", "label", "뉴스",
|
||||||
|
"children", List.of(
|
||||||
|
Map.of("id", "newsroom", "label", "뉴스룸", "path", "/news/newsroom"),
|
||||||
|
Map.of("id", "blog", "label", "기술 블로그", "path", "/news/blog")
|
||||||
|
))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
32
backend/src/main/java/kr/co/zioinfo/web/model/AdminUser.java
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
package kr.co.zioinfo.web.model;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.*;
|
||||||
|
import org.springframework.data.annotation.CreatedDate;
|
||||||
|
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Entity @Table(name = "admin_user")
|
||||||
|
@Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
|
||||||
|
@EntityListeners(AuditingEntityListener.class)
|
||||||
|
public class AdminUser {
|
||||||
|
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(nullable = false, unique = true, length = 50)
|
||||||
|
private String username;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private String password; // BCrypt encoded
|
||||||
|
|
||||||
|
@Column(length = 100)
|
||||||
|
private String displayName;
|
||||||
|
|
||||||
|
@Column(length = 100)
|
||||||
|
private String email;
|
||||||
|
|
||||||
|
private boolean enabled = true;
|
||||||
|
|
||||||
|
@CreatedDate
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
}
|
||||||
40
backend/src/main/java/kr/co/zioinfo/web/model/Inquiry.java
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
package kr.co.zioinfo.web.model;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import jakarta.validation.constraints.*;
|
||||||
|
import lombok.*;
|
||||||
|
import org.springframework.data.annotation.CreatedDate;
|
||||||
|
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Entity @Table(name = "inquiry")
|
||||||
|
@Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
|
||||||
|
@EntityListeners(AuditingEntityListener.class)
|
||||||
|
public class Inquiry {
|
||||||
|
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@NotBlank @Column(nullable = false, length = 50)
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
@NotBlank @Email @Column(nullable = false, length = 100)
|
||||||
|
private String email;
|
||||||
|
|
||||||
|
@Column(length = 30)
|
||||||
|
private String phone;
|
||||||
|
|
||||||
|
@NotBlank @Column(nullable = false, length = 200)
|
||||||
|
private String subject;
|
||||||
|
|
||||||
|
@NotBlank @Column(nullable = false, columnDefinition = "TEXT")
|
||||||
|
private String content;
|
||||||
|
|
||||||
|
@Column(length = 50)
|
||||||
|
private String category; // 제품문의 | 기술지원 | 사업제안 | 기타
|
||||||
|
|
||||||
|
private boolean agreePrivacy = false;
|
||||||
|
private String status = "PENDING"; // PENDING | ANSWERED
|
||||||
|
|
||||||
|
@CreatedDate
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
}
|
||||||
36
backend/src/main/java/kr/co/zioinfo/web/model/News.java
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
package kr.co.zioinfo.web.model;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.*;
|
||||||
|
import org.springframework.data.annotation.CreatedDate;
|
||||||
|
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Entity @Table(name = "news")
|
||||||
|
@Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
|
||||||
|
@EntityListeners(AuditingEntityListener.class)
|
||||||
|
public class News {
|
||||||
|
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 200)
|
||||||
|
private String title;
|
||||||
|
|
||||||
|
@Column(length = 50)
|
||||||
|
private String category; // 공지사항 | 보도자료 | 이벤트
|
||||||
|
|
||||||
|
@Column(columnDefinition = "TEXT")
|
||||||
|
private String content;
|
||||||
|
|
||||||
|
@Column(length = 500)
|
||||||
|
private String summary;
|
||||||
|
|
||||||
|
@Column(length = 300)
|
||||||
|
private String thumbnailUrl;
|
||||||
|
|
||||||
|
private boolean visible = true;
|
||||||
|
private int viewCount = 0;
|
||||||
|
|
||||||
|
@CreatedDate
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
}
|
||||||
41
backend/src/main/java/kr/co/zioinfo/web/model/Recruit.java
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
package kr.co.zioinfo.web.model;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.*;
|
||||||
|
import org.springframework.data.annotation.CreatedDate;
|
||||||
|
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Entity @Table(name = "recruit")
|
||||||
|
@Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
|
||||||
|
@EntityListeners(AuditingEntityListener.class)
|
||||||
|
public class Recruit {
|
||||||
|
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 200)
|
||||||
|
private String title;
|
||||||
|
|
||||||
|
@Column(length = 50)
|
||||||
|
private String department; // 개발팀, 영업팀, 기획팀 등
|
||||||
|
|
||||||
|
@Column(length = 20)
|
||||||
|
private String jobType; // 정규직, 계약직, 인턴
|
||||||
|
|
||||||
|
@Column(columnDefinition = "TEXT")
|
||||||
|
private String description; // 담당업무
|
||||||
|
|
||||||
|
@Column(columnDefinition = "TEXT")
|
||||||
|
private String requirements; // 지원자격
|
||||||
|
|
||||||
|
@Column(columnDefinition = "TEXT")
|
||||||
|
private String preferred; // 우대사항
|
||||||
|
|
||||||
|
private LocalDate deadline;
|
||||||
|
private int headcount = 1;
|
||||||
|
private boolean active = true;
|
||||||
|
|
||||||
|
@CreatedDate
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
}
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
package kr.co.zioinfo.web.repository;
|
||||||
|
|
||||||
|
import kr.co.zioinfo.web.model.AdminUser;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public interface AdminUserRepository extends JpaRepository<AdminUser, Long> {
|
||||||
|
Optional<AdminUser> findByUsername(String username);
|
||||||
|
boolean existsByUsername(String username);
|
||||||
|
}
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
package kr.co.zioinfo.web.repository;
|
||||||
|
|
||||||
|
import kr.co.zioinfo.web.model.Inquiry;
|
||||||
|
import org.springframework.data.domain.*;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public interface InquiryRepository extends JpaRepository<Inquiry, Long> {
|
||||||
|
Page<Inquiry> findByStatus(String status, Pageable p);
|
||||||
|
long countByStatus(String status);
|
||||||
|
List<Inquiry> findTop5ByOrderByCreatedAtDesc();
|
||||||
|
}
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
package kr.co.zioinfo.web.repository;
|
||||||
|
|
||||||
|
import kr.co.zioinfo.web.model.News;
|
||||||
|
import org.springframework.data.domain.*;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public interface NewsRepository extends JpaRepository<News, Long> {
|
||||||
|
Page<News> findByVisibleTrue(Pageable p);
|
||||||
|
Page<News> findByCategoryAndVisibleTrue(String category, Pageable p);
|
||||||
|
long countByVisibleTrue();
|
||||||
|
List<News> findTop5ByOrderByCreatedAtDesc();
|
||||||
|
}
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
package kr.co.zioinfo.web.repository;
|
||||||
|
|
||||||
|
import kr.co.zioinfo.web.model.Recruit;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public interface RecruitRepository extends JpaRepository<Recruit, Long> {
|
||||||
|
List<Recruit> findByActiveTrueOrderByCreatedAtDesc();
|
||||||
|
Page<Recruit> findAllByOrderByCreatedAtDesc(Pageable pageable);
|
||||||
|
long countByActiveTrue();
|
||||||
|
}
|
||||||
@ -0,0 +1,35 @@
|
|||||||
|
package kr.co.zioinfo.web.security;
|
||||||
|
|
||||||
|
import jakarta.servlet.*;
|
||||||
|
import jakarta.servlet.http.*;
|
||||||
|
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 JwtAuthFilter 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.extractUsername(token);
|
||||||
|
var auth = new UsernamePasswordAuthenticationToken(
|
||||||
|
username, null, List.of(new SimpleGrantedAuthority("ROLE_ADMIN")));
|
||||||
|
SecurityContextHolder.getContext().setAuthentication(auth);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
chain.doFilter(req, res);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,49 @@
|
|||||||
|
package kr.co.zioinfo.web.security;
|
||||||
|
|
||||||
|
import io.jsonwebtoken.*;
|
||||||
|
import io.jsonwebtoken.security.Keys;
|
||||||
|
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;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class JwtUtil {
|
||||||
|
|
||||||
|
private final SecretKey key;
|
||||||
|
private final long expirationMs;
|
||||||
|
|
||||||
|
public JwtUtil(
|
||||||
|
@Value("${zioinfo.jwt.secret:zioinfo-admin-secret-key-must-be-at-least-32-chars}") String secret,
|
||||||
|
@Value("${zioinfo.jwt.expiration-ms:28800000}") long expirationMs) {
|
||||||
|
this.key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
|
||||||
|
this.expirationMs = expirationMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String generate(String username) {
|
||||||
|
return Jwts.builder()
|
||||||
|
.subject(username)
|
||||||
|
.issuedAt(new Date())
|
||||||
|
.expiration(new Date(System.currentTimeMillis() + expirationMs))
|
||||||
|
.signWith(key)
|
||||||
|
.compact();
|
||||||
|
}
|
||||||
|
|
||||||
|
public String extractUsername(String token) {
|
||||||
|
return parse(token).getPayload().getSubject();
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isValid(String token) {
|
||||||
|
try {
|
||||||
|
parse(token);
|
||||||
|
return true;
|
||||||
|
} catch (JwtException | IllegalArgumentException e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Jws<Claims> parse(String token) {
|
||||||
|
return Jwts.parser().verifyWith(key).build().parseSignedClaims(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
package kr.co.zioinfo.web.service;
|
||||||
|
|
||||||
|
import kr.co.zioinfo.web.model.Inquiry;
|
||||||
|
import kr.co.zioinfo.web.repository.InquiryRepository;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
|
public class InquiryService {
|
||||||
|
private final InquiryRepository repo;
|
||||||
|
|
||||||
|
public Inquiry save(Inquiry inquiry) {
|
||||||
|
Inquiry saved = repo.save(inquiry);
|
||||||
|
log.info("문의 접수: id={} name={} subject={}", saved.getId(), saved.getName(), saved.getSubject());
|
||||||
|
// TODO: 이메일 발송 (MailService 연동)
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
package kr.co.zioinfo.web.service;
|
||||||
|
|
||||||
|
import kr.co.zioinfo.web.model.News;
|
||||||
|
import kr.co.zioinfo.web.repository.NewsRepository;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.data.domain.*;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class NewsService {
|
||||||
|
private final NewsRepository repo;
|
||||||
|
|
||||||
|
public Page<News> findAll(String category, Pageable pageable) {
|
||||||
|
if (category != null && !category.isBlank()) {
|
||||||
|
return repo.findByCategoryAndVisibleTrue(category, pageable);
|
||||||
|
}
|
||||||
|
return repo.findByVisibleTrue(pageable);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<News> findById(Long id) {
|
||||||
|
return repo.findById(id).map(n -> {
|
||||||
|
n.setViewCount(n.getViewCount() + 1);
|
||||||
|
return repo.save(n);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
52
backend/src/main/resources/application.yml
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
server:
|
||||||
|
port: 8080
|
||||||
|
servlet:
|
||||||
|
encoding:
|
||||||
|
charset: UTF-8
|
||||||
|
force: true
|
||||||
|
|
||||||
|
spring:
|
||||||
|
application:
|
||||||
|
name: zioinfo-web
|
||||||
|
datasource:
|
||||||
|
# SQLite — 파일 기반 DB (data/ 디렉토리 자동 생성)
|
||||||
|
url: jdbc:sqlite:./data/zioinfo.db
|
||||||
|
driver-class-name: org.sqlite.JDBC
|
||||||
|
jpa:
|
||||||
|
database-platform: org.hibernate.community.dialect.SQLiteDialect
|
||||||
|
hibernate:
|
||||||
|
ddl-auto: update # 스키마 자동 갱신 (운영: validate)
|
||||||
|
show-sql: false
|
||||||
|
properties:
|
||||||
|
hibernate:
|
||||||
|
format_sql: true
|
||||||
|
# SQLite는 foreign key 비활성 기본 → 명시 활성화
|
||||||
|
javax.persistence.schema-generation.database.action: none
|
||||||
|
mail:
|
||||||
|
host: ${MAIL_HOST:smtp.gmail.com}
|
||||||
|
port: ${MAIL_PORT:587}
|
||||||
|
username: ${MAIL_USERNAME:}
|
||||||
|
password: ${MAIL_PASSWORD:}
|
||||||
|
properties:
|
||||||
|
mail.smtp.auth: true
|
||||||
|
mail.smtp.starttls.enable: true
|
||||||
|
|
||||||
|
zioinfo:
|
||||||
|
jwt:
|
||||||
|
secret: zioinfo-admin-jwt-secret-key-must-be-at-least-32-chars-long
|
||||||
|
expiration-ms: 28800000 # 8시간
|
||||||
|
company:
|
||||||
|
name: (주)지오정보기술
|
||||||
|
email: info@zioinfo.co.kr
|
||||||
|
phone: 02-000-0000
|
||||||
|
address: 서울특별시
|
||||||
|
cors:
|
||||||
|
allowed-origins:
|
||||||
|
- http://localhost:3000
|
||||||
|
- http://localhost:5173
|
||||||
|
- http://www.zioinfo.co.kr
|
||||||
|
|
||||||
|
logging:
|
||||||
|
level:
|
||||||
|
kr.co.zioinfo: DEBUG
|
||||||
|
org.hibernate.SQL: WARN
|
||||||
@ -0,0 +1 @@
|
|||||||
|
import{r as a,j as e,L as n}from"./index-ChpGil2q.js";const h=s=>fetch(s,{headers:{Authorization:`Bearer ${localStorage.getItem("admin_token")}`}}).then(l=>l.json());function x(){var t,r;const[s,l]=a.useState(null),[c,d]=a.useState(!0);if(a.useEffect(()=>{h("/api/admin/dashboard").then(l).finally(()=>d(!1))},[]),c)return e.jsx("p",{style:{color:"#64748b",fontSize:14},children:"로딩 중..."});if(!s)return null;const o=[{icon:"📰",label:"전체 뉴스",value:s.totalNews,sub:`공개 ${s.visibleNews}건`,color:"blue"},{icon:"📩",label:"전체 문의",value:s.totalInquiries,sub:`미답변 ${s.pendingInquiries}건`,color:s.pendingInquiries>0?"red":"green"},{icon:"👥",label:"채용공고",value:s.totalRecruits,sub:`진행중 ${s.activeRecruits}건`,color:"green"}];return e.jsxs(e.Fragment,{children:[e.jsxs("div",{className:"admin-stats",children:[o.map(i=>e.jsxs("div",{className:"stat-card",children:[e.jsx("div",{className:`stat-icon ${i.color}`,children:i.icon}),e.jsxs("div",{className:"stat-info",children:[e.jsx("h4",{children:i.value}),e.jsxs("p",{children:[i.label,e.jsx("br",{}),e.jsx("span",{style:{fontSize:11},children:i.sub})]})]})]},i.label)),s.pendingInquiries>0&&e.jsxs("div",{className:"stat-card",style:{borderLeft:"3px solid #ef4444"},children:[e.jsx("div",{className:"stat-icon red",children:"🔔"}),e.jsxs("div",{className:"stat-info",children:[e.jsx("h4",{style:{color:"#ef4444"},children:s.pendingInquiries}),e.jsxs("p",{children:["미답변 문의",e.jsx("br",{}),e.jsx(n,{to:"/admin/inquiries",style:{fontSize:11,color:"#ef4444"},children:"바로가기 →"})]})]})]})]}),e.jsxs("div",{style:{display:"grid",gridTemplateColumns:"1fr 1fr",gap:16},children:[e.jsxs("div",{className:"admin-card",children:[e.jsxs("div",{className:"admin-card-header",children:[e.jsx("h3",{children:"📰 최근 뉴스"}),e.jsx(n,{to:"/admin/news",className:"btn btn-outline btn-sm",children:"전체보기"})]}),e.jsxs("ul",{className:"recent-list",children:[(s.recentNews||[]).map(i=>e.jsxs("li",{children:[e.jsx("span",{className:"rl-dot"}),e.jsx("span",{className:"rl-title",children:i.title}),e.jsx("span",{className:"rl-meta",children:i.category})]},i.id)),!((t=s.recentNews)!=null&&t.length)&&e.jsx("li",{style:{color:"#94a3b8",fontSize:13},children:"등록된 뉴스가 없습니다."})]})]}),e.jsxs("div",{className:"admin-card",children:[e.jsxs("div",{className:"admin-card-header",children:[e.jsx("h3",{children:"📩 최근 문의"}),e.jsx(n,{to:"/admin/inquiries",className:"btn btn-outline btn-sm",children:"전체보기"})]}),e.jsxs("ul",{className:"recent-list",children:[(s.recentInquiries||[]).map(i=>e.jsxs("li",{children:[e.jsx("span",{className:"rl-dot",style:{background:i.status==="PENDING"?"#ef4444":"#22c55e"}}),e.jsx("span",{className:"rl-title",children:i.subject}),e.jsx("span",{className:"rl-meta",children:i.name})]},i.id)),!((r=s.recentInquiries)!=null&&r.length)&&e.jsx("li",{style:{color:"#94a3b8",fontSize:13},children:"접수된 문의가 없습니다."})]})]})]})]})}export{x as default};
|
||||||
@ -0,0 +1 @@
|
|||||||
|
import{c as g,u as x,r as s,j as a,N as b,O as j}from"./index-ChpGil2q.js";/* empty css */const N=[{section:"메인"},{path:"/admin/dashboard",icon:"📊",label:"대시보드"},{section:"콘텐츠 관리"},{path:"/admin/news",icon:"📰",label:"뉴스/공지사항"},{path:"/admin/recruit",icon:"👥",label:"채용공고"},{section:"고객 관리"},{path:"/admin/inquiries",icon:"📩",label:"문의 관리",badgeKey:"pendingInquiries"},{section:"시스템"},{path:"/admin/settings",icon:"⚙️",label:"설정"}];function y(){const i=g(),d=x(),[t,r]=s.useState(null),[l,m]=s.useState("대시보드"),[o,h]=s.useState({});s.useEffect(()=>{const e=localStorage.getItem("admin_token");if(!e){i("/admin/login");return}const n=JSON.parse(localStorage.getItem("admin_user")||"{}");r(n),u(e)},[i]),s.useEffect(()=>{m({"/admin/dashboard":"대시보드","/admin/news":"뉴스/공지사항 관리","/admin/inquiries":"문의 관리","/admin/recruit":"채용공고 관리","/admin/settings":"설정"}[d.pathname]||"관리자")},[d.pathname]);const u=async e=>{try{const n=await fetch("/api/admin/dashboard",{headers:{Authorization:`Bearer ${e}`}});if(n.ok){const c=await n.json();h({pendingInquiries:c.pendingInquiries||0})}}catch{}},p=()=>{localStorage.removeItem("admin_token"),localStorage.removeItem("admin_user"),i("/admin/login")};return t?a.jsxs("div",{className:"admin-wrap",children:[a.jsxs("aside",{className:"admin-sidebar",children:[a.jsxs("div",{className:"admin-sidebar-logo",children:[a.jsx("h2",{children:"ZioInfo Admin"}),a.jsx("span",{children:"(주)지오정보기술 관리자"})]}),a.jsx("nav",{className:"admin-nav",children:N.map((e,n)=>e.section?a.jsx("div",{className:"admin-nav-section",children:e.section},n):a.jsxs(b,{to:e.path,className:({isActive:c})=>c?"active":"",children:[a.jsx("span",{className:"nav-icon",children:e.icon}),e.label,e.badgeKey&&o[e.badgeKey]>0&&a.jsx("span",{className:"admin-nav-badge",children:o[e.badgeKey]})]},e.path))}),a.jsx("div",{className:"admin-sidebar-footer",children:a.jsx("button",{onClick:p,children:"🚪 로그아웃"})})]}),a.jsxs("main",{className:"admin-main",children:[a.jsxs("div",{className:"admin-topbar",children:[a.jsx("h1",{children:l}),a.jsxs("div",{className:"admin-topbar-right",children:[a.jsxs("span",{className:"admin-user-badge",children:["👤 ",t.displayName||t.username]}),a.jsx("a",{href:"/",target:"_blank",rel:"noreferrer",style:{fontSize:12,color:"#64748b",textDecoration:"none"},children:"🌐 홈페이지 보기"})]})]}),a.jsx("div",{className:"admin-content",children:a.jsx(j,{})})]})]}):null}export{y as default};
|
||||||
@ -0,0 +1 @@
|
|||||||
|
import{r as i,c as p,j as e}from"./index-ChpGil2q.js";/* empty css */function x(){const[t,o]=i.useState({username:"",password:""}),[l,r]=i.useState(""),[d,c]=i.useState(!1),m=p(),u=async a=>{a.preventDefault(),r(""),c(!0);try{const s=await fetch("/api/admin/login",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(t)}),n=await s.json();if(!s.ok){r(n.message||"로그인 실패");return}localStorage.setItem("admin_token",n.token),localStorage.setItem("admin_user",JSON.stringify({username:n.username,displayName:n.displayName})),m("/admin/dashboard")}catch{r("서버 연결 오류가 발생했습니다.")}finally{c(!1)}};return e.jsx("div",{className:"admin-login-page",children:e.jsxs("div",{className:"admin-login-box",children:[e.jsxs("div",{className:"login-logo",children:[e.jsx("span",{className:"login-badge",children:"ADMIN"}),e.jsx("h1",{children:"(주)지오정보기술"}),e.jsx("p",{children:"홈페이지 관리자 시스템"})]}),l&&e.jsxs("div",{className:"login-error",children:["⚠ ",l]}),e.jsxs("form",{onSubmit:u,children:[e.jsxs("div",{className:"login-input-group",children:[e.jsx("label",{children:"아이디"}),e.jsx("input",{type:"text",placeholder:"관리자 아이디",value:t.username,required:!0,onChange:a=>o(s=>({...s,username:a.target.value}))})]}),e.jsxs("div",{className:"login-input-group",children:[e.jsx("label",{children:"비밀번호"}),e.jsx("input",{type:"password",placeholder:"비밀번호",value:t.password,required:!0,onChange:a=>o(s=>({...s,password:a.target.value}))})]}),e.jsx("button",{type:"submit",className:"login-btn",disabled:d,children:d?"로그인 중...":"로그인"})]}),e.jsxs("p",{style:{textAlign:"center",marginTop:20,fontSize:12,color:"#94a3b8"},children:["홈페이지로 돌아가기: ",e.jsx("a",{href:"/",style:{color:"#4f6ef7"},children:"메인 페이지"})]})]})})}export{x as default};
|
||||||
@ -0,0 +1 @@
|
|||||||
|
import{c as u,r as d,j as e}from"./index-ChpGil2q.js";const h=()=>localStorage.getItem("admin_token");function f(){const m=u(),i=JSON.parse(localStorage.getItem("admin_user")||"{}"),[r,n]=d.useState({currentPassword:"",newPassword:"",confirmPassword:""}),[t,o]=d.useState(null),[l,c]=d.useState(!1),p=async()=>{if(r.newPassword!==r.confirmPassword){o({text:"새 비밀번호가 일치하지 않습니다.",type:"error"});return}if(r.newPassword.length<8){o({text:"비밀번호는 8자 이상이어야 합니다.",type:"error"});return}c(!0);const s=await fetch("/api/admin/password",{method:"PUT",headers:{"Content-Type":"application/json",Authorization:`Bearer ${h()}`},body:JSON.stringify({currentPassword:r.currentPassword,newPassword:r.newPassword})}),a=await s.json();c(!1),s.ok?(o({text:"비밀번호가 변경되었습니다. 다시 로그인해주세요.",type:"success"}),n({currentPassword:"",newPassword:"",confirmPassword:""}),setTimeout(()=>{localStorage.removeItem("admin_token"),m("/admin/login")},2e3)):o({text:a.message||"변경 실패",type:"error"})};return e.jsxs("div",{style:{maxWidth:520},children:[e.jsxs("div",{className:"admin-card",style:{marginBottom:20},children:[e.jsx("div",{className:"admin-card-header",children:e.jsx("h3",{children:"👤 계정 정보"})}),e.jsx("div",{style:{display:"grid",gap:12},children:[["아이디",i.username],["표시 이름",i.displayName||"-"]].map(([s,a])=>e.jsxs("div",{style:{display:"flex",alignItems:"center",gap:12},children:[e.jsx("span",{style:{fontSize:12,fontWeight:600,color:"#64748b",width:80},children:s}),e.jsx("span",{style:{fontSize:14},children:a})]},s))})]}),e.jsxs("div",{className:"admin-card",children:[e.jsx("div",{className:"admin-card-header",children:e.jsx("h3",{children:"🔒 비밀번호 변경"})}),t&&e.jsx("div",{style:{padding:"10px 14px",borderRadius:7,marginBottom:16,fontSize:13,background:t.type==="error"?"#fff1f2":"#f0fdf4",color:t.type==="error"?"#dc2626":"#16a34a",border:`1px solid ${t.type==="error"?"#fecaca":"#bbf7d0"}`},children:t.text}),e.jsxs("div",{className:"form-group",children:[e.jsx("label",{children:"현재 비밀번호"}),e.jsx("input",{type:"password",className:"form-control",value:r.currentPassword,onChange:s=>n(a=>({...a,currentPassword:s.target.value}))})]}),e.jsxs("div",{className:"form-group",children:[e.jsx("label",{children:"새 비밀번호"}),e.jsx("input",{type:"password",className:"form-control",value:r.newPassword,placeholder:"8자 이상",onChange:s=>n(a=>({...a,newPassword:s.target.value}))})]}),e.jsxs("div",{className:"form-group",children:[e.jsx("label",{children:"새 비밀번호 확인"}),e.jsx("input",{type:"password",className:"form-control",value:r.confirmPassword,onChange:s=>n(a=>({...a,confirmPassword:s.target.value}))})]}),e.jsx("button",{className:"btn btn-primary",onClick:p,disabled:l||!r.currentPassword||!r.newPassword,children:l?"변경 중...":"비밀번호 변경"})]})]})}export{f as default};
|
||||||
@ -0,0 +1 @@
|
|||||||
|
.ref-filters{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:24px}.ref-filter-btn{padding:7px 18px;border-radius:20px;border:1px solid var(--gray-200);font-size:13px;font-weight:500;color:var(--gray-600);cursor:pointer;transition:all var(--fast) var(--ease);background:var(--white)}.ref-filter-btn:hover{border-color:var(--primary);color:var(--primary)}.ref-filter-btn.active{background:var(--primary);border-color:var(--primary);color:#fff}.ref-table-wrap{overflow-x:auto;border-radius:12px;border:1px solid var(--gray-200)}.ref-table{width:100%;border-collapse:collapse;min-width:800px}.ref-table th{background:var(--secondary);color:#fffc;padding:14px 16px;text-align:left;font-size:12px;font-weight:600;letter-spacing:.5px}.ref-table td{padding:13px 16px;font-size:13px;border-bottom:1px solid var(--gray-100);vertical-align:middle}.ref-table tr:last-child td{border-bottom:none}.ref-table tr:hover td{background:var(--gray-50)}.ref-period{color:var(--gray-500);font-size:12px;white-space:nowrap}.ref-client{font-weight:700;color:var(--gray-800);white-space:nowrap}.ref-project{color:var(--gray-700)}.ref-role{padding:3px 10px;border-radius:12px;font-size:11px;font-weight:700;background:var(--primary-light);color:var(--primary);white-space:nowrap}.ref-tech{font-size:12px;color:var(--gray-500)}.ref-cat-badge{padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600;white-space:nowrap}.partner-card{padding:32px 24px;text-align:center}.partner-logo{font-size:48px;margin-bottom:12px}.partner-tier{display:inline-block;padding:3px 12px;border-radius:12px;font-size:11px;font-weight:700;margin-bottom:12px}.partner-name{font-size:16px;font-weight:700;color:var(--gray-900);margin-bottom:10px}.partner-desc{font-size:13px;color:var(--gray-600);line-height:1.6}.partner-cta{margin-top:64px;text-align:center;padding:56px;background:linear-gradient(135deg,var(--primary-light),rgba(0,163,224,.08));border-radius:16px;border:1px solid var(--gray-200)}.partner-cta h3{font-size:24px;font-weight:800;margin-bottom:12px}.partner-cta p{color:var(--gray-600);margin-bottom:24px;font-size:15px}
|
||||||
@ -0,0 +1 @@
|
|||||||
|
.inner-page{padding-top:var(--header-h)}.page-hero{background:linear-gradient(135deg,var(--secondary),var(--primary-dark));padding:60px 0;color:#fff}.page-hero-title{font-size:40px;font-weight:900;margin:8px 0 12px}.page-hero p{color:#ffffffbf;font-size:16px}
|
||||||
@ -0,0 +1 @@
|
|||||||
|
.contact-page{padding-top:var(--header-h)}.page-hero{background:linear-gradient(135deg,var(--secondary),var(--primary-dark));padding:60px 0;color:#fff}.page-hero-title{font-size:40px;font-weight:900;margin:8px 0 12px}.page-hero p{color:#ffffffbf;font-size:16px}.contact-grid{display:grid;grid-template-columns:340px 1fr;gap:48px;align-items:start}.contact-info h2{font-size:22px;font-weight:700;margin-bottom:28px}.info-item{display:flex;gap:16px;margin-bottom:24px;align-items:flex-start}.info-icon{font-size:24px}.info-item strong{display:block;font-size:13px;font-weight:700;color:var(--gray-500);margin-bottom:2px}.info-item p{font-size:15px;color:var(--gray-800)}.contact-form{padding:36px}.contact-form h2{font-size:22px;font-weight:700;margin-bottom:24px}.form-alert{padding:12px 16px;border-radius:var(--radius-sm);font-size:14px;margin-bottom:16px}.form-alert.success{background:#d1fae5;color:#065f46}.form-alert.error{background:#fee2e2;color:#991b1b}.form-row{display:grid;grid-template-columns:1fr 1fr;gap:16px}.form-group{display:flex;flex-direction:column;gap:6px;margin-bottom:16px}.form-group label{font-size:13px;font-weight:600;color:var(--gray-700)}.required{color:var(--danger)}.form-group input,.form-group select,.form-group textarea{padding:10px 14px;border:1px solid var(--gray-200);border-radius:var(--radius-sm);font-size:14px;font-family:inherit;transition:border-color var(--fast);outline:none}.form-group input:focus,.form-group select:focus,.form-group textarea:focus{border-color:var(--primary);box-shadow:0 0 0 3px #0051a21a}.privacy-agree{display:flex;align-items:center;gap:10px;font-size:13px;color:var(--gray-600);margin-bottom:20px;cursor:pointer}.privacy-agree a{color:var(--primary)}@media (max-width: 1024px){.contact-grid{grid-template-columns:1fr}}@media (max-width: 768px){.form-row{grid-template-columns:1fr}}
|
||||||
@ -0,0 +1 @@
|
|||||||
|
import{r as i,j as e}from"./index-ChpGil2q.js";import{a as j}from"./index-DcNlVx-A.js";function y(){const[a,t]=i.useState({name:"",email:"",phone:"",category:"제품문의",subject:"",content:"",agreePrivacy:!1}),[r,l]=i.useState(null),[o,d]=i.useState(!1),s=n=>{const{name:c,value:m,type:p,checked:x}=n.target;t(u=>({...u,[c]:p==="checkbox"?x:m}))},h=async n=>{if(n.preventDefault(),!a.agreePrivacy){l({type:"error",msg:"개인정보 수집·이용에 동의해주세요."});return}d(!0);try{await j.post("/api/inquiry",a),l({type:"success",msg:"문의가 접수되었습니다. 빠른 시일 내에 연락드리겠습니다."}),t({name:"",email:"",phone:"",category:"제품문의",subject:"",content:"",agreePrivacy:!1})}catch{l({type:"error",msg:"문의 접수 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요."})}finally{d(!1)}};return e.jsxs("main",{id:"main-content",className:"contact-page",children:[e.jsx("div",{className:"page-hero",children:e.jsxs("div",{className:"container",children:[e.jsx("span",{className:"section-label",children:"Contact Us"}),e.jsx("h1",{className:"page-hero-title",children:"문의하기"}),e.jsx("p",{children:"GUARDiA ITSM 도입 문의 및 제품 상담을 받아드립니다."})]})}),e.jsx("section",{className:"section",children:e.jsxs("div",{className:"container contact-grid",children:[e.jsxs("div",{className:"contact-info",children:[e.jsx("h2",{children:"연락처 정보"}),[{icon:"📞",label:"대표전화",value:"02-000-0000"},{icon:"✉️",label:"이메일",value:"info@zioinfo.co.kr"},{icon:"🕐",label:"운영시간",value:"평일 09:00 ~ 18:00"},{icon:"📍",label:"주소",value:"서울특별시"}].map((n,c)=>e.jsxs("div",{className:"info-item",children:[e.jsx("span",{className:"info-icon",children:n.icon}),e.jsxs("div",{children:[e.jsx("strong",{children:n.label}),e.jsx("p",{children:n.value})]})]},c))]}),e.jsxs("form",{className:"contact-form card",onSubmit:h,children:[e.jsx("h2",{children:"온라인 문의"}),r&&e.jsxs("div",{className:`form-alert ${r.type}`,children:[r.type==="success"?"✅":"❌"," ",r.msg]}),e.jsxs("div",{className:"form-row",children:[e.jsxs("div",{className:"form-group",children:[e.jsxs("label",{htmlFor:"name",children:["성함 ",e.jsx("span",{className:"required",children:"*"})]}),e.jsx("input",{id:"name",name:"name",type:"text",required:!0,value:a.name,onChange:s,placeholder:"홍길동"})]}),e.jsxs("div",{className:"form-group",children:[e.jsx("label",{htmlFor:"phone",children:"연락처"}),e.jsx("input",{id:"phone",name:"phone",type:"tel",value:a.phone,onChange:s,placeholder:"010-0000-0000"})]})]}),e.jsxs("div",{className:"form-row",children:[e.jsxs("div",{className:"form-group",children:[e.jsxs("label",{htmlFor:"email",children:["이메일 ",e.jsx("span",{className:"required",children:"*"})]}),e.jsx("input",{id:"email",name:"email",type:"email",required:!0,value:a.email,onChange:s,placeholder:"your@email.com"})]}),e.jsxs("div",{className:"form-group",children:[e.jsx("label",{htmlFor:"category",children:"문의 유형"}),e.jsxs("select",{id:"category",name:"category",value:a.category,onChange:s,children:[e.jsx("option",{children:"제품문의"}),e.jsx("option",{children:"데모 신청"}),e.jsx("option",{children:"기술지원"}),e.jsx("option",{children:"사업제안"}),e.jsx("option",{children:"채용문의"}),e.jsx("option",{children:"기타"})]})]})]}),e.jsxs("div",{className:"form-group",children:[e.jsxs("label",{htmlFor:"subject",children:["제목 ",e.jsx("span",{className:"required",children:"*"})]}),e.jsx("input",{id:"subject",name:"subject",type:"text",required:!0,value:a.subject,onChange:s,placeholder:"문의 제목을 입력해주세요"})]}),e.jsxs("div",{className:"form-group",children:[e.jsxs("label",{htmlFor:"content",children:["문의 내용 ",e.jsx("span",{className:"required",children:"*"})]}),e.jsx("textarea",{id:"content",name:"content",rows:6,required:!0,value:a.content,onChange:s,placeholder:"문의 내용을 자세히 작성해주세요."})]}),e.jsxs("label",{className:"privacy-agree",children:[e.jsx("input",{type:"checkbox",name:"agreePrivacy",checked:a.agreePrivacy,onChange:s}),e.jsxs("span",{children:["개인정보 수집·이용에 동의합니다. ",e.jsx("a",{href:"/privacy",target:"_blank",children:"[보기]"})]})]}),e.jsx("button",{type:"submit",className:"btn btn-primary btn-lg",style:{width:"100%"},disabled:o,children:o?"전송 중...":"문의 접수하기"})]})]})})]})}export{y as default};
|
||||||
@ -0,0 +1 @@
|
|||||||
|
.notice-back{font-size:14px;color:var(--primary);margin-bottom:24px;display:inline-flex;align-items:center;gap:4px;cursor:pointer;background:none;border:none}.news-cat-badge{display:inline-block;padding:4px 12px;border-radius:12px;font-size:12px;font-weight:700;margin-bottom:12px}.news-cat-badge.hot{background:#ef44441f;color:var(--danger)}.news-main{padding:40px;cursor:pointer;background:linear-gradient(135deg,var(--secondary),var(--primary-dark));border:none;margin-bottom:0}.news-main:hover{transform:none;box-shadow:var(--shadow-lg)}.news-main-title{font-size:26px;font-weight:900;color:#fff;margin-bottom:16px;line-height:1.35}.news-main-summary{font-size:15px;color:#ffffffb3;line-height:1.8;margin-bottom:16px;max-width:640px}.news-date{font-size:12px;color:#ffffff80}.news-card{padding:28px;cursor:pointer;display:flex;flex-direction:column}.news-card-title{font-size:15px;font-weight:700;color:var(--gray-900);margin-bottom:10px;line-height:1.5;flex:1}.news-card-summary{font-size:13px;color:var(--gray-600);line-height:1.7;margin-bottom:12px;flex:1}.news-date{font-size:12px;color:var(--gray-400)}.blog-card{padding:28px;display:flex;flex-direction:column}.blog-tag{display:inline-block;padding:4px 12px;border-radius:12px;font-size:12px;font-weight:700;margin-bottom:14px;align-self:flex-start}.blog-title{font-size:16px;font-weight:700;color:var(--gray-900);line-height:1.5;margin-bottom:12px}.blog-summary{font-size:13px;color:var(--gray-600);line-height:1.7;flex:1;margin-bottom:16px}.blog-meta{display:flex;gap:16px;font-size:12px;color:var(--gray-400);margin-bottom:16px}.blog-read-btn{padding:10px 20px;background:var(--primary-light);color:var(--primary);border-radius:8px;font-size:14px;font-weight:700;border:none;cursor:pointer;transition:all var(--fast);text-align:center}.blog-read-btn:hover{background:var(--primary);color:#fff}
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
import{j as e,b as o,a as n,r as h,N as x}from"./index-ChpGil2q.js";/* empty css */const p=[{path:"/news/newsroom",label:"뉴스룸"},{path:"/news/blog",label:"기술 블로그"}];function d({title:a}){return e.jsxs(e.Fragment,{children:[e.jsx("div",{className:"page-hero",children:e.jsxs("div",{className:"container",children:[e.jsx("span",{className:"section-label",children:"News"}),e.jsx("h1",{className:"page-hero-title",children:a})]})}),e.jsx("nav",{className:"sub-nav",children:e.jsx("div",{className:"container",children:p.map(t=>e.jsx(x,{to:t.path,className:({isActive:i})=>"sub-nav-item"+(i?" active":""),children:t.label},t.path))})})]})}const l=[{id:1,cat:"제품 출시",date:"2026.05.15",title:"GUARDiA ITSM v2.0 정식 출시 — AI ChatOps 오케스트레이션 플랫폼",summary:"메신저 한 줄 명령으로 1,000개+ 공공기관 레거시 인프라를 자동 운영하는 GUARDiA ITSM v2.0이 정식 출시되었습니다. 신규 기능으로 AI 자연어 명령, 에이전트리스 배포 엔진, 멀티테넌트 지원이 추가됐습니다.",content:`GUARDiA ITSM v2.0은 공공기관의 레거시 IT 인프라 운영 자동화를 위한 AI 기반 플랫폼입니다.
|
||||||
|
|
||||||
|
주요 신기능:
|
||||||
|
- AI ChatOps: 메신저 자연어 명령 → Ollama LLM 파싱 → 자동 실행
|
||||||
|
- 에이전트리스 배포: SSH/SFTP만으로 WAS 배포·롤백 자동화
|
||||||
|
- 멀티테넌트: 1,000개+ 기관 동시 관리
|
||||||
|
- GS인증 1등급 신청 완료
|
||||||
|
|
||||||
|
자세한 사항은 GUARDiA 소개 페이지를 참조해 주십시오.`,hot:!0},{id:2,cat:"수주 소식",date:"2026.04.20",title:"삼성전자 차세대 CRM 시스템 DB 마이그레이션 프로젝트 수주",summary:"(주)지오정보기술이 삼성전자 차세대 CRM 구축 프로젝트의 DB Migration/DA/튜닝을 담당합니다. EDB PostgreSQL 환경으로의 전환을 포함한 대규모 DB 현대화 작업을 수행합니다.",content:"삼성전자와의 두 번째 협력 프로젝트로, DB 마이그레이션 및 성능 튜닝을 담당합니다.",hot:!1},{id:3,cat:"기술 인증",date:"2026.03.10",title:"GUARDiA ITSM GS인증 1등급 신청 완료 — TTA 심사 예정",summary:"GUARDiA ITSM이 한국정보통신기술협회(TTA)에 GS인증 1등급을 신청하였습니다. 기능적합성, 신뢰성, 사용성, 보안성 등 ISO/IEC 25010 기준 8대 품질 특성 심사를 앞두고 있습니다.",content:"GS인증 심사는 2026년 9월 예정이며, 1등급 취득 시 조달청 나라장터 우선 등재가 가능합니다.",hot:!1},{id:4,cat:"수주 소식",date:"2026.02.15",title:"국민연금공단 차세대 시스템 구축 — AA 역할 수행",summary:"국민연금공단 차세대 시스템 구축 프로젝트에 Application Architect(AA)로 참여합니다. JSP/Java, Nexacro, Spring 기반의 대규모 공공기관 시스템 구축을 담당합니다.",content:"국민연금관리공단의 차세대 시스템은 수천만 가입자의 연금 관리 시스템으로, CI/CD 파이프라인 기반의 현대적인 개발 환경을 구축합니다.",hot:!1},{id:5,cat:"기업 소식",date:"2025.12.01",title:"2025년 사업실적 — 연간 프로젝트 10건 성공 수행",summary:"2025년 한 해 동안 삼성전자, 서울신용보증재단, 헌법재판소 등 10개 주요 프로젝트를 성공적으로 완료했습니다. 매출은 전년 대비 25% 성장하였습니다.",content:"창립 이래 최대 성과를 기록한 2025년 사업실적을 공유드립니다.",hot:!1},{id:6,cat:"파트너십",date:"2025.09.10",title:"Tibero 공식 파트너사 등록 — 공공기관 DB 전환 솔루션 강화",summary:"국산 DBMS Tibero의 공식 파트너사로 등록되었습니다. Oracle에서 Tibero로의 마이그레이션 및 공공기관 DB 현대화 사업을 공동으로 추진합니다.",content:"공공기관의 Oracle 라이선스 절감을 위한 Tibero 전환 프로젝트를 전문적으로 지원합니다.",hot:!1}];function c(){const[a,t]=h.useState(null),i=l.find(s=>s.id===a);return e.jsxs("main",{id:"main-content",className:"inner-page",children:[e.jsx(d,{title:"뉴스룸"}),e.jsx("section",{className:"section",children:e.jsx("div",{className:"container",children:i?e.jsxs("div",{style:{maxWidth:"760px",margin:"0 auto"},children:[e.jsx("button",{className:"notice-back",onClick:()=>t(null),children:"← 뉴스 목록"}),e.jsxs("div",{className:"news-detail card",style:{padding:"40px"},children:[e.jsx("span",{className:"news-cat-badge",style:{background:"var(--primary-light)",color:"var(--primary)"},children:i.cat}),e.jsx("h2",{style:{fontSize:"24px",fontWeight:"900",margin:"16px 0 8px",lineHeight:"1.4"},children:i.title}),e.jsx("p",{style:{fontSize:"13px",color:"var(--gray-400)",marginBottom:"32px"},children:i.date}),e.jsx("div",{className:"divider divider-left",style:{marginBottom:"32px"}}),i.content.split(`
|
||||||
|
`).map((s,m)=>s.trim()?e.jsx("p",{style:{fontSize:"15px",color:"var(--gray-700)",lineHeight:"1.85",marginBottom:"16px"},children:s},m):null)]})]}):e.jsxs(e.Fragment,{children:[e.jsx("div",{className:"news-main card",onClick:()=>t(l[0].id),children:e.jsxs("div",{className:"news-main-content",children:[e.jsxs("span",{className:"news-cat-badge hot",children:["🔥 ",l[0].cat]}),e.jsx("h2",{className:"news-main-title",children:l[0].title}),e.jsx("p",{className:"news-main-summary",children:l[0].summary}),e.jsx("span",{className:"news-date",children:l[0].date})]})}),e.jsx("div",{className:"grid-3",style:{marginTop:"24px"},children:l.slice(1).map(s=>e.jsxs("div",{className:"card news-card",onClick:()=>t(s.id),children:[e.jsx("span",{className:"news-cat-badge",style:{background:"var(--primary-light)",color:"var(--primary)"},children:s.cat}),e.jsx("h3",{className:"news-card-title",children:s.title}),e.jsx("p",{className:"news-card-summary",children:s.summary}),e.jsx("span",{className:"news-date",children:s.date})]},s.id))})]})})})]})}const j=[{id:1,tag:"AI·LLM",date:"2026.05.20",title:"온프레미스 Ollama로 폐쇄망 ChatOps 구현하기",summary:"인터넷 없이 내부망에서 LLM을 운영하는 방법. Llama-3-8B 모델을 Ollama로 구동하고 FastAPI와 연동하는 전체 과정을 설명합니다.",readMin:12},{id:2,tag:"DevOps",date:"2026.05.10",title:"에이전트리스 WAS 배포 자동화 — paramiko SSH로 레거시 서버 관리",summary:"JEUS·Tomcat 등 레거시 WAS에 SSH/SFTP만으로 배포하는 방법. 백업→배포→헬스체크→롤백 파이프라인 구현 예제.",readMin:15},{id:3,tag:"보안",date:"2026.04.28",title:"AES-256-GCM으로 서버 자격증명을 안전하게 저장하는 법",summary:"공공기관 서버 SSH 비밀번호를 DB에 안전하게 암호화 저장하는 방법. IV·암호문·GCM Tag 구조 설계와 Python 구현.",readMin:8},{id:4,tag:"데이터베이스",date:"2026.04.15",title:"Oracle 19c → EDB PostgreSQL 마이그레이션 실전 가이드",summary:"삼성전자 CRM 프로젝트에서 실제 수행한 Oracle→EDB 마이그레이션 경험 공유. Smeta, ExemOne 활용 SQL 변환 전략.",readMin:20},{id:5,tag:"성능",date:"2026.03.25",title:"공공기관 행정정보시스템 SQL 튜닝 — 서울시립대 사례",summary:"대학행정정보시스템 성능 개선 프로젝트 실전 사례. JMeter 부하테스트와 Oracle 실행계획 분석으로 응답시간 60% 단축.",readMin:18},{id:6,tag:"아키텍처",date:"2026.03.10",title:"FastAPI 비동기 WebSocket으로 실시간 대시보드 구축하기",summary:"GUARDiA ITSM 실시간 모니터링 대시보드 구현 방법. FastAPI SSE + WebSocket + React를 조합한 풀스택 아키텍처.",readMin:14}],r={"AI·LLM":"#7c3aed",DevOps:"#0051A2",보안:"#dc2626",데이터베이스:"#d97706",성능:"#059669",아키텍처:"#0891b2"};function g(){return e.jsxs("main",{id:"main-content",className:"inner-page",children:[e.jsx(d,{title:"기술 블로그"}),e.jsx("section",{className:"section",children:e.jsxs("div",{className:"container",children:[e.jsxs("div",{className:"section-header",children:[e.jsx("span",{className:"section-label",children:"Tech Blog"}),e.jsx("h2",{className:"section-title",children:"기술 인사이트 공유"}),e.jsx("p",{className:"section-desc",children:"20년 이상의 프로젝트 경험에서 얻은 기술 노하우를 공유합니다"})]}),e.jsx("div",{className:"grid-3",children:j.map(a=>e.jsxs("div",{className:"card blog-card",children:[e.jsx("div",{className:"blog-tag",style:{background:r[a.tag]+"18",color:r[a.tag]},children:a.tag}),e.jsx("h3",{className:"blog-title",children:a.title}),e.jsx("p",{className:"blog-summary",children:a.summary}),e.jsxs("div",{className:"blog-meta",children:[e.jsxs("span",{children:["📅 ",a.date]}),e.jsxs("span",{children:["⏱ ",a.readMin,"분 읽기"]})]}),e.jsx("button",{className:"blog-read-btn",onClick:()=>alert("블로그 상세 페이지는 준비 중입니다."),children:"읽기 →"})]},a.id))})]})})]})}function u(){return e.jsxs(o,{children:[e.jsx(n,{path:"newsroom",element:e.jsx(c,{})}),e.jsx(n,{path:"blog",element:e.jsx(g,{})}),e.jsx(n,{path:"press",element:e.jsx(c,{})}),e.jsx(n,{path:"*",element:e.jsx(c,{})})]})}export{u as default};
|
||||||
@ -0,0 +1 @@
|
|||||||
|
import{j as e,L as t}from"./index-ChpGil2q.js";function i(){return e.jsxs("main",{style:{paddingTop:"var(--header-h)",minHeight:"60vh",display:"flex",alignItems:"center",justifyContent:"center",flexDirection:"column",gap:"16px",textAlign:"center"},children:[e.jsx("div",{style:{fontSize:"72px"},children:"404"}),e.jsx("h1",{style:{fontSize:"24px",fontWeight:"700"},children:"페이지를 찾을 수 없습니다"}),e.jsx("p",{style:{color:"var(--gray-600)"},children:"요청하신 페이지가 존재하지 않거나 이동되었습니다."}),e.jsx(t,{to:"/",className:"btn btn-primary",children:"홈으로 돌아가기"})]})}export{i as default};
|
||||||
@ -0,0 +1 @@
|
|||||||
|
.notice-back{font-size:14px;color:var(--primary);margin-bottom:24px;display:inline-flex;align-items:center;gap:4px;cursor:pointer;background:none;border:none}.job-card{padding:28px;display:flex;gap:24px;align-items:flex-start;cursor:pointer}.job-card:hover{border-color:var(--primary)}.job-info{flex:1}.job-title{font-size:18px;font-weight:700;color:var(--gray-900);margin-bottom:8px}.job-desc{font-size:13px;color:var(--gray-600);line-height:1.6;margin-bottom:12px}.job-stack{display:flex;gap:6px;flex-wrap:wrap}.job-tech{padding:4px 10px;background:var(--secondary);color:var(--accent);border-radius:4px;font-size:11px;font-weight:600}.job-meta{display:flex;flex-direction:column;gap:8px;align-items:flex-end;min-width:100px}.job-meta>div{display:flex;flex-direction:column;align-items:flex-end;font-size:13px;color:var(--gray-700)}.job-meta-label{font-size:11px;color:var(--gray-400);margin-bottom:2px}.welfare-cat{font-size:18px;font-weight:800;color:var(--gray-900);margin-bottom:20px}.welfare-card{padding:28px;text-align:center}.welfare-icon{font-size:36px;margin-bottom:12px}.welfare-name{font-size:15px;font-weight:700;margin-bottom:8px;color:var(--gray-900)}.welfare-desc{font-size:13px;color:var(--gray-600);line-height:1.6}.talent-wrap{background:var(--gray-50);border-radius:16px;padding:56px;margin-top:32px}.apply-form .form-group{margin-bottom:20px}.apply-form label{display:block;font-size:13px;font-weight:600;color:var(--gray-700);margin-bottom:6px}.apply-form input,.apply-form select,.apply-form textarea{width:100%;padding:12px 14px;border:1px solid var(--gray-200);border-radius:8px;font-size:14px;font-family:inherit;transition:border-color var(--fast)}.apply-form input:focus,.apply-form select:focus,.apply-form textarea:focus{outline:none;border-color:var(--primary)}.apply-form .form-row{display:grid;grid-template-columns:1fr 1fr;gap:16px}.required{color:var(--danger)}.apply-success{text-align:center;padding:80px 40px;background:var(--gray-50);border-radius:16px}.apply-success h3{font-size:24px;font-weight:800;margin-bottom:16px}.apply-success p{font-size:15px;color:var(--gray-600);line-height:1.8}@media (max-width:768px){.job-card{flex-direction:column}.job-meta{align-items:flex-start;flex-direction:row;flex-wrap:wrap}.apply-form .form-row{grid-template-columns:1fr}}
|
||||||
@ -0,0 +1 @@
|
|||||||
|
.sol-hero-grid{display:grid;grid-template-columns:1fr 1fr;gap:64px;align-items:center}.sol-title{font-size:clamp(26px,3.5vw,40px);font-weight:900;color:var(--gray-900);line-height:1.25;margin:12px 0 20px}.sol-title em{color:var(--primary);font-style:normal}.sol-desc{font-size:15px;color:var(--gray-600);line-height:1.85;margin-bottom:24px}.sol-features{display:flex;flex-direction:column;gap:10px}.sol-feature-item{display:flex;gap:10px;font-size:14px;color:var(--gray-700)}.sol-check{color:var(--accent);font-weight:700;flex-shrink:0}.sol-module-card{padding:32px 24px}.sol-module-icon{font-size:36px;margin-bottom:16px}.sol-module-card h3{font-size:16px;font-weight:700;margin-bottom:10px;color:var(--gray-900)}.sol-module-card p{font-size:13px;color:var(--gray-600);line-height:1.7}.sol-visual{display:flex;justify-content:center}.sol-screen{background:var(--secondary);border-radius:16px;padding:24px;width:100%;max-width:360px;box-shadow:var(--shadow-lg)}.sol-screen-header{display:flex;align-items:center;gap:8px;margin-bottom:20px;font-size:12px;color:#fff9;font-weight:600}.sol-screen-header span{width:10px;height:10px;border-radius:50%;background:var(--accent);flex-shrink:0}.sol-chart-bar-wrap{display:flex;gap:8px;height:120px;align-items:flex-end;margin-bottom:20px}.sol-chart-bar{flex:1;background:linear-gradient(to top,var(--primary),var(--accent));border-radius:4px 4px 0 0}.sol-stat-row{display:flex;gap:12px}.sol-stat{flex:1;background:#ffffff0f;border-radius:8px;padding:12px;text-align:center}.sol-stat strong{display:block;font-size:14px;color:#fff;font-weight:700}.sol-stat span{font-size:10px;color:#ffffff80;margin-top:4px;display:block}.crm-items{display:flex;flex-direction:column;gap:12px}.crm-item{display:flex;align-items:center;gap:12px;background:#ffffff0d;border-radius:8px;padding:10px 12px}.crm-avatar{width:32px;height:32px;border-radius:50%;background:var(--primary);display:flex;align-items:center;justify-content:center;font-size:13px;font-weight:700;color:#fff;flex-shrink:0}.crm-info{flex:1}.crm-info strong{display:block;font-size:13px;color:#fff;font-weight:600}.crm-info span{font-size:11px;color:#ffffff80}.crm-status{font-size:11px;font-weight:700}.bi-kpis{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:20px}.bi-kpi{background:#ffffff0f;border-radius:8px;padding:12px}.bi-kpi-label{display:block;font-size:10px;color:#ffffff80;margin-bottom:4px}.bi-kpi-val{display:block;font-size:15px;color:#fff;font-weight:700}.bi-kpi-delta{font-size:11px;font-weight:600}.bi-bar-chart{display:flex;gap:12px;height:80px;align-items:flex-end}.bi-bar-group{flex:1;display:flex;flex-direction:column;align-items:center;gap:6px;height:100%}.bi-bar-pair{display:flex;gap:4px;width:100%;height:100%;align-items:flex-end}.bi-bar{flex:1;border-radius:3px 3px 0 0}.bi-bar.revenue{background:var(--accent)}.bi-bar.cost{background:#ef444499}.bi-bar-group span{font-size:10px;color:#ffffff80}@media (max-width: 768px){.sol-hero-grid{grid-template-columns:1fr}.sol-visual{order:-1}}
|
||||||
@ -0,0 +1 @@
|
|||||||
|
.notice-header-row{display:grid;grid-template-columns:80px 1fr 100px;gap:16px;padding:12px 16px;background:var(--gray-50);border-radius:8px 8px 0 0;font-size:12px;font-weight:700;color:var(--gray-500);border:1px solid var(--gray-200);border-bottom:none}.notice-row{display:grid;grid-template-columns:80px 1fr 100px;gap:16px;padding:14px 16px;border:1px solid var(--gray-200);border-top:none;cursor:pointer;align-items:center;transition:background var(--fast)}.notice-row:last-child{border-radius:0 0 8px 8px}.notice-row:hover{background:var(--gray-50)}.notice-cat{display:inline-block;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:700;text-align:center}.notice-title-text{font-size:14px;color:var(--gray-800);display:flex;align-items:center;gap:8px}.notice-hot{background:var(--danger);color:#fff;font-size:10px;padding:2px 6px;border-radius:4px;font-weight:700;flex-shrink:0}.notice-date{font-size:12px;color:var(--gray-500)}.notice-detail{max-width:760px}.notice-back{font-size:14px;color:var(--primary);margin-bottom:24px;display:inline-flex;align-items:center;gap:4px;cursor:pointer;background:none;border:none}.notice-detail-header{border-bottom:2px solid var(--gray-200);padding-bottom:20px;margin-bottom:32px}.notice-detail-header h2{font-size:22px;font-weight:800;margin:12px 0 8px}.notice-body{display:flex;flex-direction:column;gap:16px;font-size:15px;line-height:1.85;color:var(--gray-700)}.faq-cat-wrap{margin-bottom:40px}.faq-cat-title{font-size:16px;font-weight:800;color:var(--primary);margin-bottom:12px;padding-bottom:8px;border-bottom:2px solid var(--primary-light)}.faq-item{border:1px solid var(--gray-200);border-radius:8px;margin-bottom:8px;overflow:hidden;transition:box-shadow var(--fast)}.faq-item.open{box-shadow:var(--shadow);border-color:var(--primary-light)}.faq-q{width:100%;display:flex;align-items:center;gap:14px;padding:16px 20px;font-size:15px;font-weight:600;color:var(--gray-800);text-align:left;background:none;border:none;cursor:pointer;transition:background var(--fast)}.faq-q:hover{background:var(--gray-50)}.faq-icon{width:24px;height:24px;border-radius:50%;background:var(--primary);color:#fff;display:flex;align-items:center;justify-content:center;font-size:16px;flex-shrink:0}.faq-a{padding:0 20px 20px 58px;font-size:14px;color:var(--gray-600);line-height:1.8}.faq-more{text-align:center;padding:48px;background:var(--gray-50);border-radius:12px;margin-top:32px}.faq-more p{color:var(--gray-600);margin-bottom:20px;font-size:16px}.catalog-card{padding:0;display:flex;flex-direction:column}.catalog-icon-wrap{padding:32px;display:flex;align-items:center;justify-content:center}.catalog-icon{font-size:48px}.catalog-info{padding:0 24px 16px;flex:1}.catalog-title{font-size:15px;font-weight:700;margin-bottom:8px;color:var(--gray-900)}.catalog-desc{font-size:13px;color:var(--gray-600);margin-bottom:12px;line-height:1.6}.catalog-meta{display:flex;gap:12px;font-size:12px;color:var(--gray-400)}.catalog-btn{margin:0 16px 20px;padding:12px;border-radius:8px;background:var(--gray-50);border:1px solid var(--gray-200);font-size:14px;font-weight:600;color:var(--primary);cursor:pointer;transition:all var(--fast);text-align:center}.catalog-btn:hover{background:var(--primary);color:#fff;border-color:var(--primary)}.catalog-request{text-align:center;padding:56px;background:var(--gray-50);border-radius:16px;margin-top:48px}.catalog-request h3{font-size:22px;font-weight:800;margin-bottom:12px}.catalog-request p{color:var(--gray-600);margin-bottom:24px}
|
||||||
68
backend/src/main/resources/static/assets/index-ChpGil2q.js
Normal file
BIN
backend/src/main/resources/static/favicon.ico
Normal file
|
After Width: | Height: | Size: 17 KiB |
22
backend/src/main/resources/static/index.html
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="description" content="(주)지오정보기술 — AI 기반 레거시 인프라 자율 운영 플랫폼 GUARDiA ITSM 및 ERP·CRM·BI 솔루션">
|
||||||
|
<meta name="keywords" content="지오정보기술, GUARDiA, ITSM, 인프라자동화, 공공기관, ERP, ChatOps">
|
||||||
|
<meta property="og:title" content="(주)지오정보기술">
|
||||||
|
<meta property="og:description" content="AI 기반 레거시 인프라 자율 운영 플랫폼 GUARDiA ITSM">
|
||||||
|
<meta property="og:type" content="website">
|
||||||
|
<title>(주)지오정보기술</title>
|
||||||
|
<link rel="icon" type="image/png" href="/favicon.ico">
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700;900&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
<script type="module" crossorigin src="/assets/index-ChpGil2q.js"></script>
|
||||||
|
<link rel="stylesheet" crossorigin href="/assets/index-Dk81znn6.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
BIN
backend/src/main/resources/static/logo-white.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
backend/src/main/resources/static/logo.png
Normal file
|
After Width: | Height: | Size: 140 KiB |
BIN
backend/src/main/resources/static/screenshots/01_dashboard.png
Normal file
|
After Width: | Height: | Size: 88 KiB |
BIN
backend/src/main/resources/static/screenshots/01_home.png
Normal file
|
After Width: | Height: | Size: 1019 KiB |
|
After Width: | Height: | Size: 615 KiB |
BIN
backend/src/main/resources/static/screenshots/02_guardia.png
Normal file
|
After Width: | Height: | Size: 748 KiB |
|
After Width: | Height: | Size: 355 KiB |
BIN
backend/src/main/resources/static/screenshots/02_sr_list.png
Normal file
|
After Width: | Height: | Size: 88 KiB |
BIN
backend/src/main/resources/static/screenshots/03_company.png
Normal file
|
After Width: | Height: | Size: 242 KiB |
|
After Width: | Height: | Size: 202 KiB |
BIN
backend/src/main/resources/static/screenshots/03_si_project.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
backend/src/main/resources/static/screenshots/04_contact.png
Normal file
|
After Width: | Height: | Size: 257 KiB |
|
After Width: | Height: | Size: 209 KiB |
BIN
backend/src/main/resources/static/screenshots/04_incidents.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
backend/src/main/resources/static/screenshots/05_agents.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
backend/src/main/resources/static/screenshots/05_news.png
Normal file
|
After Width: | Height: | Size: 261 KiB |
|
After Width: | Height: | Size: 221 KiB |
BIN
backend/src/main/resources/static/screenshots/06_license.png
Normal file
|
After Width: | Height: | Size: 98 KiB |
BIN
backend/src/main/resources/static/screenshots/06_mobile_home.png
Normal file
|
After Width: | Height: | Size: 201 KiB |
@ -0,0 +1,212 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"page": "홈",
|
||||||
|
"url": "/",
|
||||||
|
"http": 200,
|
||||||
|
"loadMs": 5849,
|
||||||
|
"title": "(주)지오정보기술",
|
||||||
|
"h1": "AI 기반 인프라자율 운영 플랫폼",
|
||||||
|
"errors": 0,
|
||||||
|
"ok": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"page": "GUARDiA ITSM",
|
||||||
|
"url": "/solution/guardia",
|
||||||
|
"http": 200,
|
||||||
|
"loadMs": 1292,
|
||||||
|
"title": "(주)지오정보기술",
|
||||||
|
"h1": "GUARDiA ITSM",
|
||||||
|
"errors": 0,
|
||||||
|
"ok": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"page": "솔루션-ERP",
|
||||||
|
"url": "/solution/erp",
|
||||||
|
"http": 200,
|
||||||
|
"loadMs": 1767,
|
||||||
|
"title": "(주)지오정보기술",
|
||||||
|
"h1": "ERP 솔루션",
|
||||||
|
"errors": 0,
|
||||||
|
"ok": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"page": "솔루션-CRM",
|
||||||
|
"url": "/solution/crm",
|
||||||
|
"http": 200,
|
||||||
|
"loadMs": 1139,
|
||||||
|
"title": "(주)지오정보기술",
|
||||||
|
"h1": "CRM 솔루션",
|
||||||
|
"errors": 0,
|
||||||
|
"ok": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"page": "솔루션-BI",
|
||||||
|
"url": "/solution/bi",
|
||||||
|
"http": 200,
|
||||||
|
"loadMs": 966,
|
||||||
|
"title": "(주)지오정보기술",
|
||||||
|
"h1": "BI 솔루션",
|
||||||
|
"errors": 0,
|
||||||
|
"ok": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"page": "회사-CEO인사말",
|
||||||
|
"url": "/company/greeting",
|
||||||
|
"http": 200,
|
||||||
|
"loadMs": 1098,
|
||||||
|
"title": "(주)지오정보기술",
|
||||||
|
"h1": "CEO 인사말",
|
||||||
|
"errors": 0,
|
||||||
|
"ok": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"page": "회사-연혁",
|
||||||
|
"url": "/company/history",
|
||||||
|
"http": 200,
|
||||||
|
"loadMs": 1548,
|
||||||
|
"title": "(주)지오정보기술",
|
||||||
|
"h1": "연혁",
|
||||||
|
"errors": 0,
|
||||||
|
"ok": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"page": "회사-조직도",
|
||||||
|
"url": "/company/organization",
|
||||||
|
"http": 200,
|
||||||
|
"loadMs": 892,
|
||||||
|
"title": "(주)지오정보기술",
|
||||||
|
"h1": "조직도",
|
||||||
|
"errors": 0,
|
||||||
|
"ok": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"page": "회사-CI소개",
|
||||||
|
"url": "/company/ci",
|
||||||
|
"http": 200,
|
||||||
|
"loadMs": 1007,
|
||||||
|
"title": "(주)지오정보기술",
|
||||||
|
"h1": "CI 소개",
|
||||||
|
"errors": 0,
|
||||||
|
"ok": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"page": "회사-오시는길",
|
||||||
|
"url": "/company/location",
|
||||||
|
"http": 200,
|
||||||
|
"loadMs": 1070,
|
||||||
|
"title": "(주)지오정보기술",
|
||||||
|
"h1": "오시는 길",
|
||||||
|
"errors": 0,
|
||||||
|
"ok": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"page": "사업-레퍼런스",
|
||||||
|
"url": "/business/reference",
|
||||||
|
"http": 200,
|
||||||
|
"loadMs": 1111,
|
||||||
|
"title": "(주)지오정보기술",
|
||||||
|
"h1": "구축 레퍼런스",
|
||||||
|
"errors": 0,
|
||||||
|
"ok": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"page": "사업-파트너",
|
||||||
|
"url": "/business/partner",
|
||||||
|
"http": 200,
|
||||||
|
"loadMs": 1090,
|
||||||
|
"title": "(주)지오정보기술",
|
||||||
|
"h1": "파트너",
|
||||||
|
"errors": 0,
|
||||||
|
"ok": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"page": "지원-공지사항",
|
||||||
|
"url": "/support/notice",
|
||||||
|
"http": 200,
|
||||||
|
"loadMs": 949,
|
||||||
|
"title": "(주)지오정보기술",
|
||||||
|
"h1": "공지사항",
|
||||||
|
"errors": 0,
|
||||||
|
"ok": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"page": "지원-FAQ",
|
||||||
|
"url": "/support/faq",
|
||||||
|
"http": 200,
|
||||||
|
"loadMs": 931,
|
||||||
|
"title": "(주)지오정보기술",
|
||||||
|
"h1": "자주 묻는 질문",
|
||||||
|
"errors": 0,
|
||||||
|
"ok": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"page": "지원-카탈로그",
|
||||||
|
"url": "/support/catalog",
|
||||||
|
"http": 200,
|
||||||
|
"loadMs": 963,
|
||||||
|
"title": "(주)지오정보기술",
|
||||||
|
"h1": "카탈로그",
|
||||||
|
"errors": 0,
|
||||||
|
"ok": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"page": "지원-문의하기",
|
||||||
|
"url": "/support/contact",
|
||||||
|
"http": 200,
|
||||||
|
"loadMs": 1007,
|
||||||
|
"title": "(주)지오정보기술",
|
||||||
|
"h1": "문의하기",
|
||||||
|
"errors": 0,
|
||||||
|
"ok": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"page": "채용-공고",
|
||||||
|
"url": "/recruit/jobs",
|
||||||
|
"http": 200,
|
||||||
|
"loadMs": 984,
|
||||||
|
"title": "(주)지오정보기술",
|
||||||
|
"h1": "채용공고",
|
||||||
|
"errors": 0,
|
||||||
|
"ok": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"page": "채용-복리후생",
|
||||||
|
"url": "/recruit/welfare",
|
||||||
|
"http": 200,
|
||||||
|
"loadMs": 1275,
|
||||||
|
"title": "(주)지오정보기술",
|
||||||
|
"h1": "복리후생",
|
||||||
|
"errors": 0,
|
||||||
|
"ok": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"page": "채용-지원하기",
|
||||||
|
"url": "/recruit/apply",
|
||||||
|
"http": 200,
|
||||||
|
"loadMs": 880,
|
||||||
|
"title": "(주)지오정보기술",
|
||||||
|
"h1": "지원하기",
|
||||||
|
"errors": 0,
|
||||||
|
"ok": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"page": "뉴스-뉴스룸",
|
||||||
|
"url": "/news/newsroom",
|
||||||
|
"http": 200,
|
||||||
|
"loadMs": 1144,
|
||||||
|
"title": "(주)지오정보기술",
|
||||||
|
"h1": "뉴스룸",
|
||||||
|
"errors": 0,
|
||||||
|
"ok": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"page": "뉴스-블로그",
|
||||||
|
"url": "/news/blog",
|
||||||
|
"http": 200,
|
||||||
|
"loadMs": 989,
|
||||||
|
"title": "(주)지오정보기술",
|
||||||
|
"h1": "기술 블로그",
|
||||||
|
"errors": 0,
|
||||||
|
"ok": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
After Width: | Height: | Size: 191 KiB |
BIN
backend/src/main/resources/static/screenshots/business_ref.png
Normal file
|
After Width: | Height: | Size: 187 KiB |
BIN
backend/src/main/resources/static/screenshots/company_ci.png
Normal file
|
After Width: | Height: | Size: 180 KiB |
|
After Width: | Height: | Size: 239 KiB |
|
After Width: | Height: | Size: 202 KiB |
|
After Width: | Height: | Size: 169 KiB |
BIN
backend/src/main/resources/static/screenshots/company_org.png
Normal file
|
After Width: | Height: | Size: 179 KiB |
BIN
backend/src/main/resources/static/screenshots/guardia.png
Normal file
|
After Width: | Height: | Size: 355 KiB |
BIN
backend/src/main/resources/static/screenshots/home.png
Normal file
|
After Width: | Height: | Size: 615 KiB |
BIN
backend/src/main/resources/static/screenshots/news_blog.png
Normal file
|
After Width: | Height: | Size: 222 KiB |
BIN
backend/src/main/resources/static/screenshots/news_newsroom.png
Normal file
|
After Width: | Height: | Size: 362 KiB |
BIN
backend/src/main/resources/static/screenshots/recruit_apply.png
Normal file
|
After Width: | Height: | Size: 201 KiB |
BIN
backend/src/main/resources/static/screenshots/recruit_jobs.png
Normal file
|
After Width: | Height: | Size: 221 KiB |
|
After Width: | Height: | Size: 220 KiB |
BIN
backend/src/main/resources/static/screenshots/solution_bi.png
Normal file
|
After Width: | Height: | Size: 216 KiB |
BIN
backend/src/main/resources/static/screenshots/solution_crm.png
Normal file
|
After Width: | Height: | Size: 219 KiB |
BIN
backend/src/main/resources/static/screenshots/solution_erp.png
Normal file
|
After Width: | Height: | Size: 215 KiB |
|
After Width: | Height: | Size: 193 KiB |
|
After Width: | Height: | Size: 209 KiB |
BIN
backend/src/main/resources/static/screenshots/support_faq.png
Normal file
|
After Width: | Height: | Size: 182 KiB |
BIN
backend/src/main/resources/static/screenshots/support_notice.png
Normal file
|
After Width: | Height: | Size: 204 KiB |
@ -0,0 +1,67 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"page": "홈",
|
||||||
|
"url": "/",
|
||||||
|
"status": 200,
|
||||||
|
"loadMs": 9745,
|
||||||
|
"title": "(주)지오정보기술",
|
||||||
|
"links": 37,
|
||||||
|
"images": 2,
|
||||||
|
"h1": 1,
|
||||||
|
"errors": 0,
|
||||||
|
"errorMsgs": [],
|
||||||
|
"screenshot": "C:\\GUARDiA\\workspace\\zioinfo-web\\frontend\\public\\screenshots\\01_home.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"page": "GUARDiA 소개",
|
||||||
|
"url": "/solution/guardia",
|
||||||
|
"status": 200,
|
||||||
|
"loadMs": 1130,
|
||||||
|
"title": "(주)지오정보기술",
|
||||||
|
"links": 27,
|
||||||
|
"images": 8,
|
||||||
|
"h1": 1,
|
||||||
|
"errors": 0,
|
||||||
|
"errorMsgs": [],
|
||||||
|
"screenshot": "C:\\GUARDiA\\workspace\\zioinfo-web\\frontend\\public\\screenshots\\02_guardia.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"page": "회사소개",
|
||||||
|
"url": "/company/greeting",
|
||||||
|
"status": 200,
|
||||||
|
"loadMs": 971,
|
||||||
|
"title": "(주)지오정보기술",
|
||||||
|
"links": 23,
|
||||||
|
"images": 2,
|
||||||
|
"h1": 1,
|
||||||
|
"errors": 0,
|
||||||
|
"errorMsgs": [],
|
||||||
|
"screenshot": "C:\\GUARDiA\\workspace\\zioinfo-web\\frontend\\public\\screenshots\\03_company.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"page": "문의하기",
|
||||||
|
"url": "/support/contact",
|
||||||
|
"status": 200,
|
||||||
|
"loadMs": 890,
|
||||||
|
"title": "(주)지오정보기술",
|
||||||
|
"links": 24,
|
||||||
|
"images": 2,
|
||||||
|
"h1": 1,
|
||||||
|
"errors": 0,
|
||||||
|
"errorMsgs": [],
|
||||||
|
"screenshot": "C:\\GUARDiA\\workspace\\zioinfo-web\\frontend\\public\\screenshots\\04_contact.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"page": "뉴스",
|
||||||
|
"url": "/news/press",
|
||||||
|
"status": 200,
|
||||||
|
"loadMs": 1007,
|
||||||
|
"title": "(주)지오정보기술",
|
||||||
|
"links": 23,
|
||||||
|
"images": 2,
|
||||||
|
"h1": 1,
|
||||||
|
"errors": 0,
|
||||||
|
"errorMsgs": [],
|
||||||
|
"screenshot": "C:\\GUARDiA\\workspace\\zioinfo-web\\frontend\\public\\screenshots\\05_news.png"
|
||||||
|
}
|
||||||
|
]
|
||||||
69
deploy/01_oracle_cloud_guide.md
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
# Oracle Cloud Always Free — zio-server 구축 가이드
|
||||||
|
|
||||||
|
## 1단계: Oracle Cloud 계정 생성
|
||||||
|
|
||||||
|
1. https://www.oracle.com/cloud/free/ 접속
|
||||||
|
2. "Start for free" 클릭
|
||||||
|
3. 정보 입력:
|
||||||
|
- Country: South Korea
|
||||||
|
- 이름, 이메일, 비밀번호
|
||||||
|
4. **신용카드 등록 필수** (과금 없음 — 인증용)
|
||||||
|
5. 가입 완료 후 홈 리전 선택: **South Korea Central (Seoul)**
|
||||||
|
|
||||||
|
> ⚠️ 홈 리전은 변경 불가 — 반드시 Seoul 선택
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2단계: VM 인스턴스 생성 (zio-server)
|
||||||
|
|
||||||
|
### 콘솔 접속
|
||||||
|
Oracle Cloud Console → Compute → Instances → Create Instance
|
||||||
|
|
||||||
|
### 설정값
|
||||||
|
|
||||||
|
| 항목 | 값 |
|
||||||
|
|------|----|
|
||||||
|
| **Name** | `zio-server` |
|
||||||
|
| **Image** | Ubuntu 22.04 (Canonical) |
|
||||||
|
| **Shape** | VM.Standard.A1.Flex (Ampere) |
|
||||||
|
| **OCPU** | 4 |
|
||||||
|
| **Memory** | 24 GB |
|
||||||
|
| **Boot Volume** | 100 GB |
|
||||||
|
| **Network** | Default VCN, Public Subnet |
|
||||||
|
| **공인 IP** | Assign public IP: Yes |
|
||||||
|
|
||||||
|
### SSH 키 생성
|
||||||
|
```
|
||||||
|
로컬에서:
|
||||||
|
ssh-keygen -t rsa -b 4096 -f C:\Users\{username}\.ssh\zio-server
|
||||||
|
```
|
||||||
|
- 생성된 `zio-server.pub` 내용을 콘솔에 붙여넣기
|
||||||
|
|
||||||
|
### 생성 완료
|
||||||
|
- 약 2~3분 후 Running 상태 확인
|
||||||
|
- 공인 IP 메모 (예: 140.238.xxx.xxx)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3단계: 방화벽 오픈 (Security List)
|
||||||
|
|
||||||
|
Networking → Virtual Cloud Networks → Default VCN
|
||||||
|
→ Security Lists → Default Security List
|
||||||
|
→ Add Ingress Rules:
|
||||||
|
|
||||||
|
| 포트 | 프로토콜 | 용도 |
|
||||||
|
|------|---------|------|
|
||||||
|
| 22 | TCP | SSH |
|
||||||
|
| 80 | TCP | HTTP |
|
||||||
|
| 443 | TCP | HTTPS |
|
||||||
|
| 8080 | TCP | Spring Boot (개발용) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4단계: SSH 접속
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
ssh -i C:\Users\{username}\.ssh\zio-server ubuntu@{공인IP}
|
||||||
|
```
|
||||||
|
|
||||||
|
접속 성공 후 → 5단계 서버 설정 스크립트 실행
|
||||||
110
deploy/02_server_setup.sh
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# ============================================================
|
||||||
|
# zio-server 초기 환경 구성 스크립트
|
||||||
|
# Oracle Cloud Ubuntu 22.04 ARM (Ampere A1)
|
||||||
|
# 실행: bash 02_server_setup.sh
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
set -e
|
||||||
|
GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m'
|
||||||
|
info() { echo -e "${GREEN}[OK]${NC} $1"; }
|
||||||
|
section() { echo -e "\n${CYAN}=== $1 ===${NC}"; }
|
||||||
|
|
||||||
|
section "1. 시스템 업데이트"
|
||||||
|
sudo apt-get update -y && sudo apt-get upgrade -y
|
||||||
|
sudo apt-get install -y curl wget git unzip net-tools ufw htop
|
||||||
|
info "시스템 업데이트 완료"
|
||||||
|
|
||||||
|
section "2. Java 21 설치 (Spring Boot용)"
|
||||||
|
sudo apt-get install -y openjdk-21-jdk
|
||||||
|
java -version
|
||||||
|
info "Java 21 설치 완료"
|
||||||
|
|
||||||
|
section "3. Node.js 20 LTS 설치 (React 빌드용)"
|
||||||
|
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
|
||||||
|
sudo apt-get install -y nodejs
|
||||||
|
node -v && npm -v
|
||||||
|
info "Node.js $(node -v) 설치 완료"
|
||||||
|
|
||||||
|
section "4. Nginx 설치"
|
||||||
|
sudo apt-get install -y nginx
|
||||||
|
sudo systemctl enable nginx
|
||||||
|
sudo systemctl start nginx
|
||||||
|
info "Nginx 설치 완료"
|
||||||
|
|
||||||
|
section "5. UFW 방화벽 설정"
|
||||||
|
sudo ufw allow ssh
|
||||||
|
sudo ufw allow 80/tcp
|
||||||
|
sudo ufw allow 443/tcp
|
||||||
|
sudo ufw allow 8080/tcp
|
||||||
|
sudo ufw --force enable
|
||||||
|
sudo ufw status
|
||||||
|
info "방화벽 설정 완료"
|
||||||
|
|
||||||
|
# Oracle Cloud 내부 iptables도 열기 (필수!)
|
||||||
|
section "6. Oracle Cloud iptables 규칙 추가"
|
||||||
|
sudo iptables -I INPUT 6 -m state --state NEW -p tcp --dport 80 -j ACCEPT
|
||||||
|
sudo iptables -I INPUT 6 -m state --state NEW -p tcp --dport 443 -j ACCEPT
|
||||||
|
sudo iptables -I INPUT 6 -m state --state NEW -p tcp --dport 8080 -j ACCEPT
|
||||||
|
sudo netfilter-persistent save 2>/dev/null || {
|
||||||
|
sudo apt-get install -y iptables-persistent
|
||||||
|
sudo netfilter-persistent save
|
||||||
|
}
|
||||||
|
info "iptables 규칙 저장 완료"
|
||||||
|
|
||||||
|
section "7. 앱 디렉터리 생성"
|
||||||
|
sudo mkdir -p /var/www/zioinfo
|
||||||
|
sudo mkdir -p /opt/zioinfo/app
|
||||||
|
sudo chown -R ubuntu:ubuntu /var/www/zioinfo /opt/zioinfo
|
||||||
|
info "디렉터리 생성 완료"
|
||||||
|
|
||||||
|
section "8. Nginx 설정"
|
||||||
|
sudo tee /etc/nginx/sites-available/zioinfo > /dev/null <<'NGINX'
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /var/www/zioinfo;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# React SPA — 모든 경로를 index.html로
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Spring Boot API 프록시
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://localhost:8080;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_read_timeout 60s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 정적 파일 캐시
|
||||||
|
location ~* \.(js|css|png|jpg|gif|ico|svg|woff2)$ {
|
||||||
|
expires 30d;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# 보안 헤더
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN";
|
||||||
|
add_header X-Content-Type-Options "nosniff";
|
||||||
|
add_header X-XSS-Protection "1; mode=block";
|
||||||
|
|
||||||
|
# Gzip 압축
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
}
|
||||||
|
NGINX
|
||||||
|
|
||||||
|
sudo ln -sf /etc/nginx/sites-available/zioinfo /etc/nginx/sites-enabled/
|
||||||
|
sudo rm -f /etc/nginx/sites-enabled/default
|
||||||
|
sudo nginx -t && sudo systemctl reload nginx
|
||||||
|
info "Nginx 설정 완료"
|
||||||
|
|
||||||
|
section "✅ 서버 초기 구성 완료!"
|
||||||
|
echo ""
|
||||||
|
echo -e "${YELLOW}다음 단계: 로컬에서 03_deploy.sh 실행${NC}"
|
||||||
|
echo -e "서버 IP: $(curl -s ifconfig.me)"
|
||||||
110
deploy/03_deploy.ps1
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
# ============================================================
|
||||||
|
# zio-server 홈페이지 배포 스크립트 (Windows PowerShell)
|
||||||
|
# 실행: .\deploy\03_deploy.ps1 -ServerIP "140.238.xxx.xxx"
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory=$true)]
|
||||||
|
[string]$ServerIP,
|
||||||
|
[string]$KeyPath = "$env:USERPROFILE\.ssh\zio-server",
|
||||||
|
[string]$User = "ubuntu"
|
||||||
|
)
|
||||||
|
|
||||||
|
$GREEN = "`e[32m"
|
||||||
|
$YELLOW = "`e[33m"
|
||||||
|
$CYAN = "`e[36m"
|
||||||
|
$NC = "`e[0m"
|
||||||
|
|
||||||
|
function Log-Info { param($msg) Write-Host "${GREEN}[OK]${NC} $msg" }
|
||||||
|
function Log-Section { param($msg) Write-Host "`n${CYAN}=== $msg ===${NC}" }
|
||||||
|
function Log-Warn { param($msg) Write-Host "${YELLOW}[!]${NC} $msg" }
|
||||||
|
|
||||||
|
$SSH = "ssh -i `"$KeyPath`" -o StrictHostKeyChecking=no ${User}@${ServerIP}"
|
||||||
|
$SCP = "scp -i `"$KeyPath`" -o StrictHostKeyChecking=no"
|
||||||
|
$ROOT = "C:\GUARDiA\workspace\zioinfo-web"
|
||||||
|
|
||||||
|
Log-Section "1. React 프론트엔드 빌드"
|
||||||
|
Set-Location "$ROOT\frontend"
|
||||||
|
|
||||||
|
# vite.config.js 빌드 경로를 임시 dist로 변경
|
||||||
|
$viteCfg = Get-Content "vite.config.js" -Raw
|
||||||
|
$buildCfg = $viteCfg -replace "outDir: '.*?'", "outDir: 'dist'"
|
||||||
|
$buildCfg | Set-Content "vite.config.js" -Encoding utf8
|
||||||
|
|
||||||
|
npm run build
|
||||||
|
if ($LASTEXITCODE -ne 0) { Write-Error "빌드 실패"; exit 1 }
|
||||||
|
Log-Info "React 빌드 완료 → frontend/dist/"
|
||||||
|
|
||||||
|
Log-Section "2. 빌드 파일 서버 업로드"
|
||||||
|
Invoke-Expression "$SSH 'rm -rf /var/www/zioinfo/* && mkdir -p /var/www/zioinfo'"
|
||||||
|
Invoke-Expression "$SCP -r `"$ROOT\frontend\dist\*`" ${User}@${ServerIP}:/var/www/zioinfo/"
|
||||||
|
Log-Info "정적 파일 업로드 완료"
|
||||||
|
|
||||||
|
Log-Section "3. Spring Boot JAR 빌드"
|
||||||
|
Set-Location "$ROOT"
|
||||||
|
if (Test-Path "pom.xml") {
|
||||||
|
# Maven 빌드 (Spring Boot 백엔드)
|
||||||
|
$mvnw = if (Test-Path "mvnw.cmd") { ".\mvnw.cmd" } else { "mvn" }
|
||||||
|
& $mvnw clean package -DskipTests -q
|
||||||
|
$jar = Get-ChildItem "target\*.jar" -Exclude "*sources*" | Select-Object -First 1
|
||||||
|
if ($jar) {
|
||||||
|
Log-Info "JAR 빌드 완료: $($jar.Name)"
|
||||||
|
Invoke-Expression "$SCP `"$($jar.FullName)`" ${User}@${ServerIP}:/opt/zioinfo/app/zioinfo.jar"
|
||||||
|
Log-Info "JAR 업로드 완료"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log-Warn "pom.xml 없음 — Spring Boot 배포 스킵 (정적 파일만 배포)"
|
||||||
|
}
|
||||||
|
|
||||||
|
Log-Section "4. systemd 서비스 등록 (Spring Boot)"
|
||||||
|
$serviceScript = @'
|
||||||
|
# Spring Boot 서비스 설정
|
||||||
|
sudo tee /etc/systemd/system/zioinfo.service > /dev/null <<SERVICE
|
||||||
|
[Unit]
|
||||||
|
Description=Zioinfo Spring Boot Application
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
User=ubuntu
|
||||||
|
WorkingDirectory=/opt/zioinfo/app
|
||||||
|
ExecStart=/usr/bin/java -jar -Xms256m -Xmx512m /opt/zioinfo/app/zioinfo.jar
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=10
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
SERVICE
|
||||||
|
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable zioinfo
|
||||||
|
sudo systemctl restart zioinfo 2>/dev/null || echo "서비스 시작 대기 중..."
|
||||||
|
'@
|
||||||
|
|
||||||
|
if (Test-Path "$ROOT\target\*.jar") {
|
||||||
|
Invoke-Expression "$SSH '$serviceScript'"
|
||||||
|
Log-Info "Spring Boot 서비스 등록 완료"
|
||||||
|
}
|
||||||
|
|
||||||
|
Log-Section "5. Nginx 재시작 및 최종 확인"
|
||||||
|
$checkScript = @"
|
||||||
|
sudo systemctl reload nginx
|
||||||
|
echo '--- Nginx 상태 ---'
|
||||||
|
sudo systemctl is-active nginx
|
||||||
|
echo '--- 포트 확인 ---'
|
||||||
|
ss -tlnp | grep -E ':80|:443|:8080'
|
||||||
|
echo '--- 디스크 사용량 ---'
|
||||||
|
df -h /
|
||||||
|
echo '--- 메모리 ---'
|
||||||
|
free -h
|
||||||
|
"@
|
||||||
|
Invoke-Expression "$SSH '$checkScript'"
|
||||||
|
|
||||||
|
Log-Section "✅ 배포 완료!"
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "${GREEN}홈페이지 주소:${NC} http://$ServerIP"
|
||||||
|
Write-Host "${GREEN}SSH 접속:${NC} ssh -i `"$KeyPath`" ubuntu@$ServerIP"
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "${YELLOW}브라우저에서 확인:${NC}"
|
||||||
|
Start-Process "http://$ServerIP"
|
||||||