feat: 관리자 대시보드 — 토큰·비용·응답시간 집계 API + 앱 /admin 페이지

- backend apps/stats: GET /api/v1/admin/stats?from&to → totals / byUser / byDay. ChatMessage(assistant) 집계만, is_superuser 아니면 403. 테스트 3개
- frontend features/admin: 요약 카드 4개(요청·토큰·비용·평균 응답), 일별 토큰 막대(div), 사용자별 표, 기간 프리셋(오늘/7/30/90일). 헤더에 ADMIN 만 아이콘. 테스트 2개

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
lee-hyeon-cheol
2026-09-21 13:39:06 +09:00
co-authored by Claude Fable 5.1
parent aa37cef13d
commit d4a5f20787
16 changed files with 517 additions and 2 deletions
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class StatsConfig(AppConfig):
name = "apps.stats"
verbose_name = "관리자 통계(사용량·비용·시간)"
+7
View File
@@ -0,0 +1,7 @@
from django.urls import path
from .views import AdminStatsView
urlpatterns = [
path("stats", AdminStatsView.as_view()),
]
+106
View File
@@ -0,0 +1,106 @@
"""관리자 대시보드 집계 — ChatMessage(assistant 행)에 이미 쌓인 토큰·비용·시간을 모아서 줌.
GET /api/v1/admin/stats?from=YYYY-MM-DD&to=YYYY-MM-DD
{ totals, byUser[], byDay[] } (is_superuser 만. 아니면 403 FORBIDDEN)
새로 모으는 건 없음. 기간 기본은 최근 30일(to 포함).
"""
from datetime import date, datetime, time, timedelta
from django.db.models import Avg, Count, F, Sum
from django.db.models.functions import TruncDate
from django.utils import timezone
from rest_framework.response import Response
from rest_framework.views import APIView
from apps.chat.models import ChatMessage
from common.envelope import CodedError
MAX_DAYS = 366
def _parse_range(request) -> tuple[date, date]:
today = timezone.localdate()
raw_from, raw_to = request.query_params.get("from"), request.query_params.get("to")
try:
d_to = date.fromisoformat(raw_to) if raw_to else today
d_from = date.fromisoformat(raw_from) if raw_from else d_to - timedelta(days=29)
except ValueError:
raise CodedError(400, "VALIDATION_ERROR", "from/to 는 YYYY-MM-DD 여야 해")
if d_from > d_to:
raise CodedError(400, "VALIDATION_ERROR", "from 이 to 보다 늦어")
if (d_to - d_from).days >= MAX_DAYS:
raise CodedError(400, "VALIDATION_ERROR", f"기간은 최대 {MAX_DAYS}")
return d_from, d_to
def _agg(qs):
"""공통 집계 — 요청 수·토큰·비용·평균 응답시간."""
return qs.aggregate(
requests=Count("id"),
input_tokens=Sum("input_tokens"),
output_tokens=Sum("output_tokens"),
cost_usd=Sum("cost_usd"),
avg_elapsed_ms=Avg("elapsed_ms"),
)
def _row(d: dict) -> dict:
return {
"requests": d["requests"] or 0,
"inputTokens": d["input_tokens"] or 0,
"outputTokens": d["output_tokens"] or 0,
"totalTokens": (d["input_tokens"] or 0) + (d["output_tokens"] or 0),
"costUsd": round(d["cost_usd"] or 0.0, 6),
"avgElapsedMs": round(d["avg_elapsed_ms"]) if d["avg_elapsed_ms"] is not None else None,
}
class AdminStatsView(APIView):
def get(self, request):
if not request.user.is_superuser:
raise CodedError(403, "FORBIDDEN", "관리자만 볼 수 있어")
d_from, d_to = _parse_range(request)
tz = timezone.get_current_timezone()
start = datetime.combine(d_from, time.min, tzinfo=tz)
end = datetime.combine(d_to + timedelta(days=1), time.min, tzinfo=tz)
base = ChatMessage.objects.filter(role="assistant", created_at__gte=start, created_at__lt=end)
by_user = (
base.values(user_id=F("session__user_id"), email=F("session__user__email"), name=F("session__user__user_name"))
.annotate(
requests=Count("id"),
input_tokens=Sum("input_tokens"),
output_tokens=Sum("output_tokens"),
cost_usd=Sum("cost_usd"),
avg_elapsed_ms=Avg("elapsed_ms"),
sessions=Count("session_id", distinct=True),
)
.order_by("-cost_usd", "-requests")
)
by_day = (
base.annotate(day=TruncDate("created_at", tzinfo=tz))
.values("day")
.annotate(
requests=Count("id"),
input_tokens=Sum("input_tokens"),
output_tokens=Sum("output_tokens"),
cost_usd=Sum("cost_usd"),
avg_elapsed_ms=Avg("elapsed_ms"),
)
.order_by("day")
)
return Response(
{
"from": d_from.isoformat(),
"to": d_to.isoformat(),
"totals": {**_row(_agg(base)), "users": base.values("session__user_id").distinct().count(),
"sessions": base.values("session_id").distinct().count()},
"byUser": [
{"userId": r["user_id"], "email": r["email"], "name": r["name"] or "", "sessions": r["sessions"], **_row(r)}
for r in by_user
],
"byDay": [{"day": r["day"].isoformat(), **_row(r)} for r in by_day],
}
)
+1
View File
@@ -100,6 +100,7 @@ INSTALLED_APPS = [
"apps.accounts",
"apps.chat",
"apps.gateway",
"apps.stats",
]
MIDDLEWARE = [
+1
View File
@@ -21,5 +21,6 @@ urlpatterns = [
path("api/v1/auth/", include("apps.accounts.urls")),
path("api/v1/users/me", MeView.as_view()),
path("api/v1/chat/", include("apps.chat.urls")),
path("api/v1/admin/", include("apps.stats.urls")), # 관리자 대시보드 집계
path("", include("apps.gateway.urls")), # /api/ito/* — 사내 LLM(FabriX) 중계
]
+60
View File
@@ -0,0 +1,60 @@
"""GET /api/v1/admin/stats — 관리자만, 토큰·비용·시간 집계."""
from datetime import timedelta
import pytest
from django.utils import timezone
from rest_framework.test import APIClient
from apps.chat.models import ChatMessage, ChatSession
def _msg(session, role="assistant", days_ago=0, **kw):
m = ChatMessage.objects.create(session=session, role=role, content="x", **kw)
if days_ago:
ChatMessage.objects.filter(id=m.id).update(created_at=timezone.now() - timedelta(days=days_ago))
return m
@pytest.fixture
def admin_api(db, django_user_model):
admin = django_user_model.objects.create_superuser(username="a@x.com", email="a@x.com", password="x")
c = APIClient()
c.force_authenticate(admin)
return c, admin
def test_non_admin_forbidden(auth_api):
res = auth_api.get("/api/v1/admin/stats")
assert res.status_code == 403 and res.json()["code"] == "FORBIDDEN"
def test_totals_by_user_by_day(admin_api, user):
api, admin = admin_api
s1 = ChatSession.objects.create(id="s1", user=user)
s2 = ChatSession.objects.create(id="s2", user=admin)
_msg(s1, role="user") # user 행은 집계 제외
_msg(s1, input_tokens=100, output_tokens=50, cost_usd=0.01, elapsed_ms=1000)
_msg(s1, input_tokens=200, output_tokens=100, cost_usd=0.02, elapsed_ms=3000, days_ago=1)
_msg(s2, input_tokens=10, output_tokens=5, cost_usd=0.001, elapsed_ms=500)
_msg(s2, input_tokens=999, output_tokens=999, cost_usd=9.0, elapsed_ms=9, days_ago=40) # 기간 밖
data = api.get("/api/v1/admin/stats").json()["data"]
t = data["totals"]
assert t["requests"] == 3 and t["inputTokens"] == 310 and t["outputTokens"] == 155 and t["totalTokens"] == 465
assert t["costUsd"] == pytest.approx(0.031) and t["avgElapsedMs"] == 1500
assert t["users"] == 2 and t["sessions"] == 2
by_user = {r["email"]: r for r in data["byUser"]}
assert by_user[user.email]["requests"] == 2 and by_user[user.email]["totalTokens"] == 450
assert by_user[user.email]["sessions"] == 1 and by_user[user.email]["avgElapsedMs"] == 2000
assert data["byUser"][0]["email"] == user.email # 비용 큰 순
assert len(data["byDay"]) == 2 and sum(r["requests"] for r in data["byDay"]) == 3
def test_range_validation(admin_api):
api, _ = admin_api
assert api.get("/api/v1/admin/stats?from=2026-13-01").status_code == 400
assert api.get("/api/v1/admin/stats?from=2026-09-10&to=2026-09-01").status_code == 400
res = api.get("/api/v1/admin/stats?from=2026-09-01&to=2026-09-02")
assert res.status_code == 200 and res.json()["data"]["from"] == "2026-09-01"