feat: admin system

This commit is contained in:
ZioCI 2026-05-30 18:41:49 +09:00
commit d46843bf26
196 changed files with 11140 additions and 0 deletions

16
.gitignore vendored Normal file
View 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
View 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)
}
}
}

View 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
View 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>

View File

@ -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);
}
}

View File

@ -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());
}
}

View File

@ -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;
}
}

View File

@ -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());
}
}

View File

@ -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")
))
));
}
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View File

@ -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);
}

View File

@ -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();
}

View File

@ -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();
}

View File

@ -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();
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -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);
});
}
}

View 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

View File

@ -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};

File diff suppressed because one or more lines are too long

View File

@ -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};

View File

@ -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};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -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};

File diff suppressed because one or more lines are too long

View File

@ -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}

View File

@ -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}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -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}}

View File

@ -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};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -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}

View File

@ -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};

View File

@ -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};

View File

@ -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}}

File diff suppressed because one or more lines are too long

View File

@ -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}}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -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}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1019 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 615 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 748 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 355 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

View File

@ -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
}
]

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 355 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 615 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 362 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

View File

@ -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"
}
]

View 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
View 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
View 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"

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