Files
leehc991028andClaude Fable 5.1 2869985139 feat(backend): Django + OpenCode 백엔드 추가 — 5_django_backend/
앱이 기대하는 base-backend 계약(envelope·JWT·/chat/stream SSE)을 그대로 구현.
Django 는 로그인·세션 미러(SQLite/PG)·OpenCode 이벤트 번역만 맡고, 답변은 전용
OpenCode 인스턴스(opencode/ workspace, codeassist 에이전트)가 만듦.
- accounts: 이메일 로그인, access 60분 / refresh 14일, entra/config 는 501
- chat: 세션 목록·검색·메시지·취소 + POST /chat/stream 어댑터(part.delta→token,
  idle→usage/done, 클라 끊겨도 턴 감시 태스크가 DB 마무리, 첫 이벤트 60초 타임아웃)
- OpenCode 1.18 멀티 프로젝트라 모든 요청에 ?directory= 부착
- docs-lib 에 OpenCode SDK 1.18.6 타입 원본, pytest 32

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 21:28:31 +09:00

104 lines
3.9 KiB
Python

"""
세션 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)