From 5e9e0da9728b32694b94f0fc2806ae3c3feed0ca Mon Sep 17 00:00:00 2001 From: Flook Date: Sat, 15 Nov 2025 06:40:19 +0700 Subject: [PATCH] =?UTF-8?q?=E0=B8=9E=E0=B8=B1=E0=B8=92=E0=B8=99=E0=B8=B2?= =?UTF-8?q?=20API=20Gateway=20Rate=20Limiting=20Logic=20=E0=B9=81=E0=B8=A5?= =?UTF-8?q?=E0=B8=B0=20Global=20Auditing=20Logic=20=E0=B9=83=E0=B8=99?= =?UTF-8?q?=E0=B8=9D=E0=B8=B1=E0=B9=88=E0=B8=87=20Backend=20(Django=20DRF)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/api/migrations/0001_initial.py | 37 +++++++++++ backend/api/models.py | 25 ++++++- backend/api/serializers/__init__.py | 0 backend/api/serializers/audit_serializer.py | 16 +++++ backend/api/views/audit_viewset.py | 65 +++++++++++++++++++ backend/core/settings.py | 12 +++- backend/core/urls.py | 11 +++- .../repositories/ai_model_repository.py | 24 +++++++ .../services/ai_model_service.py | 30 ++++++++- .../model_registry/views/ai_model_viewset.py | 5 +- 10 files changed, 218 insertions(+), 7 deletions(-) create mode 100644 backend/api/migrations/0001_initial.py create mode 100644 backend/api/serializers/__init__.py create mode 100644 backend/api/serializers/audit_serializer.py create mode 100644 backend/api/views/audit_viewset.py diff --git a/backend/api/migrations/0001_initial.py b/backend/api/migrations/0001_initial.py new file mode 100644 index 0000000..3e803e2 --- /dev/null +++ b/backend/api/migrations/0001_initial.py @@ -0,0 +1,37 @@ +# Generated by Django 5.2.7 on 2025-11-14 23:02 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('model_registry', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='InferenceAuditLog', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('timestamp', models.DateTimeField(auto_now_add=True, verbose_name='เวลาเรียกใช้')), + ('endpoint_url', models.CharField(max_length=500, verbose_name='FastAPI Endpoint')), + ('http_status', models.IntegerField(verbose_name='HTTP Status Code')), + ('latency_ms', models.FloatField(verbose_name='Latency (ms)')), + ('is_success', models.BooleanField(default=False, verbose_name='สำเร็จหรือไม่')), + ('response_summary', models.TextField(blank=True, null=True, verbose_name='ผลลัพธ์สรุป/ข้อความ error')), + ('model', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='model_registry.aimodel', verbose_name='Model ที่ถูกเรียก')), + ('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='ผู้ใช้งาน')), + ], + options={ + 'verbose_name': 'Inference Audit Log', + 'verbose_name_plural': 'Inference Audit Logs', + 'ordering': ['-timestamp'], + }, + ), + ] diff --git a/backend/api/models.py b/backend/api/models.py index 71a8362..77c95c9 100644 --- a/backend/api/models.py +++ b/backend/api/models.py @@ -1,3 +1,26 @@ from django.db import models +from django.conf import settings +from model_registry.models import AiModel -# Create your models here. +class InferenceAuditLog(models.Model): + """บันทึกทุกคำสั่งรัน Inference ที่เข้ามาใน Gateway""" + + # ข้อมูลผู้ใช้/โมเดล + # ใช้ ForeignKey ไปยัง CustomUser และ AiModel + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, verbose_name="ผู้ใช้งาน") + model = models.ForeignKey(AiModel, on_delete=models.SET_NULL, null=True, verbose_name="Model ที่ถูกเรียก") + + # ข้อมูลการประมวลผล + timestamp = models.DateTimeField(auto_now_add=True, verbose_name="เวลาเรียกใช้") + endpoint_url = models.CharField(max_length=500, verbose_name="FastAPI Endpoint") + http_status = models.IntegerField(verbose_name="HTTP Status Code") + latency_ms = models.FloatField(verbose_name="Latency (ms)") + is_success = models.BooleanField(default=False, verbose_name="สำเร็จหรือไม่") + + # ผลลัพธ์ + response_summary = models.TextField(blank=True, null=True, verbose_name="ผลลัพธ์สรุป/ข้อความ error") + + class Meta: + ordering = ['-timestamp'] + verbose_name = "Inference Audit Log" + verbose_name_plural = "Inference Audit Logs" diff --git a/backend/api/serializers/__init__.py b/backend/api/serializers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/api/serializers/audit_serializer.py b/backend/api/serializers/audit_serializer.py new file mode 100644 index 0000000..ddb730d --- /dev/null +++ b/backend/api/serializers/audit_serializer.py @@ -0,0 +1,16 @@ +from rest_framework import serializers +from api.models import InferenceAuditLog + +class InferenceAuditLogSerializer(serializers.ModelSerializer): + # แสดงข้อมูล Model และ User ที่เกี่ยวข้อง + model_name = serializers.CharField(source='model.name', read_only=True) + username = serializers.CharField(source='user.username', read_only=True) + + class Meta: + model = InferenceAuditLog + fields = ( + 'id', 'user', 'username', 'model', 'model_name', + 'timestamp', 'http_status', 'latency_ms', + 'is_success', 'response_summary' + ) + read_only_fields = fields \ No newline at end of file diff --git a/backend/api/views/audit_viewset.py b/backend/api/views/audit_viewset.py new file mode 100644 index 0000000..0b72c69 --- /dev/null +++ b/backend/api/views/audit_viewset.py @@ -0,0 +1,65 @@ +from rest_framework import viewsets, permissions, status +from rest_framework.response import Response +from rest_framework.decorators import action +from django.db.models import Avg, Count, Q +from django.utils import timezone +from datetime import timedelta + +from api.models import InferenceAuditLog +from api.serializers.audit_serializer import InferenceAuditLogSerializer +from permissions.permission_classes import IsAdminOrManager # ใช้สิทธิ์เดียวกันกับ Model Registry + +class AuditLogViewSet(viewsets.ReadOnlyModelViewSet): + """ + API สำหรับการเข้าถึง Inference Audit Log และสถิติรวม + """ + queryset = InferenceAuditLog.objects.all() + serializer_class = InferenceAuditLogSerializer + permission_classes = [permissions.IsAuthenticated] # อนุญาตให้เข้าถึงเมื่อล็อกอินแล้ว + + # ใช้ 'id' เป็นฟิลด์ค้นหา + lookup_field = 'id' + + # ใช้ 'id' เป็นชื่อพารามิเตอร์ใน URL + lookup_url_kwarg = 'id' + + def retrieve(self, request, *args, **kwargs): + # บังคับให้ Lookup Key (pk) เป็น String + kwargs[self.lookup_url_kwarg] = str(kwargs[self.lookup_url_kwarg]) + + return super().retrieve(request, *args, **kwargs) + + def get_queryset(self): + # คืน Log ล่าสุด 10 รายการ (สำหรับ Recent Events ใน Dashboard) + return self.queryset.select_related('model', 'user')[:10] + + # ----------------------------------------------- + # Custom Action: ดึงสถิติรวมสำหรับ Dashboard + # Endpoint: GET /api/v1/audit/inference-summary/ + # ----------------------------------------------- + @action(detail=False, methods=['get'], url_path='inference-summary') + def get_summary(self, request): + one_day_ago = timezone.now() - timedelta(hours=24) + + # 1. คำนวณสถิติรวม (Global Metrics) + metrics = self.queryset.filter(timestamp__gte=one_day_ago).aggregate( + total_runs=Count('id'), + success_count=Count('id', filter=Q(is_success=True)), + avg_latency_ms=Avg('latency_ms') + ) + + # 2. คำนวณ Success Rate + total = metrics.get('total_runs', 0) + success = metrics.get('success_count', 0) + success_rate = (success / total) * 100 if total > 0 else 0 + + # 3. คำนวณสถิติแยกตาม Model (แผนในอนาคต) + # model_stats = self.queryset.filter(...).values('model__name').annotate(...) + + return Response({ + "time_window": "24 hours", + "total_runs": total, + "success_rate": round(success_rate, 2), + "avg_latency_ms": round(metrics.get('avg_latency_ms', 0) or 0, 2), + "last_logs": InferenceAuditLogSerializer(self.get_queryset(), many=True).data + }) \ No newline at end of file diff --git a/backend/core/settings.py b/backend/core/settings.py index 52e0abb..409311b 100644 --- a/backend/core/settings.py +++ b/backend/core/settings.py @@ -189,12 +189,20 @@ REST_FRAMEWORK = { 'rest_framework.renderers.JSONRenderer', 'rest_framework.renderers.BrowsableAPIRenderer', ), - # เพิ่มการตั้งค่าเพื่อรองรับตัวเลขขนาดใหญ่ใน JSON 'COERCE_DECIMAL_TO_STRING': False, # ตั้งค่านี้ไว้เผื่อ - # ตัวเลขขนาดใหญ่ (เช่น BigIntegerField) ต้องถูก Serialize เป็น String 'DATETIME_FORMAT': "%Y-%m-%d %H:%M:%S", + + 'DEFAULT_THROTTLE_CLASSES': [ + 'rest_framework.throttling.AnonRateThrottle', + 'rest_framework.throttling.UserRateThrottle', + ], + 'DEFAULT_THROTTLE_RATES': { + 'anon': '100/day', + 'user': '50/minute', + 'inference_burst': '20/minute', # Rate Limit สำหรับงานหนัก + } } # 3. ตั้งค่า DJOSER (เพื่อจัดการ Auth Endpoints) diff --git a/backend/core/urls.py b/backend/core/urls.py index b7eaefe..f465993 100644 --- a/backend/core/urls.py +++ b/backend/core/urls.py @@ -21,8 +21,9 @@ from rest_framework.routers import DefaultRouter from model_registry.views.ai_model_viewset import AiModelRegistryViewSet from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView, SpectacularRedocView -# Import Health Check View ในแอพ /api +# Import View ในแอพ /api from api.views.health_check_view import SystemHealthCheck +from api.views.audit_viewset import AuditLogViewSet # 1. กำหนดตัวแปร router ก่อนใช้งาน router = DefaultRouter() @@ -32,7 +33,13 @@ router = DefaultRouter() router.register( r'models', AiModelRegistryViewSet, - basename='aimodel' # basename จำเป็นเมื่อ ViewSet ไม่ได้สืบทอดจาก Model + basename='aimodel' +) +# URL: /api/v1/audit/ (AuditLogViewSet) +router.register( + r'audit', + AuditLogViewSet, + basename='auditlog', ) # 3. ลงทะเบียน ViewSet อื่น ๆ diff --git a/backend/model_registry/repositories/ai_model_repository.py b/backend/model_registry/repositories/ai_model_repository.py index eb30a9e..bbb8564 100644 --- a/backend/model_registry/repositories/ai_model_repository.py +++ b/backend/model_registry/repositories/ai_model_repository.py @@ -1,5 +1,6 @@ from ..models import AiModel from typing import List, Optional +from api.models import InferenceAuditLog class AiModelRepository: """จัดการการเข้าถึงฐานข้อมูลสำหรับ AiModel โดยเฉพาะ""" @@ -20,4 +21,27 @@ class AiModelRepository: def delete(self, model_instance: AiModel): model_instance.delete() + + def log_inference(self, model_pk: str, user_pk: int, endpoint: str, + status_code: int, latency: float, success: bool, summary: str): + """บันทึก Inference Log ลงใน InferenceAuditLog Model""" + + try: + # 1. ดึง Instance Model และ User มาใช้ (ถ้ามี) + model_instance = self.get_by_id(model_pk) + + # 2. สร้าง Log Record + InferenceAuditLog.objects.create( + user_id=user_pk, + model=model_instance, + endpoint_url=endpoint, + http_status=status_code, + latency_ms=latency, + is_success=success, + response_summary=summary + ) + except Exception as e: + # ควรมี Loggin.error ที่นี่ เพื่อบันทึกว่าการบันทึก Log ล้มเหลว + print(f"CRITICAL: Failed to save audit log: {e}") + # Logic อื่น ๆ ที่เกี่ยวข้องกับการเข้าถึง DB \ No newline at end of file diff --git a/backend/model_registry/services/ai_model_service.py b/backend/model_registry/services/ai_model_service.py index 781e5e0..1714fce 100644 --- a/backend/model_registry/services/ai_model_service.py +++ b/backend/model_registry/services/ai_model_service.py @@ -5,6 +5,7 @@ import requests from django.conf import settings from django.core.cache import cache from django.core.files.uploadedfile import UploadedFile +import time class ConnectionError(Exception): """Custom Exception สำหรับการเชื่อมต่อล้มเหลว""" @@ -88,6 +89,8 @@ class AiModelService: # --- Proxy Execution Logic --- + start_time = time.time() + # 1. สร้าง Full Inference URL full_url = model.full_inference_url() @@ -122,11 +125,36 @@ class AiModelService: cache.set(cache_key, result, timeout=3600) # 7. บันทึก Audit Log (Logic ในอนาคต) - # self.repo.log_inference_request(pk, user_id, full_url, response.status_code) + end_time = time.time() + latency = round((end_time - start_time) * 1000, 2) + summary = "Inference completed successfully." + self.repo.log_inference( + model_pk=str(pk), # ส่งเป็น String + user_pk=user_id, + endpoint=full_url, + status_code=response.status_code, + latency=latency, + success=True, + summary=summary + ) return result except requests.exceptions.HTTPError as e: + end_time = time.time() + latency = round((end_time - start_time) * 1000, 2) + summary = f"HTTP Error {e.response.status_code}: {e.response.text[:200]}" + + # บันทึก Audit Log เมื่อล้มเหลว (HTTP Error) + self.repo.log_inference( + model_pk=str(pk), + user_pk=user_id, + endpoint=full_url, + status_code=e.response.status_code, + latency=latency, + success=False, + summary=summary + ) detail = f"AI Service returned error {response.status_code}: {response.text}" raise ConnectionError(detail) except requests.exceptions.RequestException as e: diff --git a/backend/model_registry/views/ai_model_viewset.py b/backend/model_registry/views/ai_model_viewset.py index 8356f48..3dbee25 100644 --- a/backend/model_registry/views/ai_model_viewset.py +++ b/backend/model_registry/views/ai_model_viewset.py @@ -12,6 +12,8 @@ from permissions.permission_classes import IsAdminOrManager from rest_framework.permissions import IsAuthenticated from rest_framework.parsers import MultiPartParser +from rest_framework.throttling import UserRateThrottle + # Dependency Injection: สร้าง Instance ของ Repository และ Service # สำหรับโปรเจกต์ขนาดเล็กสามารถทำแบบนี้ได้ repo = AiModelRepository() @@ -117,7 +119,8 @@ class AiModelRegistryViewSet(viewsets.ModelViewSet): methods=['post'], url_path='run-inference', parser_classes=[MultiPartParser], - permission_classes=[IsAuthenticated] + permission_classes=[IsAuthenticated], + throttle_classes=[UserRateThrottle] # ถ้าผู้ใช้เรียกเกิน 50 ครั้ง/นาที จะถูกปฏิเสธด้วย 429 Too Many Requests ) def run_inference(self, request, pk=None): """