guardia-itsm/routers/search.py

139 lines
5.2 KiB
Python

"""
통합 검색 API (모바일 기능 #50).
GET /api/search/?q={query}&types=sr,server,kb,institution
SR, 서버(CMDB), KB 문서, 기관을 동시에 검색하여 타입별 결과를 반환.
보안: 서버 결과는 ServerOut 안전 필드만 반환(ip_addr/ssh_user/os_pw_enc 제외).
CUSTOMER 역할은 자신의 기관 SR/서버만 조회.
"""
from __future__ import annotations
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select, or_
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user
from database import get_db
from models import (
Institution, KBDocument, Server, SRRequest, User, UserRole,
)
router = APIRouter(prefix="/api/search", tags=["Search"])
_PER_TYPE_LIMIT = 5
async def _customer_inst_id(user: User, db: AsyncSession) -> Optional[int]:
"""CUSTOMER 역할이면 소속 기관 id 반환, 아니면 None."""
if user.role == UserRole.CUSTOMER and user.inst_code:
inst = (await db.execute(
select(Institution).where(Institution.inst_code == user.inst_code)
)).scalars().first()
return inst.id if inst else -1
return None
@router.get("/")
async def global_search(
q: str = Query(..., min_length=1, description="검색어"),
types: str = Query("sr,server,kb,institution", description="콤마 구분 검색 대상"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""SR + 서버 + KB + 기관 통합 검색."""
wanted = {t.strip() for t in types.split(",") if t.strip()}
if not wanted:
raise HTTPException(422, "types에 최소 하나의 검색 대상을 지정하세요.")
like = f"%{q}%"
results: dict = {}
cust_inst_id = await _customer_inst_id(current_user, db)
# ── SR 검색 ──────────────────────────────────────────────────────────
if "sr" in wanted:
sr_q = select(SRRequest).where(
or_(SRRequest.title.ilike(like),
SRRequest.description.ilike(like),
SRRequest.sr_id.ilike(like))
)
if cust_inst_id is not None:
sr_q = sr_q.where(SRRequest.inst_id == cust_inst_id)
sr_q = sr_q.order_by(SRRequest.created_at.desc()).limit(_PER_TYPE_LIMIT)
srs = (await db.execute(sr_q)).scalars().all()
results["sr"] = [
{
"sr_id": s.sr_id,
"title": s.title,
"status": s.status,
"priority": s.priority,
"sr_type": s.sr_type,
}
for s in srs
]
# ── 서버(CMDB) 검색 — 자격증명 필드 절대 제외 ──────────────────────────
if "server" in wanted:
srv_q = select(Server).where(
or_(Server.server_name.ilike(like),
Server.server_role.ilike(like),
Server.os_type.ilike(like))
)
if cust_inst_id is not None:
srv_q = srv_q.where(Server.inst_id == cust_inst_id)
srv_q = srv_q.limit(_PER_TYPE_LIMIT)
servers = (await db.execute(srv_q)).scalars().all()
results["server"] = [
{
"id": s.id,
"server_name": s.server_name,
"server_role": s.server_role,
"os_type": s.os_type,
"inst_id": s.inst_id,
# ip_addr / ssh_user / os_pw_enc 절대 미포함
}
for s in servers
]
# ── KB 검색 ──────────────────────────────────────────────────────────
if "kb" in wanted:
kb_q = select(KBDocument).where(
or_(KBDocument.title.ilike(like),
KBDocument.symptoms.ilike(like),
KBDocument.tags.ilike(like))
).limit(_PER_TYPE_LIMIT)
kbs = (await db.execute(kb_q)).scalars().all()
results["kb"] = [
{
"doc_id": k.doc_id,
"title": k.title,
"category": k.category,
"tags": k.tags,
}
for k in kbs
]
# ── 기관 검색 ────────────────────────────────────────────────────────
if "institution" in wanted:
inst_q = select(Institution).where(
or_(Institution.inst_name.ilike(like),
Institution.inst_code.ilike(like))
)
if cust_inst_id is not None and cust_inst_id != -1:
inst_q = inst_q.where(Institution.id == cust_inst_id)
inst_q = inst_q.limit(_PER_TYPE_LIMIT)
insts = (await db.execute(inst_q)).scalars().all()
results["institution"] = [
{
"id": i.id,
"inst_code": i.inst_code,
"inst_name": i.inst_name,
}
for i in insts
]
total = sum(len(v) for v in results.values())
return {"query": q, "total": total, "results": results}