""" 통합 검색 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}