G-1: 메신저 Webhook Relay + _send_to_room 실제 httpx 호출 구현 G-2: POST /api/tasks/bulk SR 대량작업 엔드포인트 (최대 100건) G-3: 라이선스 만료 알림 스케줄러 (매일 09:00 KST) G-4: 체험판 upgrade_banner 필드 + license.py 배너 로직 G-5: core/auto_rca.py + incidents/problem auto-rca 엔드포인트 G-6: core/deploy_impact.py + vibe impact-analysis 엔드포인트 G-7: core/ticket_classifier.py + SR 생성 시 AI 분류 + ai-suggestion API G-8: VulnPatchRecord 모델 + vuln_scan 패치추적 4개 엔드포인트 G-9: core/jira_sync.py + gateway Jira/Confluence 연동 엔드포인트 G-10: core/push_notify.py + routers/push.py + PushSubscription 모델 G-11: approvals 다중승인 (위임/서명/기한초과/마감연장) G-12: alembic.ini + migrations/ + cicd/migrate_to_postgres.sh 하네스: guardia-orchestrator 확장기능 Phase 반영 봇명령어: /sr /status /license /bulk 슬래시 명령어 추가 설치스크립트: setup/ (Ubuntu, CentOS, RHEL, Windows) --test 옵션 포함 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
348 lines
12 KiB
Python
348 lines
12 KiB
Python
"""
|
|
C-3: Problem Management API 라우터
|
|
|
|
엔드포인트:
|
|
POST /api/problem/ — 문제 레코드 생성
|
|
GET /api/problem/ — 문제 목록
|
|
GET /api/problem/{prb_id} — 문제 상세
|
|
PATCH /api/problem/{prb_id} — 문제 수정
|
|
POST /api/problem/{prb_id}/rca — RCA 기록
|
|
POST /api/problem/{prb_id}/workaround — 임시 해결 기록
|
|
POST /api/problem/{prb_id}/resolve — 해결 처리
|
|
POST /api/problem/{prb_id}/close — 종결 처리
|
|
POST /api/problem/{prb_id}/notes — 활동 노트 추가
|
|
GET /api/problem/{prb_id}/notes — 활동 노트 목록
|
|
GET /api/problem/known-errors — Known Error DB
|
|
GET /api/problem/stats — Problem 통계
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
from datetime import datetime, timedelta
|
|
from typing import List, Optional
|
|
|
|
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
|
from sqlalchemy import select, desc, func, and_, or_
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from core.auth import get_current_user
|
|
from database import get_db
|
|
from models import (
|
|
User, UserRole,
|
|
ProblemRecord, ProblemRecordOut, ProblemRecordCreate,
|
|
ProblemNote, ProblemNoteCreate,
|
|
ProblemStatus, ProblemCategory,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(prefix="/api/problem", tags=["problem"])
|
|
|
|
|
|
async def _next_problem_id(db: AsyncSession) -> str:
|
|
today = datetime.utcnow().strftime("%Y%m%d")
|
|
prefix = f"PRB-{today}-"
|
|
last = (await db.execute(
|
|
select(ProblemRecord.problem_id)
|
|
.where(ProblemRecord.problem_id.like(f"{prefix}%"))
|
|
.order_by(desc(ProblemRecord.problem_id)).limit(1)
|
|
)).scalar()
|
|
seq = 1 if not last else (int(last.split("-")[-1]) + 1 if last else 1)
|
|
return f"{prefix}{seq:04d}"
|
|
|
|
|
|
async def _get_problem(db: AsyncSession, prb_id: str) -> ProblemRecord:
|
|
prb = (await db.execute(
|
|
select(ProblemRecord).where(ProblemRecord.problem_id == prb_id)
|
|
)).scalars().first()
|
|
if not prb:
|
|
raise HTTPException(404, f"문제 레코드 {prb_id}를 찾을 수 없습니다.")
|
|
return prb
|
|
|
|
|
|
@router.post("/", response_model=ProblemRecordOut, status_code=201)
|
|
async def create_problem(
|
|
body: ProblemRecordCreate,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""문제 레코드 생성."""
|
|
problem_id = await _next_problem_id(db)
|
|
prb = ProblemRecord(
|
|
problem_id = problem_id,
|
|
title = body.title,
|
|
description = body.description,
|
|
status = ProblemStatus.OPEN.value,
|
|
priority = body.priority.upper(),
|
|
category = body.category.upper(),
|
|
related_sr_ids = json.dumps(body.related_sr_ids or [], ensure_ascii=False),
|
|
ci_id = body.ci_id,
|
|
affected_users = body.affected_users,
|
|
incident_count = body.incident_count,
|
|
total_downtime_min = body.total_downtime_min,
|
|
created_by = current_user.username,
|
|
)
|
|
db.add(prb)
|
|
await db.commit()
|
|
await db.refresh(prb)
|
|
return prb
|
|
|
|
|
|
@router.get("/known-errors", response_model=List[ProblemRecordOut])
|
|
async def known_errors(
|
|
limit: int = Query(20, ge=1, le=100),
|
|
db: AsyncSession = Depends(get_db),
|
|
_u: User = Depends(get_current_user),
|
|
):
|
|
"""Known Error DB 조회."""
|
|
rows = (await db.execute(
|
|
select(ProblemRecord)
|
|
.where(ProblemRecord.known_error == True)
|
|
.order_by(desc(ProblemRecord.updated_at))
|
|
.limit(limit)
|
|
)).scalars().all()
|
|
return rows
|
|
|
|
|
|
@router.get("/stats")
|
|
async def problem_stats(
|
|
days: int = Query(30, ge=1, le=365),
|
|
db: AsyncSession = Depends(get_db),
|
|
_u: User = Depends(get_current_user),
|
|
):
|
|
"""Problem 통계."""
|
|
since = datetime.utcnow() - timedelta(days=days)
|
|
total = (await db.execute(
|
|
select(func.count()).select_from(ProblemRecord).where(ProblemRecord.created_at >= since)
|
|
)).scalar() or 0
|
|
|
|
by_status = {}
|
|
for st, cnt in (await db.execute(
|
|
select(ProblemRecord.status, func.count())
|
|
.where(ProblemRecord.created_at >= since)
|
|
.group_by(ProblemRecord.status)
|
|
)).all():
|
|
by_status[st] = cnt
|
|
|
|
known_err_count = (await db.execute(
|
|
select(func.count()).select_from(ProblemRecord)
|
|
.where(ProblemRecord.known_error == True)
|
|
)).scalar() or 0
|
|
|
|
return {
|
|
"period_days": days,
|
|
"total": total,
|
|
"by_status": by_status,
|
|
"known_errors": known_err_count,
|
|
"open": by_status.get("OPEN", 0),
|
|
"investigating": by_status.get("INVESTIGATING", 0),
|
|
"rca_done": by_status.get("RCA_DONE", 0),
|
|
}
|
|
|
|
|
|
@router.get("/", response_model=List[ProblemRecordOut])
|
|
async def list_problems(
|
|
status: Optional[str] = Query(None),
|
|
priority: Optional[str] = Query(None),
|
|
category: Optional[str] = Query(None),
|
|
limit: int = Query(20, ge=1, le=100),
|
|
offset: int = Query(0, ge=0),
|
|
db: AsyncSession = Depends(get_db),
|
|
_u: User = Depends(get_current_user),
|
|
):
|
|
"""문제 레코드 목록."""
|
|
conditions = []
|
|
if status: conditions.append(ProblemRecord.status == status.upper())
|
|
if priority: conditions.append(ProblemRecord.priority == priority.upper())
|
|
if category: conditions.append(ProblemRecord.category == category.upper())
|
|
|
|
q = select(ProblemRecord)
|
|
if conditions:
|
|
q = q.where(and_(*conditions))
|
|
q = q.order_by(desc(ProblemRecord.created_at)).limit(limit).offset(offset)
|
|
return (await db.execute(q)).scalars().all()
|
|
|
|
|
|
@router.get("/{prb_id}", response_model=ProblemRecordOut)
|
|
async def get_problem(prb_id: str, db: AsyncSession = Depends(get_db), _u: User = Depends(get_current_user)):
|
|
return await _get_problem(db, prb_id)
|
|
|
|
|
|
@router.patch("/{prb_id}", response_model=ProblemRecordOut)
|
|
async def update_problem(
|
|
prb_id: str,
|
|
body: dict = Body(...),
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""문제 레코드 수정."""
|
|
prb = await _get_problem(db, prb_id)
|
|
for k, v in body.items():
|
|
if hasattr(prb, k) and k not in ("id", "problem_id", "created_at"):
|
|
setattr(prb, k, v)
|
|
await db.commit()
|
|
await db.refresh(prb)
|
|
return prb
|
|
|
|
|
|
@router.post("/{prb_id}/rca")
|
|
async def record_rca(
|
|
prb_id: str,
|
|
root_cause: str = Body(..., embed=True),
|
|
permanent_fix: Optional[str] = Body(None, embed=True),
|
|
known_error: bool = Body(False, embed=True),
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""RCA (근본 원인 분석) 기록."""
|
|
prb = await _get_problem(db, prb_id)
|
|
prb.root_cause = root_cause
|
|
prb.permanent_fix = permanent_fix
|
|
prb.known_error = known_error
|
|
prb.status = ProblemStatus.RCA_DONE.value
|
|
prb.rca_completed_at = datetime.utcnow()
|
|
|
|
note = ProblemNote(
|
|
problem_id_fk = prb.id,
|
|
note_type = "RCA",
|
|
content = f"RCA 완료: {root_cause[:200]}",
|
|
author = current_user.username,
|
|
)
|
|
db.add(note)
|
|
await db.commit()
|
|
return {"problem_id": prb_id, "status": prb.status, "known_error": known_error}
|
|
|
|
|
|
@router.post("/{prb_id}/workaround")
|
|
async def record_workaround(
|
|
prb_id: str,
|
|
workaround: str = Body(..., embed=True),
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""임시 해결 방법 기록."""
|
|
prb = await _get_problem(db, prb_id)
|
|
prb.workaround = workaround
|
|
if prb.status not in (ProblemStatus.RESOLVED.value, ProblemStatus.CLOSED.value):
|
|
prb.status = ProblemStatus.WORKAROUND.value
|
|
|
|
note = ProblemNote(
|
|
problem_id_fk = prb.id,
|
|
note_type = "WORKAROUND",
|
|
content = f"임시 해결책: {workaround[:200]}",
|
|
author = current_user.username,
|
|
)
|
|
db.add(note)
|
|
await db.commit()
|
|
return {"problem_id": prb_id, "status": prb.status}
|
|
|
|
|
|
@router.post("/{prb_id}/resolve")
|
|
async def resolve_problem(
|
|
prb_id: str,
|
|
resolution: Optional[str] = Body(None, embed=True),
|
|
rfc_id: Optional[str] = Body(None, embed=True),
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""문제 해결 처리."""
|
|
prb = await _get_problem(db, prb_id)
|
|
prb.status = ProblemStatus.RESOLVED.value
|
|
prb.resolved_at = datetime.utcnow()
|
|
if resolution: prb.permanent_fix = resolution
|
|
if rfc_id: prb.rfc_id = rfc_id
|
|
|
|
note = ProblemNote(
|
|
problem_id_fk = prb.id,
|
|
note_type = "RESOLUTION",
|
|
content = resolution or "문제 해결 완료",
|
|
author = current_user.username,
|
|
)
|
|
db.add(note)
|
|
await db.commit()
|
|
return {"problem_id": prb_id, "status": prb.status}
|
|
|
|
|
|
@router.post("/{prb_id}/close")
|
|
async def close_problem(
|
|
prb_id: str,
|
|
note_text: Optional[str] = Body(None, embed=True),
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""문제 종결 처리."""
|
|
prb = await _get_problem(db, prb_id)
|
|
if prb.status != ProblemStatus.RESOLVED.value:
|
|
raise HTTPException(400, f"RESOLVED 상태가 아닙니다: {prb.status}")
|
|
prb.status = ProblemStatus.CLOSED.value
|
|
prb.closed_at = datetime.utcnow()
|
|
await db.commit()
|
|
return {"problem_id": prb_id, "status": prb.status}
|
|
|
|
|
|
@router.post("/{prb_id}/notes")
|
|
async def add_note(
|
|
prb_id: str,
|
|
body: ProblemNoteCreate,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""활동 노트 추가."""
|
|
prb = await _get_problem(db, prb_id)
|
|
if prb.status == ProblemStatus.INVESTIGATING.value or prb.status == ProblemStatus.OPEN.value:
|
|
prb.status = ProblemStatus.INVESTIGATING.value
|
|
note = ProblemNote(
|
|
problem_id_fk = prb.id,
|
|
note_type = body.note_type.upper(),
|
|
content = body.content,
|
|
author = current_user.username,
|
|
)
|
|
db.add(note)
|
|
await db.commit()
|
|
return {"ok": True, "note_type": body.note_type}
|
|
|
|
|
|
@router.get("/{prb_id}/notes")
|
|
async def get_notes(
|
|
prb_id: str,
|
|
db: AsyncSession = Depends(get_db),
|
|
_u: User = Depends(get_current_user),
|
|
):
|
|
"""활동 노트 목록."""
|
|
prb = await _get_problem(db, prb_id)
|
|
notes = (await db.execute(
|
|
select(ProblemNote)
|
|
.where(ProblemNote.problem_id_fk == prb.id)
|
|
.order_by(ProblemNote.created_at)
|
|
)).scalars().all()
|
|
return [
|
|
{"id": n.id, "note_type": n.note_type, "content": n.content,
|
|
"author": n.author, "created_at": n.created_at.isoformat()}
|
|
for n in notes
|
|
]
|
|
|
|
|
|
# ── G-5: 자동 RCA 분석 ────────────────────────────────────────────────────────
|
|
|
|
@router.post("/{prb_id}/auto-rca")
|
|
async def auto_rca_problem(
|
|
prb_id: str,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""Ollama LLM으로 Problem RCA 초안 자동 생성 후 레코드에 저장."""
|
|
prb = await _get_problem(db, prb_id)
|
|
try:
|
|
from core.auto_rca import analyze_problem_rca
|
|
result = await analyze_problem_rca(prb.id, db)
|
|
except Exception as e:
|
|
raise HTTPException(500, f"RCA 분석 오류: {str(e)[:200]}")
|
|
|
|
rca_data = result["rca"]
|
|
prb.root_cause = rca_data.get("root_cause", "")
|
|
prb.permanent_fix = json.dumps(rca_data.get("prevention", []), ensure_ascii=False)
|
|
prb.rca_completed_at = datetime.utcnow()
|
|
prb.updated_at = datetime.utcnow()
|
|
await db.commit()
|
|
return result
|