From 6e02e7efe060eaad0157fff2bfc0e367b7cec46e Mon Sep 17 00:00:00 2001 From: DESKTOP-TKLFCPRython Date: Sat, 30 May 2026 18:40:24 +0900 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=ED=99=88=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EA=B4=80=EB=A6=AC=EC=9E=90=20=EC=8B=9C=EC=8A=A4?= =?UTF-8?q?=ED=85=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Spring Security + JWT 인증 (8시간 토큰) - AdminUser / Recruit 엔터티 추가 - AdminController: 로그인, 대시보드, 뉴스/문의/채용 CRUD - React 어드민 SPA: /admin/* 라우트 (Header/Footer 없음) - 로그인, 대시보드, 뉴스 관리, 문의 관리, 채용공고 관리, 설정 - Jenkinsfile: 서버 환경 맞춤 CI/CD 파이프라인 - .gitignore 추가 Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 16 ++ Jenkinsfile | 97 +++++++++ backend/pom.xml | 25 +++ .../zioinfo/web/config/DataInitializer.java | 71 +++++-- .../co/zioinfo/web/config/SecurityConfig.java | 61 ++++++ .../web/controller/AdminController.java | 182 +++++++++++++++++ .../zioinfo/web/controller/ApiController.java | 9 +- .../kr/co/zioinfo/web/model/AdminUser.java | 32 +++ .../java/kr/co/zioinfo/web/model/Recruit.java | 41 ++++ .../web/repository/AdminUserRepository.java | 10 + .../web/repository/InquiryRepository.java | 10 +- .../web/repository/NewsRepository.java | 5 + .../web/repository/RecruitRepository.java | 13 ++ .../zioinfo/web/security/JwtAuthFilter.java | 35 ++++ .../kr/co/zioinfo/web/security/JwtUtil.java | 49 +++++ backend/src/main/resources/application.yml | 3 + frontend/src/App.jsx | 88 +++++--- frontend/src/pages/admin/AdminDashboard.jsx | 93 +++++++++ frontend/src/pages/admin/AdminInquiry.jsx | 152 ++++++++++++++ frontend/src/pages/admin/AdminLayout.jsx | 108 ++++++++++ frontend/src/pages/admin/AdminLogin.jsx | 66 ++++++ frontend/src/pages/admin/AdminNews.jsx | 175 ++++++++++++++++ frontend/src/pages/admin/AdminRecruit.jsx | 169 ++++++++++++++++ frontend/src/pages/admin/AdminSettings.jsx | 91 +++++++++ frontend/src/pages/admin/admin.css | 188 ++++++++++++++++++ 25 files changed, 1748 insertions(+), 41 deletions(-) create mode 100644 .gitignore create mode 100644 Jenkinsfile create mode 100644 backend/src/main/java/kr/co/zioinfo/web/config/SecurityConfig.java create mode 100644 backend/src/main/java/kr/co/zioinfo/web/controller/AdminController.java create mode 100644 backend/src/main/java/kr/co/zioinfo/web/model/AdminUser.java create mode 100644 backend/src/main/java/kr/co/zioinfo/web/model/Recruit.java create mode 100644 backend/src/main/java/kr/co/zioinfo/web/repository/AdminUserRepository.java create mode 100644 backend/src/main/java/kr/co/zioinfo/web/repository/RecruitRepository.java create mode 100644 backend/src/main/java/kr/co/zioinfo/web/security/JwtAuthFilter.java create mode 100644 backend/src/main/java/kr/co/zioinfo/web/security/JwtUtil.java create mode 100644 frontend/src/pages/admin/AdminDashboard.jsx create mode 100644 frontend/src/pages/admin/AdminInquiry.jsx create mode 100644 frontend/src/pages/admin/AdminLayout.jsx create mode 100644 frontend/src/pages/admin/AdminLogin.jsx create mode 100644 frontend/src/pages/admin/AdminNews.jsx create mode 100644 frontend/src/pages/admin/AdminRecruit.jsx create mode 100644 frontend/src/pages/admin/AdminSettings.jsx create mode 100644 frontend/src/pages/admin/admin.css diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..877cd0c --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 0000000..38ed33f --- /dev/null +++ b/Jenkinsfile @@ -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) + } + } +} diff --git a/backend/pom.xml b/backend/pom.xml index f7954cb..fb87fb6 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -67,6 +67,31 @@ spring-boot-starter-mail + + + org.springframework.boot + spring-boot-starter-security + + + + + io.jsonwebtoken + jjwt-api + 0.12.3 + + + io.jsonwebtoken + jjwt-impl + 0.12.3 + runtime + + + io.jsonwebtoken + jjwt-jackson + 0.12.3 + runtime + + org.projectlombok diff --git a/backend/src/main/java/kr/co/zioinfo/web/config/DataInitializer.java b/backend/src/main/java/kr/co/zioinfo/web/config/DataInitializer.java index 18d17f4..bf8114a 100644 --- a/backend/src/main/java/kr/co/zioinfo/web/config/DataInitializer.java +++ b/backend/src/main/java/kr/co/zioinfo/web/config/DataInitializer.java @@ -1,53 +1,64 @@ package kr.co.zioinfo.web.config; -import kr.co.zioinfo.web.model.News; -import kr.co.zioinfo.web.repository.NewsRepository; +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 배포·운영을 자동화하는 온프레미스 플랫폼입니다. " + - "1,000개 이상 관공서를 대상으로 하며 외부 클라우드 의존 없이 완전 폐쇄망 환경에서 동작합니다.") + .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\n" + - "GUARDiA ITSM 라이브 데모 및 도입 상담을 진행합니다.") + .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("행정안전부는 공공기관 정보화 사업에 적합한 소프트웨어를 심사하여 우수제품을 선정합니다. " + - "GUARDiA는 보안성, 안정성, 공공 적합성에서 높은 평가를 받았습니다.") + .content("행정안전부는 공공기관 정보화 사업에 적합한 소프트웨어를 심사하여 우수제품을 선정합니다.") .visible(true).viewCount(214).build()); - newsRepo.save(News.builder() - .title("Spring 2026 개발자 컨퍼런스 기술 발표") - .category("이벤트") - .summary("지오정보기술 개발팀이 AI 에이전트 기반 ChatOps 구현 사례를 발표합니다.") - .content("발표 주제: '1000개 관공서 인프라를 메신저 봇 하나로 관리하기'\n" + - "일시: 2026년 5월 20일 / 장소: 서울 COEX 컨퍼런스룸") - .visible(true).viewCount(63).build()); - newsRepo.save(News.builder() .title("특허 등록 — 에이전트리스 레거시 인프라 자동화 방법") .category("공지사항") @@ -55,4 +66,32 @@ public class DataInitializer implements CommandLineRunner { .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()); + } } diff --git a/backend/src/main/java/kr/co/zioinfo/web/config/SecurityConfig.java b/backend/src/main/java/kr/co/zioinfo/web/config/SecurityConfig.java new file mode 100644 index 0000000..ae341ad --- /dev/null +++ b/backend/src/main/java/kr/co/zioinfo/web/config/SecurityConfig.java @@ -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; + } +} diff --git a/backend/src/main/java/kr/co/zioinfo/web/controller/AdminController.java b/backend/src/main/java/kr/co/zioinfo/web/controller/AdminController.java new file mode 100644 index 0000000..bb1cb0a --- /dev/null +++ b/backend/src/main/java/kr/co/zioinfo/web/controller/AdminController.java @@ -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 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> dashboard() { + Map 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> 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 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 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> 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 result = (status != null && !status.isBlank()) + ? inquiryRepo.findByStatus(status, pageable) + : inquiryRepo.findAll(pageable); + return ResponseEntity.ok(result); + } + + @GetMapping("/inquiries/{id}") + public ResponseEntity 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 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> adminRecruits( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size) { + return ResponseEntity.ok(recruitRepo.findAllByOrderByCreatedAtDesc( + PageRequest.of(page, size))); + } + + @PostMapping("/recruits") + public ResponseEntity 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 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 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()); + } +} diff --git a/backend/src/main/java/kr/co/zioinfo/web/controller/ApiController.java b/backend/src/main/java/kr/co/zioinfo/web/controller/ApiController.java index 7bbd6b1..c8a2d87 100644 --- a/backend/src/main/java/kr/co/zioinfo/web/controller/ApiController.java +++ b/backend/src/main/java/kr/co/zioinfo/web/controller/ApiController.java @@ -2,6 +2,7 @@ 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; @@ -14,11 +15,11 @@ import java.util.*; @RestController @RequestMapping("/api") @RequiredArgsConstructor -@CrossOrigin(origins = {"http://localhost:3000", "http://localhost:5173"}) public class ApiController { private final NewsService newsService; private final InquiryService inquiryService; + private final RecruitRepository recruitRepo; // ── 회사 정보 ──────────────────────────────────────────────── @GetMapping("/company") @@ -136,6 +137,12 @@ public class ApiController { )); } + // ── 채용공고 (공개) ─────────────────────────────────────────── + @GetMapping("/recruits") + public ResponseEntity getRecruits() { + return ResponseEntity.ok(recruitRepo.findByActiveTrueOrderByCreatedAtDesc()); + } + // ── 메뉴 구조 ──────────────────────────────────────────────── @GetMapping("/menu") public ResponseEntity>> getMenu() { diff --git a/backend/src/main/java/kr/co/zioinfo/web/model/AdminUser.java b/backend/src/main/java/kr/co/zioinfo/web/model/AdminUser.java new file mode 100644 index 0000000..14eff8e --- /dev/null +++ b/backend/src/main/java/kr/co/zioinfo/web/model/AdminUser.java @@ -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; +} diff --git a/backend/src/main/java/kr/co/zioinfo/web/model/Recruit.java b/backend/src/main/java/kr/co/zioinfo/web/model/Recruit.java new file mode 100644 index 0000000..9973fa7 --- /dev/null +++ b/backend/src/main/java/kr/co/zioinfo/web/model/Recruit.java @@ -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; +} diff --git a/backend/src/main/java/kr/co/zioinfo/web/repository/AdminUserRepository.java b/backend/src/main/java/kr/co/zioinfo/web/repository/AdminUserRepository.java new file mode 100644 index 0000000..8b12d81 --- /dev/null +++ b/backend/src/main/java/kr/co/zioinfo/web/repository/AdminUserRepository.java @@ -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 { + Optional findByUsername(String username); + boolean existsByUsername(String username); +} diff --git a/backend/src/main/java/kr/co/zioinfo/web/repository/InquiryRepository.java b/backend/src/main/java/kr/co/zioinfo/web/repository/InquiryRepository.java index 962e79a..6b5f88b 100644 --- a/backend/src/main/java/kr/co/zioinfo/web/repository/InquiryRepository.java +++ b/backend/src/main/java/kr/co/zioinfo/web/repository/InquiryRepository.java @@ -1,4 +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; -public interface InquiryRepository extends JpaRepository {} +import java.util.List; + +public interface InquiryRepository extends JpaRepository { + Page findByStatus(String status, Pageable p); + long countByStatus(String status); + List findTop5ByOrderByCreatedAtDesc(); +} diff --git a/backend/src/main/java/kr/co/zioinfo/web/repository/NewsRepository.java b/backend/src/main/java/kr/co/zioinfo/web/repository/NewsRepository.java index 68f7f7b..ce007bc 100644 --- a/backend/src/main/java/kr/co/zioinfo/web/repository/NewsRepository.java +++ b/backend/src/main/java/kr/co/zioinfo/web/repository/NewsRepository.java @@ -1,8 +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 { Page findByVisibleTrue(Pageable p); Page findByCategoryAndVisibleTrue(String category, Pageable p); + long countByVisibleTrue(); + List findTop5ByOrderByCreatedAtDesc(); } diff --git a/backend/src/main/java/kr/co/zioinfo/web/repository/RecruitRepository.java b/backend/src/main/java/kr/co/zioinfo/web/repository/RecruitRepository.java new file mode 100644 index 0000000..65331ad --- /dev/null +++ b/backend/src/main/java/kr/co/zioinfo/web/repository/RecruitRepository.java @@ -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 { + List findByActiveTrueOrderByCreatedAtDesc(); + Page findAllByOrderByCreatedAtDesc(Pageable pageable); + long countByActiveTrue(); +} diff --git a/backend/src/main/java/kr/co/zioinfo/web/security/JwtAuthFilter.java b/backend/src/main/java/kr/co/zioinfo/web/security/JwtAuthFilter.java new file mode 100644 index 0000000..58e1131 --- /dev/null +++ b/backend/src/main/java/kr/co/zioinfo/web/security/JwtAuthFilter.java @@ -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); + } +} diff --git a/backend/src/main/java/kr/co/zioinfo/web/security/JwtUtil.java b/backend/src/main/java/kr/co/zioinfo/web/security/JwtUtil.java new file mode 100644 index 0000000..6802336 --- /dev/null +++ b/backend/src/main/java/kr/co/zioinfo/web/security/JwtUtil.java @@ -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 parse(String token) { + return Jwts.parser().verifyWith(key).build().parseSignedClaims(token); + } +} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 2187e8f..40e7a4c 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -32,6 +32,9 @@ spring: 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 diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index ae8f822..04abb3d 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,42 +1,84 @@ import React, { Suspense, lazy } from 'react'; -import { Routes, Route, useLocation } from 'react-router-dom'; +import { Routes, Route, Navigate, useLocation } from 'react-router-dom'; import Header from './components/layout/Header'; import Footer from './components/layout/Footer'; -const Home = lazy(() => import('./pages/Home')); -const GuardiaDetail = lazy(() => import('./pages/GuardiaDetail')); -const Company = lazy(() => import('./pages/Company')); -const Contact = lazy(() => import('./pages/Contact')); -const NewsPage = lazy(() => import('./pages/NewsPage')); -const Recruit = lazy(() => import('./pages/Recruit')); -const NotFound = lazy(() => import('./pages/NotFound')); +const Home = lazy(() => import('./pages/Home')); +const GuardiaDetail = lazy(() => import('./pages/GuardiaDetail')); +const SolutionPage = lazy(() => import('./pages/SolutionPage')); +const Company = lazy(() => import('./pages/Company')); +const Business = lazy(() => import('./pages/Business')); +const Contact = lazy(() => import('./pages/Contact')); +const Support = lazy(() => import('./pages/Support')); +const NewsPage = lazy(() => import('./pages/NewsPage')); +const Recruit = lazy(() => import('./pages/Recruit')); +const NotFound = lazy(() => import('./pages/NotFound')); + +// Admin +const AdminLogin = lazy(() => import('./pages/admin/AdminLogin')); +const AdminLayout = lazy(() => import('./pages/admin/AdminLayout')); +const AdminDashboard = lazy(() => import('./pages/admin/AdminDashboard')); +const AdminNews = lazy(() => import('./pages/admin/AdminNews')); +const AdminInquiry = lazy(() => import('./pages/admin/AdminInquiry')); +const AdminRecruit = lazy(() => import('./pages/admin/AdminRecruit')); +const AdminSettings = lazy(() => import('./pages/admin/AdminSettings')); function Loading() { return ( -
+
로딩 중...
); } -export default function App() { - const location = useLocation(); +function PublicLayout({ children }) { return ( <>
- }> - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - + }>{children}