Compare commits

..

No commits in common. "d46843bf268708e064ae42e9388e39cd0887c8af" and "fee9b812f6eca188f29291018c6bfd5326fe9fce" have entirely different histories.

114 changed files with 5480 additions and 194 deletions

View File

@ -19,6 +19,7 @@ public class AdminController {
private final NewsRepository newsRepo;
private final InquiryRepository inquiryRepo;
private final RecruitRepository recruitRepo;
private final MemberRepository memberRepo;
private final JwtUtil jwtUtil;
private final PasswordEncoder passwordEncoder;
@ -47,6 +48,8 @@ public class AdminController {
stats.put("pendingInquiries", inquiryRepo.countByStatus("PENDING"));
stats.put("totalRecruits", recruitRepo.count());
stats.put("activeRecruits", recruitRepo.countByActiveTrue());
stats.put("totalMembers", memberRepo.count());
stats.put("activeMembers", memberRepo.countByActiveTrue());
stats.put("recentInquiries", inquiryRepo.findTop5ByOrderByCreatedAtDesc());
stats.put("recentNews", newsRepo.findTop5ByOrderByCreatedAtDesc());
return ResponseEntity.ok(stats);
@ -164,6 +167,43 @@ public class AdminController {
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")
public ResponseEntity<?> changePassword(

View File

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

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1 @@
.inner-page{padding-top:var(--header-h)}.page-hero{background:linear-gradient(135deg,var(--secondary),var(--primary-dark));padding:60px 0;color:#fff}.page-hero-title{font-size:40px;font-weight:900;margin:8px 0 12px}.page-hero p{color:#ffffffbf;font-size:16px}.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}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
.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}}

File diff suppressed because one or more lines are too long

View File

@ -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 파싱 자동 실행

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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

View File

@ -1,22 +1,96 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="(주)지오정보기술 — AI 기반 레거시 인프라 자율 운영 플랫폼 GUARDiA ITSM 및 ERP·CRM·BI 솔루션">
<meta name="keywords" content="지오정보기술, GUARDiA, ITSM, 인프라자동화, 공공기관, ERP, ChatOps">
<meta property="og:title" content="(주)지오정보기술">
<meta property="og:description" content="AI 기반 레거시 인프라 자율 운영 플랫폼 GUARDiA ITSM">
<meta property="og:type" content="website">
<title>(주)지오정보기술</title>
<link rel="icon" type="image/png" href="/favicon.ico">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700;900&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<script type="module" crossorigin src="/assets/index-ChpGil2q.js"></script>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- 기본 SEO -->
<title>(주)지오정보기술 — AI 기반 인프라 자율 운영 플랫폼 GUARDiA ITSM</title>
<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: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">
</head>
<body>
<div id="root"></div>
</body>
</html>
</head>
<body>
<div id="root"></div>
</body>
</html>

View 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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

View File

@ -0,0 +1,52 @@
server:
port: 8080
servlet:
encoding:
charset: UTF-8
force: true
spring:
application:
name: zioinfo-web
datasource:
# SQLite — 파일 기반 DB (data/ 디렉토리 자동 생성)
url: jdbc:sqlite:./data/zioinfo.db
driver-class-name: org.sqlite.JDBC
jpa:
database-platform: org.hibernate.community.dialect.SQLiteDialect
hibernate:
ddl-auto: update # 스키마 자동 갱신 (운영: validate)
show-sql: false
properties:
hibernate:
format_sql: true
# SQLite는 foreign key 비활성 기본 → 명시 활성화
javax.persistence.schema-generation.database.action: none
mail:
host: ${MAIL_HOST:smtp.gmail.com}
port: ${MAIL_PORT:587}
username: ${MAIL_USERNAME:}
password: ${MAIL_PASSWORD:}
properties:
mail.smtp.auth: true
mail.smtp.starttls.enable: true
zioinfo:
jwt:
secret: zioinfo-admin-jwt-secret-key-must-be-at-least-32-chars-long
expiration-ms: 28800000 # 8시간
company:
name: (주)지오정보기술
email: info@zioinfo.co.kr
phone: 02-000-0000
address: 서울특별시
cors:
allowed-origins:
- http://localhost:3000
- http://localhost:5173
- http://www.zioinfo.co.kr
logging:
level:
kr.co.zioinfo: DEBUG
org.hibernate.SQL: WARN

View File

@ -0,0 +1,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

View File

@ -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
View 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
View 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
View 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
View 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
View 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
View 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
View 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'
' (주)지오정보기술 &nbsp;|&nbsp; 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
View 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
View 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
View 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
View 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
View 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
View 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">&#128737;</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">&#128231; 이메일 발송 테스트</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">&#9989; 발송 성공</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">&#9989; 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">&#9989; 서명됨</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">&#129302; 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
View 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
View 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')

View 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 구축 완료')

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

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

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

View 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 모두 지원)')

View File

@ -3,13 +3,87 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="(주)지오정보기술 — AI 기반 레거시 인프라 자율 운영 플랫폼 GUARDiA ITSM 및 ERP·CRM·BI 솔루션">
<meta name="keywords" content="지오정보기술, GUARDiA, ITSM, 인프라자동화, 공공기관, ERP, ChatOps">
<meta property="og:title" content="(주)지오정보기술">
<meta property="og:description" content="AI 기반 레거시 인프라 자율 운영 플랫폼 GUARDiA ITSM">
<!-- 기본 SEO -->
<title>(주)지오정보기술 — AI 기반 인프라 자율 운영 플랫폼 GUARDiA ITSM</title>
<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">
<title>(주)지오정보기술</title>
<link rel="icon" type="image/png" href="/favicon.ico">
<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">

BIN
frontend/public/CI.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

View File

@ -14,6 +14,12 @@ const NewsPage = lazy(() => import('./pages/NewsPage'));
const Recruit = lazy(() => import('./pages/Recruit'));
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
const AdminLogin = lazy(() => import('./pages/admin/AdminLogin'));
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 AdminRecruit = lazy(() => import('./pages/admin/AdminRecruit'));
const AdminSettings = lazy(() => import('./pages/admin/AdminSettings'));
const AdminMember = lazy(() => import('./pages/admin/AdminMember'));
function Loading() {
return (
@ -57,6 +64,7 @@ export default function App() {
<Route path="news" element={<AdminNews />} />
<Route path="inquiries" element={<AdminInquiry />} />
<Route path="recruit" element={<AdminRecruit />} />
<Route path="members" element={<AdminMember />} />
<Route path="settings" element={<AdminSettings />} />
</Route>
<Route path="*" element={<Navigate to="/admin/login" replace />} />
@ -77,6 +85,11 @@ export default function App() {
<Route path="/support/*" element={<Support />} />
<Route path="/recruit/*" element={<Recruit />} />
<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 />} />
</Routes>
</PublicLayout>

View File

@ -48,8 +48,9 @@ export default function Footer() {
{/* 회사 정보 */}
<div className="footer-brand">
<Link to="/" className="footer-logo">
<img src="/logo-white.png" alt="(주)지오정보기술 로고" height="36"
onError={e => { e.target.style.display='none'; e.target.nextSibling.style.display='block'; }} />
<img src="/지오정보기술로고.png" alt="(주)지오정보기술 로고" height="36"
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'}}>
<strong>Zio</strong>Info
</span>
@ -61,7 +62,7 @@ export default function Footer() {
<div className="footer-contact-list">
<div className="footer-contact-item">
<span className="contact-label">대표전화</span>
<span>02-000-0000</span>
<a href="tel:031-483-1766">031-483-1766</a>
</div>
<div className="footer-contact-item">
<span className="contact-label">이메일</span>
@ -69,7 +70,7 @@ export default function Footer() {
</div>
<div className="footer-contact-item">
<span className="contact-label">주소</span>
<span>서울특별시</span>
<span>경기도 안산시 단원구 광덕4로 220 오피스브이 578</span>
</div>
</div>
</div>

View File

@ -1,5 +1,5 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Link, useLocation } from 'react-router-dom';
import React, { useState, useEffect } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import './Header.css';
const MENU = [
@ -59,7 +59,27 @@ export default function Header() {
const [scrolled, setScrolled] = useState(false);
const [activeMenu, setActiveMenu] = useState(null);
const [mobileOpen, setMobileOpen] = useState(false);
const [member, setMember] = useState(null);
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(() => {
const onScroll = () => setScrolled(window.scrollY > 60);
@ -85,8 +105,8 @@ export default function Header() {
<div className="header-inner container">
{/* 로고 */}
<Link to="/" className="logo" aria-label="(주)지오정보기술 홈으로">
<img src="/logo.png" alt="(주)지오정보기술 로고" height="40"
onError={e => { e.target.style.display='none'; e.target.nextSibling.style.display='flex'; }} />
<img src="/지오정보기술로고.png" alt="(주)지오정보기술 로고" height="40"
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'}}>
<strong>Zio</strong>Info
</span>
@ -119,10 +139,25 @@ export default function Header() {
))}
</nav>
{/* 문의 버튼 */}
<Link to="/support/contact" className="btn btn-primary btn-sm header-cta">
문의하기
</Link>
{/* 우측 버튼 영역 */}
<div style={{ display:'flex', alignItems:'center', gap:8 }}>
<Link to="/support/contact" className="btn btn-outline btn-sm">문의하기</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="모바일 메뉴"
@ -148,9 +183,13 @@ export default function Header() {
</div>
</details>
))}
<Link to="/support/contact" className="btn btn-primary" style={{margin:'16px'}}>
문의하기
</Link>
<div style={{ display:'flex', gap:8, margin:'16px' }}>
<Link to="/support/contact" className="btn btn-outline" style={{ flex:1 }}>문의하기</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>
)}
</header>

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

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

View File

@ -4,4 +4,16 @@
padding: 60px 0; color: #fff;
}
.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; }

View File

@ -82,6 +82,7 @@
.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-logo-wrap { display: flex; gap: 24px; flex-wrap: wrap; }
.ci-logo-showcase { display: flex; gap: 24px; flex-wrap: wrap; }
.ci-logo-box {
flex: 1; min-width: 200px; padding: 48px 32px; border-radius: 12px;
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-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-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-swatch { height: 100px; }
.ci-color-info { padding: 16px; display: flex; flex-direction: column; gap: 4px; }

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