""" 세션 API — 프론트 `features/snap/api/snap.api.ts` 계약. 전부 본인 세션만. GET /api/v1/chat/sessions?page&limit → SnapSession[] + meta POST /api/v1/chat/sessions → SnapSession (OpenCode 세션 생성 + 미러) GET /api/v1/chat/sessions/search?query&page&limit → SnapMessage[] + meta (본문 icontains) GET /api/v1/chat/sessions/{id}/messages → SnapSessionDetail POST /api/v1/chat/sessions/{id}/cancel → null (OpenCode abort + isGenerating 해제) 스트림(POST /chat/stream)은 stream.py — async 라 DRF 밖. """ import logging import httpx from django.shortcuts import get_object_or_404 from rest_framework.response import Response from rest_framework.views import APIView from common.envelope import CodedError, paged from common.opencode_service import opencode_service from .models import ChatMessage, ChatSession log = logging.getLogger(__name__) MAX_LIMIT = 100 def _page_args(request) -> tuple[int, int]: try: page = max(1, int(request.query_params.get("page", 1))) limit = min(MAX_LIMIT, max(1, int(request.query_params.get("limit", 20)))) except ValueError: raise CodedError(400, "VALIDATION_ERROR", "page/limit 은 정수여야 해") return page, limit def _slice(qs, page: int, limit: int): total = qs.count() items = list(qs[(page - 1) * limit : page * limit]) return items, total def _own_session(request, session_id: str) -> ChatSession: return get_object_or_404(ChatSession, id=session_id, user=request.user) def _upstream(e: Exception) -> CodedError: if isinstance(e, httpx.ConnectError): return CodedError(503, "UPSTREAM_UNAVAILABLE", "OpenCode 서버에 연결할 수 없어") if isinstance(e, httpx.HTTPStatusError): return CodedError(502, "UPSTREAM_ERROR", f"OpenCode 오류: {e.response.text[:300]}") log.exception("OpenCode 호출 실패") return CodedError(502, "UPSTREAM_ERROR", f"OpenCode 중계 오류: {e}") class SessionListView(APIView): def get(self, request): page, limit = _page_args(request) items, total = _slice(ChatSession.objects.filter(user=request.user), page, limit) return Response(paged([s.as_dto() for s in items], page, limit, total)) def post(self, request): try: created = opencode_service.create_session() except Exception as e: # noqa: BLE001 raise _upstream(e) session = ChatSession.objects.create(id=created["id"], user=request.user) return Response(session.as_dto(), status=201) class SearchView(APIView): def get(self, request): query = (request.query_params.get("query") or "").strip() if not query: raise CodedError(400, "VALIDATION_ERROR", "query 가 비었어") page, limit = _page_args(request) qs = ( ChatMessage.objects.filter(session__user=request.user, content__icontains=query) .select_related("session") .order_by("-created_at", "-id") ) items, total = _slice(qs, page, limit) return Response(paged([m.as_dto() for m in items], page, limit, total)) class SessionMessagesView(APIView): def get(self, request, session_id: str): session = _own_session(request, session_id) return Response({**session.as_dto(), "messages": [m.as_dto() for m in session.messages.all()]}) class CancelView(APIView): def post(self, request, session_id: str): session = _own_session(request, session_id) try: opencode_service.abort_session(session.id) except Exception: # noqa: BLE001 — best-effort. 이미 끝났으면 실패해도 됨 log.warning("abort 실패 (이미 끝났을 수 있음): %s", session.id) if session.is_generating: session.is_generating = False session.save(update_fields=["is_generating", "updated_at"]) return Response(None)