Compare commits
No commits in common. "d46843bf268708e064ae42e9388e39cd0887c8af" and "fee9b812f6eca188f29291018c6bfd5326fe9fce" have entirely different histories.
d46843bf26
...
fee9b812f6
@ -19,6 +19,7 @@ public class AdminController {
|
|||||||
private final NewsRepository newsRepo;
|
private final NewsRepository newsRepo;
|
||||||
private final InquiryRepository inquiryRepo;
|
private final InquiryRepository inquiryRepo;
|
||||||
private final RecruitRepository recruitRepo;
|
private final RecruitRepository recruitRepo;
|
||||||
|
private final MemberRepository memberRepo;
|
||||||
private final JwtUtil jwtUtil;
|
private final JwtUtil jwtUtil;
|
||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
|
|
||||||
@ -47,6 +48,8 @@ public class AdminController {
|
|||||||
stats.put("pendingInquiries", inquiryRepo.countByStatus("PENDING"));
|
stats.put("pendingInquiries", inquiryRepo.countByStatus("PENDING"));
|
||||||
stats.put("totalRecruits", recruitRepo.count());
|
stats.put("totalRecruits", recruitRepo.count());
|
||||||
stats.put("activeRecruits", recruitRepo.countByActiveTrue());
|
stats.put("activeRecruits", recruitRepo.countByActiveTrue());
|
||||||
|
stats.put("totalMembers", memberRepo.count());
|
||||||
|
stats.put("activeMembers", memberRepo.countByActiveTrue());
|
||||||
stats.put("recentInquiries", inquiryRepo.findTop5ByOrderByCreatedAtDesc());
|
stats.put("recentInquiries", inquiryRepo.findTop5ByOrderByCreatedAtDesc());
|
||||||
stats.put("recentNews", newsRepo.findTop5ByOrderByCreatedAtDesc());
|
stats.put("recentNews", newsRepo.findTop5ByOrderByCreatedAtDesc());
|
||||||
return ResponseEntity.ok(stats);
|
return ResponseEntity.ok(stats);
|
||||||
@ -164,6 +167,43 @@ public class AdminController {
|
|||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 회원 관리 ────────────────────────────────────────────
|
||||||
|
@GetMapping("/members")
|
||||||
|
public ResponseEntity<?> listMembers(
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "20") int size,
|
||||||
|
@RequestParam(required = false) String keyword) {
|
||||||
|
Pageable pg = PageRequest.of(page, size, Sort.by("createdAt").descending());
|
||||||
|
return ResponseEntity.ok(memberRepo.search(keyword, pg));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/members/{id}")
|
||||||
|
public ResponseEntity<?> getMember(@PathVariable Long id) {
|
||||||
|
return memberRepo.findById(id)
|
||||||
|
.map(ResponseEntity::ok)
|
||||||
|
.orElse(ResponseEntity.notFound().build());
|
||||||
|
}
|
||||||
|
|
||||||
|
@PatchMapping("/members/{id}/status")
|
||||||
|
public ResponseEntity<?> toggleMemberStatus(@PathVariable Long id,
|
||||||
|
@RequestBody Map<String, Boolean> body) {
|
||||||
|
return memberRepo.findById(id).map(m -> {
|
||||||
|
m.setActive(body.getOrDefault("active", !m.isActive()));
|
||||||
|
memberRepo.save(m);
|
||||||
|
return ResponseEntity.ok(Map.of("id", m.getId(), "active", m.isActive()));
|
||||||
|
}).orElse(ResponseEntity.notFound().build());
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/members/{id}")
|
||||||
|
public ResponseEntity<?> deleteMember(@PathVariable Long id) {
|
||||||
|
if (!memberRepo.existsById(id)) return ResponseEntity.notFound().build();
|
||||||
|
memberRepo.deleteById(id);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 대시보드 통계에 회원 수 추가 ─────────────────────────
|
||||||
|
// (기존 dashboard 메서드에 totalMembers 추가는 별도 수정)
|
||||||
|
|
||||||
// ── 비밀번호 변경 ────────────────────────────────────────
|
// ── 비밀번호 변경 ────────────────────────────────────────
|
||||||
@PutMapping("/password")
|
@PutMapping("/password")
|
||||||
public ResponseEntity<?> changePassword(
|
public ResponseEntity<?> changePassword(
|
||||||
|
|||||||
@ -0,0 +1,149 @@
|
|||||||
|
package kr.co.zioinfo.web.controller;
|
||||||
|
|
||||||
|
import kr.co.zioinfo.web.model.Member;
|
||||||
|
import kr.co.zioinfo.web.repository.MemberRepository;
|
||||||
|
import kr.co.zioinfo.web.security.JwtUtil;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/members")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class MemberController {
|
||||||
|
|
||||||
|
private final MemberRepository memberRepo;
|
||||||
|
private final JwtUtil jwtUtil;
|
||||||
|
private final PasswordEncoder passwordEncoder;
|
||||||
|
|
||||||
|
// ── 회원가입 ─────────────────────────────────────────────────
|
||||||
|
@PostMapping("/register")
|
||||||
|
public ResponseEntity<?> register(@RequestBody Map<String, String> body) {
|
||||||
|
String email = body.get("email");
|
||||||
|
String name = body.get("name");
|
||||||
|
String pw = body.get("password");
|
||||||
|
String phone = body.getOrDefault("phone", "");
|
||||||
|
String company = body.getOrDefault("company", "");
|
||||||
|
|
||||||
|
if (email == null || name == null || pw == null)
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("message", "이름, 이메일, 비밀번호는 필수입니다."));
|
||||||
|
|
||||||
|
if (memberRepo.existsByEmail(email))
|
||||||
|
return ResponseEntity.status(409).body(Map.of("message", "이미 가입된 이메일입니다."));
|
||||||
|
|
||||||
|
if (pw.length() < 8)
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("message", "비밀번호는 8자 이상이어야 합니다."));
|
||||||
|
|
||||||
|
Member m = Member.builder()
|
||||||
|
.name(name)
|
||||||
|
.email(email)
|
||||||
|
.password(passwordEncoder.encode(pw))
|
||||||
|
.phone(phone)
|
||||||
|
.company(company)
|
||||||
|
.role("USER")
|
||||||
|
.active(true)
|
||||||
|
.build();
|
||||||
|
memberRepo.save(m);
|
||||||
|
|
||||||
|
String token = jwtUtil.generate("member:" + m.getEmail());
|
||||||
|
return ResponseEntity.ok(buildResponse(m, token));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 이메일 중복 확인 ─────────────────────────────────────────
|
||||||
|
@GetMapping("/check-email")
|
||||||
|
public ResponseEntity<?> checkEmail(@RequestParam String email) {
|
||||||
|
return ResponseEntity.ok(Map.of("exists", memberRepo.existsByEmail(email)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 로그인 ───────────────────────────────────────────────────
|
||||||
|
@PostMapping("/login")
|
||||||
|
public ResponseEntity<?> login(@RequestBody Map<String, String> body) {
|
||||||
|
String email = body.get("email");
|
||||||
|
String pw = body.get("password");
|
||||||
|
return memberRepo.findByEmail(email)
|
||||||
|
.filter(m -> m.isActive() && passwordEncoder.matches(pw, m.getPassword()))
|
||||||
|
.map(m -> ResponseEntity.ok(buildResponse(m, jwtUtil.generate("member:" + m.getEmail()))))
|
||||||
|
.orElse(ResponseEntity.status(401).body(Map.of("message", "이메일 또는 비밀번호가 올바르지 않습니다.")));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SNS 로그인 (카카오·네이버·구글) ─────────────────────────
|
||||||
|
@PostMapping("/social-login")
|
||||||
|
public ResponseEntity<?> socialLogin(@RequestBody Map<String, String> body) {
|
||||||
|
String provider = body.get("provider"); // kakao | naver | google
|
||||||
|
String email = body.get("email");
|
||||||
|
String name = body.getOrDefault("name", "회원");
|
||||||
|
String socialId = body.getOrDefault("id", "");
|
||||||
|
|
||||||
|
if (email == null || email.isBlank())
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("message", "SNS 이메일 정보가 없습니다."));
|
||||||
|
|
||||||
|
// 기존 회원이면 로그인, 없으면 자동 가입
|
||||||
|
Member m = memberRepo.findByEmail(email).orElseGet(() ->
|
||||||
|
memberRepo.save(Member.builder()
|
||||||
|
.name(name)
|
||||||
|
.email(email)
|
||||||
|
.password(passwordEncoder.encode(UUID.randomUUID().toString()))
|
||||||
|
.role("USER")
|
||||||
|
.active(true)
|
||||||
|
.build())
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!m.isActive())
|
||||||
|
return ResponseEntity.status(403).body(Map.of("message", "비활성화된 계정입니다."));
|
||||||
|
|
||||||
|
String token = jwtUtil.generate("member:" + m.getEmail());
|
||||||
|
Map<String, Object> res = buildResponse(m, token);
|
||||||
|
res.put("provider", provider);
|
||||||
|
return ResponseEntity.ok(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 내 정보 조회 ─────────────────────────────────────────────
|
||||||
|
@GetMapping("/me")
|
||||||
|
public ResponseEntity<?> getMe(@RequestHeader("Authorization") String authHeader) {
|
||||||
|
return extractEmail(authHeader)
|
||||||
|
.flatMap(memberRepo::findByEmail)
|
||||||
|
.map(m -> ResponseEntity.ok(buildResponse(m, null)))
|
||||||
|
.orElse(ResponseEntity.status(401).body(Map.of("message", "인증이 필요합니다.")));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 내 정보 수정 ─────────────────────────────────────────────
|
||||||
|
@PutMapping("/me")
|
||||||
|
public ResponseEntity<?> updateMe(@RequestHeader("Authorization") String authHeader,
|
||||||
|
@RequestBody Map<String, String> body) {
|
||||||
|
return extractEmail(authHeader)
|
||||||
|
.flatMap(memberRepo::findByEmail)
|
||||||
|
.map(m -> {
|
||||||
|
if (body.containsKey("name")) m.setName(body.get("name"));
|
||||||
|
if (body.containsKey("phone")) m.setPhone(body.get("phone"));
|
||||||
|
if (body.containsKey("company")) m.setCompany(body.get("company"));
|
||||||
|
if (body.containsKey("password") && body.get("password").length() >= 8)
|
||||||
|
m.setPassword(passwordEncoder.encode(body.get("password")));
|
||||||
|
memberRepo.save(m);
|
||||||
|
return ResponseEntity.ok(buildResponse(m, null));
|
||||||
|
})
|
||||||
|
.orElse(ResponseEntity.status(401).body(Map.of("message", "인증이 필요합니다.")));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 내부 유틸 ────────────────────────────────────────────────
|
||||||
|
private Optional<String> extractEmail(String authHeader) {
|
||||||
|
if (authHeader == null || !authHeader.startsWith("Bearer ")) return Optional.empty();
|
||||||
|
String subject = jwtUtil.extractUsername(authHeader.substring(7));
|
||||||
|
if (subject == null || !subject.startsWith("member:")) return Optional.empty();
|
||||||
|
return Optional.of(subject.substring(7));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> buildResponse(Member m, String token) {
|
||||||
|
Map<String, Object> r = new LinkedHashMap<>();
|
||||||
|
r.put("id", m.getId());
|
||||||
|
r.put("name", m.getName());
|
||||||
|
r.put("email", m.getEmail());
|
||||||
|
r.put("phone", m.getPhone());
|
||||||
|
r.put("company", m.getCompany());
|
||||||
|
r.put("role", m.getRole());
|
||||||
|
r.put("createdAt", m.getCreatedAt());
|
||||||
|
if (token != null) r.put("token", token);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
}
|
||||||
41
backend/src/main/java/kr/co/zioinfo/web/model/Member.java
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
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 = "member")
|
||||||
|
@Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
|
||||||
|
@EntityListeners(AuditingEntityListener.class)
|
||||||
|
public class Member {
|
||||||
|
|
||||||
|
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@NotBlank @Column(nullable = false, length = 50)
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
@NotBlank @Email @Column(nullable = false, unique = true, length = 100)
|
||||||
|
private String email;
|
||||||
|
|
||||||
|
@NotBlank @Column(nullable = false)
|
||||||
|
private String password;
|
||||||
|
|
||||||
|
@Column(length = 20)
|
||||||
|
private String phone;
|
||||||
|
|
||||||
|
@Column(length = 100)
|
||||||
|
private String company; // 소속 회사/기관 (선택)
|
||||||
|
|
||||||
|
@Column(length = 30)
|
||||||
|
private String role = "USER"; // USER | ADMIN
|
||||||
|
|
||||||
|
private boolean active = true;
|
||||||
|
|
||||||
|
@CreatedDate
|
||||||
|
@Column(updatable = false)
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
}
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
package kr.co.zioinfo.web.repository;
|
||||||
|
|
||||||
|
import kr.co.zioinfo.web.model.Member;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public interface MemberRepository extends JpaRepository<Member, Long> {
|
||||||
|
|
||||||
|
Optional<Member> findByEmail(String email);
|
||||||
|
|
||||||
|
boolean existsByEmail(String email);
|
||||||
|
|
||||||
|
@Query("SELECT m FROM Member m WHERE " +
|
||||||
|
"(:keyword IS NULL OR m.name LIKE %:keyword% OR m.email LIKE %:keyword% OR m.company LIKE %:keyword%)")
|
||||||
|
Page<Member> search(@Param("keyword") String keyword, Pageable pageable);
|
||||||
|
|
||||||
|
long countByActiveTrue();
|
||||||
|
}
|
||||||
BIN
backend/src/main/resources/static/CI.jpg
Normal file
|
After Width: | Height: | Size: 30 KiB |
@ -1 +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};
|
import{r as a,j as e,L as n}from"./index-CpO7mTKO.js";const h=s=>fetch(s,{headers:{Authorization:`Bearer ${localStorage.getItem("admin_token")}`}}).then(l=>l.json());function x(){var t,r;const[s,l]=a.useState(null),[c,d]=a.useState(!0);if(a.useEffect(()=>{h("/api/admin/dashboard").then(l).finally(()=>d(!1))},[]),c)return e.jsx("p",{style:{color:"#64748b",fontSize:14},children:"로딩 중..."});if(!s)return null;const o=[{icon:"📰",label:"전체 뉴스",value:s.totalNews,sub:`공개 ${s.visibleNews}건`,color:"blue"},{icon:"📩",label:"전체 문의",value:s.totalInquiries,sub:`미답변 ${s.pendingInquiries}건`,color:s.pendingInquiries>0?"red":"green"},{icon:"👥",label:"채용공고",value:s.totalRecruits,sub:`진행중 ${s.activeRecruits}건`,color:"green"}];return e.jsxs(e.Fragment,{children:[e.jsxs("div",{className:"admin-stats",children:[o.map(i=>e.jsxs("div",{className:"stat-card",children:[e.jsx("div",{className:`stat-icon ${i.color}`,children:i.icon}),e.jsxs("div",{className:"stat-info",children:[e.jsx("h4",{children:i.value}),e.jsxs("p",{children:[i.label,e.jsx("br",{}),e.jsx("span",{style:{fontSize:11},children:i.sub})]})]})]},i.label)),s.pendingInquiries>0&&e.jsxs("div",{className:"stat-card",style:{borderLeft:"3px solid #ef4444"},children:[e.jsx("div",{className:"stat-icon red",children:"🔔"}),e.jsxs("div",{className:"stat-info",children:[e.jsx("h4",{style:{color:"#ef4444"},children:s.pendingInquiries}),e.jsxs("p",{children:["미답변 문의",e.jsx("br",{}),e.jsx(n,{to:"/admin/inquiries",style:{fontSize:11,color:"#ef4444"},children:"바로가기 →"})]})]})]})]}),e.jsxs("div",{style:{display:"grid",gridTemplateColumns:"1fr 1fr",gap:16},children:[e.jsxs("div",{className:"admin-card",children:[e.jsxs("div",{className:"admin-card-header",children:[e.jsx("h3",{children:"📰 최근 뉴스"}),e.jsx(n,{to:"/admin/news",className:"btn btn-outline btn-sm",children:"전체보기"})]}),e.jsxs("ul",{className:"recent-list",children:[(s.recentNews||[]).map(i=>e.jsxs("li",{children:[e.jsx("span",{className:"rl-dot"}),e.jsx("span",{className:"rl-title",children:i.title}),e.jsx("span",{className:"rl-meta",children:i.category})]},i.id)),!((t=s.recentNews)!=null&&t.length)&&e.jsx("li",{style:{color:"#94a3b8",fontSize:13},children:"등록된 뉴스가 없습니다."})]})]}),e.jsxs("div",{className:"admin-card",children:[e.jsxs("div",{className:"admin-card-header",children:[e.jsx("h3",{children:"📩 최근 문의"}),e.jsx(n,{to:"/admin/inquiries",className:"btn btn-outline btn-sm",children:"전체보기"})]}),e.jsxs("ul",{className:"recent-list",children:[(s.recentInquiries||[]).map(i=>e.jsxs("li",{children:[e.jsx("span",{className:"rl-dot",style:{background:i.status==="PENDING"?"#ef4444":"#22c55e"}}),e.jsx("span",{className:"rl-title",children:i.subject}),e.jsx("span",{className:"rl-meta",children:i.name})]},i.id)),!((r=s.recentInquiries)!=null&&r.length)&&e.jsx("li",{style:{color:"#94a3b8",fontSize:13},children:"접수된 문의가 없습니다."})]})]})]})]})}export{x as default};
|
||||||
@ -0,0 +1 @@
|
|||||||
|
import{c as g,u as b,r as s,j as a,N as x,O as j}from"./index-CpO7mTKO.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"},{path:"/admin/members",icon:"👤",label:"회원 관리"},{section:"시스템"},{path:"/admin/settings",icon:"⚙️",label:"설정"}];function y(){const i=g(),c=b(),[t,o]=s.useState(null),[l,m]=s.useState("대시보드"),[r,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")||"{}");o(n),u(e)},[i]),s.useEffect(()=>{m({"/admin/dashboard":"대시보드","/admin/news":"뉴스/공지사항 관리","/admin/inquiries":"문의 관리","/admin/recruit":"채용공고 관리","/admin/members":"회원 관리","/admin/settings":"설정"}[c.pathname]||"관리자")},[c.pathname]);const u=async e=>{try{const n=await fetch("/api/admin/dashboard",{headers:{Authorization:`Bearer ${e}`}});if(n.ok){const d=await n.json();h({pendingInquiries:d.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(x,{to:e.path,className:({isActive:d})=>d?"active":"",children:[a.jsx("span",{className:"nav-icon",children:e.icon}),e.label,e.badgeKey&&r[e.badgeKey]>0&&a.jsx("span",{className:"admin-nav-badge",children:r[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};
|
||||||
@ -1 +0,0 @@
|
|||||||
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};
|
|
||||||
@ -1 +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};
|
import{r as i,c as p,j as e}from"./index-CpO7mTKO.js";/* empty css */function x(){const[t,o]=i.useState({username:"",password:""}),[l,r]=i.useState(""),[d,c]=i.useState(!1),m=p(),u=async a=>{a.preventDefault(),r(""),c(!0);try{const s=await fetch("/api/admin/login",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(t)}),n=await s.json();if(!s.ok){r(n.message||"로그인 실패");return}localStorage.setItem("admin_token",n.token),localStorage.setItem("admin_user",JSON.stringify({username:n.username,displayName:n.displayName})),m("/admin/dashboard")}catch{r("서버 연결 오류가 발생했습니다.")}finally{c(!1)}};return e.jsx("div",{className:"admin-login-page",children:e.jsxs("div",{className:"admin-login-box",children:[e.jsxs("div",{className:"login-logo",children:[e.jsx("span",{className:"login-badge",children:"ADMIN"}),e.jsx("h1",{children:"(주)지오정보기술"}),e.jsx("p",{children:"홈페이지 관리자 시스템"})]}),l&&e.jsxs("div",{className:"login-error",children:["⚠ ",l]}),e.jsxs("form",{onSubmit:u,children:[e.jsxs("div",{className:"login-input-group",children:[e.jsx("label",{children:"아이디"}),e.jsx("input",{type:"text",placeholder:"관리자 아이디",value:t.username,required:!0,onChange:a=>o(s=>({...s,username:a.target.value}))})]}),e.jsxs("div",{className:"login-input-group",children:[e.jsx("label",{children:"비밀번호"}),e.jsx("input",{type:"password",placeholder:"비밀번호",value:t.password,required:!0,onChange:a=>o(s=>({...s,password:a.target.value}))})]}),e.jsx("button",{type:"submit",className:"login-btn",disabled:d,children:d?"로그인 중...":"로그인"})]}),e.jsxs("p",{style:{textAlign:"center",marginTop:20,fontSize:12,color:"#94a3b8"},children:["홈페이지로 돌아가기: ",e.jsx("a",{href:"/",style:{color:"#4f6ef7"},children:"메인 페이지"})]})]})})}export{x as default};
|
||||||
@ -0,0 +1 @@
|
|||||||
|
import{r as d,j as t}from"./index-CpO7mTKO.js";const c="/api/admin/members",w=()=>localStorage.getItem("admin_token"),p=()=>({Authorization:`Bearer ${w()}`,"Content-Type":"application/json"});function C(){const[x,y]=d.useState({content:[],totalElements:0}),[n,i]=d.useState(0),[f,j]=d.useState(""),[a,S]=d.useState(""),[m,h]=d.useState(!1),l=async(e=n,r=a)=>{h(!0);try{const o=new URLSearchParams({page:e,size:20,...r&&{keyword:r}}),u=await fetch(`${c}?${o}`,{headers:p()});u.ok&&y(await u.json())}finally{h(!1)}};d.useEffect(()=>{l()},[n,a]);const k=e=>{e.preventDefault(),i(0),S(f)},v=async(e,r)=>{confirm(`${r?"비활성화":"활성화"}하시겠습니까?`)&&(await fetch(`${c}/${e}/status`,{method:"PATCH",headers:p(),body:JSON.stringify({active:!r})}),l())},z=async e=>{confirm("삭제하면 복구할 수 없습니다. 삭제하시겠습니까?")&&(await fetch(`${c}/${e}`,{method:"DELETE",headers:p()}),l())},g=x.content||[],b=x.totalElements||0,s=Math.ceil(b/20);return t.jsxs("div",{children:[t.jsxs("div",{style:{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:24},children:[t.jsxs("div",{children:[t.jsx("h2",{style:{fontSize:20,fontWeight:700,color:"#1e293b",margin:0},children:"회원 관리"}),t.jsxs("p",{style:{fontSize:13,color:"#64748b",margin:"4px 0 0"},children:["총 ",b.toLocaleString(),"명"]})]}),t.jsxs("form",{onSubmit:k,style:{display:"flex",gap:8},children:[t.jsx("input",{value:f,onChange:e=>j(e.target.value),placeholder:"이름 / 이메일 / 소속 검색",style:{padding:"8px 14px",border:"1px solid #e2e8f0",borderRadius:8,fontSize:13,width:240,outline:"none"}}),t.jsx("button",{type:"submit",style:{padding:"8px 16px",background:"#1a5fd8",color:"#fff",border:"none",borderRadius:8,fontSize:13,cursor:"pointer"},children:"검색"})]})]}),t.jsx("div",{style:{background:"#fff",border:"1px solid #e2e8f0",borderRadius:10,overflow:"hidden"},children:t.jsxs("table",{style:{width:"100%",borderCollapse:"collapse",fontSize:13},children:[t.jsx("thead",{children:t.jsx("tr",{style:{background:"#f8fafc"},children:["ID","이름","이메일","연락처","소속","가입일","상태","액션"].map(e=>t.jsx("th",{style:{padding:"10px 14px",textAlign:"left",fontWeight:600,color:"#475569",borderBottom:"1px solid #e2e8f0"},children:e},e))})}),t.jsx("tbody",{children:m?t.jsx("tr",{children:t.jsx("td",{colSpan:8,style:{padding:40,textAlign:"center",color:"#94a3b8"},children:"로딩 중..."})}):g.length===0?t.jsx("tr",{children:t.jsx("td",{colSpan:8,style:{padding:40,textAlign:"center",color:"#94a3b8"},children:a?"검색 결과가 없습니다.":"등록된 회원이 없습니다."})}):g.map(e=>t.jsxs("tr",{style:{borderBottom:"1px solid #f1f5f9"},children:[t.jsxs("td",{style:{padding:"10px 14px",color:"#94a3b8"},children:["#",e.id]}),t.jsx("td",{style:{padding:"10px 14px",fontWeight:600,color:"#1e293b"},children:e.name}),t.jsx("td",{style:{padding:"10px 14px",color:"#475569"},children:e.email}),t.jsx("td",{style:{padding:"10px 14px",color:"#64748b"},children:e.phone||"-"}),t.jsx("td",{style:{padding:"10px 14px",color:"#64748b"},children:e.company||"-"}),t.jsx("td",{style:{padding:"10px 14px",color:"#64748b",fontSize:12},children:e.createdAt?new Date(e.createdAt).toLocaleDateString("ko-KR"):"-"}),t.jsx("td",{style:{padding:"10px 14px"},children:t.jsx("span",{style:{padding:"3px 10px",borderRadius:12,fontSize:11,fontWeight:700,background:e.active?"#dcfce7":"#fee2e2",color:e.active?"#16a34a":"#dc2626"},children:e.active?"활성":"비활성"})}),t.jsx("td",{style:{padding:"10px 14px"},children:t.jsxs("div",{style:{display:"flex",gap:6},children:[t.jsx("button",{onClick:()=>v(e.id,e.active),style:{padding:"4px 10px",fontSize:11,borderRadius:6,cursor:"pointer",background:e.active?"#fef3c7":"#dcfce7",color:e.active?"#92400e":"#166534",border:"none"},children:e.active?"비활성화":"활성화"}),t.jsx("button",{onClick:()=>z(e.id),style:{padding:"4px 10px",fontSize:11,borderRadius:6,cursor:"pointer",background:"#fef2f2",color:"#dc2626",border:"none"},children:"삭제"})]})})]},e.id))})]})}),s>1&&t.jsxs("div",{style:{display:"flex",gap:6,justifyContent:"center",marginTop:20},children:[t.jsx("button",{onClick:()=>i(e=>Math.max(0,e-1)),disabled:n===0,style:{padding:"6px 14px",borderRadius:6,border:"1px solid #e2e8f0",background:"#fff",cursor:"pointer",fontSize:13},children:"이전"}),Array.from({length:Math.min(5,s)},(e,r)=>{const o=Math.max(0,Math.min(n-2,s-5))+r;return t.jsx("button",{onClick:()=>i(o),style:{padding:"6px 12px",borderRadius:6,fontSize:13,cursor:"pointer",border:o===n?"none":"1px solid #e2e8f0",background:o===n?"#1a5fd8":"#fff",color:o===n?"#fff":"#475569"},children:o+1},o)}),t.jsx("button",{onClick:()=>i(e=>Math.min(s-1,e+1)),disabled:n===s-1,style:{padding:"6px 14px",borderRadius:6,border:"1px solid #e2e8f0",background:"#fff",cursor:"pointer",fontSize:13},children:"다음"})]})]})}export{C as default};
|
||||||
@ -1 +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};
|
import{c as u,r as d,j as e}from"./index-CpO7mTKO.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};
|
||||||
@ -1 +0,0 @@
|
|||||||
.inner-page{padding-top:var(--header-h)}.page-hero{background:linear-gradient(135deg,var(--secondary),var(--primary-dark));padding:60px 0;color:#fff}.page-hero-title{font-size:40px;font-weight:900;margin:8px 0 12px}.page-hero p{color:#ffffffbf;font-size:16px}
|
|
||||||
@ -0,0 +1 @@
|
|||||||
|
.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}.prose{color:var(--gray-700);line-height:1.8;font-size:15px}.prose h2{font-size:18px;font-weight:700;color:var(--gray-900);margin:32px 0 12px;border-left:4px solid var(--accent);padding-left:12px}.prose p{margin-bottom:14px}.prose ul{margin:0 0 14px 20px}.prose ul li{margin-bottom:6px}.prose a{color:var(--accent)}.policy-table{width:100%;border-collapse:collapse;margin:14px 0;font-size:14px}.policy-table th,.policy-table td{padding:10px 14px;border:1px solid var(--gray-200)}.policy-table th{background:var(--gray-50);font-weight:600;color:var(--gray-700);text-align:left}.policy-footer{margin-top:40px;padding:20px;background:var(--gray-50);border-radius:10px;font-size:13px;color:var(--gray-500);line-height:1.8}
|
||||||
@ -1 +0,0 @@
|
|||||||
import{r as i,j as e}from"./index-ChpGil2q.js";import{a as j}from"./index-DcNlVx-A.js";function y(){const[a,t]=i.useState({name:"",email:"",phone:"",category:"제품문의",subject:"",content:"",agreePrivacy:!1}),[r,l]=i.useState(null),[o,d]=i.useState(!1),s=n=>{const{name:c,value:m,type:p,checked:x}=n.target;t(u=>({...u,[c]:p==="checkbox"?x:m}))},h=async n=>{if(n.preventDefault(),!a.agreePrivacy){l({type:"error",msg:"개인정보 수집·이용에 동의해주세요."});return}d(!0);try{await j.post("/api/inquiry",a),l({type:"success",msg:"문의가 접수되었습니다. 빠른 시일 내에 연락드리겠습니다."}),t({name:"",email:"",phone:"",category:"제품문의",subject:"",content:"",agreePrivacy:!1})}catch{l({type:"error",msg:"문의 접수 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요."})}finally{d(!1)}};return e.jsxs("main",{id:"main-content",className:"contact-page",children:[e.jsx("div",{className:"page-hero",children:e.jsxs("div",{className:"container",children:[e.jsx("span",{className:"section-label",children:"Contact Us"}),e.jsx("h1",{className:"page-hero-title",children:"문의하기"}),e.jsx("p",{children:"GUARDiA ITSM 도입 문의 및 제품 상담을 받아드립니다."})]})}),e.jsx("section",{className:"section",children:e.jsxs("div",{className:"container contact-grid",children:[e.jsxs("div",{className:"contact-info",children:[e.jsx("h2",{children:"연락처 정보"}),[{icon:"📞",label:"대표전화",value:"02-000-0000"},{icon:"✉️",label:"이메일",value:"info@zioinfo.co.kr"},{icon:"🕐",label:"운영시간",value:"평일 09:00 ~ 18:00"},{icon:"📍",label:"주소",value:"서울특별시"}].map((n,c)=>e.jsxs("div",{className:"info-item",children:[e.jsx("span",{className:"info-icon",children:n.icon}),e.jsxs("div",{children:[e.jsx("strong",{children:n.label}),e.jsx("p",{children:n.value})]})]},c))]}),e.jsxs("form",{className:"contact-form card",onSubmit:h,children:[e.jsx("h2",{children:"온라인 문의"}),r&&e.jsxs("div",{className:`form-alert ${r.type}`,children:[r.type==="success"?"✅":"❌"," ",r.msg]}),e.jsxs("div",{className:"form-row",children:[e.jsxs("div",{className:"form-group",children:[e.jsxs("label",{htmlFor:"name",children:["성함 ",e.jsx("span",{className:"required",children:"*"})]}),e.jsx("input",{id:"name",name:"name",type:"text",required:!0,value:a.name,onChange:s,placeholder:"홍길동"})]}),e.jsxs("div",{className:"form-group",children:[e.jsx("label",{htmlFor:"phone",children:"연락처"}),e.jsx("input",{id:"phone",name:"phone",type:"tel",value:a.phone,onChange:s,placeholder:"010-0000-0000"})]})]}),e.jsxs("div",{className:"form-row",children:[e.jsxs("div",{className:"form-group",children:[e.jsxs("label",{htmlFor:"email",children:["이메일 ",e.jsx("span",{className:"required",children:"*"})]}),e.jsx("input",{id:"email",name:"email",type:"email",required:!0,value:a.email,onChange:s,placeholder:"your@email.com"})]}),e.jsxs("div",{className:"form-group",children:[e.jsx("label",{htmlFor:"category",children:"문의 유형"}),e.jsxs("select",{id:"category",name:"category",value:a.category,onChange:s,children:[e.jsx("option",{children:"제품문의"}),e.jsx("option",{children:"데모 신청"}),e.jsx("option",{children:"기술지원"}),e.jsx("option",{children:"사업제안"}),e.jsx("option",{children:"채용문의"}),e.jsx("option",{children:"기타"})]})]})]}),e.jsxs("div",{className:"form-group",children:[e.jsxs("label",{htmlFor:"subject",children:["제목 ",e.jsx("span",{className:"required",children:"*"})]}),e.jsx("input",{id:"subject",name:"subject",type:"text",required:!0,value:a.subject,onChange:s,placeholder:"문의 제목을 입력해주세요"})]}),e.jsxs("div",{className:"form-group",children:[e.jsxs("label",{htmlFor:"content",children:["문의 내용 ",e.jsx("span",{className:"required",children:"*"})]}),e.jsx("textarea",{id:"content",name:"content",rows:6,required:!0,value:a.content,onChange:s,placeholder:"문의 내용을 자세히 작성해주세요."})]}),e.jsxs("label",{className:"privacy-agree",children:[e.jsx("input",{type:"checkbox",name:"agreePrivacy",checked:a.agreePrivacy,onChange:s}),e.jsxs("span",{children:["개인정보 수집·이용에 동의합니다. ",e.jsx("a",{href:"/privacy",target:"_blank",children:"[보기]"})]})]}),e.jsx("button",{type:"submit",className:"btn btn-primary btn-lg",style:{width:"100%"},disabled:o,children:o?"전송 중...":"문의 접수하기"})]})]})})]})}export{y as default};
|
|
||||||
@ -0,0 +1 @@
|
|||||||
|
.auth-page{min-height:100vh;display:flex;align-items:center;justify-content:center;background:linear-gradient(135deg,#f0f4ff,#e8f0fe);padding:40px 16px}.auth-box{background:#fff;border-radius:16px;box-shadow:0 4px 32px #0000001a;padding:40px 40px 32px;width:100%;max-width:440px}.auth-logo{display:flex;align-items:center;gap:10px;text-decoration:none;color:var(--primary, #1a3a6b);font-weight:700;font-size:16px;margin-bottom:28px}.auth-tabs{display:flex;border-bottom:2px solid #e2e8f0;margin-bottom:24px}.auth-tab{flex:1;padding:10px;border:none;background:none;font-size:15px;font-weight:600;color:#94a3b8;cursor:pointer;border-bottom:2px solid transparent;margin-bottom:-2px;transition:all .2s}.auth-tab.active{color:var(--accent, #1a5fd8);border-bottom-color:var(--accent, #1a5fd8)}.auth-error{background:#fef2f2;border:1px solid #fecaca;color:#dc2626;border-radius:8px;padding:10px 14px;font-size:13px;margin-bottom:16px}.auth-form{display:flex;flex-direction:column;gap:16px}.auth-form .form-group{display:flex;flex-direction:column;gap:6px}.auth-form label{font-size:13px;font-weight:600;color:#374151}.auth-form .required{color:#ef4444}.auth-form input{padding:10px 14px;border:1.5px solid #e2e8f0;border-radius:8px;font-size:14px;color:#1e293b;transition:border-color .2s;outline:none}.auth-form input:focus{border-color:var(--accent, #1a5fd8)}.btn-full{width:100%;justify-content:center}.sns-divider{display:flex;align-items:center;gap:12px;font-size:12px;color:#94a3b8;margin:4px 0}.sns-divider:before,.sns-divider:after{content:"";flex:1;height:1px;background:#e2e8f0}.sns-buttons{display:flex;flex-direction:column;gap:8px}.sns-btn{display:flex;align-items:center;gap:10px;padding:10px 16px;border-radius:8px;border:1.5px solid #e2e8f0;font-size:14px;font-weight:600;cursor:pointer;background:#fff;transition:all .2s}.sns-btn:hover{transform:translateY(-1px);box-shadow:0 2px 8px #00000014}.sns-kakao{border-color:#fee500;background:#fee500;color:#191919}.sns-naver{border-color:#03c75a;background:#03c75a;color:#fff}.sns-google{border-color:#e2e8f0;color:#374151}.sns-icon{width:20px;text-align:center;font-weight:900}.auth-switch{font-size:13px;color:#64748b;text-align:center;margin:0}.link-btn{background:none;border:none;color:var(--accent, #1a5fd8);font-weight:600;cursor:pointer;text-decoration:underline;font-size:inherit}.auth-terms{font-size:11px;color:#94a3b8;text-align:center;margin-top:20px}.auth-terms a{color:#64748b}.member-guard{position:relative}.member-guard-overlay{position:absolute;top:0;right:0;bottom:0;left:0;background:#ffffffd9;-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px);display:flex;flex-direction:column;align-items:center;justify-content:center;border-radius:inherit;z-index:10;gap:12px}.member-guard-icon{font-size:36px}.member-guard-text{font-size:15px;font-weight:600;color:#1e293b}.member-guard-sub{font-size:13px;color:#64748b}@media (max-width: 480px){.auth-box{padding:28px 20px 24px}}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
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 기반 플랫폼입니다.
|
import{j as e,b as o,a as n,r as h,N as x}from"./index-CpO7mTKO.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 파싱 → 자동 실행
|
- AI ChatOps: 메신저 자연어 명령 → Ollama LLM 파싱 → 자동 실행
|
||||||
@ -1 +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};
|
import{j as e,L as t}from"./index-CpO7mTKO.js";function i(){return e.jsxs("main",{style:{paddingTop:"var(--header-h)",minHeight:"60vh",display:"flex",alignItems:"center",justifyContent:"center",flexDirection:"column",gap:"16px",textAlign:"center"},children:[e.jsx("div",{style:{fontSize:"72px"},children:"404"}),e.jsx("h1",{style:{fontSize:"24px",fontWeight:"700"},children:"페이지를 찾을 수 없습니다"}),e.jsx("p",{style:{color:"var(--gray-600)"},children:"요청하신 페이지가 존재하지 않거나 이동되었습니다."}),e.jsx(t,{to:"/",className:"btn btn-primary",children:"홈으로 돌아가기"})]})}export{i as default};
|
||||||
@ -0,0 +1 @@
|
|||||||
|
import{j as s}from"./index-CpO7mTKO.js";/* empty css */import{u as i}from"./useSeoMeta-DR7HJfMM.js";function c(){return i({title:"개인정보처리방침",description:"(주)지오정보기술 개인정보처리방침. 수집 항목, 보유 기간, 이용자 권리, 개인정보 보호책임자 안내.",path:"/privacy"}),s.jsx("main",{id:"main-content",className:"inner-page",children:s.jsx("section",{className:"section",children:s.jsxs("div",{className:"container",style:{maxWidth:"800px"},children:[s.jsxs("div",{className:"section-header",children:[s.jsx("span",{className:"section-label",children:"Privacy Policy"}),s.jsx("h1",{className:"section-title",children:"개인정보처리방침"}),s.jsx("p",{className:"section-desc",style:{fontSize:"13px",color:"var(--gray-500)"},children:"시행일자: 2026년 01월 01일 | 최종 수정: 2026년 05월 31일"})]}),s.jsxs("div",{className:"prose",children:[s.jsx("p",{children:'(주)지오정보기술(이하 "회사")은 이용자의 개인정보를 중요시하며, 「개인정보 보호법」 및 관련 법령을 준수합니다.'}),s.jsx("h2",{children:"제1조 (개인정보의 수집 항목 및 목적)"}),s.jsxs("table",{className:"policy-table",children:[s.jsx("thead",{children:s.jsxs("tr",{children:[s.jsx("th",{children:"구분"}),s.jsx("th",{children:"수집 항목"}),s.jsx("th",{children:"수집 목적"})]})}),s.jsxs("tbody",{children:[s.jsxs("tr",{children:[s.jsx("td",{children:"회원가입"}),s.jsx("td",{children:"이름, 이메일, 비밀번호, 연락처, 소속"}),s.jsx("td",{children:"회원 식별, 서비스 제공"})]}),s.jsxs("tr",{children:[s.jsx("td",{children:"문의 접수"}),s.jsx("td",{children:"이름, 이메일, 연락처, 문의 내용"}),s.jsx("td",{children:"문의 처리, 답변 발송"})]}),s.jsxs("tr",{children:[s.jsx("td",{children:"채용 지원"}),s.jsx("td",{children:"이름, 이메일, 연락처, 이력서"}),s.jsx("td",{children:"채용 심사, 합격자 통보"})]}),s.jsxs("tr",{children:[s.jsx("td",{children:"서비스 이용"}),s.jsx("td",{children:"접속 IP, 쿠키, 방문 이력"}),s.jsx("td",{children:"서비스 개선, 보안"})]})]})]}),s.jsx("h2",{children:"제2조 (개인정보의 보유 및 이용 기간)"}),s.jsxs("ul",{children:[s.jsx("li",{children:"회원 정보: 회원 탈퇴 시 즉시 파기"}),s.jsx("li",{children:"문의/상담 이력: 처리 완료 후 3년"}),s.jsx("li",{children:"채용 서류: 채용 완료 후 6개월 (불합격자 즉시 파기 원칙)"}),s.jsx("li",{children:"법령에 의한 보존이 필요한 경우 해당 기간"})]}),s.jsx("h2",{children:"제3조 (개인정보의 제3자 제공)"}),s.jsx("p",{children:"회사는 이용자의 개인정보를 원칙적으로 외부에 제공하지 않습니다. 단, 이용자의 동의가 있거나 법령에 의한 경우에는 예외로 합니다."}),s.jsx("h2",{children:"제4조 (개인정보 처리의 위탁)"}),s.jsx("p",{children:"현재 개인정보 처리를 위탁하는 업무는 없습니다. 추후 위탁 발생 시 사전 고지합니다."}),s.jsx("h2",{children:"제5조 (이용자의 권리)"}),s.jsxs("ul",{children:[s.jsx("li",{children:"개인정보 열람, 정정, 삭제, 처리정지 요청 가능"}),s.jsx("li",{children:"요청 방법: 이메일(info@zioinfo.co.kr) 또는 전화(031-483-1766)"}),s.jsx("li",{children:"처리 기간: 요청 후 10일 이내"})]}),s.jsx("h2",{children:"제6조 (개인정보의 파기)"}),s.jsx("p",{children:"보유 기간 만료 시 지체 없이 파기합니다. 전자 파일은 복구 불가능한 방법으로 삭제하며, 서면은 분쇄 또는 소각합니다."}),s.jsx("h2",{children:"제7조 (쿠키 사용)"}),s.jsx("p",{children:"홈페이지 이용 편의를 위해 쿠키를 사용합니다. 브라우저 설정에서 쿠키 허용 여부를 조정할 수 있습니다."}),s.jsx("h2",{children:"제8조 (개인정보 보호책임자)"}),s.jsx("table",{className:"policy-table",children:s.jsxs("tbody",{children:[s.jsxs("tr",{children:[s.jsx("td",{children:"성명"}),s.jsx("td",{children:"홍영택"})]}),s.jsxs("tr",{children:[s.jsx("td",{children:"직책"}),s.jsx("td",{children:"대표이사"})]}),s.jsxs("tr",{children:[s.jsx("td",{children:"연락처"}),s.jsx("td",{children:"031-483-1766"})]}),s.jsxs("tr",{children:[s.jsx("td",{children:"이메일"}),s.jsx("td",{children:"info@zioinfo.co.kr"})]})]})}),s.jsx("h2",{children:"제9조 (개인정보 침해 신고)"}),s.jsxs("ul",{children:[s.jsx("li",{children:"개인정보보호위원회 개인정보 침해 신고센터: (국번없이) 182"}),s.jsx("li",{children:"대검찰청 사이버수사과: (국번없이) 1301"}),s.jsx("li",{children:"경찰청 사이버안전국: (국번없이) 182"})]}),s.jsxs("p",{className:"policy-footer",children:["본 방침은 2026년 01월 01일부터 시행됩니다.",s.jsx("br",{}),"변경 시 홈페이지를 통해 공지합니다."]})]})]})})})}export{c as default};
|
||||||
@ -0,0 +1 @@
|
|||||||
|
import{j as a,L as n}from"./index-CpO7mTKO.js";/* empty css */import{u as s}from"./useSeoMeta-DR7HJfMM.js";const p=[{title:"홈",icon:"🏠",links:[{label:"메인 홈페이지",path:"/"}]},{title:"회사소개",icon:"🏢",links:[{label:"CEO 인사말",path:"/company/greeting"},{label:"연혁",path:"/company/history"},{label:"조직도",path:"/company/organization"},{label:"CI 소개",path:"/company/ci"},{label:"오시는 길",path:"/company/location"}]},{title:"솔루션",icon:"🛡️",links:[{label:"GUARDiA ITSM",path:"/solution/guardia",badge:"NEW"},{label:"ERP 솔루션",path:"/solution/erp"},{label:"CRM 솔루션",path:"/solution/crm"},{label:"BI 솔루션",path:"/solution/bi"}]},{title:"사업실적",icon:"📊",links:[{label:"구축 레퍼런스",path:"/business/reference"},{label:"파트너",path:"/business/partner"}]},{title:"고객지원",icon:"💬",links:[{label:"공지사항",path:"/support/notice"},{label:"FAQ",path:"/support/faq"},{label:"카탈로그",path:"/support/catalog"},{label:"문의하기",path:"/support/contact"}]},{title:"채용",icon:"👥",links:[{label:"채용공고",path:"/recruit/jobs"},{label:"복리후생",path:"/recruit/welfare"},{label:"지원하기",path:"/recruit/apply"}]},{title:"뉴스",icon:"📰",links:[{label:"뉴스룸",path:"/news/newsroom"},{label:"기술 블로그",path:"/news/blog"}]},{title:"회원",icon:"🔑",links:[{label:"로그인 / 회원가입",path:"/login"}]},{title:"정책",icon:"📋",links:[{label:"개인정보처리방침",path:"/privacy"},{label:"이용약관",path:"/terms"},{label:"사이트맵",path:"/sitemap"}]}];function h(){return s({title:"사이트맵",description:"(주)지오정보기술 홈페이지 전체 메뉴 안내. 회사소개, 솔루션, 사업실적, 고객지원, 채용, 뉴스 등 모든 페이지를 확인하세요.",path:"/sitemap"}),a.jsx("main",{id:"main-content",className:"inner-page",children:a.jsx("section",{className:"section",children:a.jsxs("div",{className:"container",style:{maxWidth:"960px"},children:[a.jsxs("div",{className:"section-header",children:[a.jsx("span",{className:"section-label",children:"Sitemap"}),a.jsx("h1",{className:"section-title",children:"사이트맵"}),a.jsx("p",{className:"section-desc",children:"(주)지오정보기술 홈페이지 전체 메뉴 안내"})]}),a.jsx("div",{style:{display:"grid",gridTemplateColumns:"repeat(auto-fill, minmax(220px, 1fr))",gap:"24px",marginTop:"40px"},children:p.map((l,t)=>a.jsxs("div",{style:{background:"#fff",borderRadius:"12px",padding:"24px",boxShadow:"0 2px 12px rgba(0,0,0,.06)",border:"1px solid var(--gray-200)"},children:[a.jsxs("h2",{style:{fontSize:"16px",fontWeight:"700",color:"var(--gray-900)",marginBottom:"16px",display:"flex",alignItems:"center",gap:"8px"},children:[a.jsx("span",{children:l.icon})," ",l.title]}),a.jsx("ul",{style:{listStyle:"none",display:"flex",flexDirection:"column",gap:"10px"},children:l.links.map((e,i)=>a.jsx("li",{children:a.jsxs(n,{to:e.path,style:{color:"var(--primary)",textDecoration:"none",fontSize:"14px",display:"flex",alignItems:"center",gap:"6px"},children:[a.jsx("span",{style:{color:"var(--gray-400)",fontSize:"12px"},children:"›"}),e.label,e.badge&&a.jsx("span",{style:{fontSize:"10px",padding:"1px 6px",background:"var(--accent)",color:"#fff",borderRadius:"8px",fontWeight:"700"},children:e.badge})]})},i))})]},t))})]})})})}export{h as default};
|
||||||
68
backend/src/main/resources/static/assets/index-CpO7mTKO.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
import{r as $}from"./index-CpO7mTKO.js";const u="https://zioinfo.co.kr",d="(주)지오정보기술";function S({title:a,description:o,path:m="",image:c="/logo.png",keywords:r=""}){$.useEffect(()=>{const l=a?`${a} | ${d}`:d,i=`${u}${m}`,p=c.startsWith("http")?c:`${u}${c}`;document.title=l;const t=(s,f,g)=>{let n=document.querySelector(s);if(!n){n=document.createElement("meta");const[h,E]=s.replace("meta[","").replace("]","").split("=");n.setAttribute(h,E.replace(/"/g,"")),document.head.appendChild(n)}n.setAttribute(f,g)};let e=document.querySelector('link[rel="canonical"]');e||(e=document.createElement("link"),e.rel="canonical",document.head.appendChild(e)),e.href=i,t('meta[name="description"]',"content",o),r&&t('meta[name="keywords"]',"content",r),t('meta[property="og:title"]',"content",l),t('meta[property="og:description"]',"content",o),t('meta[property="og:url"]',"content",i),t('meta[property="og:image"]',"content",p),t('meta[name="twitter:title"]',"content",l),t('meta[name="twitter:description"]',"content",o),t('meta[name="twitter:image"]',"content",p)},[a,o,m,c,r])}export{S as u};
|
||||||
@ -1,22 +1,96 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="ko">
|
<html lang="ko">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<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">
|
<!-- 기본 SEO -->
|
||||||
<meta property="og:title" content="(주)지오정보기술">
|
<title>(주)지오정보기술 — AI 기반 인프라 자율 운영 플랫폼 GUARDiA ITSM</title>
|
||||||
<meta property="og:description" content="AI 기반 레거시 인프라 자율 운영 플랫폼 GUARDiA ITSM">
|
<meta name="description" content="(주)지오정보기술은 AI 기반 레거시 인프라 자율 운영 플랫폼 GUARDiA ITSM을 개발합니다. 1,000개 이상 공공기관 IT 인프라를 메신저 한 줄 명령으로 자동 운영하세요.">
|
||||||
<meta property="og:type" content="website">
|
<meta name="keywords" content="지오정보기술, GUARDiA, ITSM, AI인프라자동화, 공공기관IT, 레거시인프라, ChatOps, 에이전트리스배포, ERP, CRM, SI, 안산IT기업">
|
||||||
<title>(주)지오정보기술</title>
|
<meta name="author" content="(주)지오정보기술">
|
||||||
<link rel="icon" type="image/png" href="/favicon.ico">
|
<meta name="robots" content="index, follow">
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="canonical" href="https://zioinfo.co.kr/">
|
||||||
<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">
|
<!-- Open Graph (카카오·페이스북·네이버) -->
|
||||||
<script type="module" crossorigin src="/assets/index-ChpGil2q.js"></script>
|
<meta property="og:type" content="website">
|
||||||
|
<meta property="og:site_name" content="(주)지오정보기술">
|
||||||
|
<meta property="og:title" content="(주)지오정보기술 — GUARDiA ITSM AI 인프라 자동화">
|
||||||
|
<meta property="og:description" content="메신저 한 줄 명령으로 공공기관 레거시 서버를 자동 운영. GUARDiA ITSM으로 IT 운영 혁신을 경험하세요.">
|
||||||
|
<meta property="og:url" content="https://zioinfo.co.kr/">
|
||||||
|
<meta property="og:image" content="https://zioinfo.co.kr/logo.png">
|
||||||
|
<meta property="og:image:width" content="1200">
|
||||||
|
<meta property="og:image:height" content="630">
|
||||||
|
<meta property="og:locale" content="ko_KR">
|
||||||
|
|
||||||
|
<!-- Twitter Card -->
|
||||||
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
|
<meta name="twitter:title" content="(주)지오정보기술 — GUARDiA ITSM">
|
||||||
|
<meta name="twitter:description" content="AI 기반 레거시 인프라 자율 운영 플랫폼. 공공기관 IT 운영 자동화의 새로운 기준.">
|
||||||
|
<meta name="twitter:image" content="https://zioinfo.co.kr/logo.png">
|
||||||
|
|
||||||
|
<!-- 네이버 서치어드바이저 인증 (등록 후 content 값 교체) -->
|
||||||
|
<!-- <meta name="naver-site-verification" content="YOUR_NAVER_CODE"> -->
|
||||||
|
<!-- 구글 서치콘솔 인증 (등록 후 content 값 교체) -->
|
||||||
|
<!-- <meta name="google-site-verification" content="YOUR_GOOGLE_CODE"> -->
|
||||||
|
|
||||||
|
<!-- JSON-LD: 기업 정보 -->
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "Organization",
|
||||||
|
"name": "(주)지오정보기술",
|
||||||
|
"alternateName": "ZioInfo",
|
||||||
|
"url": "https://zioinfo.co.kr",
|
||||||
|
"logo": "https://zioinfo.co.kr/logo.png",
|
||||||
|
"description": "AI 기반 레거시 인프라 자율 운영 플랫폼 GUARDiA ITSM 및 ERP·CRM·BI 솔루션 전문 IT 기업",
|
||||||
|
"foundingDate": "2000",
|
||||||
|
"address": {
|
||||||
|
"@type": "PostalAddress",
|
||||||
|
"streetAddress": "광덕4로 220 오피스브이 578호",
|
||||||
|
"addressLocality": "안산시 단원구",
|
||||||
|
"addressRegion": "경기도",
|
||||||
|
"postalCode": "15440",
|
||||||
|
"addressCountry": "KR"
|
||||||
|
},
|
||||||
|
"contactPoint": {
|
||||||
|
"@type": "ContactPoint",
|
||||||
|
"telephone": "+82-31-483-1766",
|
||||||
|
"contactType": "customer service",
|
||||||
|
"availableLanguage": "Korean"
|
||||||
|
},
|
||||||
|
"founder": { "@type": "Person", "name": "홍영택" }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- JSON-LD: 웹사이트 -->
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "WebSite",
|
||||||
|
"name": "(주)지오정보기술",
|
||||||
|
"url": "https://zioinfo.co.kr",
|
||||||
|
"potentialAction": {
|
||||||
|
"@type": "SearchAction",
|
||||||
|
"target": "https://zioinfo.co.kr/support/faq?q={search_term_string}",
|
||||||
|
"query-input": "required name=search_term_string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Favicon -->
|
||||||
|
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
||||||
|
<link rel="shortcut icon" href="/favicon.ico">
|
||||||
|
<link rel="apple-touch-icon" href="/logo.png">
|
||||||
|
|
||||||
|
<!-- Fonts -->
|
||||||
|
<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-CpO7mTKO.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-Dk81znn6.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-Dk81znn6.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
24
backend/src/main/resources/static/robots.txt
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
# robots.txt — (주)지오정보기술
|
||||||
|
# https://zioinfo.co.kr
|
||||||
|
|
||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
# 관리자 페이지 크롤링 차단
|
||||||
|
Disallow: /admin/
|
||||||
|
Disallow: /api/
|
||||||
|
|
||||||
|
# 크롤러별 개별 설정
|
||||||
|
User-agent: Googlebot
|
||||||
|
Allow: /
|
||||||
|
Crawl-delay: 1
|
||||||
|
|
||||||
|
User-agent: Yeti
|
||||||
|
Allow: /
|
||||||
|
Crawl-delay: 1
|
||||||
|
|
||||||
|
User-agent: Baiduspider
|
||||||
|
Disallow: /
|
||||||
|
|
||||||
|
# 사이트맵 위치
|
||||||
|
Sitemap: https://zioinfo.co.kr/sitemap.xml
|
||||||
165
backend/src/main/resources/static/sitemap.xml
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
|
||||||
|
xmlns:xhtml="http://www.w3.org/1999/xhtml">
|
||||||
|
|
||||||
|
<!-- 홈 -->
|
||||||
|
<url>
|
||||||
|
<loc>https://zioinfo.co.kr/</loc>
|
||||||
|
<lastmod>2026-05-31</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>1.0</priority>
|
||||||
|
</url>
|
||||||
|
|
||||||
|
<!-- 솔루션 -->
|
||||||
|
<url>
|
||||||
|
<loc>https://zioinfo.co.kr/solution/guardia</loc>
|
||||||
|
<lastmod>2026-05-31</lastmod>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.9</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://zioinfo.co.kr/solution/erp</loc>
|
||||||
|
<lastmod>2026-05-31</lastmod>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://zioinfo.co.kr/solution/crm</loc>
|
||||||
|
<lastmod>2026-05-31</lastmod>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://zioinfo.co.kr/solution/bi</loc>
|
||||||
|
<lastmod>2026-05-31</lastmod>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
|
||||||
|
<!-- 회사소개 -->
|
||||||
|
<url>
|
||||||
|
<loc>https://zioinfo.co.kr/company/greeting</loc>
|
||||||
|
<lastmod>2026-05-31</lastmod>
|
||||||
|
<changefreq>yearly</changefreq>
|
||||||
|
<priority>0.8</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://zioinfo.co.kr/company/history</loc>
|
||||||
|
<lastmod>2026-05-31</lastmod>
|
||||||
|
<changefreq>yearly</changefreq>
|
||||||
|
<priority>0.6</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://zioinfo.co.kr/company/organization</loc>
|
||||||
|
<lastmod>2026-05-31</lastmod>
|
||||||
|
<changefreq>yearly</changefreq>
|
||||||
|
<priority>0.5</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://zioinfo.co.kr/company/ci</loc>
|
||||||
|
<lastmod>2026-05-31</lastmod>
|
||||||
|
<changefreq>yearly</changefreq>
|
||||||
|
<priority>0.5</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://zioinfo.co.kr/company/location</loc>
|
||||||
|
<lastmod>2026-05-31</lastmod>
|
||||||
|
<changefreq>yearly</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
|
||||||
|
<!-- 사업실적 -->
|
||||||
|
<url>
|
||||||
|
<loc>https://zioinfo.co.kr/business/reference</loc>
|
||||||
|
<lastmod>2026-05-31</lastmod>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://zioinfo.co.kr/business/partner</loc>
|
||||||
|
<lastmod>2026-05-31</lastmod>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.6</priority>
|
||||||
|
</url>
|
||||||
|
|
||||||
|
<!-- 고객지원 -->
|
||||||
|
<url>
|
||||||
|
<loc>https://zioinfo.co.kr/support/notice</loc>
|
||||||
|
<lastmod>2026-05-31</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://zioinfo.co.kr/support/faq</loc>
|
||||||
|
<lastmod>2026-05-31</lastmod>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://zioinfo.co.kr/support/catalog</loc>
|
||||||
|
<lastmod>2026-05-31</lastmod>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.6</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://zioinfo.co.kr/support/contact</loc>
|
||||||
|
<lastmod>2026-05-31</lastmod>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.8</priority>
|
||||||
|
</url>
|
||||||
|
|
||||||
|
<!-- 채용 -->
|
||||||
|
<url>
|
||||||
|
<loc>https://zioinfo.co.kr/recruit/jobs</loc>
|
||||||
|
<lastmod>2026-05-31</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://zioinfo.co.kr/recruit/welfare</loc>
|
||||||
|
<lastmod>2026-05-31</lastmod>
|
||||||
|
<changefreq>yearly</changefreq>
|
||||||
|
<priority>0.5</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://zioinfo.co.kr/recruit/apply</loc>
|
||||||
|
<lastmod>2026-05-31</lastmod>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.6</priority>
|
||||||
|
</url>
|
||||||
|
|
||||||
|
<!-- 뉴스 -->
|
||||||
|
<url>
|
||||||
|
<loc>https://zioinfo.co.kr/news/newsroom</loc>
|
||||||
|
<lastmod>2026-05-31</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://zioinfo.co.kr/news/blog</loc>
|
||||||
|
<lastmod>2026-05-31</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.6</priority>
|
||||||
|
</url>
|
||||||
|
|
||||||
|
<!-- 정책 -->
|
||||||
|
<url>
|
||||||
|
<loc>https://zioinfo.co.kr/privacy</loc>
|
||||||
|
<lastmod>2026-05-31</lastmod>
|
||||||
|
<changefreq>yearly</changefreq>
|
||||||
|
<priority>0.4</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://zioinfo.co.kr/terms</loc>
|
||||||
|
<lastmod>2026-05-31</lastmod>
|
||||||
|
<changefreq>yearly</changefreq>
|
||||||
|
<priority>0.4</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://zioinfo.co.kr/sitemap</loc>
|
||||||
|
<lastmod>2026-05-31</lastmod>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.3</priority>
|
||||||
|
</url>
|
||||||
|
|
||||||
|
</urlset>
|
||||||
BIN
backend/src/main/resources/static/ziologo.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
backend/src/main/resources/static/지오정보기술로고.png
Normal file
|
After Width: | Height: | Size: 140 KiB |
BIN
backend/src/main/resources/static/지오정보기술사옥전경.png
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
52
backend/target/classes/application.yml
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
server:
|
||||||
|
port: 8080
|
||||||
|
servlet:
|
||||||
|
encoding:
|
||||||
|
charset: UTF-8
|
||||||
|
force: true
|
||||||
|
|
||||||
|
spring:
|
||||||
|
application:
|
||||||
|
name: zioinfo-web
|
||||||
|
datasource:
|
||||||
|
# SQLite — 파일 기반 DB (data/ 디렉토리 자동 생성)
|
||||||
|
url: jdbc:sqlite:./data/zioinfo.db
|
||||||
|
driver-class-name: org.sqlite.JDBC
|
||||||
|
jpa:
|
||||||
|
database-platform: org.hibernate.community.dialect.SQLiteDialect
|
||||||
|
hibernate:
|
||||||
|
ddl-auto: update # 스키마 자동 갱신 (운영: validate)
|
||||||
|
show-sql: false
|
||||||
|
properties:
|
||||||
|
hibernate:
|
||||||
|
format_sql: true
|
||||||
|
# SQLite는 foreign key 비활성 기본 → 명시 활성화
|
||||||
|
javax.persistence.schema-generation.database.action: none
|
||||||
|
mail:
|
||||||
|
host: ${MAIL_HOST:smtp.gmail.com}
|
||||||
|
port: ${MAIL_PORT:587}
|
||||||
|
username: ${MAIL_USERNAME:}
|
||||||
|
password: ${MAIL_PASSWORD:}
|
||||||
|
properties:
|
||||||
|
mail.smtp.auth: true
|
||||||
|
mail.smtp.starttls.enable: true
|
||||||
|
|
||||||
|
zioinfo:
|
||||||
|
jwt:
|
||||||
|
secret: zioinfo-admin-jwt-secret-key-must-be-at-least-32-chars-long
|
||||||
|
expiration-ms: 28800000 # 8시간
|
||||||
|
company:
|
||||||
|
name: (주)지오정보기술
|
||||||
|
email: info@zioinfo.co.kr
|
||||||
|
phone: 02-000-0000
|
||||||
|
address: 서울특별시
|
||||||
|
cors:
|
||||||
|
allowed-origins:
|
||||||
|
- http://localhost:3000
|
||||||
|
- http://localhost:5173
|
||||||
|
- http://www.zioinfo.co.kr
|
||||||
|
|
||||||
|
logging:
|
||||||
|
level:
|
||||||
|
kr.co.zioinfo: DEBUG
|
||||||
|
org.hibernate.SQL: WARN
|
||||||
BIN
backend/target/classes/kr/co/zioinfo/web/model/Inquiry.class
Normal file
BIN
backend/target/classes/kr/co/zioinfo/web/model/News.class
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
kr\co\zioinfo\web\model\Inquiry.class
|
||||||
|
kr\co\zioinfo\web\model\Inquiry$InquiryBuilder.class
|
||||||
|
kr\co\zioinfo\web\config\DataInitializer.class
|
||||||
|
kr\co\zioinfo\web\service\InquiryService.class
|
||||||
|
kr\co\zioinfo\web\repository\InquiryRepository.class
|
||||||
|
kr\co\zioinfo\web\ZioinfoWebApplication.class
|
||||||
|
kr\co\zioinfo\web\controller\ApiController.class
|
||||||
|
kr\co\zioinfo\web\model\News$NewsBuilder.class
|
||||||
|
kr\co\zioinfo\web\repository\NewsRepository.class
|
||||||
|
kr\co\zioinfo\web\service\NewsService.class
|
||||||
|
kr\co\zioinfo\web\model\News.class
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
C:\GUARDiA\workspace\zioinfo-web\backend\src\main\java\kr\co\zioinfo\web\config\SecurityConfig.java
|
||||||
|
C:\GUARDiA\workspace\zioinfo-web\backend\src\main\java\kr\co\zioinfo\web\service\NewsService.java
|
||||||
|
C:\GUARDiA\workspace\zioinfo-web\backend\src\main\java\kr\co\zioinfo\web\controller\AdminController.java
|
||||||
|
C:\GUARDiA\workspace\zioinfo-web\backend\src\main\java\kr\co\zioinfo\web\ZioinfoWebApplication.java
|
||||||
|
C:\GUARDiA\workspace\zioinfo-web\backend\src\main\java\kr\co\zioinfo\web\repository\InquiryRepository.java
|
||||||
|
C:\GUARDiA\workspace\zioinfo-web\backend\src\main\java\kr\co\zioinfo\web\service\InquiryService.java
|
||||||
|
C:\GUARDiA\workspace\zioinfo-web\backend\src\main\java\kr\co\zioinfo\web\controller\ApiController.java
|
||||||
|
C:\GUARDiA\workspace\zioinfo-web\backend\src\main\java\kr\co\zioinfo\web\repository\AdminUserRepository.java
|
||||||
|
C:\GUARDiA\workspace\zioinfo-web\backend\src\main\java\kr\co\zioinfo\web\model\Inquiry.java
|
||||||
|
C:\GUARDiA\workspace\zioinfo-web\backend\src\main\java\kr\co\zioinfo\web\model\News.java
|
||||||
|
C:\GUARDiA\workspace\zioinfo-web\backend\src\main\java\kr\co\zioinfo\web\config\DataInitializer.java
|
||||||
|
C:\GUARDiA\workspace\zioinfo-web\backend\src\main\java\kr\co\zioinfo\web\model\Recruit.java
|
||||||
|
C:\GUARDiA\workspace\zioinfo-web\backend\src\main\java\kr\co\zioinfo\web\repository\RecruitRepository.java
|
||||||
|
C:\GUARDiA\workspace\zioinfo-web\backend\src\main\java\kr\co\zioinfo\web\security\JwtUtil.java
|
||||||
|
C:\GUARDiA\workspace\zioinfo-web\backend\src\main\java\kr\co\zioinfo\web\repository\NewsRepository.java
|
||||||
|
C:\GUARDiA\workspace\zioinfo-web\backend\src\main\java\kr\co\zioinfo\web\model\AdminUser.java
|
||||||
|
C:\GUARDiA\workspace\zioinfo-web\backend\src\main\java\kr\co\zioinfo\web\security\JwtAuthFilter.java
|
||||||
79
deploy/deploy_now.py
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""직접 배포 스크립트 - Spring Boot JAR + React 정적 파일"""
|
||||||
|
import paramiko, time, sys, os, io, zipfile
|
||||||
|
|
||||||
|
HOST = '101.79.17.164'
|
||||||
|
USER = 'root'
|
||||||
|
PASS = '1q2w3e!Q'
|
||||||
|
LOCAL_JAR = 'C:/GUARDiA/workspace/zioinfo-web/backend/target/zioinfo-web-1.0.0.jar'
|
||||||
|
LOCAL_STATIC = 'C:/GUARDiA/workspace/zioinfo-web/backend/src/main/resources/static'
|
||||||
|
|
||||||
|
client = paramiko.SSHClient()
|
||||||
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
client.connect(HOST, username=USER, password=PASS, timeout=15)
|
||||||
|
sftp = client.open_sftp()
|
||||||
|
|
||||||
|
def run(label, cmd, timeout=90):
|
||||||
|
print(f'\n[{label}]')
|
||||||
|
chan = client.get_transport().open_session()
|
||||||
|
chan.set_combine_stderr(True)
|
||||||
|
chan.exec_command(cmd)
|
||||||
|
start = time.time()
|
||||||
|
while not chan.exit_status_ready():
|
||||||
|
if chan.recv_ready():
|
||||||
|
sys.stdout.buffer.write(chan.recv(4096))
|
||||||
|
sys.stdout.flush()
|
||||||
|
if time.time() - start > timeout:
|
||||||
|
print('[TIMEOUT]')
|
||||||
|
break
|
||||||
|
time.sleep(0.2)
|
||||||
|
while chan.recv_ready():
|
||||||
|
sys.stdout.buffer.write(chan.recv(4096))
|
||||||
|
sys.stdout.flush()
|
||||||
|
rc = chan.recv_exit_status()
|
||||||
|
print(f'exit={rc}')
|
||||||
|
return rc
|
||||||
|
|
||||||
|
# 1. 디렉터리 준비
|
||||||
|
run('디렉터리 준비',
|
||||||
|
'mkdir -p /opt/zioinfo/app /var/www/zioinfo /var/log/zioinfo && '
|
||||||
|
'chown -R jenkins:jenkins /opt/zioinfo /var/www/zioinfo /var/log/zioinfo && echo ok')
|
||||||
|
|
||||||
|
# 2. JAR 업로드
|
||||||
|
print('\n[JAR 업로드 중...]')
|
||||||
|
sftp.put(LOCAL_JAR, '/opt/zioinfo/app/app.jar')
|
||||||
|
size_mb = os.path.getsize(LOCAL_JAR) // 1024 // 1024
|
||||||
|
print(f'JAR 업로드 완료: {size_mb}MB')
|
||||||
|
|
||||||
|
# 3. 정적 파일 zip 후 업로드
|
||||||
|
print('\n[정적 파일 패키징 중...]')
|
||||||
|
zip_buf = io.BytesIO()
|
||||||
|
count = 0
|
||||||
|
with zipfile.ZipFile(zip_buf, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
for root, dirs, files in os.walk(LOCAL_STATIC):
|
||||||
|
rel = os.path.relpath(root, LOCAL_STATIC).replace('\\', '/')
|
||||||
|
for fname in files:
|
||||||
|
arc = fname if rel == '.' else f'{rel}/{fname}'
|
||||||
|
zf.write(os.path.join(root, fname), arc)
|
||||||
|
count += 1
|
||||||
|
zip_buf.seek(0)
|
||||||
|
with sftp.open('/tmp/static.zip', 'wb') as f:
|
||||||
|
f.write(zip_buf.read())
|
||||||
|
print(f'{count}개 정적 파일 업로드')
|
||||||
|
|
||||||
|
# 4. 정적 파일 배포
|
||||||
|
run('정적 파일 배포',
|
||||||
|
'cd /var/www/zioinfo && unzip -q -o /tmp/static.zip && echo deployed && ls | head -5')
|
||||||
|
|
||||||
|
# 5. Spring Boot 서비스 시작
|
||||||
|
run('Spring Boot 기동',
|
||||||
|
'systemctl restart zioinfo && sleep 8 && systemctl is-active zioinfo && '
|
||||||
|
'journalctl -u zioinfo -n 8 --no-pager')
|
||||||
|
|
||||||
|
# 6. API 헬스체크
|
||||||
|
run('API 헬스체크',
|
||||||
|
'curl -s -o /dev/null -w "HTTP %{http_code}" http://localhost:8080/api/company && echo " OK"')
|
||||||
|
|
||||||
|
sftp.close()
|
||||||
|
client.close()
|
||||||
|
print('\n배포 완료!')
|
||||||
81
deploy/fix_https_8443.py
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Nginx 8443 HTTPS 설정 수정"""
|
||||||
|
import paramiko, time, sys
|
||||||
|
|
||||||
|
HOST = '101.79.17.164'; USER = 'root'; PASS = '1q2w3e!Q'
|
||||||
|
client = paramiko.SSHClient()
|
||||||
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
client.connect(HOST, username=USER, password=PASS, timeout=15)
|
||||||
|
sftp = client.open_sftp()
|
||||||
|
|
||||||
|
def run(label, cmd, timeout=20):
|
||||||
|
print(f'\n[{label}]')
|
||||||
|
chan = client.get_transport().open_session()
|
||||||
|
chan.set_combine_stderr(True)
|
||||||
|
chan.exec_command(cmd)
|
||||||
|
start = time.time()
|
||||||
|
while not chan.exit_status_ready():
|
||||||
|
if chan.recv_ready(): sys.stdout.buffer.write(chan.recv(4096)); sys.stdout.flush()
|
||||||
|
if time.time() - start > timeout: break
|
||||||
|
time.sleep(0.2)
|
||||||
|
while chan.recv_ready(): sys.stdout.buffer.write(chan.recv(4096))
|
||||||
|
sys.stdout.flush()
|
||||||
|
chan.recv_exit_status()
|
||||||
|
|
||||||
|
guardia_https = r"""server {
|
||||||
|
listen 8443 ssl;
|
||||||
|
server_name _;
|
||||||
|
ssl_certificate /etc/ssl/guardia/server.crt;
|
||||||
|
ssl_certificate_key /etc/ssl/guardia/server.key;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
|
||||||
|
ssl_prefer_server_ciphers off;
|
||||||
|
client_max_body_size 100M;
|
||||||
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||||
|
add_header X-Frame-Options DENY always;
|
||||||
|
add_header X-Content-Type-Options nosniff always;
|
||||||
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8001;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
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_set_header X-Forwarded-Proto https;
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
}
|
||||||
|
location /api/ {
|
||||||
|
limit_req zone=guardia_api burst=10 nodelay;
|
||||||
|
proxy_pass http://127.0.0.1:8001;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-Proto https;
|
||||||
|
proxy_read_timeout 60s;
|
||||||
|
}
|
||||||
|
location /ws/ {
|
||||||
|
proxy_pass http://127.0.0.1:8001;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
with sftp.open('/etc/nginx/sites-available/guardia-https', 'w') as f:
|
||||||
|
f.write(guardia_https)
|
||||||
|
sftp.close()
|
||||||
|
|
||||||
|
run('Nginx 설정 검증', 'nginx -t')
|
||||||
|
run('Nginx 리로드', 'systemctl reload nginx && echo NGINX_OK')
|
||||||
|
time.sleep(2)
|
||||||
|
run('HTTPS 8443 테스트', 'curl -sk https://localhost:8443/api/external/health -w " HTTP %{http_code}"')
|
||||||
|
run('CORS 테스트 (HTTPS)',
|
||||||
|
'curl -sk -I -X OPTIONS https://localhost:8443/api/external/health '
|
||||||
|
'-H "Origin: https://portal.myorg.go.kr" | grep -i access-control')
|
||||||
|
|
||||||
|
client.close()
|
||||||
|
print('\n완료')
|
||||||
56
deploy/fix_nginx.py
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import paramiko, time, sys
|
||||||
|
|
||||||
|
HOST = '101.79.17.164'; USER = 'root'; PASS = '1q2w3e!Q'
|
||||||
|
client = paramiko.SSHClient()
|
||||||
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
client.connect(HOST, username=USER, password=PASS, timeout=15)
|
||||||
|
sftp = client.open_sftp()
|
||||||
|
|
||||||
|
def run(cmd, timeout=20):
|
||||||
|
chan = client.get_transport().open_session()
|
||||||
|
chan.set_combine_stderr(True)
|
||||||
|
chan.exec_command(cmd)
|
||||||
|
start = time.time()
|
||||||
|
while not chan.exit_status_ready():
|
||||||
|
if chan.recv_ready(): sys.stdout.buffer.write(chan.recv(4096)); sys.stdout.flush()
|
||||||
|
if time.time() - start > timeout: break
|
||||||
|
time.sleep(0.2)
|
||||||
|
while chan.recv_ready(): sys.stdout.buffer.write(chan.recv(4096))
|
||||||
|
sys.stdout.flush()
|
||||||
|
chan.recv_exit_status()
|
||||||
|
|
||||||
|
nginx_conf = r"""server {
|
||||||
|
listen 80 default_server;
|
||||||
|
server_name _;
|
||||||
|
root /var/www/zioinfo;
|
||||||
|
index index.html;
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
add_header Cache-Control no-cache;
|
||||||
|
}
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://127.0.0.1:8082;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_read_timeout 60s;
|
||||||
|
}
|
||||||
|
location ~* \.(js|css|png|jpg|gif|ico|svg|woff2|ttf)$ {
|
||||||
|
expires 30d;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/plain text/css application/javascript application/json;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
with sftp.open('/etc/nginx/sites-available/zioinfo', 'w') as f:
|
||||||
|
f.write(nginx_conf)
|
||||||
|
sftp.close()
|
||||||
|
|
||||||
|
run('nginx -t && systemctl reload nginx && echo NGINX_OK')
|
||||||
|
run('curl -s -o /dev/null -w "HTTP %{http_code}" http://localhost/api/company && echo " via Nginx OK"')
|
||||||
|
|
||||||
|
client.close()
|
||||||
|
print('완료')
|
||||||
130
deploy/gitea_messenger.py
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""GUARDiA Messenger Gitea 저장소 생성 + Push"""
|
||||||
|
import paramiko, time, sys, os, io, zipfile
|
||||||
|
|
||||||
|
HOST = '101.79.17.164'
|
||||||
|
USER = 'root'
|
||||||
|
PASS = '1q2w3e!Q'
|
||||||
|
LOCAL_APP = 'C:/GUARDiA/app'
|
||||||
|
SEP = chr(92)
|
||||||
|
|
||||||
|
client = paramiko.SSHClient()
|
||||||
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
client.connect(HOST, username=USER, password=PASS, timeout=15)
|
||||||
|
sftp = client.open_sftp()
|
||||||
|
|
||||||
|
def run(label, cmd, timeout=60):
|
||||||
|
print(f'\n[{label}]')
|
||||||
|
chan = client.get_transport().open_session()
|
||||||
|
chan.set_combine_stderr(True)
|
||||||
|
chan.exec_command(cmd)
|
||||||
|
start = time.time()
|
||||||
|
while not chan.exit_status_ready():
|
||||||
|
if chan.recv_ready(): sys.stdout.buffer.write(chan.recv(4096)); sys.stdout.flush()
|
||||||
|
if time.time()-start > timeout: print('[TIMEOUT]'); break
|
||||||
|
time.sleep(0.3)
|
||||||
|
while chan.recv_ready(): sys.stdout.buffer.write(chan.recv(4096))
|
||||||
|
sys.stdout.flush()
|
||||||
|
chan.recv_exit_status()
|
||||||
|
|
||||||
|
# --- 1. 저장소 생성 ---
|
||||||
|
create_py = """
|
||||||
|
import urllib.request, json, base64, urllib.error
|
||||||
|
G = 'http://localhost:3000'
|
||||||
|
h = {'Content-Type':'application/json','Authorization':'Basic '+base64.b64encode(b'zio:Zio@Admin2026!').decode()}
|
||||||
|
d = json.dumps({'name':'guardia-messenger','description':'GUARDiA Messenger Mobile App','private':False,'auto_init':False}).encode()
|
||||||
|
req = urllib.request.Request(G+'/api/v1/user/repos',data=d,method='POST',headers=h)
|
||||||
|
try:
|
||||||
|
r=urllib.request.urlopen(req)
|
||||||
|
print('OK:',json.loads(r.read()).get('full_name'))
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
b=e.read(); print('HTTP',e.code,b.decode()[:100])
|
||||||
|
"""
|
||||||
|
with sftp.open('/tmp/cr.py','w') as f: f.write(create_py)
|
||||||
|
run('저장소 생성', 'python3 /tmp/cr.py')
|
||||||
|
|
||||||
|
# --- 2. 소스 패키징 ---
|
||||||
|
print('\n[소스 패키징]')
|
||||||
|
SKIP = {'node_modules','.git','android','ios','__pycache__','.expo'}
|
||||||
|
buf = io.BytesIO(); n = 0
|
||||||
|
with zipfile.ZipFile(buf,'w',zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
for root,dirs,files in os.walk(LOCAL_APP):
|
||||||
|
dirs[:] = [d for d in dirs if d not in SKIP]
|
||||||
|
rel = os.path.relpath(root,LOCAL_APP).replace(SEP,'/')
|
||||||
|
for f in files:
|
||||||
|
if f.endswith(('.pyc','.log')): continue
|
||||||
|
arc = f if rel=='.' else f'{rel}/{f}'
|
||||||
|
zf.write(os.path.join(root,f),arc); n+=1
|
||||||
|
buf.seek(0)
|
||||||
|
with sftp.open('/tmp/msg.zip','wb') as f: f.write(buf.read())
|
||||||
|
print(f' {n}개 파일')
|
||||||
|
|
||||||
|
# --- 3. git push (서버 스크립트) ---
|
||||||
|
push_py = """
|
||||||
|
import subprocess, os
|
||||||
|
|
||||||
|
os.makedirs('/tmp/msg-push', exist_ok=True)
|
||||||
|
subprocess.run(['rm','-rf','/tmp/msg-push'], check=False)
|
||||||
|
os.makedirs('/tmp/msg-push')
|
||||||
|
subprocess.run(['unzip','-q','/tmp/msg.zip','-d','/tmp/msg-push'], check=True)
|
||||||
|
|
||||||
|
env = {'HOME':'/root','PATH':'/usr/bin:/bin'}
|
||||||
|
cwd = '/tmp/msg-push'
|
||||||
|
|
||||||
|
for cmd in [
|
||||||
|
['git','init','-q'],
|
||||||
|
['git','config','user.email','ci@zioinfo.co.kr'],
|
||||||
|
['git','config','user.name','ZioCI'],
|
||||||
|
['git','add','-A'],
|
||||||
|
['git','commit','-q','-m','feat: GUARDiA Messenger v1.0.0 initial commit'],
|
||||||
|
['git','remote','add','origin','http://zio:Zio%40Admin2026%21@localhost:3000/zio/guardia-messenger.git'],
|
||||||
|
['git','push','origin','main'],
|
||||||
|
]:
|
||||||
|
r = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True)
|
||||||
|
if r.returncode != 0 and cmd[1] not in ['config','remote']:
|
||||||
|
print('ERR',cmd[1],r.stderr[:100])
|
||||||
|
else:
|
||||||
|
print('OK',cmd[1])
|
||||||
|
"""
|
||||||
|
with sftp.open('/tmp/push.py','w') as f: f.write(push_py)
|
||||||
|
run('Git Push', 'python3 /tmp/push.py', 60)
|
||||||
|
|
||||||
|
# --- 4. Webhook ---
|
||||||
|
wh_py = """
|
||||||
|
import urllib.request, json, base64
|
||||||
|
G='http://localhost:3000'
|
||||||
|
h={'Content-Type':'application/json','Authorization':'Basic '+base64.b64encode(b'zio:Zio@Admin2026!').decode()}
|
||||||
|
d=json.dumps({'type':'gitea','active':True,'config':{'url':'http://localhost:9999/','content_type':'json','secret':'zioinfo-deploy-2026'},'events':['push']}).encode()
|
||||||
|
req=urllib.request.Request(G+'/api/v1/repos/zio/guardia-messenger/hooks',data=d,method='POST',headers=h)
|
||||||
|
try:
|
||||||
|
r=urllib.request.urlopen(req); print('Webhook OK, ID:',json.loads(r.read()).get('id'))
|
||||||
|
except Exception as e: print('Webhook 오류:',e)
|
||||||
|
"""
|
||||||
|
with sftp.open('/tmp/wh.py','w') as f: f.write(wh_py)
|
||||||
|
run('Webhook 등록', 'python3 /tmp/wh.py')
|
||||||
|
|
||||||
|
# --- 5. 최종 확인 ---
|
||||||
|
check_py = """
|
||||||
|
import urllib.request, json, base64
|
||||||
|
G='http://localhost:3000'
|
||||||
|
h={'Authorization':'Basic '+base64.b64encode(b'zio:Zio@Admin2026!').decode()}
|
||||||
|
r=urllib.request.Request(G+'/api/v1/repos/zio/guardia-messenger',headers=h)
|
||||||
|
try:
|
||||||
|
d=json.loads(urllib.request.urlopen(r).read())
|
||||||
|
print('저장소:', d.get('full_name'))
|
||||||
|
print('URL: ', d.get('html_url'))
|
||||||
|
print('브랜치:', d.get('default_branch'))
|
||||||
|
except Exception as e: print('오류:',e)
|
||||||
|
|
||||||
|
# 브랜치 목록
|
||||||
|
r2=urllib.request.Request(G+'/api/v1/repos/zio/guardia-messenger/branches',headers=h)
|
||||||
|
try:
|
||||||
|
branches=json.loads(urllib.request.urlopen(r2).read())
|
||||||
|
print('브랜치 목록:', [b['name'] for b in branches])
|
||||||
|
except: pass
|
||||||
|
"""
|
||||||
|
with sftp.open('/tmp/check.py','w') as f: f.write(check_py)
|
||||||
|
sftp.close()
|
||||||
|
run('최종 확인', 'python3 /tmp/check.py')
|
||||||
|
|
||||||
|
client.close()
|
||||||
74
deploy/guardia_mail.py
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import smtplib
|
||||||
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
TO = 'ythong86@gmail.com'
|
||||||
|
FROM = 'guardia@zioinfo.co.kr'
|
||||||
|
NOW = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
|
||||||
|
text = (
|
||||||
|
'GUARDiA ITSM 이메일 발송 테스트\n\n'
|
||||||
|
'발신 서버: mail.zioinfo.co.kr (101.79.17.164)\n'
|
||||||
|
'발신자: guardia@zioinfo.co.kr\n'
|
||||||
|
'발송 시각: ' + NOW + ' KST\n'
|
||||||
|
'인증: SPF PASS + DKIM 서명\n\n'
|
||||||
|
'zio 서버 Postfix + OpenDKIM이 정상 작동 중입니다.\n\n'
|
||||||
|
'(주)지오정보기술 | GUARDiA ITSM v2.0\n'
|
||||||
|
'http://101.79.17.164:8001\n'
|
||||||
|
)
|
||||||
|
|
||||||
|
html = (
|
||||||
|
'<!DOCTYPE html><html lang="ko"><head><meta charset="UTF-8"></head>'
|
||||||
|
'<body style="font-family:sans-serif;background:#f0f2f5;padding:30px 20px;margin:0">'
|
||||||
|
'<div style="max-width:560px;margin:0 auto;background:#fff;border-radius:12px;overflow:hidden;box-shadow:0 4px 20px rgba(0,0,0,.1)">'
|
||||||
|
'<div style="background:#1a3a6b;padding:24px 32px">'
|
||||||
|
'<h1 style="color:#fff;margin:0;font-size:20px">🛡️ GUARDiA ITSM</h1>'
|
||||||
|
'<p style="color:#aac4e8;margin:4px 0 0;font-size:12px">(주)지오정보기술 AI 인프라 자율 운영 플랫폼</p>'
|
||||||
|
'</div>'
|
||||||
|
'<div style="padding:28px 32px">'
|
||||||
|
'<h2 style="color:#1a3a6b;margin:0 0 14px">📧 이메일 발송 테스트</h2>'
|
||||||
|
'<div style="background:#f0fdf4;border:1px solid #bbf7d0;border-radius:8px;padding:14px 18px;margin-bottom:20px">'
|
||||||
|
'<p style="color:#166534;font-weight:700;margin:0 0 4px">✅ 발송 성공</p>'
|
||||||
|
'<p style="color:#475569;font-size:12px;margin:0">SPF + DKIM 인증을 통과하여 발송되었습니다.</p>'
|
||||||
|
'</div>'
|
||||||
|
'<table style="width:100%;border-collapse:collapse;font-size:13px">'
|
||||||
|
'<tr style="background:#f8fafc"><td style="padding:9px 14px;font-weight:600;color:#64748b;border:1px solid #e2e8f0;width:32%">발신 서버</td><td style="padding:9px 14px;border:1px solid #e2e8f0">mail.zioinfo.co.kr (101.79.17.164)</td></tr>'
|
||||||
|
'<tr><td style="padding:9px 14px;font-weight:600;color:#64748b;border:1px solid #e2e8f0">발신자</td><td style="padding:9px 14px;border:1px solid #e2e8f0">guardia@zioinfo.co.kr</td></tr>'
|
||||||
|
'<tr style="background:#f8fafc"><td style="padding:9px 14px;font-weight:600;color:#64748b;border:1px solid #e2e8f0">발송 시각</td>'
|
||||||
|
'<td style="padding:9px 14px;border:1px solid #e2e8f0">' + NOW + ' KST</td></tr>'
|
||||||
|
'<tr><td style="padding:9px 14px;font-weight:600;color:#64748b;border:1px solid #e2e8f0">SPF 인증</td><td style="padding:9px 14px;border:1px solid #e2e8f0"><span style="background:#dcfce7;color:#16a34a;padding:2px 8px;border-radius:10px;font-size:11px;font-weight:600">✅ PASS</span></td></tr>'
|
||||||
|
'<tr style="background:#f8fafc"><td style="padding:9px 14px;font-weight:600;color:#64748b;border:1px solid #e2e8f0">DKIM 서명</td><td style="padding:9px 14px;border:1px solid #e2e8f0"><span style="background:#dcfce7;color:#16a34a;padding:2px 8px;border-radius:10px;font-size:11px;font-weight:600">✅ 서명됨</span></td></tr>'
|
||||||
|
'</table>'
|
||||||
|
'<div style="margin-top:20px;padding:14px 18px;background:#eff2ff;border-radius:8px;border:1px solid #c7d2fe">'
|
||||||
|
'<p style="color:#1a3a6b;font-weight:700;margin:0 0 8px;font-size:13px">🤖 GUARDiA ITSM 알림 기능</p>'
|
||||||
|
'<ul style="color:#475569;font-size:12px;margin:0;padding-left:18px;line-height:2">'
|
||||||
|
'<li>SR 접수/완료 알림</li>'
|
||||||
|
'<li>인시던트 긴급 알림</li>'
|
||||||
|
'<li>SLA 위반 경고</li>'
|
||||||
|
'<li>배포 완료/실패 알림</li>'
|
||||||
|
'<li>라이선스 만료 알림</li>'
|
||||||
|
'</ul></div>'
|
||||||
|
'</div>'
|
||||||
|
'<div style="background:#f8fafc;padding:14px 32px;border-top:1px solid #e2e8f0;text-align:center;font-size:11px;color:#94a3b8">'
|
||||||
|
'GUARDiA ITSM v2.0 | (주)지오정보기술 | guardia@zioinfo.co.kr'
|
||||||
|
'</div></div></body></html>'
|
||||||
|
)
|
||||||
|
|
||||||
|
msg = MIMEMultipart('alternative')
|
||||||
|
msg['Subject'] = '[GUARDiA] SMTP 이메일 발송 테스트 - ' + NOW
|
||||||
|
msg['From'] = 'GUARDiA ITSM <' + FROM + '>'
|
||||||
|
msg['To'] = TO
|
||||||
|
msg['X-Mailer'] = 'GUARDiA ITSM v2.0'
|
||||||
|
msg.attach(MIMEText(text, 'plain', 'utf-8'))
|
||||||
|
msg.attach(MIMEText(html, 'html', 'utf-8'))
|
||||||
|
|
||||||
|
try:
|
||||||
|
with smtplib.SMTP('localhost', 25, timeout=15) as smtp:
|
||||||
|
smtp.ehlo('mail.zioinfo.co.kr')
|
||||||
|
smtp.sendmail(FROM, [TO], msg.as_string())
|
||||||
|
print('OK 발송 완료 - ythong86@gmail.com 수신함을 확인하세요!')
|
||||||
|
print('발신: ' + FROM + ' → ' + TO)
|
||||||
|
print('시각: ' + NOW + ' KST')
|
||||||
|
except Exception as ex:
|
||||||
|
print('FAIL:', ex)
|
||||||
125
deploy/guardia_mail_v2.py
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
"""
|
||||||
|
GUARDiA 이메일 발송 v2 — 스팸 점수 최소화
|
||||||
|
- Reply-To 헤더 추가
|
||||||
|
- List-Unsubscribe 헤더 추가
|
||||||
|
- 적절한 Message-ID
|
||||||
|
- 텍스트 본문 충실히 작성
|
||||||
|
- HTML 과도한 스타일 제거
|
||||||
|
"""
|
||||||
|
import smtplib, socket
|
||||||
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
from email.utils import formatdate, make_msgid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
TO = 'ythong86@gmail.com'
|
||||||
|
FROM = 'guardia@zioinfo.co.kr'
|
||||||
|
NOW = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
DOMAIN = 'zioinfo.co.kr'
|
||||||
|
|
||||||
|
text = (
|
||||||
|
'GUARDiA ITSM 이메일 발송 테스트\n\n'
|
||||||
|
'안녕하세요,\n\n'
|
||||||
|
'(주)지오정보기술 GUARDiA ITSM 시스템에서 발송한 테스트 메일입니다.\n\n'
|
||||||
|
'[발송 정보]\n'
|
||||||
|
'- 발신자: guardia@zioinfo.co.kr\n'
|
||||||
|
'- 발신 서버: mail.zioinfo.co.kr (101.79.17.164)\n'
|
||||||
|
'- 발송 시각: ' + NOW + ' KST\n'
|
||||||
|
'- 인증: SPF PASS + DKIM 서명\n\n'
|
||||||
|
'[GUARDiA ITSM 이메일 알림 기능]\n'
|
||||||
|
'- SR(서비스 요청) 접수 및 완료 알림\n'
|
||||||
|
'- 인시던트 발생 긴급 알림\n'
|
||||||
|
'- SLA 위반 경고 알림\n'
|
||||||
|
'- 배포 완료/실패 알림\n'
|
||||||
|
'- 라이선스 만료 알림\n\n'
|
||||||
|
'이 메일은 GUARDiA ITSM 시스템 테스트 목적으로 발송되었습니다.\n\n'
|
||||||
|
'--\n'
|
||||||
|
'(주)지오정보기술\n'
|
||||||
|
'GUARDiA ITSM v2.0\n'
|
||||||
|
'guardia@zioinfo.co.kr\n'
|
||||||
|
'http://101.79.17.164:8001\n'
|
||||||
|
)
|
||||||
|
|
||||||
|
html = (
|
||||||
|
'<!DOCTYPE html>\n'
|
||||||
|
'<html lang="ko">\n'
|
||||||
|
'<head>\n'
|
||||||
|
' <meta charset="UTF-8">\n'
|
||||||
|
' <meta name="viewport" content="width=device-width, initial-scale=1.0">\n'
|
||||||
|
' <title>GUARDiA ITSM</title>\n'
|
||||||
|
'</head>\n'
|
||||||
|
'<body style="margin:0;padding:20px;font-family:Arial,sans-serif;background:#f5f5f5;color:#333">\n'
|
||||||
|
'<table width="100%" cellpadding="0" cellspacing="0" border="0">\n'
|
||||||
|
'<tr><td align="center">\n'
|
||||||
|
'<table width="560" cellpadding="0" cellspacing="0" border="0" style="background:#ffffff;border-radius:6px;overflow:hidden">\n'
|
||||||
|
|
||||||
|
'<!-- 헤더 -->\n'
|
||||||
|
'<tr><td style="background:#1a3a6b;padding:20px 30px">\n'
|
||||||
|
' <h1 style="color:#ffffff;margin:0;font-size:18px;font-weight:bold">GUARDiA ITSM</h1>\n'
|
||||||
|
' <p style="color:#aac4e8;margin:4px 0 0;font-size:12px">(주)지오정보기술 AI 인프라 자율 운영 플랫폼</p>\n'
|
||||||
|
'</td></tr>\n'
|
||||||
|
|
||||||
|
'<!-- 본문 -->\n'
|
||||||
|
'<tr><td style="padding:24px 30px">\n'
|
||||||
|
' <h2 style="color:#1a3a6b;font-size:16px;margin:0 0 12px">이메일 발송 테스트</h2>\n'
|
||||||
|
' <p style="margin:0 0 16px;line-height:1.6">안녕하세요,<br>\n'
|
||||||
|
' (주)지오정보기술 GUARDiA ITSM 시스템 테스트 메일입니다.</p>\n'
|
||||||
|
|
||||||
|
' <table width="100%" cellpadding="8" cellspacing="0" border="0" style="border-collapse:collapse;font-size:13px;margin-bottom:16px">\n'
|
||||||
|
' <tr style="background:#f8f8f8">\n'
|
||||||
|
' <td style="border:1px solid #e0e0e0;font-weight:bold;color:#555;width:32%">발신자</td>\n'
|
||||||
|
' <td style="border:1px solid #e0e0e0;color:#333">guardia@zioinfo.co.kr</td>\n'
|
||||||
|
' </tr>\n'
|
||||||
|
' <tr>\n'
|
||||||
|
' <td style="border:1px solid #e0e0e0;font-weight:bold;color:#555">발송 시각</td>\n'
|
||||||
|
' <td style="border:1px solid #e0e0e0;color:#333">' + NOW + ' KST</td>\n'
|
||||||
|
' </tr>\n'
|
||||||
|
' <tr style="background:#f8f8f8">\n'
|
||||||
|
' <td style="border:1px solid #e0e0e0;font-weight:bold;color:#555">SPF 인증</td>\n'
|
||||||
|
' <td style="border:1px solid #e0e0e0;color:#2e7d32">PASS</td>\n'
|
||||||
|
' </tr>\n'
|
||||||
|
' <tr>\n'
|
||||||
|
' <td style="border:1px solid #e0e0e0;font-weight:bold;color:#555">DKIM 서명</td>\n'
|
||||||
|
' <td style="border:1px solid #e0e0e0;color:#2e7d32">서명됨</td>\n'
|
||||||
|
' </tr>\n'
|
||||||
|
' </table>\n'
|
||||||
|
|
||||||
|
' <p style="margin:0;font-size:13px;color:#666;line-height:1.6">'
|
||||||
|
'GUARDiA ITSM은 SR 접수, 인시던트 알림, SLA 경고, 배포 알림 등을 이메일로 전송합니다.</p>\n'
|
||||||
|
'</td></tr>\n'
|
||||||
|
|
||||||
|
'<!-- 푸터 -->\n'
|
||||||
|
'<tr><td style="background:#f8f8f8;padding:14px 30px;border-top:1px solid #e0e0e0;text-align:center">\n'
|
||||||
|
' <p style="margin:0;font-size:11px;color:#999">\n'
|
||||||
|
' (주)지오정보기술 | guardia@zioinfo.co.kr | http://101.79.17.164:8001\n'
|
||||||
|
' </p>\n'
|
||||||
|
'</td></tr>\n'
|
||||||
|
'</table>\n'
|
||||||
|
'</td></tr>\n'
|
||||||
|
'</table>\n'
|
||||||
|
'</body>\n'
|
||||||
|
'</html>\n'
|
||||||
|
)
|
||||||
|
|
||||||
|
msg = MIMEMultipart('alternative')
|
||||||
|
msg['Subject'] = '[GUARDiA] 이메일 발송 테스트 - ' + NOW
|
||||||
|
msg['From'] = '(주)지오정보기술 GUARDiA <' + FROM + '>'
|
||||||
|
msg['To'] = TO
|
||||||
|
msg['Reply-To'] = FROM
|
||||||
|
msg['Date'] = formatdate(localtime=True)
|
||||||
|
msg['Message-ID'] = make_msgid(domain=DOMAIN)
|
||||||
|
msg['List-Unsubscribe'] = '<mailto:' + FROM + '?subject=unsubscribe>'
|
||||||
|
msg['X-Mailer'] = 'GUARDiA ITSM v2.0'
|
||||||
|
|
||||||
|
msg.attach(MIMEText(text, 'plain', 'utf-8'))
|
||||||
|
msg.attach(MIMEText(html, 'html', 'utf-8'))
|
||||||
|
|
||||||
|
try:
|
||||||
|
with smtplib.SMTP('localhost', 25, timeout=15) as smtp:
|
||||||
|
smtp.ehlo('mail.zioinfo.co.kr')
|
||||||
|
smtp.sendmail(FROM, [TO], msg.as_string())
|
||||||
|
print('OK 발송 완료')
|
||||||
|
print('수신: ' + TO)
|
||||||
|
print('시각: ' + NOW + ' KST')
|
||||||
|
except Exception as ex:
|
||||||
|
print('FAIL:', ex)
|
||||||
107
deploy/guardia_mail_v3.py
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
"""
|
||||||
|
GUARDiA 이메일 발송 v3 — 피싱 경고 제거
|
||||||
|
핵심 수정: IP 직접 링크 제거, 도메인 사용, 간결한 본문
|
||||||
|
"""
|
||||||
|
import smtplib
|
||||||
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
from email.utils import formatdate, make_msgid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
TO = 'ythong86@gmail.com'
|
||||||
|
FROM = 'guardia@zioinfo.co.kr'
|
||||||
|
DOMAIN = 'zioinfo.co.kr'
|
||||||
|
NOW = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
|
||||||
|
# 텍스트 본문 — IP 링크 없음, 간결하게
|
||||||
|
text = (
|
||||||
|
'안녕하세요,\n\n'
|
||||||
|
'(주)지오정보기술 GUARDiA ITSM 메일 서버 테스트입니다.\n\n'
|
||||||
|
'이 메일은 zio 서버의 Postfix + OpenDKIM 정상 동작을 확인하기 위해 발송되었습니다.\n\n'
|
||||||
|
'발신자: guardia@' + DOMAIN + '\n'
|
||||||
|
'발송 시각: ' + NOW + ' KST\n\n'
|
||||||
|
'-- \n'
|
||||||
|
'(주)지오정보기술\n'
|
||||||
|
'guardia@' + DOMAIN + '\n'
|
||||||
|
)
|
||||||
|
|
||||||
|
# HTML 본문 — IP 링크 완전 제거, 심플하게
|
||||||
|
html = (
|
||||||
|
'<!DOCTYPE html>\n'
|
||||||
|
'<html lang="ko">\n'
|
||||||
|
'<head><meta charset="UTF-8"><title>GUARDiA ITSM</title></head>\n'
|
||||||
|
'<body style="margin:0;padding:20px;font-family:Arial,sans-serif;'
|
||||||
|
'background:#f5f5f5;color:#333333">\n'
|
||||||
|
|
||||||
|
'<table width="560" cellpadding="0" cellspacing="0" border="0" '
|
||||||
|
'align="center" style="background:#ffffff;border-radius:6px">\n'
|
||||||
|
|
||||||
|
' <tr>\n'
|
||||||
|
' <td style="background:#1a3a6b;padding:20px 28px;border-radius:6px 6px 0 0">\n'
|
||||||
|
' <p style="color:#ffffff;margin:0;font-size:18px;font-weight:bold">'
|
||||||
|
'GUARDiA ITSM</p>\n'
|
||||||
|
' <p style="color:#aac4e8;margin:4px 0 0;font-size:12px">'
|
||||||
|
'(주)지오정보기술</p>\n'
|
||||||
|
' </td>\n'
|
||||||
|
' </tr>\n'
|
||||||
|
|
||||||
|
' <tr>\n'
|
||||||
|
' <td style="padding:24px 28px">\n'
|
||||||
|
' <p style="margin:0 0 12px;font-size:15px;color:#1a3a6b;font-weight:bold">'
|
||||||
|
'메일 서버 테스트</p>\n'
|
||||||
|
' <p style="margin:0 0 16px;font-size:13px;line-height:1.7;color:#444">\n'
|
||||||
|
' zio 서버의 메일 서비스가 정상 동작하고 있습니다.<br>\n'
|
||||||
|
' 본 메일은 시스템 점검 목적으로 발송되었습니다.\n'
|
||||||
|
' </p>\n'
|
||||||
|
' <table cellpadding="7" cellspacing="0" border="0" '
|
||||||
|
'style="border-collapse:collapse;font-size:13px;width:100%">\n'
|
||||||
|
' <tr style="background:#f8f8f8">\n'
|
||||||
|
' <td style="border:1px solid #e0e0e0;color:#666;width:30%">발신자</td>\n'
|
||||||
|
' <td style="border:1px solid #e0e0e0;color:#333">'
|
||||||
|
'guardia@' + DOMAIN + '</td>\n'
|
||||||
|
' </tr>\n'
|
||||||
|
' <tr>\n'
|
||||||
|
' <td style="border:1px solid #e0e0e0;color:#666">발송 시각</td>\n'
|
||||||
|
' <td style="border:1px solid #e0e0e0;color:#333">' + NOW + ' KST</td>\n'
|
||||||
|
' </tr>\n'
|
||||||
|
' <tr style="background:#f8f8f8">\n'
|
||||||
|
' <td style="border:1px solid #e0e0e0;color:#666">SPF / DKIM</td>\n'
|
||||||
|
' <td style="border:1px solid #e0e0e0;color:#2e7d32">인증 완료</td>\n'
|
||||||
|
' </tr>\n'
|
||||||
|
' </table>\n'
|
||||||
|
' </td>\n'
|
||||||
|
' </tr>\n'
|
||||||
|
|
||||||
|
' <tr>\n'
|
||||||
|
' <td style="padding:12px 28px;border-top:1px solid #eeeeee;'
|
||||||
|
'text-align:center;font-size:11px;color:#999999;border-radius:0 0 6px 6px">\n'
|
||||||
|
' (주)지오정보기술 | guardia@' + DOMAIN + '\n'
|
||||||
|
' </td>\n'
|
||||||
|
' </tr>\n'
|
||||||
|
|
||||||
|
'</table>\n'
|
||||||
|
'</body>\n'
|
||||||
|
'</html>\n'
|
||||||
|
)
|
||||||
|
|
||||||
|
msg = MIMEMultipart('alternative')
|
||||||
|
msg['Subject'] = '[GUARDiA] 메일 서버 테스트 - ' + NOW
|
||||||
|
msg['From'] = '(주)지오정보기술 GUARDiA <' + FROM + '>'
|
||||||
|
msg['To'] = TO
|
||||||
|
msg['Reply-To'] = FROM
|
||||||
|
msg['Date'] = formatdate(localtime=True)
|
||||||
|
msg['Message-ID'] = make_msgid(domain=DOMAIN)
|
||||||
|
msg['List-Unsubscribe'] = '<mailto:' + FROM + '?subject=unsubscribe>'
|
||||||
|
|
||||||
|
msg.attach(MIMEText(text, 'plain', 'utf-8'))
|
||||||
|
msg.attach(MIMEText(html, 'html', 'utf-8'))
|
||||||
|
|
||||||
|
try:
|
||||||
|
with smtplib.SMTP('localhost', 25, timeout=15) as smtp:
|
||||||
|
smtp.ehlo('mail.' + DOMAIN)
|
||||||
|
smtp.sendmail(FROM, [TO], msg.as_string())
|
||||||
|
print('OK 발송 완료 - ' + TO)
|
||||||
|
print('시각: ' + NOW + ' KST')
|
||||||
|
print('IP 링크 완전 제거 / SPF+DKIM 적용')
|
||||||
|
except Exception as ex:
|
||||||
|
print('FAIL:', ex)
|
||||||
110
deploy/install_ai.py
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""AI 플랫폼 설치 스크립트"""
|
||||||
|
import paramiko, time, sys
|
||||||
|
|
||||||
|
HOST='101.79.17.164'; USER='root'; PASS='1q2w3e!Q'
|
||||||
|
client = paramiko.SSHClient()
|
||||||
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
client.connect(HOST, username=USER, password=PASS, timeout=15)
|
||||||
|
sftp = client.open_sftp()
|
||||||
|
|
||||||
|
def run(label, cmd, timeout=300):
|
||||||
|
print(f'\n[{label}]')
|
||||||
|
chan = client.get_transport().open_session()
|
||||||
|
chan.set_combine_stderr(True)
|
||||||
|
chan.exec_command(cmd)
|
||||||
|
start = time.time()
|
||||||
|
while not chan.exit_status_ready():
|
||||||
|
if chan.recv_ready(): sys.stdout.buffer.write(chan.recv(4096)); sys.stdout.flush()
|
||||||
|
if time.time()-start > timeout: print('[TIMEOUT]'); break
|
||||||
|
time.sleep(0.5)
|
||||||
|
while chan.recv_ready(): sys.stdout.buffer.write(chan.recv(4096))
|
||||||
|
sys.stdout.flush()
|
||||||
|
rc = chan.recv_exit_status()
|
||||||
|
print(f'exit={rc}'); return rc
|
||||||
|
|
||||||
|
install_script = """#!/bin/bash
|
||||||
|
VENV=/opt/guardia/venv/bin/pip
|
||||||
|
$VENV install -q --upgrade langchain langchain-community langchain-ollama chromadb sentence-transformers
|
||||||
|
$VENV install -q langgraph langchain-chroma
|
||||||
|
echo DONE
|
||||||
|
"""
|
||||||
|
with sftp.open('/tmp/install_ai.sh', 'w') as f:
|
||||||
|
f.write(install_script)
|
||||||
|
sftp.close()
|
||||||
|
|
||||||
|
run('AI 패키지 설치', 'bash /tmp/install_ai.sh 2>&1 | tail -10', 300)
|
||||||
|
|
||||||
|
# 검증 스크립트
|
||||||
|
verify = """
|
||||||
|
import sys
|
||||||
|
results = []
|
||||||
|
for pkg in ['langchain', 'chromadb', 'langchain_community', 'langchain_ollama', 'langgraph']:
|
||||||
|
try:
|
||||||
|
m = __import__(pkg)
|
||||||
|
ver = getattr(m, '__version__', 'ok')
|
||||||
|
results.append('OK ' + pkg + '==' + str(ver))
|
||||||
|
except ImportError as e:
|
||||||
|
results.append('FAIL ' + pkg + ': ' + str(e))
|
||||||
|
|
||||||
|
for r in results:
|
||||||
|
print(r)
|
||||||
|
ok_cnt = sum(1 for r in results if r.startswith('OK'))
|
||||||
|
print('\\n결과: ' + str(ok_cnt) + '/' + str(len(results)) + ' PASS')
|
||||||
|
"""
|
||||||
|
sftp = client.open_sftp()
|
||||||
|
with sftp.open('/tmp/verify_ai.py', 'w') as f:
|
||||||
|
f.write(verify)
|
||||||
|
sftp.close()
|
||||||
|
|
||||||
|
run('설치 검증', '/opt/guardia/venv/bin/python3 /tmp/verify_ai.py')
|
||||||
|
|
||||||
|
# 임베딩 모델 다운로드 확인
|
||||||
|
run('nomic-embed-text 확인', 'ollama list | grep -i nomic || echo "not yet downloaded"')
|
||||||
|
|
||||||
|
# 코드베이스 임베딩 스크립트 생성
|
||||||
|
embed_script = '''#!/usr/bin/env python3
|
||||||
|
"""GUARDiA 코드베이스를 ChromaDB에 임베딩"""
|
||||||
|
import os, sys
|
||||||
|
os.environ["ANONYMIZED_TELEMETRY"] = "False"
|
||||||
|
|
||||||
|
from langchain_community.document_loaders import DirectoryLoader, TextLoader
|
||||||
|
from langchain.text_splitter import RecursiveCharacterTextSplitter
|
||||||
|
from langchain_ollama import OllamaEmbeddings
|
||||||
|
from langchain_chroma import Chroma
|
||||||
|
|
||||||
|
APP_DIR = "/opt/guardia/app"
|
||||||
|
VDB_DIR = "/opt/guardia/vectordb"
|
||||||
|
|
||||||
|
print("문서 로딩...")
|
||||||
|
loader = DirectoryLoader(APP_DIR, glob="**/*.py",
|
||||||
|
loader_cls=TextLoader, loader_kwargs={"encoding":"utf-8", "autodetect_encoding":True})
|
||||||
|
docs = loader.load()
|
||||||
|
print(f"로드된 파일: {len(docs)}개")
|
||||||
|
|
||||||
|
splitter = RecursiveCharacterTextSplitter(chunk_size=800, chunk_overlap=100)
|
||||||
|
chunks = splitter.split_documents(docs)
|
||||||
|
print(f"청크 수: {len(chunks)}")
|
||||||
|
|
||||||
|
embeddings = OllamaEmbeddings(model="nomic-embed-text", base_url="http://localhost:11434")
|
||||||
|
|
||||||
|
print("임베딩 중 (시간 소요)...")
|
||||||
|
vectordb = Chroma.from_documents(chunks[:50], embeddings, # 처음 50개만 테스트
|
||||||
|
persist_directory=VDB_DIR, collection_name="guardia_codebase")
|
||||||
|
print(f"임베딩 완료. 저장 위치: {VDB_DIR}")
|
||||||
|
|
||||||
|
# 테스트 검색
|
||||||
|
results = vectordb.similarity_search("라이선스 키 등록", k=2)
|
||||||
|
print("\\n테스트 검색 결과:")
|
||||||
|
for r in results:
|
||||||
|
print(f" - {r.metadata.get('source','?')}: {r.page_content[:60]}...")
|
||||||
|
'''
|
||||||
|
|
||||||
|
sftp = client.open_sftp()
|
||||||
|
with sftp.open('/opt/guardia/app/scripts/embed_codebase.py', 'w') as f:
|
||||||
|
f.write(embed_script)
|
||||||
|
run('embed_codebase.py 생성', 'ls /opt/guardia/app/scripts/')
|
||||||
|
sftp.close()
|
||||||
|
|
||||||
|
client.close()
|
||||||
|
print('\nAI 플랫폼 설치 완료')
|
||||||
140
deploy/login_test.py
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""GUARDiA ITSM 로그인 방식 확인 및 라이선스 테스트"""
|
||||||
|
import paramiko, time, sys, json
|
||||||
|
|
||||||
|
HOST = '101.79.17.164'; USER = 'root'; PASS = '1q2w3e!Q'
|
||||||
|
client = paramiko.SSHClient()
|
||||||
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
client.connect(HOST, username=USER, password=PASS, timeout=15)
|
||||||
|
sftp = client.open_sftp()
|
||||||
|
|
||||||
|
test_py = '''#!/usr/bin/env python3
|
||||||
|
import urllib.request, json
|
||||||
|
|
||||||
|
BASE = "http://localhost:8001"
|
||||||
|
|
||||||
|
# 로그인 방식 탐색
|
||||||
|
login_attempts = [
|
||||||
|
("JSON username/password", "POST", "/api/auth/login",
|
||||||
|
json.dumps({"username":"admin","password":"1111"}).encode(), "application/json"),
|
||||||
|
("JSON username/password (Admin2026)", "POST", "/api/auth/login",
|
||||||
|
json.dumps({"username":"admin","password":"Admin@2026!"}).encode(), "application/json"),
|
||||||
|
("Form data", "POST", "/api/auth/login",
|
||||||
|
"username=admin&password=1111".encode(), "application/x-www-form-urlencoded"),
|
||||||
|
]
|
||||||
|
|
||||||
|
TOKEN = None
|
||||||
|
for name, method, path, body, ct in login_attempts:
|
||||||
|
req = urllib.request.Request(f"{BASE}{path}", data=body, method=method)
|
||||||
|
req.add_header("Content-Type", ct)
|
||||||
|
try:
|
||||||
|
resp = urllib.request.urlopen(req, timeout=5)
|
||||||
|
d = json.loads(resp.read())
|
||||||
|
if "access_token" in d:
|
||||||
|
TOKEN = d["access_token"]
|
||||||
|
print(f"OK 로그인 성공 [{name}]: token={TOKEN[:20]}...")
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
print(f"FAIL [{name}]: {d}")
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print(f"FAIL [{name}] HTTP {e.code}: {e.read().decode()[:100]}")
|
||||||
|
|
||||||
|
if not TOKEN:
|
||||||
|
print("모든 로그인 시도 실패")
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
# 라이선스 API 테스트
|
||||||
|
def api(method, path, data=None):
|
||||||
|
url = f"{BASE}{path}"
|
||||||
|
body = json.dumps(data).encode() if data else None
|
||||||
|
req = urllib.request.Request(url, data=body, method=method)
|
||||||
|
req.add_header("Content-Type", "application/json")
|
||||||
|
req.add_header("Authorization", f"Bearer {TOKEN}")
|
||||||
|
try:
|
||||||
|
resp = urllib.request.urlopen(req, timeout=10)
|
||||||
|
return json.loads(resp.read()), resp.status
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return json.loads(e.read()), e.code
|
||||||
|
|
||||||
|
print("\\n=== 라이선스 API 테스트 ===")
|
||||||
|
RESULTS = []
|
||||||
|
|
||||||
|
def test(name, fn):
|
||||||
|
try:
|
||||||
|
r = fn(); ok = True
|
||||||
|
except Exception as ex:
|
||||||
|
r = str(ex); ok = False
|
||||||
|
RESULTS.append((name, ok, str(r)[:80]))
|
||||||
|
print(f\'{"OK" if ok else "FAIL"} {name}: {str(r)[:80]}\')
|
||||||
|
return r if ok else None
|
||||||
|
|
||||||
|
# T2: 현재 상태
|
||||||
|
test("T2 라이선스 현재 상태", lambda: api("GET", "/api/license/status")[0].get("message"))
|
||||||
|
|
||||||
|
# T3: 체험 라이선스 (없으면 발급)
|
||||||
|
cur, _ = api("GET", "/api/license/status")
|
||||||
|
if not cur.get("activated"):
|
||||||
|
def t3():
|
||||||
|
r,s = api("POST", "/api/license/trial", {"customer":"지오정보기술 체험판","days":7})
|
||||||
|
return f"HTTP {s}: {r.get(\'message\', r.get(\'detail\', \'?\'))}"
|
||||||
|
test("T3 체험 라이선스 발급 (7일)", t3)
|
||||||
|
else:
|
||||||
|
RESULTS.append(("T3 체험 라이선스 발급", True, "이미 활성화됨"))
|
||||||
|
print("SKIP T3: 이미 활성화됨")
|
||||||
|
|
||||||
|
# T4: 활성화 후 상태
|
||||||
|
def t4():
|
||||||
|
r,_ = api("GET", "/api/license/status")
|
||||||
|
return f"valid={r.get(\'valid\')}, edition={r.get(\'edition\')}, days={r.get(\'days_remaining\')}"
|
||||||
|
test("T4 활성화 후 상태", t4)
|
||||||
|
|
||||||
|
# T5: 이력
|
||||||
|
def t5():
|
||||||
|
r,s = api("GET", "/api/license/history")
|
||||||
|
return f"HTTP {s}: {len(r) if isinstance(r,list) else \'?\'}건"
|
||||||
|
test("T5 라이선스 이력 조회", t5)
|
||||||
|
|
||||||
|
# T6: 잘못된 키 검증
|
||||||
|
def t6():
|
||||||
|
r,s = api("POST", "/api/license/verify", {"license_key":"invalid_key"})
|
||||||
|
return f"HTTP {s}: {r.get(\'detail\', r.get(\'message\',\'?\'))[:50]}"
|
||||||
|
test("T6 잘못된 키 검증 (400/422 예상)", t6)
|
||||||
|
|
||||||
|
# T7: Manager UI
|
||||||
|
try:
|
||||||
|
resp7 = urllib.request.urlopen("http://localhost:8090/", timeout=5)
|
||||||
|
test("T7 Manager UI", lambda: f"HTTP {resp7.status}")
|
||||||
|
except Exception as ex:
|
||||||
|
test("T7 Manager UI", lambda: f"ERROR: {str(ex)[:50]}")
|
||||||
|
|
||||||
|
# T8: Manager API
|
||||||
|
try:
|
||||||
|
resp8 = urllib.request.urlopen("http://localhost:8002/health", timeout=5)
|
||||||
|
d8 = json.loads(resp8.read())
|
||||||
|
test("T8 Manager Backend", lambda: d8.get("status","?"))
|
||||||
|
except Exception as ex:
|
||||||
|
test("T8 Manager Backend", lambda: f"ERROR: {str(ex)[:50]}")
|
||||||
|
|
||||||
|
print(f"\\n{'='*55}")
|
||||||
|
passed = sum(1 for _,ok,_ in RESULTS if ok)
|
||||||
|
print(f"결과: {passed}/{len(RESULTS)} PASS")
|
||||||
|
for name,ok,detail in RESULTS:
|
||||||
|
print(f\' {"OK" if ok else "FAIL"} {name}\')
|
||||||
|
print("="*55)
|
||||||
|
'''
|
||||||
|
with sftp.open('/tmp/test_lic2.py', 'w') as f:
|
||||||
|
f.write(test_py)
|
||||||
|
sftp.close()
|
||||||
|
|
||||||
|
chan = client.get_transport().open_session()
|
||||||
|
chan.set_combine_stderr(True)
|
||||||
|
chan.exec_command('python3 /tmp/test_lic2.py 2>&1')
|
||||||
|
start = time.time()
|
||||||
|
while not chan.exit_status_ready():
|
||||||
|
if chan.recv_ready(): sys.stdout.buffer.write(chan.recv(4096)); sys.stdout.flush()
|
||||||
|
if time.time() - start > 30: break
|
||||||
|
time.sleep(0.2)
|
||||||
|
while chan.recv_ready(): sys.stdout.buffer.write(chan.recv(4096))
|
||||||
|
sys.stdout.flush()
|
||||||
|
chan.recv_exit_status()
|
||||||
|
client.close()
|
||||||
171
deploy/nginx_opennet.py
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Nginx 개방망 설정 배포 스크립트"""
|
||||||
|
import paramiko, sys, time
|
||||||
|
|
||||||
|
HOST = '101.79.17.164'; USER = 'root'; PASS = '1q2w3e!Q'
|
||||||
|
client = paramiko.SSHClient()
|
||||||
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
client.connect(HOST, username=USER, password=PASS, timeout=15)
|
||||||
|
sftp = client.open_sftp()
|
||||||
|
|
||||||
|
def run(label, cmd, timeout=30):
|
||||||
|
print(f'\n[{label}]')
|
||||||
|
chan = client.get_transport().open_session()
|
||||||
|
chan.set_combine_stderr(True)
|
||||||
|
chan.exec_command(cmd)
|
||||||
|
start = time.time()
|
||||||
|
while not chan.exit_status_ready():
|
||||||
|
if chan.recv_ready(): sys.stdout.buffer.write(chan.recv(4096)); sys.stdout.flush()
|
||||||
|
if time.time() - start > timeout: print('[TIMEOUT]'); break
|
||||||
|
time.sleep(0.2)
|
||||||
|
while chan.recv_ready(): sys.stdout.buffer.write(chan.recv(4096))
|
||||||
|
sys.stdout.flush()
|
||||||
|
rc = chan.recv_exit_status()
|
||||||
|
print(f'exit={rc}'); return rc
|
||||||
|
|
||||||
|
# GUARDiA ITSM HTTPS 설정 (포트 443 + 8443)
|
||||||
|
guardia_https_nginx = r"""# ── GUARDiA ITSM HTTP→HTTPS 리다이렉트 ────────────────────────────────────
|
||||||
|
server {
|
||||||
|
listen 8001;
|
||||||
|
server_name _;
|
||||||
|
return 301 https://$host:8443$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── GUARDiA ITSM HTTPS ─────────────────────────────────────────────────────
|
||||||
|
server {
|
||||||
|
listen 8443 ssl;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
ssl_certificate /etc/ssl/guardia/server.crt;
|
||||||
|
ssl_certificate_key /etc/ssl/guardia/server.key;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
|
||||||
|
ssl_prefer_server_ciphers off;
|
||||||
|
ssl_session_cache shared:SSL:10m;
|
||||||
|
ssl_session_timeout 1d;
|
||||||
|
|
||||||
|
client_max_body_size 100M;
|
||||||
|
|
||||||
|
# 보안 헤더
|
||||||
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||||
|
add_header X-Frame-Options DENY always;
|
||||||
|
add_header X-Content-Type-Options nosniff always;
|
||||||
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
|
||||||
|
# Rate Limiting (개방망)
|
||||||
|
limit_req_zone $binary_remote_addr zone=guardia_api:10m rate=30r/m;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8001;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
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_set_header X-Forwarded-Proto https;
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
limit_req zone=guardia_api burst=10 nodelay;
|
||||||
|
proxy_pass http://127.0.0.1:8001;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
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_set_header X-Forwarded-Proto https;
|
||||||
|
proxy_read_timeout 60s;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api/external/ {
|
||||||
|
limit_req zone=guardia_api burst=5 nodelay;
|
||||||
|
proxy_pass http://127.0.0.1:8001;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-Proto https;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /ws/ {
|
||||||
|
proxy_pass http://127.0.0.1:8001;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
# 홈페이지 HTTPS 설정 (포트 443)
|
||||||
|
zioinfo_https_nginx = r"""# ── 지오정보기술 홈페이지 HTTP→HTTPS ──────────────────────────────────────
|
||||||
|
server {
|
||||||
|
listen 80 default_server;
|
||||||
|
server_name _;
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── 지오정보기술 홈페이지 HTTPS ────────────────────────────────────────────
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
ssl_certificate /etc/ssl/guardia/server.crt;
|
||||||
|
ssl_certificate_key /etc/ssl/guardia/server.key;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
|
||||||
|
ssl_prefer_server_ciphers off;
|
||||||
|
|
||||||
|
root /var/www/zioinfo;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
add_header Strict-Transport-Security "max-age=31536000" always;
|
||||||
|
add_header X-Frame-Options SAMEORIGIN always;
|
||||||
|
add_header X-Content-Type-Options nosniff always;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
add_header Cache-Control no-cache;
|
||||||
|
}
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://127.0.0.1:8082;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-Proto https;
|
||||||
|
proxy_read_timeout 60s;
|
||||||
|
}
|
||||||
|
location ~* \.(js|css|png|jpg|gif|ico|svg|woff2)$ {
|
||||||
|
expires 30d;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/plain text/css application/javascript application/json;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
with sftp.open('/etc/nginx/sites-available/guardia-https', 'w') as f:
|
||||||
|
f.write(guardia_https_nginx)
|
||||||
|
with sftp.open('/etc/nginx/sites-available/zioinfo-https', 'w') as f:
|
||||||
|
f.write(zioinfo_https_nginx)
|
||||||
|
sftp.close()
|
||||||
|
|
||||||
|
# 심볼릭 링크 교체
|
||||||
|
run('Nginx 사이트 활성화',
|
||||||
|
'ln -sf /etc/nginx/sites-available/guardia-https /etc/nginx/sites-enabled/guardia && '
|
||||||
|
'ln -sf /etc/nginx/sites-available/zioinfo-https /etc/nginx/sites-enabled/zioinfo && '
|
||||||
|
'rm -f /etc/nginx/sites-enabled/guardia-https /etc/nginx/sites-enabled/zioinfo-https && '
|
||||||
|
'echo links_ok')
|
||||||
|
|
||||||
|
run('UFW 443/8443 포트 오픈',
|
||||||
|
'ufw allow 443/tcp && ufw allow 8443/tcp && echo ufw_ok')
|
||||||
|
|
||||||
|
run('Nginx 설정 검증',
|
||||||
|
'nginx -t')
|
||||||
|
|
||||||
|
run('Nginx 리로드',
|
||||||
|
'systemctl reload nginx && echo nginx_reloaded')
|
||||||
|
|
||||||
|
client.close()
|
||||||
|
print('\nHTTPS 설정 완료')
|
||||||
126
deploy/notif_test_server.py
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""서버에서 직접 실행되는 알림 테스트 스크립트"""
|
||||||
|
import paramiko, time, sys
|
||||||
|
|
||||||
|
HOST='101.79.17.164'; USER='root'; PASS='1q2w3e!Q'
|
||||||
|
client = paramiko.SSHClient()
|
||||||
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
client.connect(HOST, username=USER, password=PASS, timeout=15)
|
||||||
|
sftp = client.open_sftp()
|
||||||
|
|
||||||
|
script = r"""
|
||||||
|
import http.client, json, sys, time
|
||||||
|
|
||||||
|
HOST = 'localhost'
|
||||||
|
PORT = 8001
|
||||||
|
RESULTS = []
|
||||||
|
|
||||||
|
def api(method, path, data=None, token=None):
|
||||||
|
conn = http.client.HTTPConnection(HOST, PORT, timeout=10)
|
||||||
|
headers = {'Content-Type':'application/json'}
|
||||||
|
if token: headers['Authorization'] = 'Bearer ' + token
|
||||||
|
body = json.dumps(data).encode() if data else None
|
||||||
|
conn.request(method, path, body=body, headers=headers)
|
||||||
|
r = conn.getresponse()
|
||||||
|
raw = r.read()
|
||||||
|
try: return json.loads(raw), r.status
|
||||||
|
except: return {'raw': raw.decode()[:200]}, r.status
|
||||||
|
|
||||||
|
def test(name, fn):
|
||||||
|
try:
|
||||||
|
r = fn(); ok = True
|
||||||
|
except Exception as e:
|
||||||
|
r = str(e)[:80]; ok = False
|
||||||
|
RESULTS.append((name, ok, str(r)[:80]))
|
||||||
|
print(('PASS' if ok else 'FAIL') + ' ' + name + ': ' + str(r)[:80])
|
||||||
|
return r if ok else None
|
||||||
|
|
||||||
|
print()
|
||||||
|
print('='*60)
|
||||||
|
print('GUARDiA ITSM -> Messenger 알림 송수신 테스트')
|
||||||
|
print('='*60)
|
||||||
|
|
||||||
|
# T1: 로그인
|
||||||
|
d, s = api('POST', '/api/auth/login', {'username':'admin','password':'1111'})
|
||||||
|
TOKEN = d.get('access_token','')
|
||||||
|
test('T1 관리자 로그인', lambda: 'OK token=' + TOKEN[:15] + '...' if TOKEN else 'FAIL: ' + str(d))
|
||||||
|
|
||||||
|
# T2: WebSocket 연결 수 확인
|
||||||
|
test('T2 WebSocket 상태', lambda: str(api('GET','/api/ws/status',token=TOKEN)))
|
||||||
|
|
||||||
|
# T3: SR 등록 → 이벤트 발생
|
||||||
|
import hashlib; sr_suffix = hashlib.md5(str(time.time()).encode()).hexdigest()[:6].upper()
|
||||||
|
def t3():
|
||||||
|
d, s = api('POST','/api/tasks', {
|
||||||
|
'title': f'[알림테스트] Messenger 연동 SR-{sr_suffix}',
|
||||||
|
'description': 'GUARDiA Messenger WebSocket 알림 연동 테스트. 앱 알림탭에서 확인하세요.',
|
||||||
|
'priority':'MEDIUM', 'sr_type':'OTHER'}, token=TOKEN)
|
||||||
|
return f'HTTP {s} sr_id={d.get("sr_id","?")} title={d.get("title","?")[:30]}'
|
||||||
|
test('T3 SR 등록 (WebSocket 이벤트 트리거)', t3)
|
||||||
|
|
||||||
|
# T4: 알림 로그 조회
|
||||||
|
def t4():
|
||||||
|
d, s = api('GET','/api/notifications/log?size=5',token=TOKEN)
|
||||||
|
cnt = len(d) if isinstance(d,list) else '?'
|
||||||
|
return f'HTTP {s} count={cnt}'
|
||||||
|
test('T4 알림 로그 조회', t4)
|
||||||
|
|
||||||
|
# T5: 메신저 알림 테스트
|
||||||
|
def t5():
|
||||||
|
d, s = api('POST','/api/notifications/test-messenger',token=TOKEN)
|
||||||
|
return f'HTTP {s}: {d.get("message",d.get("detail",str(d)[:50]))}'
|
||||||
|
test('T5 메신저 알림 테스트 API', t5)
|
||||||
|
|
||||||
|
# T6: 이메일 알림 테스트 (ythong86@gmail.com)
|
||||||
|
def t6():
|
||||||
|
d, s = api('POST','/api/notifications/test-email',
|
||||||
|
{'email':'ythong86@gmail.com','subject':'GUARDiA Messenger 알림 테스트',
|
||||||
|
'message':'GUARDiA Messenger 앱이 정상적으로 ITSM과 연동되었습니다.'},
|
||||||
|
token=TOKEN)
|
||||||
|
return f'HTTP {s}: {d.get("message",str(d)[:50])}'
|
||||||
|
test('T6 이메일 알림 발송 (ythong86@gmail.com)', t6)
|
||||||
|
|
||||||
|
# T7: 대시보드 API (앱 메인화면 연동)
|
||||||
|
def t7():
|
||||||
|
d, s = api('GET','/api/dashboard',token=TOKEN)
|
||||||
|
keys = list(d.keys())[:4]
|
||||||
|
return f'HTTP {s} keys={keys}'
|
||||||
|
test('T7 대시보드 API (앱 연동)', t7)
|
||||||
|
|
||||||
|
# T8: SR 목록 조회 (앱 SR탭 연동)
|
||||||
|
def t8():
|
||||||
|
d, s = api('GET','/api/tasks?size=3',token=TOKEN)
|
||||||
|
cnt = d.get('total_elements', d.get('total', '?'))
|
||||||
|
return f'HTTP {s} total={cnt}'
|
||||||
|
test('T8 SR 목록 API (앱 SR탭 연동)', t8)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print('='*60)
|
||||||
|
passed = sum(1 for _,ok,_ in RESULTS if ok)
|
||||||
|
print(f'결과: {passed}/{len(RESULTS)} PASS')
|
||||||
|
for name,ok,d in RESULTS:
|
||||||
|
print(('OK ' if ok else 'FAIL') + ' ' + name)
|
||||||
|
print('='*60)
|
||||||
|
print()
|
||||||
|
print('[Messenger 앱 알림 확인 방법]')
|
||||||
|
print('1. 앱 실행 -> 알림 탭')
|
||||||
|
print('2. 상단: "GUARDiA 실시간 연결" 초록 표시 확인')
|
||||||
|
print('3. SR 등록 즉시 -> 알림 목록에 [실시간] 배지로 표시')
|
||||||
|
print('4. 배포 실행 시 -> 배포 완료/실패 알림 자동 수신')
|
||||||
|
"""
|
||||||
|
|
||||||
|
with sftp.open('/tmp/notif_test.py','w') as f: f.write(script)
|
||||||
|
sftp.close()
|
||||||
|
|
||||||
|
chan = client.get_transport().open_session()
|
||||||
|
chan.set_combine_stderr(True)
|
||||||
|
chan.exec_command('python3 /tmp/notif_test.py 2>&1')
|
||||||
|
start = time.time()
|
||||||
|
while not chan.exit_status_ready():
|
||||||
|
if chan.recv_ready(): sys.stdout.buffer.write(chan.recv(4096)); sys.stdout.flush()
|
||||||
|
if time.time()-start > 30: break
|
||||||
|
time.sleep(0.3)
|
||||||
|
while chan.recv_ready(): sys.stdout.buffer.write(chan.recv(4096))
|
||||||
|
sys.stdout.flush()
|
||||||
|
chan.recv_exit_status()
|
||||||
|
client.close()
|
||||||
164
deploy/notif_test_v2.py
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""GUARDiA 알림 테스트 v2 - 정확한 API 경로 사용"""
|
||||||
|
import paramiko, time, sys
|
||||||
|
|
||||||
|
HOST='101.79.17.164'; USER='root'; PASS='1q2w3e!Q'
|
||||||
|
client = paramiko.SSHClient()
|
||||||
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
client.connect(HOST, username=USER, password=PASS, timeout=15)
|
||||||
|
sftp = client.open_sftp()
|
||||||
|
|
||||||
|
script = r"""
|
||||||
|
import http.client, json, sys, time, secrets
|
||||||
|
|
||||||
|
HOST = 'localhost'; PORT = 8001
|
||||||
|
RESULTS = []
|
||||||
|
|
||||||
|
def api(method, path, data=None, token=None):
|
||||||
|
conn = http.client.HTTPConnection(HOST, PORT, timeout=10)
|
||||||
|
headers = {'Content-Type':'application/json'}
|
||||||
|
if token: headers['Authorization'] = 'Bearer ' + token
|
||||||
|
body = json.dumps(data).encode() if data else None
|
||||||
|
conn.request(method, path, body=body, headers=headers)
|
||||||
|
r = conn.getresponse()
|
||||||
|
raw = r.read()
|
||||||
|
try: return json.loads(raw), r.status
|
||||||
|
except: return {'raw': raw.decode()[:200]}, r.status
|
||||||
|
|
||||||
|
def test(name, fn):
|
||||||
|
try:
|
||||||
|
r = fn(); ok = True
|
||||||
|
except Exception as e: r = str(e)[:80]; ok = False
|
||||||
|
RESULTS.append((name, ok, str(r)[:100]))
|
||||||
|
print(('PASS' if ok else 'FAIL') + ' ' + name + ': ' + str(r)[:90])
|
||||||
|
return r if ok else None
|
||||||
|
|
||||||
|
print()
|
||||||
|
print('='*65)
|
||||||
|
print('GUARDiA ITSM -> Messenger 알림 송수신 테스트 v2')
|
||||||
|
print('='*65)
|
||||||
|
|
||||||
|
# T1: 로그인
|
||||||
|
d, s = api('POST', '/api/auth/login', {'username':'admin','password':'1111'})
|
||||||
|
TOKEN = d.get('access_token','')
|
||||||
|
test('T1 관리자 로그인', lambda: f'HTTP {s} OK' if TOKEN else f'FAIL: {d}')
|
||||||
|
|
||||||
|
# T2: WebSocket 연결 수 (앱이 연결되면 1 이상)
|
||||||
|
def t2():
|
||||||
|
d, s = api('GET','/api/ws/status',token=TOKEN)
|
||||||
|
conns = d.get('total_connections', d.get('connection_count', 0))
|
||||||
|
msg = f'연결 수={conns}'
|
||||||
|
if conns == 0: msg += ' (앱이 서버에 연결되면 숫자 증가)'
|
||||||
|
return f'HTTP {s} {msg}'
|
||||||
|
test('T2 WebSocket 연결 상태', t2)
|
||||||
|
|
||||||
|
# T3: 올바른 SR 등록 API 탐색
|
||||||
|
def t3():
|
||||||
|
# tasks API 확인
|
||||||
|
d, s = api('GET','/api/tasks?size=1',token=TOKEN)
|
||||||
|
if isinstance(d, list):
|
||||||
|
return f'HTTP {s} list형태 SR {len(d)}건'
|
||||||
|
elif isinstance(d, dict):
|
||||||
|
cnt = d.get('total_elements', d.get('total', d.get('count','?')))
|
||||||
|
return f'HTTP {s} total={cnt}'
|
||||||
|
return f'HTTP {s} {type(d).__name__}'
|
||||||
|
test('T3 SR 목록 조회 (앱 SR탭)', t3)
|
||||||
|
|
||||||
|
# T4: SR 신규 등록 (올바른 필드)
|
||||||
|
sr_id_suffix = secrets.token_hex(3).upper()
|
||||||
|
def t4():
|
||||||
|
# GUARDiA ITSM의 nlcmd (자연어 명령) 또는 tasks로 SR 등록
|
||||||
|
# 기관 코드 없이 등록 가능한지 확인
|
||||||
|
d, s = api('POST','/api/tasks', {
|
||||||
|
'title': f'[알림테스트] Messenger 연동 {sr_id_suffix}',
|
||||||
|
'description': 'GUARDiA Messenger WebSocket 알림 연동 테스트',
|
||||||
|
'priority':'MEDIUM',
|
||||||
|
'sr_type':'INQUIRY',
|
||||||
|
'inst_id': None,
|
||||||
|
}, token=TOKEN)
|
||||||
|
if s in [200,201]:
|
||||||
|
return f'HTTP {s} SR등록: {d.get("sr_id","?")} -> WebSocket 브로드캐스트 발생'
|
||||||
|
return f'HTTP {s}: {d.get("detail","?")[:60]}'
|
||||||
|
test('T4 SR 등록 (WebSocket 이벤트 트리거)', t4)
|
||||||
|
|
||||||
|
# T5: 실시간 이벤트 직접 브로드캐스트 (내부 API)
|
||||||
|
def t5():
|
||||||
|
# SSE/WebSocket 직접 브로드캐스트 테스트
|
||||||
|
d, s = api('POST','/api/notifications/test-messenger', token=TOKEN)
|
||||||
|
return f'HTTP {s}: {str(d)[:80]}'
|
||||||
|
test('T5 메신저 알림 테스트 (내부)', t5)
|
||||||
|
|
||||||
|
# T6: 알림 로그 확인 (이벤트 기록)
|
||||||
|
def t6():
|
||||||
|
d, s = api('GET','/api/notifications/log?size=10', token=TOKEN)
|
||||||
|
if isinstance(d, list):
|
||||||
|
return f'HTTP {s} {len(d)}건 로그'
|
||||||
|
return f'HTTP {s} {d.get("total","?")}건'
|
||||||
|
test('T6 알림 로그 확인', t6)
|
||||||
|
|
||||||
|
# T7: 감사 로그에서 이벤트 확인
|
||||||
|
def t7():
|
||||||
|
d, s = api('GET','/api/audit?size=3', token=TOKEN)
|
||||||
|
if isinstance(d, list) and len(d) > 0:
|
||||||
|
latest = d[0]
|
||||||
|
return f'HTTP {s} 최신: {latest.get("action","?")} by {latest.get("username","?")}'
|
||||||
|
elif isinstance(d, dict):
|
||||||
|
items = d.get('items', d.get('content', []))
|
||||||
|
if items:
|
||||||
|
return f'HTTP {s} 최신: {items[0].get("action","?")} by {items[0].get("username","?")}'
|
||||||
|
return f'HTTP {s} {type(d).__name__}'
|
||||||
|
test('T7 감사 로그 (이벤트 추적)', t7)
|
||||||
|
|
||||||
|
# T8: AI 챗봇 API (앱 챗탭 연동)
|
||||||
|
def t8():
|
||||||
|
d, s = api('POST','/api/chatbot/message',
|
||||||
|
{'message':'안녕하세요, Messenger 연동 테스트입니다.'},
|
||||||
|
token=TOKEN)
|
||||||
|
reply = d.get('reply', d.get('message', d.get('response', str(d)[:50])))
|
||||||
|
return f'HTTP {s} 응답: {str(reply)[:60]}'
|
||||||
|
test('T8 AI 챗봇 API (앱 챗탭 연동)', t8)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print('='*65)
|
||||||
|
passed = sum(1 for _,ok,_ in RESULTS if ok)
|
||||||
|
print(f'결과: {passed}/{len(RESULTS)} PASS')
|
||||||
|
print()
|
||||||
|
for name,ok,detail in RESULTS:
|
||||||
|
print(f' {"OK " if ok else "FAIL"} {name}')
|
||||||
|
print()
|
||||||
|
print('='*65)
|
||||||
|
print()
|
||||||
|
print('[앱 알림 동작 흐름]')
|
||||||
|
print()
|
||||||
|
print(' GUARDiA ITSM GUARDiA Messenger 앱')
|
||||||
|
print(' | |')
|
||||||
|
print(' SR 등록 (POST /api/tasks) WebSocket 연결됨')
|
||||||
|
print(' | |')
|
||||||
|
print(' events.broadcast() 호출 이벤트 수신 (lastEvent)')
|
||||||
|
print(' | |')
|
||||||
|
print(' ws.broadcast() 호출 알림 탭에 즉시 표시')
|
||||||
|
print(' | |')
|
||||||
|
print(' tb_notification_log 저장 ⚡ 실시간 배지 표시')
|
||||||
|
print()
|
||||||
|
print('[Messenger 앱에서 확인할 내용]')
|
||||||
|
print('1. 알림 탭 상단: "GUARDiA 실시간 연결" + 초록 깜빡임')
|
||||||
|
print('2. SR 등록 즉시: ⚡ 실시간 배지와 함께 알림 항목 추가')
|
||||||
|
print('3. 배포 실행 시: 🚀 배포 완료 알림 자동 표시')
|
||||||
|
print('4. 인시던트 발생: 🚨 긴급 알림 즉시 수신')
|
||||||
|
"""
|
||||||
|
|
||||||
|
with sftp.open('/tmp/notif_v2.py','w') as f: f.write(script)
|
||||||
|
sftp.close()
|
||||||
|
|
||||||
|
chan = client.get_transport().open_session()
|
||||||
|
chan.set_combine_stderr(True)
|
||||||
|
chan.exec_command('python3 /tmp/notif_v2.py 2>&1')
|
||||||
|
start = time.time()
|
||||||
|
while not chan.exit_status_ready():
|
||||||
|
if chan.recv_ready(): sys.stdout.buffer.write(chan.recv(8192)); sys.stdout.flush()
|
||||||
|
if time.time()-start > 40: break
|
||||||
|
time.sleep(0.3)
|
||||||
|
while chan.recv_ready(): sys.stdout.buffer.write(chan.recv(8192))
|
||||||
|
sys.stdout.flush()
|
||||||
|
chan.recv_exit_status()
|
||||||
|
client.close()
|
||||||
138
deploy/send_guardia_mail.py
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""GUARDiA 이메일 발송 스크립트 — 서버에서 직접 실행"""
|
||||||
|
import paramiko, time, sys
|
||||||
|
|
||||||
|
HOST = '101.79.17.164'; USER = 'root'; PASS = '1q2w3e!Q'
|
||||||
|
client = paramiko.SSHClient()
|
||||||
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
client.connect(HOST, username=USER, password=PASS, timeout=15)
|
||||||
|
sftp = client.open_sftp()
|
||||||
|
|
||||||
|
# 서버에서 실행할 Python 스크립트를 파일로 저장
|
||||||
|
mail_script = r"""
|
||||||
|
import smtplib
|
||||||
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
TO = 'ythong86@gmail.com'
|
||||||
|
FROM = 'guardia@zioinfo.co.kr'
|
||||||
|
NOW = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
|
||||||
|
text_body = """안녕하세요,
|
||||||
|
|
||||||
|
GUARDiA ITSM 이메일 발송 테스트입니다.
|
||||||
|
|
||||||
|
발신 서버: mail.zioinfo.co.kr (101.79.17.164)
|
||||||
|
발신자: guardia@zioinfo.co.kr
|
||||||
|
발송 시각: """ + NOW + """ KST
|
||||||
|
인증: SPF PASS + DKIM 서명
|
||||||
|
|
||||||
|
이 메일이 수신되면 GUARDiA SMTP 서버가 정상 작동하는 것입니다.
|
||||||
|
|
||||||
|
(주)지오정보기술 | GUARDiA ITSM v2.0
|
||||||
|
http://101.79.17.164:8001
|
||||||
|
"""
|
||||||
|
|
||||||
|
html_body = """<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head><meta charset="UTF-8"></head>
|
||||||
|
<body style="font-family:-apple-system,sans-serif;background:#f0f2f5;padding:30px 20px;margin:0">
|
||||||
|
<div style="max-width:560px;margin:0 auto;background:#fff;border-radius:12px;box-shadow:0 4px 20px rgba(0,0,0,.1);overflow:hidden">
|
||||||
|
<div style="background:#1a3a6b;padding:24px 32px;display:flex;align-items:center;gap:14px">
|
||||||
|
<span style="font-size:36px">🛡</span>
|
||||||
|
<div>
|
||||||
|
<h1 style="color:#fff;margin:0;font-size:20px;font-weight:800">GUARDiA ITSM</h1>
|
||||||
|
<p style="color:#aac4e8;margin:0;font-size:12px">(주)지오정보기술 AI 인프라 자율 운영 플랫폼</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="padding:28px 32px">
|
||||||
|
<h2 style="color:#1a3a6b;margin:0 0 6px;font-size:18px">📧 이메일 발송 테스트</h2>
|
||||||
|
<p style="color:#64748b;font-size:13px;margin:0 0 20px">zio 서버 SMTP 서버 정상 동작 확인</p>
|
||||||
|
<div style="background:#f0fdf4;border:1px solid #bbf7d0;border-radius:8px;padding:14px 18px;margin-bottom:20px">
|
||||||
|
<p style="color:#166534;font-weight:700;margin:0 0 3px;font-size:14px">✅ 발송 성공</p>
|
||||||
|
<p style="color:#475569;font-size:12px;margin:0">SPF · DKIM 인증을 통과하여 정상 발송되었습니다.</p>
|
||||||
|
</div>
|
||||||
|
<table style="width:100%;border-collapse:collapse;font-size:13px">
|
||||||
|
<tr style="background:#f8fafc">
|
||||||
|
<td style="padding:9px 14px;font-weight:600;color:#64748b;border:1px solid #e2e8f0;width:32%">발신 서버</td>
|
||||||
|
<td style="padding:9px 14px;color:#1e293b;border:1px solid #e2e8f0">mail.zioinfo.co.kr (101.79.17.164)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:9px 14px;font-weight:600;color:#64748b;border:1px solid #e2e8f0">발신자</td>
|
||||||
|
<td style="padding:9px 14px;color:#1e293b;border:1px solid #e2e8f0">guardia@zioinfo.co.kr</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="background:#f8fafc">
|
||||||
|
<td style="padding:9px 14px;font-weight:600;color:#64748b;border:1px solid #e2e8f0">발송 시각</td>
|
||||||
|
<td style="padding:9px 14px;color:#1e293b;border:1px solid #e2e8f0">""" + NOW + """ KST</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:9px 14px;font-weight:600;color:#64748b;border:1px solid #e2e8f0">SPF 인증</td>
|
||||||
|
<td style="padding:9px 14px;border:1px solid #e2e8f0">
|
||||||
|
<span style="background:#dcfce7;color:#16a34a;padding:2px 8px;border-radius:10px;font-size:11px;font-weight:600">✅ PASS</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="background:#f8fafc">
|
||||||
|
<td style="padding:9px 14px;font-weight:600;color:#64748b;border:1px solid #e2e8f0">DKIM 서명</td>
|
||||||
|
<td style="padding:9px 14px;border:1px solid #e2e8f0">
|
||||||
|
<span style="background:#dcfce7;color:#16a34a;padding:2px 8px;border-radius:10px;font-size:11px;font-weight:600">✅ 서명됨</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<div style="margin-top:22px;padding:14px 18px;background:#eff2ff;border-radius:8px;border:1px solid #c7d2fe">
|
||||||
|
<p style="color:#1a3a6b;font-weight:700;margin:0 0 8px;font-size:13px">🤖 GUARDiA ITSM 이메일 알림 기능</p>
|
||||||
|
<ul style="color:#475569;font-size:12px;margin:0;padding-left:18px;line-height:2">
|
||||||
|
<li>SR 접수/완료 알림</li>
|
||||||
|
<li>인시던트 긴급 알림</li>
|
||||||
|
<li>SLA 위반 경고</li>
|
||||||
|
<li>배포 완료/실패 알림</li>
|
||||||
|
<li>라이선스 만료 알림</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="background:#f8fafc;padding:14px 32px;border-top:1px solid #e2e8f0;text-align:center;font-size:11px;color:#94a3b8">
|
||||||
|
GUARDiA ITSM v2.0 자동 발송 | (주)지오정보기술<br>
|
||||||
|
guardia@zioinfo.co.kr | http://101.79.17.164:8001
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body></html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
msg = MIMEMultipart('alternative')
|
||||||
|
msg['Subject'] = '[GUARDiA] SMTP 이메일 발송 테스트 - ' + NOW
|
||||||
|
msg['From'] = 'GUARDiA ITSM <' + FROM + '>'
|
||||||
|
msg['To'] = TO
|
||||||
|
msg['X-Mailer'] = 'GUARDiA ITSM v2.0'
|
||||||
|
msg.attach(MIMEText(text_body, 'plain', 'utf-8'))
|
||||||
|
msg.attach(MIMEText(html_body, 'html', 'utf-8'))
|
||||||
|
|
||||||
|
try:
|
||||||
|
with smtplib.SMTP('localhost', 25, timeout=15) as smtp:
|
||||||
|
smtp.ehlo('mail.zioinfo.co.kr')
|
||||||
|
refused = smtp.sendmail(FROM, [TO], msg.as_string())
|
||||||
|
if refused:
|
||||||
|
print('FAIL 거부:', refused)
|
||||||
|
else:
|
||||||
|
print('OK 발송 완료! ythong86@gmail.com 수신함을 확인하세요.')
|
||||||
|
print('발신: ' + FROM)
|
||||||
|
print('발송 시각: ' + NOW)
|
||||||
|
except Exception as ex:
|
||||||
|
print('FAIL:', ex)
|
||||||
|
"""
|
||||||
|
|
||||||
|
with sftp.open('/tmp/guardia_mail.py', 'w') as f:
|
||||||
|
f.write(mail_script)
|
||||||
|
sftp.close()
|
||||||
|
|
||||||
|
chan = client.get_transport().open_session()
|
||||||
|
chan.set_combine_stderr(True)
|
||||||
|
chan.exec_command('python3 /tmp/guardia_mail.py 2>&1')
|
||||||
|
start = time.time()
|
||||||
|
while not chan.exit_status_ready():
|
||||||
|
if chan.recv_ready(): sys.stdout.buffer.write(chan.recv(4096)); sys.stdout.flush()
|
||||||
|
if time.time() - start > 20: break
|
||||||
|
time.sleep(0.3)
|
||||||
|
while chan.recv_ready(): sys.stdout.buffer.write(chan.recv(4096))
|
||||||
|
sys.stdout.flush()
|
||||||
|
chan.recv_exit_status()
|
||||||
|
client.close()
|
||||||
146
deploy/send_test_email.py
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
zio 서버 SMTP(localhost:25/587)를 통해 이메일 발송 테스트
|
||||||
|
서버에서 직접 실행
|
||||||
|
"""
|
||||||
|
import smtplib, ssl, socket
|
||||||
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
TO = 'ythong86@gmail.com'
|
||||||
|
FROM = 'guardia@zioinfo.co.kr'
|
||||||
|
HOST = 'localhost'
|
||||||
|
|
||||||
|
def make_message(to, frm, subject, body_text, body_html=None):
|
||||||
|
msg = MIMEMultipart('alternative')
|
||||||
|
msg['Subject'] = subject
|
||||||
|
msg['From'] = f'GUARDiA ITSM <{frm}>'
|
||||||
|
msg['To'] = to
|
||||||
|
msg['X-Mailer']= 'GUARDiA ITSM v2.0'
|
||||||
|
msg.attach(MIMEText(body_text, 'plain', 'utf-8'))
|
||||||
|
if body_html:
|
||||||
|
msg.attach(MIMEText(body_html, 'html', 'utf-8'))
|
||||||
|
return msg
|
||||||
|
|
||||||
|
html_body = f"""
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head><meta charset="UTF-8"></head>
|
||||||
|
<body style="font-family: -apple-system, sans-serif; background:#f0f2f5; padding:40px 20px;">
|
||||||
|
<div style="max-width:560px; margin:0 auto; background:#fff; border-radius:12px;
|
||||||
|
box-shadow:0 4px 20px rgba(0,0,0,.1); overflow:hidden;">
|
||||||
|
<!-- 헤더 -->
|
||||||
|
<div style="background:#1a3a6b; padding:28px 32px;">
|
||||||
|
<div style="display:flex; align-items:center; gap:12px;">
|
||||||
|
<span style="font-size:32px;">🛡️</span>
|
||||||
|
<div>
|
||||||
|
<h1 style="color:#fff; margin:0; font-size:20px;">GUARDiA ITSM</h1>
|
||||||
|
<p style="color:#aac4e8; margin:0; font-size:12px;">(주)지오정보기술 AI 인프라 자율 운영 플랫폼</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 본문 -->
|
||||||
|
<div style="padding:32px;">
|
||||||
|
<h2 style="color:#1a3a6b; margin:0 0 16px;">📧 이메일 발송 테스트</h2>
|
||||||
|
<div style="background:#f0fdf4; border:1px solid #bbf7d0; border-radius:8px;
|
||||||
|
padding:16px 20px; margin-bottom:20px;">
|
||||||
|
<p style="color:#166534; font-weight:700; margin:0 0 4px;">✅ 발송 성공</p>
|
||||||
|
<p style="color:#475569; font-size:13px; margin:0;">
|
||||||
|
zio 서버의 Postfix SMTP 서버를 통해 이메일이 정상 발송되었습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<table style="width:100%; border-collapse:collapse; font-size:13px;">
|
||||||
|
<tr style="background:#f8fafc;">
|
||||||
|
<td style="padding:10px 14px; font-weight:600; color:#64748b; border:1px solid #e2e8f0; width:30%;">발신 서버</td>
|
||||||
|
<td style="padding:10px 14px; color:#1e293b; border:1px solid #e2e8f0;">mail.zioinfo.co.kr (101.79.17.164)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:10px 14px; font-weight:600; color:#64748b; border:1px solid #e2e8f0;">발신자</td>
|
||||||
|
<td style="padding:10px 14px; color:#1e293b; border:1px solid #e2e8f0;">guardia@zioinfo.co.kr</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="background:#f8fafc;">
|
||||||
|
<td style="padding:10px 14px; font-weight:600; color:#64748b; border:1px solid #e2e8f0;">발송 시각</td>
|
||||||
|
<td style="padding:10px 14px; color:#1e293b; border:1px solid #e2e8f0;">{datetime.now().strftime('%Y-%m-%d %H:%M:%S')} KST</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:10px 14px; font-weight:600; color:#64748b; border:1px solid #e2e8f0;">SMTP 포트</td>
|
||||||
|
<td style="padding:10px 14px; color:#1e293b; border:1px solid #e2e8f0;">25 (Postfix)</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="background:#f8fafc;">
|
||||||
|
<td style="padding:10px 14px; font-weight:600; color:#64748b; border:1px solid #e2e8f0;">서버 OS</td>
|
||||||
|
<td style="padding:10px 14px; color:#1e293b; border:1px solid #e2e8f0;">Ubuntu 24.04 LTS (NCloud)</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div style="margin-top:24px; padding:16px 20px; background:#eff2ff; border-radius:8px;
|
||||||
|
border:1px solid #c7d2fe;">
|
||||||
|
<p style="color:#1a3a6b; font-weight:700; margin:0 0 8px;">🤖 GUARDiA ITSM 기능 안내</p>
|
||||||
|
<ul style="color:#475569; font-size:12px; margin:0; padding-left:18px; line-height:2;">
|
||||||
|
<li>SR(서비스 요청) 접수 알림</li>
|
||||||
|
<li>인시던트 발생 및 긴급 알림</li>
|
||||||
|
<li>SLA 위반 경고</li>
|
||||||
|
<li>배포 완료/실패 알림</li>
|
||||||
|
<li>라이선스 만료 알림</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 푸터 -->
|
||||||
|
<div style="background:#f8fafc; padding:16px 32px; border-top:1px solid #e2e8f0;
|
||||||
|
text-align:center; font-size:11px; color:#94a3b8;">
|
||||||
|
이 메일은 GUARDiA ITSM 자동 발송 시스템에서 발송되었습니다.<br>
|
||||||
|
(주)지오정보기술 | guardia@zioinfo.co.kr | http://101.79.17.164:8001
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
text_body = f"""GUARDiA ITSM 이메일 발송 테스트
|
||||||
|
|
||||||
|
✅ 발송 성공
|
||||||
|
zio 서버의 Postfix SMTP 서버를 통해 이메일이 정상 발송되었습니다.
|
||||||
|
|
||||||
|
발신 서버: mail.zioinfo.co.kr (101.79.17.164)
|
||||||
|
발신자: guardia@zioinfo.co.kr
|
||||||
|
발송 시각: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} KST
|
||||||
|
SMTP 포트: 25 (Postfix)
|
||||||
|
|
||||||
|
GUARDiA ITSM 기능:
|
||||||
|
- SR(서비스 요청) 접수 알림
|
||||||
|
- 인시던트 발생 및 긴급 알림
|
||||||
|
- SLA 위반 경고
|
||||||
|
- 배포 완료/실패 알림
|
||||||
|
|
||||||
|
(주)지오정보기술 | guardia@zioinfo.co.kr
|
||||||
|
"""
|
||||||
|
|
||||||
|
print(f'수신자: {TO}')
|
||||||
|
print(f'발신자: {FROM}')
|
||||||
|
print(f'SMTP: {HOST}:25')
|
||||||
|
print()
|
||||||
|
|
||||||
|
RESULTS = []
|
||||||
|
# 방법 1: localhost:25 직접 연결
|
||||||
|
print('--- 방법 1: Postfix localhost:25 ---')
|
||||||
|
try:
|
||||||
|
msg = make_message(TO, FROM, f'[GUARDiA] 이메일 발송 테스트 - {datetime.now().strftime("%H:%M")}',
|
||||||
|
text_body, html_body)
|
||||||
|
with smtplib.SMTP(HOST, 25, timeout=15) as smtp:
|
||||||
|
smtp.ehlo('mail.zioinfo.co.kr')
|
||||||
|
refused = smtp.sendmail(FROM, [TO], msg.as_string())
|
||||||
|
if refused:
|
||||||
|
print(f'FAIL 거부된 수신자: {refused}')
|
||||||
|
RESULTS.append(('localhost:25', False, str(refused)))
|
||||||
|
else:
|
||||||
|
print('OK 발송 완료 (큐에 추가됨)')
|
||||||
|
RESULTS.append(('localhost:25', True, '큐 등록 성공'))
|
||||||
|
except Exception as ex:
|
||||||
|
print(f'FAIL {ex}')
|
||||||
|
RESULTS.append(('localhost:25', False, str(ex)[:80]))
|
||||||
|
|
||||||
|
print()
|
||||||
|
print('--- 결과 ---')
|
||||||
|
for method, ok, detail in RESULTS:
|
||||||
|
icon = 'OK ' if ok else 'FAIL'
|
||||||
|
print(f'{icon} {method}: {detail}')
|
||||||
107
deploy/setup_cicd.py
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Gitea 웹훅 + Jenkins CI/CD 완전 자동화 설정"""
|
||||||
|
import paramiko, time, sys, json
|
||||||
|
|
||||||
|
HOST = '101.79.17.164'; USER = 'root'; PASS = '1q2w3e!Q'
|
||||||
|
GITEA_USER = 'zio'; GITEA_PASS = 'Zio@Admin2026!'
|
||||||
|
|
||||||
|
client = paramiko.SSHClient()
|
||||||
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
client.connect(HOST, username=USER, password=PASS, timeout=15)
|
||||||
|
sftp = client.open_sftp()
|
||||||
|
|
||||||
|
def run(label, cmd, timeout=30):
|
||||||
|
print(f'\n[{label}]')
|
||||||
|
chan = client.get_transport().open_session()
|
||||||
|
chan.set_combine_stderr(True)
|
||||||
|
chan.exec_command(cmd)
|
||||||
|
start = time.time()
|
||||||
|
while not chan.exit_status_ready():
|
||||||
|
if chan.recv_ready(): sys.stdout.buffer.write(chan.recv(4096)); sys.stdout.flush()
|
||||||
|
if time.time() - start > timeout: break
|
||||||
|
time.sleep(0.2)
|
||||||
|
while chan.recv_ready(): sys.stdout.buffer.write(chan.recv(4096))
|
||||||
|
sys.stdout.flush()
|
||||||
|
rc = chan.recv_exit_status()
|
||||||
|
print(f'exit={rc}'); return rc
|
||||||
|
|
||||||
|
# 1. Jenkins 초기 설정 스킵 & 보안 완화 (로컬 전용)
|
||||||
|
run('Jenkins 초기설정 스킵',
|
||||||
|
'echo 2 > /var/lib/jenkins/jenkins.install.UpgradeWizard.state && '
|
||||||
|
'chown jenkins:jenkins /var/lib/jenkins/jenkins.install.UpgradeWizard.state')
|
||||||
|
|
||||||
|
# Jenkins가 CSRF 없이 트리거 허용하도록 설정 (내부망 전용)
|
||||||
|
jenkins_config_groovy = """
|
||||||
|
import jenkins.model.*
|
||||||
|
import hudson.security.*
|
||||||
|
|
||||||
|
def instance = Jenkins.getInstance()
|
||||||
|
|
||||||
|
// CSRF 설정 완화 (내부망 전용)
|
||||||
|
instance.setCrumbIssuer(null)
|
||||||
|
instance.save()
|
||||||
|
println "CSRF disabled for internal CI/CD"
|
||||||
|
"""
|
||||||
|
with sftp.open('/var/lib/jenkins/init.groovy.d/disable_csrf.groovy', 'w') as f:
|
||||||
|
f.write(jenkins_config_groovy)
|
||||||
|
run('권한 설정', 'chown -R jenkins:jenkins /var/lib/jenkins/init.groovy.d && systemctl restart jenkins && sleep 8 && systemctl is-active jenkins')
|
||||||
|
|
||||||
|
# 2. Jenkins 빌드 트리거 (CSRF 비활성화 후)
|
||||||
|
chan = client.get_transport().open_session()
|
||||||
|
chan.exec_command('cat /var/lib/jenkins/secrets/initialAdminPassword')
|
||||||
|
jenkins_pw = chan.makefile().read().decode().strip()
|
||||||
|
chan.recv_exit_status()
|
||||||
|
|
||||||
|
trigger_script = f"""#!/usr/bin/env python3
|
||||||
|
import urllib.request, base64, time
|
||||||
|
|
||||||
|
PW = '{jenkins_pw}'
|
||||||
|
cred = base64.b64encode(f'admin:{{PW}}'.encode()).decode()
|
||||||
|
headers = {{'Authorization': f'Basic {{cred}}'}}
|
||||||
|
|
||||||
|
for attempt in range(5):
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(
|
||||||
|
'http://localhost:8080/job/zioinfo-web/build',
|
||||||
|
data=b'', headers=headers)
|
||||||
|
resp = urllib.request.urlopen(req, timeout=10)
|
||||||
|
print(f'빌드 트리거 성공 HTTP {{resp.status}}')
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
print(f'시도 {{attempt+1}}: {{e}}')
|
||||||
|
time.sleep(3)
|
||||||
|
"""
|
||||||
|
with sftp.open('/tmp/trigger.py', 'w') as f:
|
||||||
|
f.write(trigger_script)
|
||||||
|
|
||||||
|
run('Jenkins 빌드 트리거', 'python3 /tmp/trigger.py')
|
||||||
|
|
||||||
|
# 3. Gitea 웹훅 확인 / 재설정 (Jenkins 빌드 URL로)
|
||||||
|
run('Gitea 웹훅 목록 확인',
|
||||||
|
'curl -s http://localhost:3000/api/v1/repos/zio/zioinfo-web/hooks '
|
||||||
|
'-u "zio:Zio@Admin2026!" | python3 -c "import json,sys; '
|
||||||
|
'hooks=json.load(sys.stdin); '
|
||||||
|
'[print(h[chr(105)+chr(100)], h[chr(99)+chr(111)+chr(110)+chr(102)+chr(105)+chr(103)][chr(117)+chr(114)+chr(108)]) for h in hooks]"')
|
||||||
|
|
||||||
|
# 웹훅을 Jenkins 빌드 직접 트리거 URL로 교체
|
||||||
|
run('Gitea 웹훅 업데이트',
|
||||||
|
f'curl -s -X DELETE http://localhost:3000/api/v1/repos/zio/zioinfo-web/hooks/1 '
|
||||||
|
f'-u "{GITEA_USER}:{GITEA_PASS}" && '
|
||||||
|
f'curl -s -X DELETE http://localhost:3000/api/v1/repos/zio/zioinfo-web/hooks/2 '
|
||||||
|
f'-u "{GITEA_USER}:{GITEA_PASS}" && '
|
||||||
|
f'curl -s -X POST http://localhost:3000/api/v1/repos/zio/zioinfo-web/hooks '
|
||||||
|
f'-H "Content-Type: application/json" '
|
||||||
|
f'-u "{GITEA_USER}:{GITEA_PASS}" '
|
||||||
|
f'-d \'{{"type":"gitea","active":true,'
|
||||||
|
f'"config":{{"url":"http://admin:{jenkins_pw}@localhost:8080/job/zioinfo-web/build",'
|
||||||
|
f'"content_type":"json"}},'
|
||||||
|
f'"events":["push"]}}\' | '
|
||||||
|
f'python3 -c "import json,sys; d=json.load(sys.stdin); print(chr(87)+chr(101)+chr(98)+chr(104)+chr(111)+chr(111)+chr(107)+chr(32)+chr(73)+chr(68)+chr(58), d.get(chr(105)+chr(100)))"')
|
||||||
|
|
||||||
|
sftp.close()
|
||||||
|
client.close()
|
||||||
|
print('\nCI/CD 설정 완료!')
|
||||||
|
print(f'Jenkins: http://{HOST}:8080 (초기PW: {jenkins_pw})')
|
||||||
|
print(f'Gitea: http://{HOST}:3000 (zio / Zio@Admin2026!)')
|
||||||
|
print(f'홈페이지: http://{HOST}')
|
||||||
|
print(f'관리자: http://{HOST}/admin')
|
||||||
126
deploy/setup_guardia_cicd.py
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""GUARDiA ITSM Gitea Push + CI/CD Webhook 설정"""
|
||||||
|
import paramiko, time, sys, os, io, zipfile
|
||||||
|
|
||||||
|
HOST='101.79.17.164'; USER='root'; PASS='1q2w3e!Q'
|
||||||
|
LOCAL_ITSM='C:/GUARDiA/itsm'
|
||||||
|
SEP=chr(92)
|
||||||
|
|
||||||
|
client = paramiko.SSHClient()
|
||||||
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
client.connect(HOST, username=USER, password=PASS, timeout=15)
|
||||||
|
sftp = client.open_sftp()
|
||||||
|
|
||||||
|
def run(label, cmd, timeout=60):
|
||||||
|
print(f'\n[{label}]')
|
||||||
|
chan = client.get_transport().open_session()
|
||||||
|
chan.set_combine_stderr(True)
|
||||||
|
chan.exec_command(cmd)
|
||||||
|
start = time.time()
|
||||||
|
while not chan.exit_status_ready():
|
||||||
|
if chan.recv_ready(): sys.stdout.buffer.write(chan.recv(4096)); sys.stdout.flush()
|
||||||
|
if time.time()-start > timeout: print('[TIMEOUT]'); break
|
||||||
|
time.sleep(0.3)
|
||||||
|
while chan.recv_ready(): sys.stdout.buffer.write(chan.recv(4096))
|
||||||
|
sys.stdout.flush()
|
||||||
|
rc = chan.recv_exit_status()
|
||||||
|
print(f'exit={rc}'); return rc
|
||||||
|
|
||||||
|
# ITSM 소스 패키징
|
||||||
|
SKIP_DIRS = {'__pycache__', '.git', 'backups', '_workspace', '.claude', 'node_modules', 'uploads'}
|
||||||
|
SKIP_EXTS = ('.pyc', '.db', '.db.bak', '.log')
|
||||||
|
|
||||||
|
zip_buf = io.BytesIO(); count = 0
|
||||||
|
with zipfile.ZipFile(zip_buf, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
for root, dirs, files in os.walk(LOCAL_ITSM):
|
||||||
|
dirs[:] = [d for d in dirs if d not in SKIP_DIRS]
|
||||||
|
rel = os.path.relpath(root, LOCAL_ITSM).replace(SEP, '/')
|
||||||
|
for f in files:
|
||||||
|
if f.endswith(SKIP_EXTS) or f.startswith('test_'): continue
|
||||||
|
arc = f if rel == '.' else f'{rel}/{f}'
|
||||||
|
zf.write(os.path.join(root, f), arc); count += 1
|
||||||
|
zip_buf.seek(0)
|
||||||
|
with sftp.open('/tmp/guardia-src.zip', 'wb') as f: f.write(zip_buf.read())
|
||||||
|
print(f'{count}개 파일 업로드')
|
||||||
|
|
||||||
|
# push 스크립트 생성
|
||||||
|
push_script = """#!/bin/bash
|
||||||
|
rm -rf /tmp/grd-push
|
||||||
|
mkdir /tmp/grd-push
|
||||||
|
cd /tmp/grd-push
|
||||||
|
unzip -q /tmp/guardia-src.zip
|
||||||
|
git init -q
|
||||||
|
git config user.email ci@zioinfo.co.kr
|
||||||
|
git config user.name ZioCI
|
||||||
|
git add -A
|
||||||
|
git commit -q -m "feat: GUARDiA ITSM v2.0 initial commit"
|
||||||
|
git remote add origin http://zio:Zio%40Admin2026%21@localhost:3000/zio/guardia-itsm.git
|
||||||
|
git push -f origin HEAD:main 2>&1
|
||||||
|
echo "PUSH_DONE"
|
||||||
|
"""
|
||||||
|
with sftp.open('/tmp/push_guardia.sh', 'w') as f: f.write(push_script)
|
||||||
|
|
||||||
|
# Webhook 설정 스크립트
|
||||||
|
webhook_script = """#!/usr/bin/env python3
|
||||||
|
import urllib.request, json, base64
|
||||||
|
|
||||||
|
GITEA = 'http://localhost:3000'
|
||||||
|
cred = base64.b64encode(b'zio:Zio@Admin2026!').decode()
|
||||||
|
headers = {'Content-Type':'application/json','Authorization':f'Basic {cred}'}
|
||||||
|
|
||||||
|
# 기존 웹훅 삭제
|
||||||
|
for hook_id in [1,2,3,4]:
|
||||||
|
req = urllib.request.Request(f'{GITEA}/api/v1/repos/zio/guardia-itsm/hooks/{hook_id}',
|
||||||
|
method='DELETE', headers=headers)
|
||||||
|
try: urllib.request.urlopen(req)
|
||||||
|
except: pass
|
||||||
|
|
||||||
|
# 새 웹훅 등록
|
||||||
|
data = json.dumps({
|
||||||
|
'type': 'gitea', 'active': True,
|
||||||
|
'config': {'url': 'http://localhost:9999/', 'content_type': 'json', 'secret': 'zioinfo-deploy-2026'},
|
||||||
|
'events': ['push']
|
||||||
|
}).encode()
|
||||||
|
req = urllib.request.Request(f'{GITEA}/api/v1/repos/zio/guardia-itsm/hooks',
|
||||||
|
data=data, method='POST', headers=headers)
|
||||||
|
resp = urllib.request.urlopen(req)
|
||||||
|
d = json.loads(resp.read())
|
||||||
|
print('Webhook ID:', d.get('id'))
|
||||||
|
"""
|
||||||
|
with sftp.open('/tmp/setup_webhook.py', 'w') as f: f.write(webhook_script)
|
||||||
|
|
||||||
|
sftp.close()
|
||||||
|
|
||||||
|
run('Gitea push', 'bash /tmp/push_guardia.sh', 60)
|
||||||
|
run('Webhook 설정', 'python3 /tmp/setup_webhook.py')
|
||||||
|
|
||||||
|
# Deploy 서버에 guardia 배포 핸들러 추가
|
||||||
|
deploy_handler_script = """
|
||||||
|
import os, sys
|
||||||
|
os.chdir('/opt/guardia/app')
|
||||||
|
|
||||||
|
# guardia deploy server 업데이트
|
||||||
|
deploy_server_path = '/opt/zioinfo/deploy_server.py'
|
||||||
|
with open(deploy_server_path, 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# guardia 배포 스텝 확인
|
||||||
|
if 'guardia' not in content:
|
||||||
|
print('Deploy server에 guardia 스텝 추가 필요')
|
||||||
|
else:
|
||||||
|
print('Deploy server: guardia 이미 포함됨')
|
||||||
|
"""
|
||||||
|
sftp2 = client.open_sftp()
|
||||||
|
with sftp2.open('/tmp/check_deploy.py', 'w') as f: f.write(deploy_handler_script)
|
||||||
|
sftp2.close()
|
||||||
|
run('Deploy 서버 확인', 'python3 /tmp/check_deploy.py')
|
||||||
|
|
||||||
|
# 브랜치 확인
|
||||||
|
run('저장소 브랜치 확인',
|
||||||
|
'curl -s http://localhost:3000/api/v1/repos/zio/guardia-itsm '
|
||||||
|
'-u zio:Zio@Admin2026! | python3 -c "import json,sys; d=json.load(sys.stdin); '
|
||||||
|
'print(chr(78)+chr(97)+chr(109)+chr(101)+chr(58), d.get(chr(102)+chr(117)+chr(108)+chr(108)+chr(95)+chr(110)+chr(97)+chr(109)+chr(101)), '
|
||||||
|
'chr(66)+chr(114)+chr(97)+chr(110)+chr(99)+chr(104)+chr(58), d.get(chr(100)+chr(101)+chr(102)+chr(97)+chr(117)+chr(108)+chr(116)+chr(95)+chr(98)+chr(114)+chr(97)+chr(110)+chr(99)+chr(104)))"')
|
||||||
|
|
||||||
|
client.close()
|
||||||
|
print('\nGUARDiA CI/CD 구축 완료')
|
||||||
123
deploy/setup_messenger_gitea.py
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""GUARDiA Messenger Gitea 저장소 생성 + 코드 Push + Webhook 설정"""
|
||||||
|
import paramiko, time, sys, os, io, zipfile, urllib.request, json, base64
|
||||||
|
|
||||||
|
HOST = '101.79.17.164'; USER = 'root'; PASS = '1q2w3e!Q'
|
||||||
|
LOCAL_APP = 'C:/GUARDiA/app'
|
||||||
|
SEP = chr(92)
|
||||||
|
GITEA = 'http://localhost:3000'
|
||||||
|
GIT_USER = 'zio'
|
||||||
|
GIT_PASS = 'Zio@Admin2026!'
|
||||||
|
cred = base64.b64encode(f'{GIT_USER}:{GIT_PASS}'.encode()).decode()
|
||||||
|
headers = {'Content-Type': 'application/json', 'Authorization': f'Basic {cred}'}
|
||||||
|
|
||||||
|
client = paramiko.SSHClient()
|
||||||
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
client.connect(HOST, username=USER, password=PASS, timeout=15)
|
||||||
|
sftp = client.open_sftp()
|
||||||
|
|
||||||
|
def run(label, cmd, timeout=60):
|
||||||
|
print(f'\n[{label}]')
|
||||||
|
chan = client.get_transport().open_session()
|
||||||
|
chan.set_combine_stderr(True)
|
||||||
|
chan.exec_command(cmd)
|
||||||
|
start = time.time()
|
||||||
|
while not chan.exit_status_ready():
|
||||||
|
if chan.recv_ready(): sys.stdout.buffer.write(chan.recv(4096)); sys.stdout.flush()
|
||||||
|
if time.time()-start > timeout: print('[TIMEOUT]'); break
|
||||||
|
time.sleep(0.3)
|
||||||
|
while chan.recv_ready(): sys.stdout.buffer.write(chan.recv(4096))
|
||||||
|
sys.stdout.flush()
|
||||||
|
rc = chan.recv_exit_status()
|
||||||
|
print(f'exit={rc}'); return rc
|
||||||
|
|
||||||
|
def gitea_api(method, path, data=None):
|
||||||
|
url = f'{GITEA}/api/v1{path}'
|
||||||
|
body = json.dumps(data).encode() if data else None
|
||||||
|
req = urllib.request.Request(url, data=body, method=method, headers=headers)
|
||||||
|
try:
|
||||||
|
resp = urllib.request.urlopen(req, timeout=10)
|
||||||
|
return json.loads(resp.read()), resp.status
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
body = e.read()
|
||||||
|
try: return json.loads(body), e.code
|
||||||
|
except: return {'error': body.decode()[:200]}, e.code
|
||||||
|
|
||||||
|
# 1. 저장소 생성
|
||||||
|
print('=== 1. Gitea 저장소 생성 ===')
|
||||||
|
d, s = gitea_api('POST', '/user/repos', {
|
||||||
|
'name': 'guardia-messenger',
|
||||||
|
'description': 'GUARDiA Messenger — React Native + Expo SDK 51 모바일 앱',
|
||||||
|
'private': False,
|
||||||
|
'auto_init': False,
|
||||||
|
})
|
||||||
|
print(f'HTTP {s}: {d.get("full_name", d.get("message", d))}')
|
||||||
|
|
||||||
|
# 2. 소스 zip 생성 (node_modules, .git, android, ios 제외)
|
||||||
|
print('\n=== 2. 소스 패키징 ===')
|
||||||
|
SKIP_DIRS = {'node_modules', '.git', 'android', 'ios', '__pycache__', '.expo'}
|
||||||
|
SKIP_EXTS = ('.pyc', '.log')
|
||||||
|
|
||||||
|
zip_buf = io.BytesIO(); count = 0
|
||||||
|
with zipfile.ZipFile(zip_buf, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
for root, dirs, files in os.walk(LOCAL_APP):
|
||||||
|
dirs[:] = [d for d in dirs if d not in SKIP_DIRS]
|
||||||
|
rel = os.path.relpath(root, LOCAL_APP).replace(SEP, '/')
|
||||||
|
for f in files:
|
||||||
|
if f.endswith(SKIP_EXTS): continue
|
||||||
|
arc = f if rel == '.' else f'{rel}/{f}'
|
||||||
|
zf.write(os.path.join(root, f), arc); count += 1
|
||||||
|
zip_buf.seek(0)
|
||||||
|
with sftp.open('/tmp/messenger-src.zip', 'wb') as f: f.write(zip_buf.read())
|
||||||
|
print(f' {count}개 파일 업로드')
|
||||||
|
|
||||||
|
# 3. 서버에서 git push
|
||||||
|
print('\n=== 3. Gitea Push ===')
|
||||||
|
push_script = f"""#!/bin/bash
|
||||||
|
rm -rf /tmp/messenger-push
|
||||||
|
mkdir /tmp/messenger-push
|
||||||
|
cd /tmp/messenger-push
|
||||||
|
unzip -q /tmp/messenger-src.zip
|
||||||
|
git init -q
|
||||||
|
git config user.email ci@zioinfo.co.kr
|
||||||
|
git config user.name ZioCI
|
||||||
|
git add -A
|
||||||
|
git commit -q -m "feat: GUARDiA Messenger v1.0.0 초기 커밋
|
||||||
|
|
||||||
|
- React Native + Expo SDK 51 + TypeScript
|
||||||
|
- 6개 화면: 로그인·대시보드·SR·AI챗봇·알림·설정
|
||||||
|
- EAS Build (APK 성공: 51096ada)
|
||||||
|
- .easignore: android/ios 제외
|
||||||
|
- plugins/withGradleProps: PNG Crunching 비활성화"
|
||||||
|
git remote add origin http://zio:Zio%40Admin2026%21@localhost:3000/zio/guardia-messenger.git
|
||||||
|
git push -f origin HEAD:main 2>&1
|
||||||
|
echo "PUSH_DONE"
|
||||||
|
"""
|
||||||
|
with sftp.open('/tmp/push_messenger.sh', 'w') as f: f.write(push_script)
|
||||||
|
sftp.close()
|
||||||
|
run('Git Push', 'bash /tmp/push_messenger.sh', 60)
|
||||||
|
|
||||||
|
# 4. Webhook 등록 (Deploy Webhook)
|
||||||
|
print('\n=== 4. Webhook 등록 ===')
|
||||||
|
wh, ws = gitea_api('POST', '/repos/zio/guardia-messenger/hooks', {
|
||||||
|
'type': 'gitea',
|
||||||
|
'active': True,
|
||||||
|
'config': {
|
||||||
|
'url': 'http://localhost:9999/',
|
||||||
|
'content_type': 'json',
|
||||||
|
'secret': 'zioinfo-deploy-2026',
|
||||||
|
},
|
||||||
|
'events': ['push'],
|
||||||
|
})
|
||||||
|
print(f'Webhook HTTP {ws}: ID={wh.get("id", wh.get("message", "?"))}')
|
||||||
|
|
||||||
|
# 5. 브랜치 확인
|
||||||
|
print('\n=== 5. 저장소 확인 ===')
|
||||||
|
repo, _ = gitea_api('GET', '/repos/zio/guardia-messenger')
|
||||||
|
print(f'저장소: {repo.get("full_name")}')
|
||||||
|
print(f'브랜치: {repo.get("default_branch")}')
|
||||||
|
print(f'URL: {repo.get("html_url")}')
|
||||||
|
|
||||||
|
client.close()
|
||||||
|
print('\n=== 완료 ===')
|
||||||
|
print('Gitea: http://101.79.17.164:3000/zio/guardia-messenger')
|
||||||
154
deploy/setup_smtp.py
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Postfix + Dovecot SMTP/IMAP 서버 설치
|
||||||
|
도메인: zioinfo.co.kr (IP 기반, DNS MX 없어도 로컬 발송 가능)
|
||||||
|
계정: info, admin, ythong, choyounbun, guardia / 비밀번호: 1q2w3e!Q
|
||||||
|
"""
|
||||||
|
import paramiko, time, sys
|
||||||
|
|
||||||
|
HOST='101.79.17.164'; USER='root'; PASS='1q2w3e!Q'
|
||||||
|
DOMAIN = 'zioinfo.co.kr'
|
||||||
|
ACCOUNTS = ['info', 'admin', 'ythong', 'choyounbun', 'guardia']
|
||||||
|
MAIL_PASS = '1q2w3e!Q'
|
||||||
|
|
||||||
|
client = paramiko.SSHClient()
|
||||||
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
client.connect(HOST, username=USER, password=PASS, timeout=15)
|
||||||
|
sftp = client.open_sftp()
|
||||||
|
|
||||||
|
def run(label, cmd, timeout=120):
|
||||||
|
print(f'\n[{label}]')
|
||||||
|
chan = client.get_transport().open_session()
|
||||||
|
chan.set_combine_stderr(True)
|
||||||
|
chan.exec_command(f'export DEBIAN_FRONTEND=noninteractive; {cmd}')
|
||||||
|
start = time.time()
|
||||||
|
while not chan.exit_status_ready():
|
||||||
|
if chan.recv_ready(): sys.stdout.buffer.write(chan.recv(4096)); sys.stdout.flush()
|
||||||
|
if time.time()-start > timeout: print('[TIMEOUT]'); break
|
||||||
|
time.sleep(0.3)
|
||||||
|
while chan.recv_ready(): sys.stdout.buffer.write(chan.recv(4096))
|
||||||
|
sys.stdout.flush()
|
||||||
|
rc = chan.recv_exit_status()
|
||||||
|
print(f'exit={rc}'); return rc
|
||||||
|
|
||||||
|
# 1. Postfix + Dovecot 설치
|
||||||
|
run('Postfix + Dovecot 설치',
|
||||||
|
'apt-get install -y -q postfix dovecot-imapd dovecot-pop3d mailutils',
|
||||||
|
180)
|
||||||
|
|
||||||
|
# 2. Postfix 설정
|
||||||
|
postfix_main_cf = f"""# Postfix Main Configuration — {DOMAIN}
|
||||||
|
smtpd_banner = $myhostname ESMTP
|
||||||
|
biff = no
|
||||||
|
append_dot_mydomain = no
|
||||||
|
|
||||||
|
# 호스트명 설정
|
||||||
|
myhostname = mail.{DOMAIN}
|
||||||
|
myorigin = /etc/mailname
|
||||||
|
mydomain = {DOMAIN}
|
||||||
|
mydestination = $myhostname, localhost.$mydomain, localhost, $mydomain
|
||||||
|
|
||||||
|
# 네트워크
|
||||||
|
inet_interfaces = all
|
||||||
|
inet_protocols = ipv4
|
||||||
|
mynetworks = 127.0.0.0/8
|
||||||
|
|
||||||
|
# 메일박스 (Maildir 형식)
|
||||||
|
home_mailbox = Maildir/
|
||||||
|
mailbox_size_limit = 0
|
||||||
|
|
||||||
|
# SMTP 인증 (Dovecot SASL)
|
||||||
|
smtpd_sasl_type = dovecot
|
||||||
|
smtpd_sasl_path = private/auth
|
||||||
|
smtpd_sasl_auth_enable = yes
|
||||||
|
smtpd_sasl_security_options = noanonymous
|
||||||
|
|
||||||
|
# TLS (자체서명 인증서 사용)
|
||||||
|
smtpd_tls_cert_file = /etc/ssl/guardia/server.crt
|
||||||
|
smtpd_tls_key_file = /etc/ssl/guardia/server.key
|
||||||
|
smtpd_use_tls = yes
|
||||||
|
smtpd_tls_session_cache_database = btree:${{data_directory}}/smtpd_scache
|
||||||
|
smtp_tls_session_cache_database = btree:${{data_directory}}/smtp_scache
|
||||||
|
|
||||||
|
# 보안
|
||||||
|
smtpd_relay_restrictions = permit_mynetworks, permit_sasl_authenticated, defer_unauth_destination
|
||||||
|
recipient_delimiter = +
|
||||||
|
"""
|
||||||
|
|
||||||
|
dovecot_conf = f"""# Dovecot Configuration — {DOMAIN}
|
||||||
|
protocols = imap pop3 lmtp
|
||||||
|
listen = *
|
||||||
|
|
||||||
|
# 인증 메커니즘
|
||||||
|
auth_mechanisms = plain login
|
||||||
|
|
||||||
|
# 메일 저장소 (Maildir)
|
||||||
|
mail_location = maildir:~/Maildir
|
||||||
|
|
||||||
|
# Postfix SASL 소켓
|
||||||
|
service auth {{
|
||||||
|
unix_listener /var/spool/postfix/private/auth {{
|
||||||
|
mode = 0660
|
||||||
|
user = postfix
|
||||||
|
group = postfix
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
|
||||||
|
# SSL/TLS
|
||||||
|
ssl = yes
|
||||||
|
ssl_cert = </etc/ssl/guardia/server.crt
|
||||||
|
ssl_key = </etc/ssl/guardia/server.key
|
||||||
|
|
||||||
|
# 인증 드라이버
|
||||||
|
passdb {{
|
||||||
|
driver = pam
|
||||||
|
}}
|
||||||
|
userdb {{
|
||||||
|
driver = passwd
|
||||||
|
}}
|
||||||
|
|
||||||
|
# 로그
|
||||||
|
log_path = /var/log/dovecot.log
|
||||||
|
"""
|
||||||
|
|
||||||
|
with sftp.open('/etc/postfix/main.cf', 'w') as f: f.write(postfix_main_cf)
|
||||||
|
with sftp.open('/etc/dovecot/dovecot.conf', 'w') as f: f.write(dovecot_conf)
|
||||||
|
with sftp.open('/etc/mailname', 'w') as f: f.write(DOMAIN + '\n')
|
||||||
|
print('설정 파일 생성 완료')
|
||||||
|
|
||||||
|
# 3. 메일 계정 생성 (시스템 사용자)
|
||||||
|
for acc in ACCOUNTS:
|
||||||
|
run(f'계정 생성 ({acc}@{DOMAIN})',
|
||||||
|
f'id {acc} 2>/dev/null || useradd -m -s /bin/false {acc} && '
|
||||||
|
f'echo \"{acc}:{MAIL_PASS}\" | chpasswd && '
|
||||||
|
f'mkdir -p /home/{acc}/Maildir/{{cur,new,tmp}} && '
|
||||||
|
f'chown -R {acc}:{acc} /home/{acc}/Maildir && '
|
||||||
|
f'echo \"{acc}@{DOMAIN} created\"',
|
||||||
|
15)
|
||||||
|
|
||||||
|
# 4. UFW 포트 오픈
|
||||||
|
run('UFW SMTP/IMAP 포트',
|
||||||
|
'ufw allow 25/tcp && ufw allow 587/tcp && '
|
||||||
|
'ufw allow 993/tcp && ufw allow 995/tcp && '
|
||||||
|
'ufw allow 143/tcp && ufw allow 110/tcp && echo ok')
|
||||||
|
|
||||||
|
# 5. Postfix master.cf에 submission 포트 추가
|
||||||
|
run('Postfix submission 포트 활성화',
|
||||||
|
'grep -q "^submission" /etc/postfix/master.cf || '
|
||||||
|
'echo "submission inet n - y - - smtpd" >> /etc/postfix/master.cf')
|
||||||
|
|
||||||
|
# 6. 서비스 시작
|
||||||
|
run('Postfix 시작', 'systemctl enable postfix && systemctl restart postfix && systemctl is-active postfix')
|
||||||
|
run('Dovecot 시작', 'systemctl enable dovecot && systemctl restart dovecot && systemctl is-active dovecot')
|
||||||
|
|
||||||
|
# 7. 로컬 메일 테스트
|
||||||
|
run('로컬 메일 발송 테스트',
|
||||||
|
f'echo "GUARDiA SMTP 테스트 메일" | mail -s "Test" info@{DOMAIN} 2>/dev/null && echo "메일 발송 OK" || echo "mail command 확인 필요"',
|
||||||
|
10)
|
||||||
|
|
||||||
|
sftp.close(); client.close()
|
||||||
|
print(f'\nSMTP 서버 구축 완료!')
|
||||||
|
print(f'도메인: @{DOMAIN}')
|
||||||
|
print(f'계정: {", ".join(ACCOUNTS)}')
|
||||||
|
print(f'SMTP: mail.{DOMAIN}:25 (로컬) / 587 (STARTTLS)')
|
||||||
|
print(f'IMAP: mail.{DOMAIN}:993 (SSL)')
|
||||||
103
deploy/test_export_import.py
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Export/Import API 기능 테스트"""
|
||||||
|
import paramiko, time, sys
|
||||||
|
|
||||||
|
HOST='101.79.17.164'; USER='root'; PASS='1q2w3e!Q'
|
||||||
|
client = paramiko.SSHClient()
|
||||||
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
client.connect(HOST, username=USER, password=PASS, timeout=15)
|
||||||
|
sftp = client.open_sftp()
|
||||||
|
|
||||||
|
test_code = r"""
|
||||||
|
import urllib.request, json, io, zipfile
|
||||||
|
|
||||||
|
BASE = 'http://localhost:8001'
|
||||||
|
body = json.dumps({'username':'admin','password':'1111'}).encode()
|
||||||
|
req = urllib.request.Request(BASE+'/api/auth/login', data=body, method='POST')
|
||||||
|
req.add_header('Content-Type','application/json')
|
||||||
|
TOKEN = json.loads(urllib.request.urlopen(req,timeout=10).read()).get('access_token','')
|
||||||
|
print('Token:', TOKEN[:20]+'...')
|
||||||
|
|
||||||
|
def api(method, path, token=TOKEN):
|
||||||
|
req = urllib.request.Request(BASE+path, method=method)
|
||||||
|
req.add_header('Authorization','Bearer '+TOKEN)
|
||||||
|
try:
|
||||||
|
r = urllib.request.urlopen(req, timeout=15)
|
||||||
|
return json.loads(r.read()), r.status
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
try: return json.loads(e.read()), e.code
|
||||||
|
except: return {}, e.code
|
||||||
|
|
||||||
|
RESULTS = []
|
||||||
|
def test(name, fn):
|
||||||
|
try:
|
||||||
|
r = fn(); ok = True
|
||||||
|
except Exception as ex:
|
||||||
|
r = str(ex); ok = False
|
||||||
|
RESULTS.append((name, ok, str(r)[:80]))
|
||||||
|
print(('OK ' if ok else 'FAIL') + ' ' + name + ': ' + str(r)[:80])
|
||||||
|
return r
|
||||||
|
|
||||||
|
print()
|
||||||
|
print('=== Export/Import API 테스트 ===')
|
||||||
|
|
||||||
|
test('T1 SR Export', lambda: api('GET','/api/export-import/export/sr?limit=5'))
|
||||||
|
test('T2 CMDB Export', lambda: api('GET','/api/export-import/export/cmdb'))
|
||||||
|
test('T3 기관 Export', lambda: api('GET','/api/export-import/export/institutions'))
|
||||||
|
test('T4 감사 로그 Export', lambda: api('GET','/api/export-import/export/audit?limit=5'))
|
||||||
|
|
||||||
|
def t5_bundle():
|
||||||
|
req = urllib.request.Request(BASE+'/api/export-import/export/bundle', method='GET')
|
||||||
|
req.add_header('Authorization','Bearer '+TOKEN)
|
||||||
|
r = urllib.request.urlopen(req, timeout=20)
|
||||||
|
data = r.read()
|
||||||
|
with zipfile.ZipFile(io.BytesIO(data)) as z:
|
||||||
|
names = z.namelist()
|
||||||
|
return 'HTTP '+str(r.status)+' ZIP='+str(len(data)//1024)+'KB files='+str(names)
|
||||||
|
test('T5 번들 ZIP Export', t5_bundle)
|
||||||
|
|
||||||
|
def t6_dry_run():
|
||||||
|
sr_data = json.dumps({'data':[{'sr_id':'DRYTEST-001','title':'Dry Run Test SR',
|
||||||
|
'status':'RECEIVED','priority':'LOW'}]}).encode()
|
||||||
|
boundary = b'testboundary123'
|
||||||
|
form = (b'--'+boundary+b'\r\n'
|
||||||
|
+b'Content-Disposition: form-data; name="file"; filename="test.json"\r\n'
|
||||||
|
+b'Content-Type: application/json\r\n\r\n'
|
||||||
|
+sr_data+b'\r\n--'+boundary+b'--\r\n')
|
||||||
|
req = urllib.request.Request(BASE+'/api/export-import/import/sr?dry_run=true',
|
||||||
|
data=form, method='POST')
|
||||||
|
req.add_header('Content-Type','multipart/form-data; boundary=testboundary123')
|
||||||
|
req.add_header('Authorization','Bearer '+TOKEN)
|
||||||
|
r = urllib.request.urlopen(req, timeout=10)
|
||||||
|
d = json.loads(r.read())
|
||||||
|
return 'HTTP '+str(r.status)+' status='+str(d.get('status'))+' total='+str(d.get('total'))
|
||||||
|
test('T6 SR Import dry_run', t6_dry_run)
|
||||||
|
|
||||||
|
r7 = urllib.request.urlopen('http://localhost:8090/export-import', timeout=5)
|
||||||
|
test('T7 Manager UI /export-import', lambda: 'HTTP '+str(r7.status))
|
||||||
|
|
||||||
|
print()
|
||||||
|
print('='*55)
|
||||||
|
passed = sum(1 for _,ok,_ in RESULTS if ok)
|
||||||
|
print('결과: '+str(passed)+'/'+str(len(RESULTS))+' PASS')
|
||||||
|
for name,ok,detail in RESULTS:
|
||||||
|
print((' OK ' if ok else ' FAIL') + ' ' + name)
|
||||||
|
print('='*55)
|
||||||
|
"""
|
||||||
|
|
||||||
|
with sftp.open('/tmp/test_ei.py', 'w') as f:
|
||||||
|
f.write(test_code)
|
||||||
|
sftp.close()
|
||||||
|
|
||||||
|
chan = client.get_transport().open_session()
|
||||||
|
chan.set_combine_stderr(True)
|
||||||
|
chan.exec_command('python3 /tmp/test_ei.py 2>&1')
|
||||||
|
start = time.time()
|
||||||
|
while not chan.exit_status_ready():
|
||||||
|
if chan.recv_ready(): sys.stdout.buffer.write(chan.recv(8192)); sys.stdout.flush()
|
||||||
|
if time.time() - start > 40: break
|
||||||
|
time.sleep(0.2)
|
||||||
|
while chan.recv_ready(): sys.stdout.buffer.write(chan.recv(8192))
|
||||||
|
sys.stdout.flush()
|
||||||
|
chan.recv_exit_status()
|
||||||
|
client.close()
|
||||||
116
deploy/test_license.py
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""라이선스 API 기능 테스트"""
|
||||||
|
import paramiko, time, sys, json, urllib.request, urllib.parse
|
||||||
|
|
||||||
|
HOST = '101.79.17.164'; USER = 'root'; PASS = '1q2w3e!Q'
|
||||||
|
BASE = f'http://{HOST}:8001'
|
||||||
|
|
||||||
|
client = paramiko.SSHClient()
|
||||||
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
client.connect(HOST, username=USER, password=PASS, timeout=15)
|
||||||
|
|
||||||
|
def ssh(cmd, timeout=15):
|
||||||
|
chan = client.get_transport().open_session()
|
||||||
|
chan.set_combine_stderr(True)
|
||||||
|
chan.exec_command(cmd)
|
||||||
|
start = time.time()
|
||||||
|
while not chan.exit_status_ready():
|
||||||
|
if time.time() - start > timeout: break
|
||||||
|
time.sleep(0.2)
|
||||||
|
out = b''
|
||||||
|
while chan.recv_ready(): out += chan.recv(8192)
|
||||||
|
chan.recv_exit_status()
|
||||||
|
return out.decode('utf-8', errors='replace').strip()
|
||||||
|
|
||||||
|
def api(method, path, data=None, token=None):
|
||||||
|
url = f'{BASE}{path}'
|
||||||
|
body = json.dumps(data).encode() if data else None
|
||||||
|
req = urllib.request.Request(url, data=body, method=method)
|
||||||
|
req.add_header('Content-Type', 'application/json')
|
||||||
|
if token:
|
||||||
|
req.add_header('Authorization', f'Bearer {token}')
|
||||||
|
try:
|
||||||
|
resp = urllib.request.urlopen(req, timeout=10)
|
||||||
|
return json.loads(resp.read()), resp.status
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return json.loads(e.read()), e.code
|
||||||
|
|
||||||
|
RESULTS = []
|
||||||
|
|
||||||
|
def test(name, fn):
|
||||||
|
try:
|
||||||
|
result = fn()
|
||||||
|
RESULTS.append((name, True, result))
|
||||||
|
print(f'✅ {name}: {str(result)[:100]}')
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
RESULTS.append((name, False, str(e)))
|
||||||
|
print(f'❌ {name}: {e}')
|
||||||
|
return None
|
||||||
|
|
||||||
|
# T1: 로그인
|
||||||
|
params = urllib.parse.urlencode({'username': 'admin', 'password': 'Admin@zioinfo2026!'})
|
||||||
|
req = urllib.request.Request(f'{BASE}/api/auth/login',
|
||||||
|
data=params.encode(), method='POST')
|
||||||
|
req.add_header('Content-Type', 'application/x-www-form-urlencoded')
|
||||||
|
resp = urllib.request.urlopen(req, timeout=10)
|
||||||
|
token_data = json.loads(resp.read())
|
||||||
|
TOKEN = token_data.get('access_token', '')
|
||||||
|
print(f'\n=== 라이선스 API 테스트 (서버: {HOST}) ===')
|
||||||
|
print(f'TOKEN: {TOKEN[:25]}...\n')
|
||||||
|
|
||||||
|
# T2: 현재 상태
|
||||||
|
test('T2 라이선스 현재 상태', lambda: api('GET', '/api/license/status', token=TOKEN)[0].get('message'))
|
||||||
|
|
||||||
|
# T3: 체험 이력 확인
|
||||||
|
prev_status, _ = api('GET', '/api/license/status', token=TOKEN)
|
||||||
|
already_activated = prev_status.get('activated', False)
|
||||||
|
|
||||||
|
if not already_activated:
|
||||||
|
# T3: 체험 라이선스 발급
|
||||||
|
def trial_test():
|
||||||
|
r, s = api('POST', '/api/license/trial',
|
||||||
|
{'customer': '지오정보기술 체험판', 'days': 7}, token=TOKEN)
|
||||||
|
return f"HTTP {s}: {r.get('message', r.get('detail', '?'))}"
|
||||||
|
test('T3 체험 라이선스 발급', trial_test)
|
||||||
|
else:
|
||||||
|
print('T3 체험 라이선스 발급: 이미 활성화됨 — SKIP')
|
||||||
|
RESULTS.append(('T3 체험 라이선스 발급', True, '이미 활성화됨 (기존 유지)'))
|
||||||
|
|
||||||
|
# T4: 활성화 후 상태 확인
|
||||||
|
def status_after():
|
||||||
|
r, _ = api('GET', '/api/license/status', token=TOKEN)
|
||||||
|
return f"valid={r.get('valid')}, edition={r.get('edition')}, days={r.get('days_remaining')}"
|
||||||
|
test('T4 활성화 후 상태', status_after)
|
||||||
|
|
||||||
|
# T5: 라이선스 이력
|
||||||
|
def history_test():
|
||||||
|
r, s = api('GET', '/api/license/history', token=TOKEN)
|
||||||
|
cnt = len(r) if isinstance(r, list) else '?'
|
||||||
|
return f"HTTP {s}: {cnt}건 이력"
|
||||||
|
test('T5 라이선스 이력 조회', history_test)
|
||||||
|
|
||||||
|
# T6: 키 검증 (유효하지 않은 키)
|
||||||
|
def verify_invalid():
|
||||||
|
r, s = api('POST', '/api/license/verify', {'license_key': 'invalid_key_test'}, token=TOKEN)
|
||||||
|
return f"HTTP {s}: {r.get('detail', r.get('message', '?'))[:60]}"
|
||||||
|
test('T6 잘못된 키 검증 (400 예상)', verify_invalid)
|
||||||
|
|
||||||
|
# T7: Manager UI 접속
|
||||||
|
ui_resp = ssh('curl -s -o /dev/null -w "%{http_code}" http://localhost:8090/licenses 2>/dev/null || '
|
||||||
|
'curl -s -o /dev/null -w "%{http_code}" http://localhost:8090/')
|
||||||
|
test('T7 Manager UI (라이선스 페이지)', lambda: f'HTTP {ui_resp}')
|
||||||
|
|
||||||
|
# T8: GUARDiA Manager API 상태
|
||||||
|
api_resp = ssh('curl -s http://localhost:8002/health 2>/dev/null')
|
||||||
|
test('T8 Manager Backend 상태', lambda: api_resp[:80])
|
||||||
|
|
||||||
|
# 결과 요약
|
||||||
|
print(f'\n{"="*50}')
|
||||||
|
passed = sum(1 for _, ok, _ in RESULTS if ok)
|
||||||
|
print(f'테스트 결과: {passed}/{len(RESULTS)} PASS')
|
||||||
|
for name, ok, detail in RESULTS:
|
||||||
|
print(f' {"✅" if ok else "❌"} {name}')
|
||||||
|
print('='*50)
|
||||||
|
|
||||||
|
client.close()
|
||||||
132
deploy/test_license_remote.py
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""서버에서 직접 라이선스 API 테스트 (paramiko exec)"""
|
||||||
|
import paramiko, time, sys, json
|
||||||
|
|
||||||
|
HOST = '101.79.17.164'; USER = 'root'; PASS = '1q2w3e!Q'
|
||||||
|
client = paramiko.SSHClient()
|
||||||
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
client.connect(HOST, username=USER, password=PASS, timeout=15)
|
||||||
|
|
||||||
|
def ssh(label, cmd, timeout=20):
|
||||||
|
print(f'\n[{label}]')
|
||||||
|
chan = client.get_transport().open_session()
|
||||||
|
chan.set_combine_stderr(True)
|
||||||
|
chan.exec_command(cmd)
|
||||||
|
start = time.time()
|
||||||
|
while not chan.exit_status_ready():
|
||||||
|
if chan.recv_ready(): sys.stdout.buffer.write(chan.recv(4096)); sys.stdout.flush()
|
||||||
|
if time.time() - start > timeout: break
|
||||||
|
time.sleep(0.2)
|
||||||
|
while chan.recv_ready(): sys.stdout.buffer.write(chan.recv(4096))
|
||||||
|
sys.stdout.flush()
|
||||||
|
rc = chan.recv_exit_status()
|
||||||
|
print(f' exit={rc}')
|
||||||
|
return rc
|
||||||
|
|
||||||
|
# 테스트 스크립트 서버에 업로드
|
||||||
|
test_script = r"""#!/usr/bin/env python3
|
||||||
|
import urllib.request, urllib.parse, json
|
||||||
|
|
||||||
|
BASE = 'http://localhost:8001'
|
||||||
|
RESULTS = []
|
||||||
|
|
||||||
|
def api(method, path, data=None, token=None):
|
||||||
|
url = f'{BASE}{path}'
|
||||||
|
body = json.dumps(data).encode() if data else None
|
||||||
|
req = urllib.request.Request(url, data=body, method=method)
|
||||||
|
req.add_header('Content-Type', 'application/json')
|
||||||
|
if token: req.add_header('Authorization', f'Bearer {token}')
|
||||||
|
try:
|
||||||
|
resp = urllib.request.urlopen(req, timeout=10)
|
||||||
|
return json.loads(resp.read()), resp.status
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return json.loads(e.read()), e.code
|
||||||
|
except Exception as ex:
|
||||||
|
return {'error': str(ex)}, 0
|
||||||
|
|
||||||
|
def test(name, fn):
|
||||||
|
try:
|
||||||
|
r = fn(); ok = True
|
||||||
|
except Exception as e:
|
||||||
|
r = str(e); ok = False
|
||||||
|
RESULTS.append((name, ok, r))
|
||||||
|
print(f'{"OK" if ok else "FAIL"} {name}: {str(r)[:80]}')
|
||||||
|
return r if ok else None
|
||||||
|
|
||||||
|
print('=== GUARDiA 라이선스 API 테스트 ===')
|
||||||
|
|
||||||
|
# T1 로그인
|
||||||
|
params = urllib.parse.urlencode({'username':'admin','password':'Admin@zioinfo2026!'})
|
||||||
|
req = urllib.request.Request(f'{BASE}/api/auth/login', data=params.encode(), method='POST')
|
||||||
|
req.add_header('Content-Type', 'application/x-www-form-urlencoded')
|
||||||
|
resp = urllib.request.urlopen(req, timeout=10)
|
||||||
|
TOKEN = json.loads(resp.read()).get('access_token','')
|
||||||
|
print(f'TOKEN: {TOKEN[:20]}...\n')
|
||||||
|
|
||||||
|
# T2 현재 상태
|
||||||
|
test('T2 현재 라이선스 상태', lambda: api('GET', '/api/license/status', token=TOKEN)[0].get('message'))
|
||||||
|
|
||||||
|
# T3 체험 발급 (이미 있으면 Skip)
|
||||||
|
cur, _ = api('GET', '/api/license/status', token=TOKEN)
|
||||||
|
if not cur.get('activated'):
|
||||||
|
def t3():
|
||||||
|
r,s = api('POST','/api/license/trial',{'customer':'지오정보기술 체험판','days':7},token=TOKEN)
|
||||||
|
return f'HTTP {s}: {r.get("message",r.get("detail","?"))}'
|
||||||
|
test('T3 체험 라이선스 발급 (7일)', t3)
|
||||||
|
else:
|
||||||
|
RESULTS.append(('T3 체험 라이선스 발급','SKIP','이미 활성화됨'))
|
||||||
|
print('SKIP T3 체험 라이선스 발급: 이미 활성화됨')
|
||||||
|
|
||||||
|
# T4 활성화 후 상태
|
||||||
|
def t4():
|
||||||
|
r,_ = api('GET','/api/license/status',token=TOKEN)
|
||||||
|
return f"valid={r.get('valid')}, edition={r.get('edition')}, days={r.get('days_remaining')}"
|
||||||
|
test('T4 활성화 후 상태 확인', t4)
|
||||||
|
|
||||||
|
# T5 이력 조회
|
||||||
|
def t5():
|
||||||
|
r,s = api('GET','/api/license/history',token=TOKEN)
|
||||||
|
cnt = len(r) if isinstance(r,list) else '?'
|
||||||
|
return f'HTTP {s}: {cnt}건'
|
||||||
|
test('T5 라이선스 이력 조회', t5)
|
||||||
|
|
||||||
|
# T6 잘못된 키 검증
|
||||||
|
def t6():
|
||||||
|
r,s = api('POST','/api/license/verify',{'license_key':'invalid_test_key'},token=TOKEN)
|
||||||
|
return f'HTTP {s}: {r.get("detail",r.get("message","?"))[:50]}'
|
||||||
|
test('T6 잘못된 키 검증 (400/422 예상)', t6)
|
||||||
|
|
||||||
|
# T7 Manager UI
|
||||||
|
import urllib.request as ur
|
||||||
|
try:
|
||||||
|
resp2 = ur.urlopen('http://localhost:8090/', timeout=5)
|
||||||
|
ui_status = resp2.status
|
||||||
|
except Exception as ex:
|
||||||
|
ui_status = str(ex)
|
||||||
|
test('T7 Manager UI 접속', lambda: f'HTTP {ui_status}')
|
||||||
|
|
||||||
|
# T8 Manager API
|
||||||
|
try:
|
||||||
|
resp3 = ur.urlopen('http://localhost:8002/health', timeout=5)
|
||||||
|
api_status = json.loads(resp3.read()).get('status','?')
|
||||||
|
except Exception as ex:
|
||||||
|
api_status = str(ex)
|
||||||
|
test('T8 Manager Backend', lambda: api_status)
|
||||||
|
|
||||||
|
# 결과 요약
|
||||||
|
print(f'\n{"="*55}')
|
||||||
|
passed = sum(1 for _,ok,_ in RESULTS if ok is True or ok=='SKIP')
|
||||||
|
print(f'테스트 결과: {passed}/{len(RESULTS)} PASS')
|
||||||
|
for name,ok,detail in RESULTS:
|
||||||
|
icon = '✅' if ok is True else ('⏭️' if ok=='SKIP' else '❌')
|
||||||
|
print(f' {icon} {name}')
|
||||||
|
print('='*55)
|
||||||
|
"""
|
||||||
|
|
||||||
|
sftp = client.open_sftp()
|
||||||
|
with sftp.open('/tmp/test_license.py', 'w') as f:
|
||||||
|
f.write(test_script)
|
||||||
|
sftp.close()
|
||||||
|
|
||||||
|
ssh('라이선스 테스트 실행', 'python3 /tmp/test_license.py 2>&1', timeout=30)
|
||||||
|
client.close()
|
||||||
151
deploy/test_notification.py
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
GUARDiA ITSM → GUARDiA Messenger 알림 송수신 테스트
|
||||||
|
|
||||||
|
테스트 항목:
|
||||||
|
T1. 관리자 로그인 (JWT 발급)
|
||||||
|
T2. WebSocket 연결 테스트 (ws://localhost:8001/ws/events)
|
||||||
|
T3. SR 등록 → 이벤트 발생 확인
|
||||||
|
T4. 알림 로그 조회 (REST API)
|
||||||
|
T5. 메신저 알림 테스트 엔드포인트
|
||||||
|
T6. Messenger 앱 알림 API 조회
|
||||||
|
"""
|
||||||
|
import paramiko, time, sys, json
|
||||||
|
|
||||||
|
HOST = '101.79.17.164'; USER = 'root'; PASS = '1q2w3e!Q'
|
||||||
|
client = paramiko.SSHClient()
|
||||||
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
client.connect(HOST, username=USER, password=PASS, timeout=15)
|
||||||
|
sftp = client.open_sftp()
|
||||||
|
|
||||||
|
def run(label, cmd, timeout=20):
|
||||||
|
print(f'\n[{label}]')
|
||||||
|
chan = client.get_transport().open_session()
|
||||||
|
chan.set_combine_stderr(True)
|
||||||
|
chan.exec_command(cmd)
|
||||||
|
start = time.time()
|
||||||
|
while not chan.exit_status_ready():
|
||||||
|
if chan.recv_ready(): sys.stdout.buffer.write(chan.recv(4096)); sys.stdout.flush()
|
||||||
|
if time.time()-start > timeout: print('[TIMEOUT]'); break
|
||||||
|
time.sleep(0.3)
|
||||||
|
while chan.recv_ready(): sys.stdout.buffer.write(chan.recv(4096))
|
||||||
|
sys.stdout.flush()
|
||||||
|
chan.recv_exit_status()
|
||||||
|
|
||||||
|
test_script = """
|
||||||
|
import urllib.request, urllib.error, json, base64, time, sys
|
||||||
|
|
||||||
|
BASE = 'http://localhost:8001'
|
||||||
|
RESULTS = []
|
||||||
|
|
||||||
|
def api(method, path, data=None, token=None, form=False):
|
||||||
|
url = BASE + path
|
||||||
|
if form:
|
||||||
|
import urllib.parse
|
||||||
|
body = urllib.parse.urlencode(data).encode() if data else None
|
||||||
|
ct = 'application/x-www-form-urlencoded'
|
||||||
|
else:
|
||||||
|
body = json.dumps(data).encode() if data else None
|
||||||
|
ct = 'application/json'
|
||||||
|
req = urllib.request.Request(url, data=body, method=method)
|
||||||
|
req.add_header('Content-Type', ct)
|
||||||
|
if token: req.add_header('Authorization', 'Bearer '+token)
|
||||||
|
try:
|
||||||
|
r = urllib.request.urlopen(req, timeout=10)
|
||||||
|
return json.loads(r.read()), r.status
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
try: return json.loads(e.read()), e.code
|
||||||
|
except: return {'error': str(e)}, e.code
|
||||||
|
|
||||||
|
def test(name, fn):
|
||||||
|
try:
|
||||||
|
r = fn(); ok = True
|
||||||
|
except Exception as e:
|
||||||
|
r = str(e); ok = False
|
||||||
|
RESULTS.append((name, ok, str(r)[:100]))
|
||||||
|
icon = 'PASS' if ok else 'FAIL'
|
||||||
|
print(f' [{icon}] {name}: {str(r)[:80]}')
|
||||||
|
return r if ok else None
|
||||||
|
|
||||||
|
print()
|
||||||
|
print('='*60)
|
||||||
|
print('GUARDiA 알림 송수신 테스트')
|
||||||
|
print('='*60)
|
||||||
|
|
||||||
|
# T1: 로그인
|
||||||
|
d, s = api('POST', '/api/auth/login', {'username':'admin','password':'1111'})
|
||||||
|
TOKEN = d.get('access_token','')
|
||||||
|
test('T1 관리자 로그인', lambda: f'HTTP {s}, Token={TOKEN[:15]}...' if TOKEN else f'FAIL: {d}')
|
||||||
|
|
||||||
|
if not TOKEN:
|
||||||
|
print('토큰 없음 - 테스트 중단')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# T2: WebSocket 상태 확인
|
||||||
|
def t2():
|
||||||
|
d, s = api('GET', '/api/ws/status', token=TOKEN)
|
||||||
|
return f'HTTP {s}, connections={d.get(\"connection_count\",d)}'
|
||||||
|
test('T2 WebSocket 상태', t2)
|
||||||
|
|
||||||
|
# T3: SR 등록 (이벤트 발생)
|
||||||
|
def t3():
|
||||||
|
import secrets
|
||||||
|
sr_id = f'TEST-{secrets.token_hex(3).upper()}'
|
||||||
|
d, s = api('POST', '/api/tasks', {
|
||||||
|
'title': f'[알림 테스트] SR {sr_id}',
|
||||||
|
'description': 'GUARDiA Messenger 알림 연동 테스트용 SR',
|
||||||
|
'priority': 'MEDIUM',
|
||||||
|
'sr_type': 'OTHER',
|
||||||
|
}, token=TOKEN)
|
||||||
|
return f'HTTP {s}, sr_id={d.get(\"sr_id\",d.get(\"detail\",\"?\"))}'
|
||||||
|
test('T3 SR 등록 (이벤트 트리거)', t3)
|
||||||
|
|
||||||
|
# T4: 알림 로그 조회
|
||||||
|
def t4():
|
||||||
|
d, s = api('GET', '/api/notifications/log?size=5', token=TOKEN)
|
||||||
|
cnt = len(d) if isinstance(d,list) else d.get('total',d.get('count','?'))
|
||||||
|
items = d[:2] if isinstance(d,list) else d.get('items',d.get('content',[]))[:2]
|
||||||
|
return f'HTTP {s}, count={cnt}'
|
||||||
|
test('T4 알림 로그 조회', t4)
|
||||||
|
|
||||||
|
# T5: 메신저 알림 테스트
|
||||||
|
def t5():
|
||||||
|
d, s = api('POST', '/api/notifications/test-messenger', token=TOKEN)
|
||||||
|
return f'HTTP {s}: {d.get(\"message\",d)}'
|
||||||
|
test('T5 메신저 알림 테스트', t5)
|
||||||
|
|
||||||
|
# T6: SSE 이벤트 버스 상태
|
||||||
|
def t6():
|
||||||
|
d, s = api('GET', '/api/dashboard', token=TOKEN)
|
||||||
|
return f'HTTP {s}: dashboard ok, keys={list(d.keys())[:3]}'
|
||||||
|
test('T6 대시보드 API (앱 연동 확인)', t6)
|
||||||
|
|
||||||
|
# T7: 알림 설정 조회
|
||||||
|
def t7():
|
||||||
|
d, s = api('GET', '/api/notifications/config', token=TOKEN)
|
||||||
|
return f'HTTP {s}: smtp={d.get(\"smtp_enabled\",\"?\")}, messenger={d.get(\"messenger_enabled\",\"?\")}'
|
||||||
|
test('T7 알림 설정 조회', t7)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print('='*60)
|
||||||
|
passed = sum(1 for _,ok,_ in RESULTS if ok)
|
||||||
|
print(f'결과: {passed}/{len(RESULTS)} PASS')
|
||||||
|
for name,ok,detail in RESULTS:
|
||||||
|
print(f' {\"OK \" if ok else \"FAIL\"} {name}')
|
||||||
|
print('='*60)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print('[앱 연동 확인]')
|
||||||
|
print('GUARDiA Messenger 앱에서:')
|
||||||
|
print('1. 로그인 → 알림 탭 탭')
|
||||||
|
print('2. 상단에 "GUARDiA 실시간 연결" 초록 표시 확인')
|
||||||
|
print('3. SR 등록 또는 배포 실행 시 알림 자동 표시')
|
||||||
|
print('4. ⚡ 실시간 태그로 WebSocket 수신 구분')
|
||||||
|
"""
|
||||||
|
|
||||||
|
with sftp.open('/tmp/test_notif.py', 'w') as f:
|
||||||
|
f.write(test_script)
|
||||||
|
sftp.close()
|
||||||
|
|
||||||
|
run('알림 테스트 실행', 'python3 /tmp/test_notif.py 2>&1', 30)
|
||||||
|
client.close()
|
||||||
73
deploy/test_trial.py
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import paramiko, time, sys
|
||||||
|
|
||||||
|
HOST = '101.79.17.164'; USER = 'root'; PASS = '1q2w3e!Q'
|
||||||
|
client = paramiko.SSHClient()
|
||||||
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
client.connect(HOST, username=USER, password=PASS, timeout=15)
|
||||||
|
sftp = client.open_sftp()
|
||||||
|
|
||||||
|
script = """
|
||||||
|
import urllib.request, json
|
||||||
|
|
||||||
|
BASE = "http://localhost:8001"
|
||||||
|
|
||||||
|
def api(method, path, data=None, token=None, form=False):
|
||||||
|
url = f"{BASE}{path}"
|
||||||
|
if form:
|
||||||
|
import urllib.parse
|
||||||
|
body = urllib.parse.urlencode(data).encode() if data else None
|
||||||
|
ct = "application/x-www-form-urlencoded"
|
||||||
|
else:
|
||||||
|
body = json.dumps(data).encode() if data else None
|
||||||
|
ct = "application/json"
|
||||||
|
req = urllib.request.Request(url, data=body, method=method)
|
||||||
|
req.add_header("Content-Type", ct)
|
||||||
|
if token: req.add_header("Authorization", f"Bearer {token}")
|
||||||
|
try:
|
||||||
|
resp = urllib.request.urlopen(req, timeout=10)
|
||||||
|
body_out = resp.read()
|
||||||
|
return json.loads(body_out) if body_out else {}, resp.status
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
body_out = e.read()
|
||||||
|
try: return json.loads(body_out), e.code
|
||||||
|
except: return {"raw": body_out.decode()[:200]}, e.code
|
||||||
|
|
||||||
|
# JSON 로그인
|
||||||
|
d, s = api("POST", "/api/auth/login", {"username":"admin","password":"1111"})
|
||||||
|
TOKEN = d.get("access_token","")
|
||||||
|
print(f"Login HTTP {s}: {'OK' if TOKEN else 'FAIL'} token={TOKEN[:20]}...")
|
||||||
|
|
||||||
|
# Trial
|
||||||
|
d2, s2 = api("POST", "/api/license/trial", {"customer":"지오정보기술 체험판","days":7}, token=TOKEN)
|
||||||
|
print(f"Trial HTTP {s2}: {json.dumps(d2, ensure_ascii=False)[:200]}")
|
||||||
|
|
||||||
|
# 상태
|
||||||
|
d3, s3 = api("GET", "/api/license/status", token=TOKEN)
|
||||||
|
print(f"Status HTTP {s3}: valid={d3.get('valid')}, edition={d3.get('edition')}, days={d3.get('days_remaining')}")
|
||||||
|
print(f" message: {d3.get('message')}")
|
||||||
|
|
||||||
|
# 이력
|
||||||
|
d4, s4 = api("GET", "/api/license/history", token=TOKEN)
|
||||||
|
cnt = len(d4) if isinstance(d4,list) else "?"
|
||||||
|
print(f"History HTTP {s4}: {cnt}건")
|
||||||
|
if isinstance(d4,list) and d4:
|
||||||
|
r = d4[0]
|
||||||
|
print(f" 최신: edition={r.get('edition')}, customer={r.get('customer')}, is_trial={r.get('is_trial')}")
|
||||||
|
"""
|
||||||
|
|
||||||
|
with sftp.open('/tmp/trial2.py', 'w') as f: f.write(script)
|
||||||
|
sftp.close()
|
||||||
|
|
||||||
|
chan = client.get_transport().open_session()
|
||||||
|
chan.set_combine_stderr(True)
|
||||||
|
chan.exec_command('python3 /tmp/trial2.py 2>&1')
|
||||||
|
start = time.time()
|
||||||
|
while not chan.exit_status_ready():
|
||||||
|
if chan.recv_ready(): sys.stdout.buffer.write(chan.recv(4096)); sys.stdout.flush()
|
||||||
|
if time.time() - start > 20: break
|
||||||
|
time.sleep(0.2)
|
||||||
|
while chan.recv_ready(): sys.stdout.buffer.write(chan.recv(4096))
|
||||||
|
sys.stdout.flush()
|
||||||
|
chan.recv_exit_status()
|
||||||
|
client.close()
|
||||||
217
deploy/test_work_order.py
Normal file
@ -0,0 +1,217 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Messenger → ITSM 작업지시 송수신 테스트
|
||||||
|
|
||||||
|
흐름:
|
||||||
|
1. Messenger 앱에서 SR(작업지시) 등록
|
||||||
|
2. ITSM에서 SR 상태 변경 (승인 → 진행 → 완료)
|
||||||
|
3. Messenger 앱으로 상태 변경 알림 수신 (WebSocket)
|
||||||
|
4. AI 챗봇으로 자연어 작업지시 → SR 자동 생성
|
||||||
|
"""
|
||||||
|
import paramiko, time, sys
|
||||||
|
|
||||||
|
HOST='101.79.17.164'; USER='root'; PASS='1q2w3e!Q'
|
||||||
|
client = paramiko.SSHClient()
|
||||||
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
client.connect(HOST, username=USER, password=PASS, timeout=15)
|
||||||
|
sftp = client.open_sftp()
|
||||||
|
|
||||||
|
script = r"""
|
||||||
|
import http.client, json, sys, time, secrets
|
||||||
|
|
||||||
|
HOST = 'localhost'; PORT = 8001
|
||||||
|
RESULTS = []
|
||||||
|
SR_ID = None # 테스트에서 생성한 SR ID 저장
|
||||||
|
|
||||||
|
def api(method, path, data=None, token=None):
|
||||||
|
conn = http.client.HTTPConnection(HOST, PORT, timeout=10)
|
||||||
|
headers = {'Content-Type':'application/json'}
|
||||||
|
if token: headers['Authorization'] = 'Bearer ' + token
|
||||||
|
body = json.dumps(data).encode() if data else None
|
||||||
|
conn.request(method, path, body=body, headers=headers)
|
||||||
|
r = conn.getresponse(); raw = r.read()
|
||||||
|
try: return json.loads(raw), r.status
|
||||||
|
except: return {'raw': raw.decode()[:300]}, r.status
|
||||||
|
|
||||||
|
def test(name, fn):
|
||||||
|
try:
|
||||||
|
r = fn(); ok = True
|
||||||
|
except Exception as e: r = str(e)[:100]; ok = False
|
||||||
|
RESULTS.append((name, ok, str(r)[:100]))
|
||||||
|
print(('PASS' if ok else 'FAIL') + ' ' + name + ': ' + str(r)[:90])
|
||||||
|
return r if ok else None
|
||||||
|
|
||||||
|
print()
|
||||||
|
print('='*65)
|
||||||
|
print('Messenger -> ITSM 작업지시 송수신 테스트')
|
||||||
|
print('='*65)
|
||||||
|
|
||||||
|
# 로그인
|
||||||
|
d, s = api('POST','/api/auth/login',{'username':'admin','password':'1111'})
|
||||||
|
TOKEN = d.get('access_token','')
|
||||||
|
if not TOKEN: print('로그인 실패'); sys.exit(1)
|
||||||
|
print(f' 로그인 OK (token={TOKEN[:15]}...)')
|
||||||
|
print()
|
||||||
|
|
||||||
|
# ── 단계 1: Messenger 앱에서 작업지시 등록 ─────────────────────────────
|
||||||
|
print('[단계 1] Messenger 앱 → ITSM 작업지시 등록')
|
||||||
|
|
||||||
|
suffix = secrets.token_hex(3).upper()
|
||||||
|
|
||||||
|
def step1_sr():
|
||||||
|
global SR_ID
|
||||||
|
# Messenger 앱의 SR 등록 화면에서 직접 POST
|
||||||
|
d, s = api('POST','/api/tasks', {
|
||||||
|
'title': f'[Messenger] 웹서버 재시작 요청 {suffix}',
|
||||||
|
'description': 'GUARDiA Messenger 앱에서 직접 등록한 작업지시입니다.\n'
|
||||||
|
'서버: web-01.zioinfo.co.kr\n'
|
||||||
|
'작업: nginx 재시작\n'
|
||||||
|
'요청자: admin (Messenger 앱)',
|
||||||
|
'priority': 'HIGH',
|
||||||
|
'sr_type': 'RESTART',
|
||||||
|
'requested_by': 'admin',
|
||||||
|
}, token=TOKEN)
|
||||||
|
if s in [200,201]:
|
||||||
|
SR_ID = d.get('id') or d.get('sr_id')
|
||||||
|
return f'HTTP {s} SR등록 성공: id={d.get("id")} sr_id={d.get("sr_id","?")} title={d.get("title","?")[:30]}'
|
||||||
|
return f'HTTP {s}: {d.get("detail","?")[:80]}'
|
||||||
|
|
||||||
|
test('S1-1 Messenger→ITSM SR 등록 (POST /api/tasks)', step1_sr)
|
||||||
|
|
||||||
|
# SR 목록에서 방금 생성한 SR 확인
|
||||||
|
def step1_verify():
|
||||||
|
d, s = api('GET','/api/tasks?size=1',token=TOKEN)
|
||||||
|
if isinstance(d,list) and len(d)>0:
|
||||||
|
latest = d[0]
|
||||||
|
return f'HTTP {s} 최신SR: {latest.get("sr_id","?")} [{latest.get("status","?")}] {latest.get("title","?")[:30]}'
|
||||||
|
return f'HTTP {s} {type(d)}'
|
||||||
|
test('S1-2 ITSM SR 목록 확인', step1_verify)
|
||||||
|
|
||||||
|
print()
|
||||||
|
|
||||||
|
# ── 단계 2: ITSM에서 SR 상태 변경 ──────────────────────────────────────
|
||||||
|
print('[단계 2] ITSM SR 상태 변경 → Messenger 알림 수신')
|
||||||
|
|
||||||
|
def step2_approve():
|
||||||
|
# SR을 APPROVED 상태로 변경 (승인)
|
||||||
|
if not SR_ID: return 'SR_ID 없음'
|
||||||
|
# PATCH /api/tasks/{id}/status 또는 PUT
|
||||||
|
d, s = api('PATCH', f'/api/tasks/{SR_ID}/status', {'status':'APPROVED'}, token=TOKEN)
|
||||||
|
if s in [200,201]:
|
||||||
|
return f'HTTP {s} 상태변경: APPROVED -> WebSocket 알림 발생'
|
||||||
|
# 다른 경로 시도
|
||||||
|
d2, s2 = api('PUT', f'/api/tasks/{SR_ID}', {'status':'IN_PROGRESS'}, token=TOKEN)
|
||||||
|
return f'HTTP {s} / {s2}: {d.get("detail","?")} / {d2.get("status","?")}'
|
||||||
|
test('S2-1 SR 승인 (APPROVED) → 앱 알림 수신', step2_approve)
|
||||||
|
|
||||||
|
def step2_inprogress():
|
||||||
|
if not SR_ID: return 'SR_ID 없음'
|
||||||
|
d, s = api('PATCH', f'/api/tasks/{SR_ID}/status', {'status':'IN_PROGRESS'}, token=TOKEN)
|
||||||
|
if s in [200,201]:
|
||||||
|
return f'HTTP {s} 상태변경: IN_PROGRESS -> 앱에 진행중 알림'
|
||||||
|
return f'HTTP {s}: {d.get("detail","?")[:60]}'
|
||||||
|
test('S2-2 SR 진행 (IN_PROGRESS) → 앱 알림 수신', step2_inprogress)
|
||||||
|
|
||||||
|
def step2_complete():
|
||||||
|
if not SR_ID: return 'SR_ID 없음'
|
||||||
|
d, s = api('PATCH', f'/api/tasks/{SR_ID}/status', {'status':'COMPLETED'}, token=TOKEN)
|
||||||
|
if s in [200,201]:
|
||||||
|
return f'HTTP {s} 상태변경: COMPLETED -> 앱에 완료 알림'
|
||||||
|
return f'HTTP {s}: {d.get("detail","?")[:60]}'
|
||||||
|
test('S2-3 SR 완료 (COMPLETED) → 앱 알림 수신', step2_complete)
|
||||||
|
|
||||||
|
print()
|
||||||
|
|
||||||
|
# ── 단계 3: AI 챗봇으로 작업지시 ──────────────────────────────────────
|
||||||
|
print('[단계 3] Messenger AI 챗봇 → 자연어 작업지시')
|
||||||
|
|
||||||
|
def step3_ai_cmd():
|
||||||
|
d, s = api('POST','/api/chatbot/message',
|
||||||
|
{'message': '웹서버 nginx를 재시작해 주세요. 긴급합니다.'},
|
||||||
|
token=TOKEN)
|
||||||
|
reply = d.get('reply', d.get('message', d.get('response', str(d)[:80])))
|
||||||
|
return f'HTTP {s} AI응답: {str(reply)[:80]}'
|
||||||
|
test('S3-1 AI챗봇 자연어 작업지시 (nginx 재시작)', step3_ai_cmd)
|
||||||
|
|
||||||
|
def step3_ai_sr():
|
||||||
|
d, s = api('POST','/api/chatbot/message',
|
||||||
|
{'message': 'SR 등록해줘: 데이터베이스 백업 실행 요청, 우선순위 HIGH'},
|
||||||
|
token=TOKEN)
|
||||||
|
reply = d.get('reply', d.get('message', str(d)[:80]))
|
||||||
|
return f'HTTP {s} AI응답: {str(reply)[:80]}'
|
||||||
|
test('S3-2 AI챗봇 SR 자동 생성 요청', step3_ai_sr)
|
||||||
|
|
||||||
|
def step3_nlcmd():
|
||||||
|
# 자연어 명령 (nlcmd)
|
||||||
|
d, s = api('POST','/api/ai-cmd',
|
||||||
|
{'message': '현재 서버 상태를 확인해줘', 'channel': 'messenger'},
|
||||||
|
token=TOKEN)
|
||||||
|
return f'HTTP {s}: {str(d)[:80]}'
|
||||||
|
test('S3-3 AI 자연어 명령 (서버 상태 확인)', step3_nlcmd)
|
||||||
|
|
||||||
|
print()
|
||||||
|
|
||||||
|
# ── 단계 4: WebSocket 이벤트 직접 확인 ─────────────────────────────────
|
||||||
|
print('[단계 4] WebSocket 이벤트 상태 확인')
|
||||||
|
|
||||||
|
def step4_ws():
|
||||||
|
d, s = api('GET','/api/ws/status',token=TOKEN)
|
||||||
|
conns = d.get('total_connections', d.get('connection_count', 0))
|
||||||
|
channels = d.get('channels', [])
|
||||||
|
return f'HTTP {s} 연결={conns}개 채널={channels[:4]}'
|
||||||
|
test('S4-1 WebSocket 연결 상태 (앱 연결 수)', step4_ws)
|
||||||
|
|
||||||
|
def step4_audit():
|
||||||
|
d, s = api('GET','/api/audit?size=5',token=TOKEN)
|
||||||
|
items = d if isinstance(d,list) else d.get('items',d.get('content',[]))
|
||||||
|
logs = [(i.get('action','?'), i.get('username','?')) for i in items[:3]]
|
||||||
|
return f'HTTP {s} 최근감사: {logs}'
|
||||||
|
test('S4-2 감사 로그 (작업지시 이력 추적)', step4_audit)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print('='*65)
|
||||||
|
passed = sum(1 for _,ok,_ in RESULTS if ok)
|
||||||
|
print(f'결과: {passed}/{len(RESULTS)} PASS')
|
||||||
|
print()
|
||||||
|
for name,ok,d in RESULTS:
|
||||||
|
print(f' {"OK " if ok else "FAIL"} {name}')
|
||||||
|
print()
|
||||||
|
print('='*65)
|
||||||
|
print()
|
||||||
|
print('[작업지시 전체 흐름]')
|
||||||
|
print()
|
||||||
|
print(' Messenger 앱 GUARDiA ITSM')
|
||||||
|
print(' | |')
|
||||||
|
print(' SR 등록 탭에서 입력 ──────────────> POST /api/tasks')
|
||||||
|
print(' | |')
|
||||||
|
print(' AI챗봇: "nginx 재시작해줘" ─────> /api/chatbot/message')
|
||||||
|
print(' | | (AI가 SR 자동 생성)')
|
||||||
|
print(' | <── WebSocket 상태 알림 ──── 관리자 승인')
|
||||||
|
print(' | <── WebSocket 완료 알림 ──── 작업 완료 처리')
|
||||||
|
print()
|
||||||
|
print('[앱에서 확인하는 방법]')
|
||||||
|
print('1. 앱 SR탭 -> "새 SR" 버튼 -> 제목/설명/우선순위 입력 -> 등록')
|
||||||
|
print(' → ITSM 관리자 웹(http://101.79.17.164:8001)에서 SR 확인')
|
||||||
|
print()
|
||||||
|
print('2. 앱 AI채팅탭 -> "서버 nginx 재시작 요청해줘" 입력')
|
||||||
|
print(' → AI가 자동으로 SR 생성 후 응답')
|
||||||
|
print()
|
||||||
|
print('3. 앱 알림탭 -> ⚡ 실시간 연결 초록 표시 확인')
|
||||||
|
print(' → ITSM에서 SR 상태 변경 시 즉시 알림 수신')
|
||||||
|
"""
|
||||||
|
|
||||||
|
with sftp.open('/tmp/test_wo.py','w') as f: f.write(script)
|
||||||
|
sftp.close()
|
||||||
|
|
||||||
|
chan = client.get_transport().open_session()
|
||||||
|
chan.set_combine_stderr(True)
|
||||||
|
chan.exec_command('python3 /tmp/test_wo.py 2>&1')
|
||||||
|
start = time.time()
|
||||||
|
while not chan.exit_status_ready():
|
||||||
|
if chan.recv_ready(): sys.stdout.buffer.write(chan.recv(8192)); sys.stdout.flush()
|
||||||
|
if time.time()-start > 50: break
|
||||||
|
time.sleep(0.3)
|
||||||
|
while chan.recv_ready(): sys.stdout.buffer.write(chan.recv(8192))
|
||||||
|
sys.stdout.flush()
|
||||||
|
chan.recv_exit_status()
|
||||||
|
client.close()
|
||||||
124
deploy/update_deploy_server.py
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Deploy Webhook 서버에 guardia-itsm 배포 스텝 추가"""
|
||||||
|
import paramiko, time, sys
|
||||||
|
|
||||||
|
HOST='101.79.17.164'; USER='root'; PASS='1q2w3e!Q'
|
||||||
|
client = paramiko.SSHClient()
|
||||||
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
client.connect(HOST, username=USER, password=PASS, timeout=15)
|
||||||
|
sftp = client.open_sftp()
|
||||||
|
|
||||||
|
def run(label, cmd, timeout=30):
|
||||||
|
print(f'[{label}]')
|
||||||
|
chan = client.get_transport().open_session()
|
||||||
|
chan.set_combine_stderr(True)
|
||||||
|
chan.exec_command(cmd)
|
||||||
|
start = time.time()
|
||||||
|
while not chan.exit_status_ready():
|
||||||
|
if chan.recv_ready(): sys.stdout.buffer.write(chan.recv(4096)); sys.stdout.flush()
|
||||||
|
if time.time()-start > timeout: break
|
||||||
|
time.sleep(0.3)
|
||||||
|
while chan.recv_ready(): sys.stdout.buffer.write(chan.recv(4096))
|
||||||
|
sys.stdout.flush()
|
||||||
|
chan.recv_exit_status()
|
||||||
|
|
||||||
|
new_deploy_server = '''#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
ZioInfo + GUARDiA CI/CD Webhook Server
|
||||||
|
포트: 9999
|
||||||
|
- zioinfo-web push → 홈페이지 빌드/배포
|
||||||
|
- guardia-itsm push → GUARDiA ITSM 재배포
|
||||||
|
"""
|
||||||
|
import http.server, subprocess, threading, json, hmac, hashlib, logging, os
|
||||||
|
|
||||||
|
SECRET = b"zioinfo-deploy-2026"
|
||||||
|
ZIOINFO_SRC = "/opt/zioinfo/src"
|
||||||
|
GUARDIA_SRC = "/opt/guardia/src"
|
||||||
|
LOG = "/var/log/zioinfo/deploy.log"
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s %(levelname)s %(message)s",
|
||||||
|
handlers=[logging.FileHandler(LOG), logging.StreamHandler()])
|
||||||
|
|
||||||
|
def deploy_zioinfo():
|
||||||
|
logging.info("=== zioinfo-web 배포 시작 ===")
|
||||||
|
steps = [
|
||||||
|
("git pull", ["git", "-C", ZIOINFO_SRC, "pull", "origin", "main"]),
|
||||||
|
("npm build", ["bash", "-c", f"cd {ZIOINFO_SRC}/frontend && npm ci --legacy-peer-deps 2>/dev/null || npm install --legacy-peer-deps && npm run build"]),
|
||||||
|
("mvn package", ["bash", "-c", f"cd {ZIOINFO_SRC}/backend && /usr/bin/mvn clean package -DskipTests -q"]),
|
||||||
|
("deploy jar", ["bash", "-c", f"cp {ZIOINFO_SRC}/backend/target/zioinfo-web-*.jar /opt/zioinfo/app/app.jar"]),
|
||||||
|
("deploy static", ["bash", "-c", f"cp -r {ZIOINFO_SRC}/backend/src/main/resources/static/. /var/www/zioinfo/"]),
|
||||||
|
("restart", ["systemctl", "restart", "zioinfo"]),
|
||||||
|
]
|
||||||
|
for name, cmd in steps:
|
||||||
|
logging.info(f"[{name}] 실행 중...")
|
||||||
|
r = subprocess.run(cmd, capture_output=True, text=True, timeout=600)
|
||||||
|
if r.returncode != 0:
|
||||||
|
logging.error(f"[{name}] 실패: {r.stderr[-300:]}")
|
||||||
|
return False
|
||||||
|
logging.info(f"[{name}] 완료")
|
||||||
|
logging.info("=== zioinfo-web 배포 완료 ===")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def deploy_guardia():
|
||||||
|
logging.info("=== GUARDiA ITSM 배포 시작 ===")
|
||||||
|
steps = [
|
||||||
|
("git clone/pull", ["bash", "-c",
|
||||||
|
f"[ -d {GUARDIA_SRC}/.git ] && git -C {GUARDIA_SRC} pull origin main "
|
||||||
|
f"|| git clone http://zio:Zio%40Admin2026%21@localhost:3000/zio/guardia-itsm.git {GUARDIA_SRC}"]),
|
||||||
|
("rsync", ["bash", "-c",
|
||||||
|
f"rsync -a --exclude=__pycache__ --exclude=*.db --exclude=.git "
|
||||||
|
f"{GUARDIA_SRC}/ /opt/guardia/app/"]),
|
||||||
|
("pip install", ["/opt/guardia/venv/bin/pip", "install", "-r",
|
||||||
|
"/opt/guardia/app/requirements.txt", "-q"]),
|
||||||
|
("restart", ["systemctl", "restart", "guardia"]),
|
||||||
|
]
|
||||||
|
for name, cmd in steps:
|
||||||
|
logging.info(f"[{name}] 실행 중...")
|
||||||
|
r = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
|
||||||
|
if r.returncode != 0:
|
||||||
|
logging.error(f"[{name}] 실패: {r.stderr[-300:]}")
|
||||||
|
return False
|
||||||
|
logging.info(f"[{name}] 완료")
|
||||||
|
logging.info("=== GUARDiA ITSM 배포 완료 ===")
|
||||||
|
return True
|
||||||
|
|
||||||
|
class WebhookHandler(http.server.BaseHTTPRequestHandler):
|
||||||
|
def do_POST(self):
|
||||||
|
length = int(self.headers.get("Content-Length", 0))
|
||||||
|
body = self.rfile.read(length)
|
||||||
|
sig = self.headers.get("X-Gitea-Signature", "")
|
||||||
|
expected = hmac.new(SECRET, body, hashlib.sha256).hexdigest()
|
||||||
|
if sig and not hmac.compare_digest(sig, expected):
|
||||||
|
self.send_response(403); self.end_headers(); return
|
||||||
|
self.send_response(202); self.end_headers()
|
||||||
|
self.wfile.write(b"Deploy queued")
|
||||||
|
# 어떤 저장소인지 판단
|
||||||
|
try:
|
||||||
|
payload = json.loads(body)
|
||||||
|
repo = payload.get("repository", {}).get("name", "")
|
||||||
|
except Exception:
|
||||||
|
repo = ""
|
||||||
|
if "guardia" in repo.lower():
|
||||||
|
threading.Thread(target=deploy_guardia, daemon=True).start()
|
||||||
|
else:
|
||||||
|
threading.Thread(target=deploy_zioinfo, daemon=True).start()
|
||||||
|
def log_message(self, fmt, *args):
|
||||||
|
logging.info(fmt % args)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
os.makedirs("/var/log/zioinfo", exist_ok=True)
|
||||||
|
logging.info("ZioInfo + GUARDiA Deploy Webhook 서버 시작 (포트 9999)")
|
||||||
|
server = http.server.HTTPServer(("0.0.0.0", 9999), WebhookHandler)
|
||||||
|
server.serve_forever()
|
||||||
|
'''
|
||||||
|
|
||||||
|
with sftp.open('/opt/zioinfo/deploy_server.py', 'w') as f:
|
||||||
|
f.write(new_deploy_server)
|
||||||
|
sftp.close()
|
||||||
|
|
||||||
|
run('Deploy 서버 재시작', 'systemctl restart zioinfo-deploy && sleep 3 && systemctl is-active zioinfo-deploy')
|
||||||
|
run('Webhook 테스트', 'curl -s -X POST http://localhost:9999/ -d "{}" -w " HTTP %{http_code}"')
|
||||||
|
|
||||||
|
client.close()
|
||||||
|
print('완료: Deploy Webhook 서버 업데이트 (zioinfo + guardia 모두 지원)')
|
||||||
@ -3,13 +3,87 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<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">
|
<!-- 기본 SEO -->
|
||||||
<meta property="og:title" content="(주)지오정보기술">
|
<title>(주)지오정보기술 — AI 기반 인프라 자율 운영 플랫폼 GUARDiA ITSM</title>
|
||||||
<meta property="og:description" content="AI 기반 레거시 인프라 자율 운영 플랫폼 GUARDiA ITSM">
|
<meta name="description" content="(주)지오정보기술은 AI 기반 레거시 인프라 자율 운영 플랫폼 GUARDiA ITSM을 개발합니다. 1,000개 이상 공공기관 IT 인프라를 메신저 한 줄 명령으로 자동 운영하세요.">
|
||||||
|
<meta name="keywords" content="지오정보기술, GUARDiA, ITSM, AI인프라자동화, 공공기관IT, 레거시인프라, ChatOps, 에이전트리스배포, ERP, CRM, SI, 안산IT기업">
|
||||||
|
<meta name="author" content="(주)지오정보기술">
|
||||||
|
<meta name="robots" content="index, follow">
|
||||||
|
<link rel="canonical" href="https://zioinfo.co.kr/">
|
||||||
|
|
||||||
|
<!-- Open Graph (카카오·페이스북·네이버) -->
|
||||||
<meta property="og:type" content="website">
|
<meta property="og:type" content="website">
|
||||||
<title>(주)지오정보기술</title>
|
<meta property="og:site_name" content="(주)지오정보기술">
|
||||||
<link rel="icon" type="image/png" href="/favicon.ico">
|
<meta property="og:title" content="(주)지오정보기술 — GUARDiA ITSM AI 인프라 자동화">
|
||||||
|
<meta property="og:description" content="메신저 한 줄 명령으로 공공기관 레거시 서버를 자동 운영. GUARDiA ITSM으로 IT 운영 혁신을 경험하세요.">
|
||||||
|
<meta property="og:url" content="https://zioinfo.co.kr/">
|
||||||
|
<meta property="og:image" content="https://zioinfo.co.kr/logo.png">
|
||||||
|
<meta property="og:image:width" content="1200">
|
||||||
|
<meta property="og:image:height" content="630">
|
||||||
|
<meta property="og:locale" content="ko_KR">
|
||||||
|
|
||||||
|
<!-- Twitter Card -->
|
||||||
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
|
<meta name="twitter:title" content="(주)지오정보기술 — GUARDiA ITSM">
|
||||||
|
<meta name="twitter:description" content="AI 기반 레거시 인프라 자율 운영 플랫폼. 공공기관 IT 운영 자동화의 새로운 기준.">
|
||||||
|
<meta name="twitter:image" content="https://zioinfo.co.kr/logo.png">
|
||||||
|
|
||||||
|
<!-- 네이버 서치어드바이저 인증 (등록 후 content 값 교체) -->
|
||||||
|
<!-- <meta name="naver-site-verification" content="YOUR_NAVER_CODE"> -->
|
||||||
|
<!-- 구글 서치콘솔 인증 (등록 후 content 값 교체) -->
|
||||||
|
<!-- <meta name="google-site-verification" content="YOUR_GOOGLE_CODE"> -->
|
||||||
|
|
||||||
|
<!-- JSON-LD: 기업 정보 -->
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "Organization",
|
||||||
|
"name": "(주)지오정보기술",
|
||||||
|
"alternateName": "ZioInfo",
|
||||||
|
"url": "https://zioinfo.co.kr",
|
||||||
|
"logo": "https://zioinfo.co.kr/logo.png",
|
||||||
|
"description": "AI 기반 레거시 인프라 자율 운영 플랫폼 GUARDiA ITSM 및 ERP·CRM·BI 솔루션 전문 IT 기업",
|
||||||
|
"foundingDate": "2000",
|
||||||
|
"address": {
|
||||||
|
"@type": "PostalAddress",
|
||||||
|
"streetAddress": "광덕4로 220 오피스브이 578호",
|
||||||
|
"addressLocality": "안산시 단원구",
|
||||||
|
"addressRegion": "경기도",
|
||||||
|
"postalCode": "15440",
|
||||||
|
"addressCountry": "KR"
|
||||||
|
},
|
||||||
|
"contactPoint": {
|
||||||
|
"@type": "ContactPoint",
|
||||||
|
"telephone": "+82-31-483-1766",
|
||||||
|
"contactType": "customer service",
|
||||||
|
"availableLanguage": "Korean"
|
||||||
|
},
|
||||||
|
"founder": { "@type": "Person", "name": "홍영택" }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- JSON-LD: 웹사이트 -->
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "WebSite",
|
||||||
|
"name": "(주)지오정보기술",
|
||||||
|
"url": "https://zioinfo.co.kr",
|
||||||
|
"potentialAction": {
|
||||||
|
"@type": "SearchAction",
|
||||||
|
"target": "https://zioinfo.co.kr/support/faq?q={search_term_string}",
|
||||||
|
"query-input": "required name=search_term_string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Favicon -->
|
||||||
|
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
||||||
|
<link rel="shortcut icon" href="/favicon.ico">
|
||||||
|
<link rel="apple-touch-icon" href="/logo.png">
|
||||||
|
|
||||||
|
<!-- Fonts -->
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<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">
|
<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">
|
||||||
|
|||||||
BIN
frontend/public/CI.jpg
Normal file
|
After Width: | Height: | Size: 30 KiB |
24
frontend/public/robots.txt
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
# robots.txt — (주)지오정보기술
|
||||||
|
# https://zioinfo.co.kr
|
||||||
|
|
||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
# 관리자 페이지 크롤링 차단
|
||||||
|
Disallow: /admin/
|
||||||
|
Disallow: /api/
|
||||||
|
|
||||||
|
# 크롤러별 개별 설정
|
||||||
|
User-agent: Googlebot
|
||||||
|
Allow: /
|
||||||
|
Crawl-delay: 1
|
||||||
|
|
||||||
|
User-agent: Yeti
|
||||||
|
Allow: /
|
||||||
|
Crawl-delay: 1
|
||||||
|
|
||||||
|
User-agent: Baiduspider
|
||||||
|
Disallow: /
|
||||||
|
|
||||||
|
# 사이트맵 위치
|
||||||
|
Sitemap: https://zioinfo.co.kr/sitemap.xml
|
||||||
165
frontend/public/sitemap.xml
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
|
||||||
|
xmlns:xhtml="http://www.w3.org/1999/xhtml">
|
||||||
|
|
||||||
|
<!-- 홈 -->
|
||||||
|
<url>
|
||||||
|
<loc>https://zioinfo.co.kr/</loc>
|
||||||
|
<lastmod>2026-05-31</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>1.0</priority>
|
||||||
|
</url>
|
||||||
|
|
||||||
|
<!-- 솔루션 -->
|
||||||
|
<url>
|
||||||
|
<loc>https://zioinfo.co.kr/solution/guardia</loc>
|
||||||
|
<lastmod>2026-05-31</lastmod>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.9</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://zioinfo.co.kr/solution/erp</loc>
|
||||||
|
<lastmod>2026-05-31</lastmod>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://zioinfo.co.kr/solution/crm</loc>
|
||||||
|
<lastmod>2026-05-31</lastmod>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://zioinfo.co.kr/solution/bi</loc>
|
||||||
|
<lastmod>2026-05-31</lastmod>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
|
||||||
|
<!-- 회사소개 -->
|
||||||
|
<url>
|
||||||
|
<loc>https://zioinfo.co.kr/company/greeting</loc>
|
||||||
|
<lastmod>2026-05-31</lastmod>
|
||||||
|
<changefreq>yearly</changefreq>
|
||||||
|
<priority>0.8</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://zioinfo.co.kr/company/history</loc>
|
||||||
|
<lastmod>2026-05-31</lastmod>
|
||||||
|
<changefreq>yearly</changefreq>
|
||||||
|
<priority>0.6</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://zioinfo.co.kr/company/organization</loc>
|
||||||
|
<lastmod>2026-05-31</lastmod>
|
||||||
|
<changefreq>yearly</changefreq>
|
||||||
|
<priority>0.5</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://zioinfo.co.kr/company/ci</loc>
|
||||||
|
<lastmod>2026-05-31</lastmod>
|
||||||
|
<changefreq>yearly</changefreq>
|
||||||
|
<priority>0.5</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://zioinfo.co.kr/company/location</loc>
|
||||||
|
<lastmod>2026-05-31</lastmod>
|
||||||
|
<changefreq>yearly</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
|
||||||
|
<!-- 사업실적 -->
|
||||||
|
<url>
|
||||||
|
<loc>https://zioinfo.co.kr/business/reference</loc>
|
||||||
|
<lastmod>2026-05-31</lastmod>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://zioinfo.co.kr/business/partner</loc>
|
||||||
|
<lastmod>2026-05-31</lastmod>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.6</priority>
|
||||||
|
</url>
|
||||||
|
|
||||||
|
<!-- 고객지원 -->
|
||||||
|
<url>
|
||||||
|
<loc>https://zioinfo.co.kr/support/notice</loc>
|
||||||
|
<lastmod>2026-05-31</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://zioinfo.co.kr/support/faq</loc>
|
||||||
|
<lastmod>2026-05-31</lastmod>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://zioinfo.co.kr/support/catalog</loc>
|
||||||
|
<lastmod>2026-05-31</lastmod>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.6</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://zioinfo.co.kr/support/contact</loc>
|
||||||
|
<lastmod>2026-05-31</lastmod>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.8</priority>
|
||||||
|
</url>
|
||||||
|
|
||||||
|
<!-- 채용 -->
|
||||||
|
<url>
|
||||||
|
<loc>https://zioinfo.co.kr/recruit/jobs</loc>
|
||||||
|
<lastmod>2026-05-31</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://zioinfo.co.kr/recruit/welfare</loc>
|
||||||
|
<lastmod>2026-05-31</lastmod>
|
||||||
|
<changefreq>yearly</changefreq>
|
||||||
|
<priority>0.5</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://zioinfo.co.kr/recruit/apply</loc>
|
||||||
|
<lastmod>2026-05-31</lastmod>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.6</priority>
|
||||||
|
</url>
|
||||||
|
|
||||||
|
<!-- 뉴스 -->
|
||||||
|
<url>
|
||||||
|
<loc>https://zioinfo.co.kr/news/newsroom</loc>
|
||||||
|
<lastmod>2026-05-31</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://zioinfo.co.kr/news/blog</loc>
|
||||||
|
<lastmod>2026-05-31</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.6</priority>
|
||||||
|
</url>
|
||||||
|
|
||||||
|
<!-- 정책 -->
|
||||||
|
<url>
|
||||||
|
<loc>https://zioinfo.co.kr/privacy</loc>
|
||||||
|
<lastmod>2026-05-31</lastmod>
|
||||||
|
<changefreq>yearly</changefreq>
|
||||||
|
<priority>0.4</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://zioinfo.co.kr/terms</loc>
|
||||||
|
<lastmod>2026-05-31</lastmod>
|
||||||
|
<changefreq>yearly</changefreq>
|
||||||
|
<priority>0.4</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://zioinfo.co.kr/sitemap</loc>
|
||||||
|
<lastmod>2026-05-31</lastmod>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.3</priority>
|
||||||
|
</url>
|
||||||
|
|
||||||
|
</urlset>
|
||||||
BIN
frontend/public/ziologo.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
frontend/public/지오정보기술로고.png
Normal file
|
After Width: | Height: | Size: 140 KiB |
BIN
frontend/public/지오정보기술사옥전경.png
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
@ -14,6 +14,12 @@ const NewsPage = lazy(() => import('./pages/NewsPage'));
|
|||||||
const Recruit = lazy(() => import('./pages/Recruit'));
|
const Recruit = lazy(() => import('./pages/Recruit'));
|
||||||
const NotFound = lazy(() => import('./pages/NotFound'));
|
const NotFound = lazy(() => import('./pages/NotFound'));
|
||||||
|
|
||||||
|
// Member Auth
|
||||||
|
const MemberLogin = lazy(() => import('./pages/MemberLogin'));
|
||||||
|
const Privacy = lazy(() => import('./pages/Privacy'));
|
||||||
|
const Terms = lazy(() => import('./pages/Terms'));
|
||||||
|
const Sitemap = lazy(() => import('./pages/Sitemap'));
|
||||||
|
|
||||||
// Admin
|
// Admin
|
||||||
const AdminLogin = lazy(() => import('./pages/admin/AdminLogin'));
|
const AdminLogin = lazy(() => import('./pages/admin/AdminLogin'));
|
||||||
const AdminLayout = lazy(() => import('./pages/admin/AdminLayout'));
|
const AdminLayout = lazy(() => import('./pages/admin/AdminLayout'));
|
||||||
@ -22,6 +28,7 @@ const AdminNews = lazy(() => import('./pages/admin/AdminNews'));
|
|||||||
const AdminInquiry = lazy(() => import('./pages/admin/AdminInquiry'));
|
const AdminInquiry = lazy(() => import('./pages/admin/AdminInquiry'));
|
||||||
const AdminRecruit = lazy(() => import('./pages/admin/AdminRecruit'));
|
const AdminRecruit = lazy(() => import('./pages/admin/AdminRecruit'));
|
||||||
const AdminSettings = lazy(() => import('./pages/admin/AdminSettings'));
|
const AdminSettings = lazy(() => import('./pages/admin/AdminSettings'));
|
||||||
|
const AdminMember = lazy(() => import('./pages/admin/AdminMember'));
|
||||||
|
|
||||||
function Loading() {
|
function Loading() {
|
||||||
return (
|
return (
|
||||||
@ -57,6 +64,7 @@ export default function App() {
|
|||||||
<Route path="news" element={<AdminNews />} />
|
<Route path="news" element={<AdminNews />} />
|
||||||
<Route path="inquiries" element={<AdminInquiry />} />
|
<Route path="inquiries" element={<AdminInquiry />} />
|
||||||
<Route path="recruit" element={<AdminRecruit />} />
|
<Route path="recruit" element={<AdminRecruit />} />
|
||||||
|
<Route path="members" element={<AdminMember />} />
|
||||||
<Route path="settings" element={<AdminSettings />} />
|
<Route path="settings" element={<AdminSettings />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="*" element={<Navigate to="/admin/login" replace />} />
|
<Route path="*" element={<Navigate to="/admin/login" replace />} />
|
||||||
@ -77,6 +85,11 @@ export default function App() {
|
|||||||
<Route path="/support/*" element={<Support />} />
|
<Route path="/support/*" element={<Support />} />
|
||||||
<Route path="/recruit/*" element={<Recruit />} />
|
<Route path="/recruit/*" element={<Recruit />} />
|
||||||
<Route path="/news/*" element={<NewsPage />} />
|
<Route path="/news/*" element={<NewsPage />} />
|
||||||
|
<Route path="/login" element={<MemberLogin />} />
|
||||||
|
<Route path="/register" element={<MemberLogin />} />
|
||||||
|
<Route path="/privacy" element={<Privacy />} />
|
||||||
|
<Route path="/terms" element={<Terms />} />
|
||||||
|
<Route path="/sitemap" element={<Sitemap />} />
|
||||||
<Route path="*" element={<NotFound />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</PublicLayout>
|
</PublicLayout>
|
||||||
|
|||||||
@ -48,8 +48,9 @@ export default function Footer() {
|
|||||||
{/* 회사 정보 */}
|
{/* 회사 정보 */}
|
||||||
<div className="footer-brand">
|
<div className="footer-brand">
|
||||||
<Link to="/" className="footer-logo">
|
<Link to="/" className="footer-logo">
|
||||||
<img src="/logo-white.png" alt="(주)지오정보기술 로고" height="36"
|
<img src="/지오정보기술로고.png" alt="(주)지오정보기술 로고" height="36"
|
||||||
onError={e => { e.target.style.display='none'; e.target.nextSibling.style.display='block'; }} />
|
style={{ filter: 'brightness(0) invert(1)' }}
|
||||||
|
onError={e => { e.target.src='/ziologo.png'; e.target.onerror = () => { e.target.style.display='none'; e.target.nextSibling.style.display='block'; }; }} />
|
||||||
<span className="footer-logo-text" style={{display:'none'}}>
|
<span className="footer-logo-text" style={{display:'none'}}>
|
||||||
<strong>Zio</strong>Info
|
<strong>Zio</strong>Info
|
||||||
</span>
|
</span>
|
||||||
@ -61,7 +62,7 @@ export default function Footer() {
|
|||||||
<div className="footer-contact-list">
|
<div className="footer-contact-list">
|
||||||
<div className="footer-contact-item">
|
<div className="footer-contact-item">
|
||||||
<span className="contact-label">대표전화</span>
|
<span className="contact-label">대표전화</span>
|
||||||
<span>02-000-0000</span>
|
<a href="tel:031-483-1766">031-483-1766</a>
|
||||||
</div>
|
</div>
|
||||||
<div className="footer-contact-item">
|
<div className="footer-contact-item">
|
||||||
<span className="contact-label">이메일</span>
|
<span className="contact-label">이메일</span>
|
||||||
@ -69,7 +70,7 @@ export default function Footer() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="footer-contact-item">
|
<div className="footer-contact-item">
|
||||||
<span className="contact-label">주소</span>
|
<span className="contact-label">주소</span>
|
||||||
<span>서울특별시</span>
|
<span>경기도 안산시 단원구 광덕4로 220 오피스브이 578호</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import './Header.css';
|
import './Header.css';
|
||||||
|
|
||||||
const MENU = [
|
const MENU = [
|
||||||
@ -59,7 +59,27 @@ export default function Header() {
|
|||||||
const [scrolled, setScrolled] = useState(false);
|
const [scrolled, setScrolled] = useState(false);
|
||||||
const [activeMenu, setActiveMenu] = useState(null);
|
const [activeMenu, setActiveMenu] = useState(null);
|
||||||
const [mobileOpen, setMobileOpen] = useState(false);
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
|
const [member, setMember] = useState(null);
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// 로그인 상태 동기화
|
||||||
|
useEffect(() => {
|
||||||
|
const sync = () => {
|
||||||
|
const u = localStorage.getItem('member_user');
|
||||||
|
setMember(u ? JSON.parse(u) : null);
|
||||||
|
};
|
||||||
|
sync();
|
||||||
|
window.addEventListener('storage', sync);
|
||||||
|
return () => window.removeEventListener('storage', sync);
|
||||||
|
}, [location]);
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
localStorage.removeItem('member_token');
|
||||||
|
localStorage.removeItem('member_user');
|
||||||
|
setMember(null);
|
||||||
|
navigate('/');
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onScroll = () => setScrolled(window.scrollY > 60);
|
const onScroll = () => setScrolled(window.scrollY > 60);
|
||||||
@ -85,8 +105,8 @@ export default function Header() {
|
|||||||
<div className="header-inner container">
|
<div className="header-inner container">
|
||||||
{/* 로고 */}
|
{/* 로고 */}
|
||||||
<Link to="/" className="logo" aria-label="(주)지오정보기술 홈으로">
|
<Link to="/" className="logo" aria-label="(주)지오정보기술 홈으로">
|
||||||
<img src="/logo.png" alt="(주)지오정보기술 로고" height="40"
|
<img src="/지오정보기술로고.png" alt="(주)지오정보기술 로고" height="40"
|
||||||
onError={e => { e.target.style.display='none'; e.target.nextSibling.style.display='flex'; }} />
|
onError={e => { e.target.src='/ziologo.png'; e.target.onerror = () => { e.target.style.display='none'; e.target.nextSibling.style.display='flex'; }; }} />
|
||||||
<span className="logo-text" style={{display:'none'}}>
|
<span className="logo-text" style={{display:'none'}}>
|
||||||
<strong>Zio</strong>Info
|
<strong>Zio</strong>Info
|
||||||
</span>
|
</span>
|
||||||
@ -119,10 +139,25 @@ export default function Header() {
|
|||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* 문의 버튼 */}
|
{/* 우측 버튼 영역 */}
|
||||||
<Link to="/support/contact" className="btn btn-primary btn-sm header-cta">
|
<div style={{ display:'flex', alignItems:'center', gap:8 }}>
|
||||||
문의하기
|
<Link to="/support/contact" className="btn btn-outline btn-sm">문의하기</Link>
|
||||||
</Link>
|
{member ? (
|
||||||
|
<div style={{ display:'flex', alignItems:'center', gap:8 }}>
|
||||||
|
<span style={{ fontSize:13, color:'var(--gray-600)', maxWidth:100,
|
||||||
|
overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>
|
||||||
|
{member.name}님
|
||||||
|
</span>
|
||||||
|
<button onClick={logout}
|
||||||
|
style={{ padding:'6px 14px', background:'none', border:'1px solid #e2e8f0',
|
||||||
|
borderRadius:8, fontSize:12, color:'#64748b', cursor:'pointer' }}>
|
||||||
|
로그아웃
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Link to="/login" className="btn btn-primary btn-sm">로그인</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 햄버거 (모바일) */}
|
{/* 햄버거 (모바일) */}
|
||||||
<button className="hamburger" aria-label="모바일 메뉴"
|
<button className="hamburger" aria-label="모바일 메뉴"
|
||||||
@ -148,9 +183,13 @@ export default function Header() {
|
|||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
))}
|
))}
|
||||||
<Link to="/support/contact" className="btn btn-primary" style={{margin:'16px'}}>
|
<div style={{ display:'flex', gap:8, margin:'16px' }}>
|
||||||
문의하기
|
<Link to="/support/contact" className="btn btn-outline" style={{ flex:1 }}>문의하기</Link>
|
||||||
</Link>
|
{member
|
||||||
|
? <button onClick={logout} className="btn btn-primary" style={{ flex:1 }}>로그아웃</button>
|
||||||
|
: <Link to="/login" className="btn btn-primary" style={{ flex:1 }}>로그인 / 가입</Link>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
83
frontend/src/hooks/useMemberAuth.jsx
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회원 인증 상태 관리 훅.
|
||||||
|
* - isLoggedIn: 로그인 여부
|
||||||
|
* - member: 회원 정보
|
||||||
|
* - requireLogin(redirectPath): 비회원이면 로그인 페이지로 이동
|
||||||
|
*/
|
||||||
|
export function useMemberAuth() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [member, setMember] = useState(null);
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const token = localStorage.getItem('member_token');
|
||||||
|
const user = localStorage.getItem('member_user');
|
||||||
|
if (token && user) {
|
||||||
|
try { setMember(JSON.parse(user)); } catch { /* 손상된 데이터 무시 */ }
|
||||||
|
}
|
||||||
|
setLoaded(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
localStorage.removeItem('member_token');
|
||||||
|
localStorage.removeItem('member_user');
|
||||||
|
setMember(null);
|
||||||
|
navigate('/');
|
||||||
|
};
|
||||||
|
|
||||||
|
const requireLogin = (redirectPath = window.location.pathname) => {
|
||||||
|
if (!member) {
|
||||||
|
navigate(`/login?redirect=${encodeURIComponent(redirectPath)}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
member,
|
||||||
|
isLoggedIn: !!member,
|
||||||
|
loaded,
|
||||||
|
logout,
|
||||||
|
requireLogin,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회원 전용 접근 보호 컴포넌트.
|
||||||
|
* 비로그인 시 잠금 오버레이 표시.
|
||||||
|
*
|
||||||
|
* 사용:
|
||||||
|
* <MemberOnly feature="상담 신청">
|
||||||
|
* <ConsultForm />
|
||||||
|
* </MemberOnly>
|
||||||
|
*/
|
||||||
|
export function MemberOnly({ children, feature = '이 기능' }) {
|
||||||
|
const { isLoggedIn, loaded } = useMemberAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
if (!loaded) return null;
|
||||||
|
|
||||||
|
if (!isLoggedIn) {
|
||||||
|
return (
|
||||||
|
<div className="member-guard" style={{ position:'relative', minHeight:120 }}>
|
||||||
|
{children}
|
||||||
|
<div className="member-guard-overlay">
|
||||||
|
<div className="member-guard-icon">🔒</div>
|
||||||
|
<div className="member-guard-text">{feature}은 회원 전용입니다</div>
|
||||||
|
<div className="member-guard-sub">로그인 후 이용하실 수 있습니다</div>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/login')}
|
||||||
|
style={{ marginTop:8, padding:'8px 24px', background:'#1a5fd8', color:'#fff',
|
||||||
|
border:'none', borderRadius:8, cursor:'pointer', fontWeight:600, fontSize:14 }}>
|
||||||
|
로그인 / 회원가입
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return children;
|
||||||
|
}
|
||||||
60
frontend/src/hooks/useSeoMeta.js
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
const BASE = 'https://zioinfo.co.kr';
|
||||||
|
const SITE = '(주)지오정보기술';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 페이지별 SEO 메타태그 동적 업데이트.
|
||||||
|
* @param {Object} opts
|
||||||
|
* @param {string} opts.title 페이지 제목 (| 사이트명 자동 추가)
|
||||||
|
* @param {string} opts.description 페이지 설명 (160자 이내 권장)
|
||||||
|
* @param {string} [opts.path] 정규 URL 경로 (예: '/solution/guardia')
|
||||||
|
* @param {string} [opts.image] OG 이미지 URL
|
||||||
|
* @param {string} [opts.keywords] 추가 키워드 (콤마 구분)
|
||||||
|
*/
|
||||||
|
export function useSeoMeta({ title, description, path = '', image = '/logo.png', keywords = '' }) {
|
||||||
|
useEffect(() => {
|
||||||
|
const fullTitle = title ? `${title} | ${SITE}` : SITE;
|
||||||
|
const canonical = `${BASE}${path}`;
|
||||||
|
const ogImage = image.startsWith('http') ? image : `${BASE}${image}`;
|
||||||
|
|
||||||
|
// title
|
||||||
|
document.title = fullTitle;
|
||||||
|
|
||||||
|
// 메타 업데이트 헬퍼
|
||||||
|
const set = (selector, attr, value) => {
|
||||||
|
let el = document.querySelector(selector);
|
||||||
|
if (!el) {
|
||||||
|
el = document.createElement('meta');
|
||||||
|
const [k, v] = selector.replace('meta[', '').replace(']', '').split('=');
|
||||||
|
el.setAttribute(k, v.replace(/"/g, ''));
|
||||||
|
document.head.appendChild(el);
|
||||||
|
}
|
||||||
|
el.setAttribute(attr, value);
|
||||||
|
};
|
||||||
|
|
||||||
|
// canonical
|
||||||
|
let canonical_el = document.querySelector('link[rel="canonical"]');
|
||||||
|
if (!canonical_el) {
|
||||||
|
canonical_el = document.createElement('link');
|
||||||
|
canonical_el.rel = 'canonical';
|
||||||
|
document.head.appendChild(canonical_el);
|
||||||
|
}
|
||||||
|
canonical_el.href = canonical;
|
||||||
|
|
||||||
|
// 기본
|
||||||
|
set('meta[name="description"]', 'content', description);
|
||||||
|
if (keywords) set('meta[name="keywords"]', 'content', keywords);
|
||||||
|
|
||||||
|
// OG
|
||||||
|
set('meta[property="og:title"]', 'content', fullTitle);
|
||||||
|
set('meta[property="og:description"]', 'content', description);
|
||||||
|
set('meta[property="og:url"]', 'content', canonical);
|
||||||
|
set('meta[property="og:image"]', 'content', ogImage);
|
||||||
|
|
||||||
|
// Twitter
|
||||||
|
set('meta[name="twitter:title"]', 'content', fullTitle);
|
||||||
|
set('meta[name="twitter:description"]', 'content', description);
|
||||||
|
set('meta[name="twitter:image"]', 'content', ogImage);
|
||||||
|
}, [title, description, path, image, keywords]);
|
||||||
|
}
|
||||||
@ -4,4 +4,16 @@
|
|||||||
padding: 60px 0; color: #fff;
|
padding: 60px 0; color: #fff;
|
||||||
}
|
}
|
||||||
.page-hero-title { font-size: 40px; font-weight: 900; margin: 8px 0 12px; }
|
.page-hero-title { font-size: 40px; font-weight: 900; margin: 8px 0 12px; }
|
||||||
.page-hero p { color: rgba(255,255,255,.75); font-size: 16px; }
|
.page-hero p { color: rgba(255,255,255,.75); font-size: 16px; }
|
||||||
|
|
||||||
|
/* ── 정책/약관 페이지 ─────────────────────────────────────── */
|
||||||
|
.prose { color: var(--gray-700); line-height: 1.8; font-size: 15px; }
|
||||||
|
.prose h2 { font-size: 18px; font-weight: 700; color: var(--gray-900); margin: 32px 0 12px; border-left: 4px solid var(--accent); padding-left: 12px; }
|
||||||
|
.prose p { margin-bottom: 14px; }
|
||||||
|
.prose ul { margin: 0 0 14px 20px; }
|
||||||
|
.prose ul li { margin-bottom: 6px; }
|
||||||
|
.prose a { color: var(--accent); }
|
||||||
|
.policy-table { width: 100%; border-collapse: collapse; margin: 14px 0; font-size: 14px; }
|
||||||
|
.policy-table th, .policy-table td { padding: 10px 14px; border: 1px solid var(--gray-200); }
|
||||||
|
.policy-table th { background: var(--gray-50); font-weight: 600; color: var(--gray-700); text-align: left; }
|
||||||
|
.policy-footer { margin-top: 40px; padding: 20px; background: var(--gray-50); border-radius: 10px; font-size: 13px; color: var(--gray-500); line-height: 1.8; }
|
||||||
|
|||||||
@ -82,6 +82,7 @@
|
|||||||
.ci-section { margin-bottom: 56px; }
|
.ci-section { margin-bottom: 56px; }
|
||||||
.ci-title { font-size: 20px; font-weight: 800; color: var(--gray-900); margin-bottom: 24px; padding-bottom: 12px; border-bottom: 2px solid var(--gray-200); }
|
.ci-title { font-size: 20px; font-weight: 800; color: var(--gray-900); margin-bottom: 24px; padding-bottom: 12px; border-bottom: 2px solid var(--gray-200); }
|
||||||
.ci-logo-wrap { display: flex; gap: 24px; flex-wrap: wrap; }
|
.ci-logo-wrap { display: flex; gap: 24px; flex-wrap: wrap; }
|
||||||
|
.ci-logo-showcase { display: flex; gap: 24px; flex-wrap: wrap; }
|
||||||
.ci-logo-box {
|
.ci-logo-box {
|
||||||
flex: 1; min-width: 200px; padding: 48px 32px; border-radius: 12px;
|
flex: 1; min-width: 200px; padding: 48px 32px; border-radius: 12px;
|
||||||
display: flex; flex-direction: column; align-items: center; gap: 16px;
|
display: flex; flex-direction: column; align-items: center; gap: 16px;
|
||||||
@ -92,7 +93,7 @@
|
|||||||
.ci-logo-mark { font-size: 32px; font-weight: 900; color: var(--accent); letter-spacing: 4px; }
|
.ci-logo-mark { font-size: 32px; font-weight: 900; color: var(--accent); letter-spacing: 4px; }
|
||||||
.ci-logo-sub { font-size: 14px; font-weight: 600; color: rgba(255,255,255,.7); letter-spacing: 2px; }
|
.ci-logo-sub { font-size: 14px; font-weight: 600; color: rgba(255,255,255,.7); letter-spacing: 2px; }
|
||||||
.ci-logo-label { font-size: 12px; color: rgba(255,255,255,.5); }
|
.ci-logo-label { font-size: 12px; color: rgba(255,255,255,.5); }
|
||||||
.ci-colors { display: grid; grid-template-columns: repeat(4, 1fr); gap: 20px; }
|
.ci-colors { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; }
|
||||||
.ci-color-card { border: 1px solid var(--gray-200); border-radius: 10px; overflow: hidden; }
|
.ci-color-card { border: 1px solid var(--gray-200); border-radius: 10px; overflow: hidden; }
|
||||||
.ci-color-swatch { height: 100px; }
|
.ci-color-swatch { height: 100px; }
|
||||||
.ci-color-info { padding: 16px; display: flex; flex-direction: column; gap: 4px; }
|
.ci-color-info { padding: 16px; display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
|||||||