feat: ADRF async views (phase1) + native async serializers (phase2) + async cache infra

This commit is contained in:
async-upgrade
2026-09-06 14:26:17 +08:00
parent 9a6577f71e
commit 8f488fcaaa
55 changed files with 2224 additions and 1513 deletions
+30 -27
View File
@@ -1,20 +1,23 @@
from rest_framework import serializers
from adrf.serializers import (
ModelSerializer, Serializer, CharField, ChoiceField, FileField,
IntegerField, BooleanField, ListField, SerializerMethodField,
)
from .models import CompressionHistory, ToolCategory, Tool, ColorHistory
class ToolCategorySerializer(serializers.ModelSerializer):
tool_count = serializers.SerializerMethodField()
class ToolCategorySerializer(ModelSerializer):
tool_count = SerializerMethodField()
class Meta:
model = ToolCategory
fields = ['id', 'name', 'icon', 'sort_order', 'tool_count', 'created_at']
def get_tool_count(self, obj):
return obj.tools.filter(is_enabled=True).count()
async def get_tool_count(self, obj):
return await obj.tools.filter(is_enabled=True).acount()
class ToolSerializer(serializers.ModelSerializer):
category_name = serializers.CharField(source='category.name', read_only=True, default='')
class ToolSerializer(ModelSerializer):
category_name = CharField(source='category.name', read_only=True, default='')
class Meta:
model = Tool
@@ -26,10 +29,10 @@ class ToolSerializer(serializers.ModelSerializer):
read_only_fields = ['id', 'created_at', 'updated_at']
class CompressionHistorySerializer(serializers.ModelSerializer):
original_size_display = serializers.SerializerMethodField()
compressed_size_display = serializers.SerializerMethodField()
created_at_display = serializers.SerializerMethodField()
class CompressionHistorySerializer(ModelSerializer):
original_size_display = SerializerMethodField()
compressed_size_display = SerializerMethodField()
created_at_display = SerializerMethodField()
class Meta:
model = CompressionHistory
@@ -41,13 +44,13 @@ class CompressionHistorySerializer(serializers.ModelSerializer):
]
read_only_fields = ['id', 'created_at']
def get_original_size_display(self, obj):
async def get_original_size_display(self, obj):
return self._format_size(obj.original_size)
def get_compressed_size_display(self, obj):
async def get_compressed_size_display(self, obj):
return self._format_size(obj.compressed_size)
def get_created_at_display(self, obj):
async def get_created_at_display(self, obj):
return obj.created_at.strftime('%Y-%m-%d %H:%M:%S')
@staticmethod
@@ -60,29 +63,29 @@ class CompressionHistorySerializer(serializers.ModelSerializer):
return f"{size_bytes / (1024 * 1024):.1f} MB"
class ImageCompressRequestSerializer(serializers.Serializer):
file = serializers.FileField()
mode = serializers.ChoiceField(choices=['lossy', 'lossless'], default='lossy')
quality = serializers.IntegerField(min_value=1, max_value=100, default=80)
format = serializers.ChoiceField(
class ImageCompressRequestSerializer(Serializer):
file = FileField()
mode = ChoiceField(choices=['lossy', 'lossless'], default='lossy')
quality = IntegerField(min_value=1, max_value=100, default=80)
format = ChoiceField(
choices=['jpeg', 'png', 'webp', 'avif', 'original'],
default='original'
)
keep_exif = serializers.BooleanField(default=True)
width = serializers.IntegerField(required=False, min_value=1, max_value=10000)
height = serializers.IntegerField(required=False, min_value=1, max_value=10000)
maintain_aspect_ratio = serializers.BooleanField(default=True)
keep_exif = BooleanField(default=True)
width = IntegerField(required=False, min_value=1, max_value=10000)
height = IntegerField(required=False, min_value=1, max_value=10000)
maintain_aspect_ratio = BooleanField(default=True)
class ColorHistorySerializer(serializers.ModelSerializer):
class ColorHistorySerializer(ModelSerializer):
class Meta:
model = ColorHistory
fields = ['id', 'color', 'created_at']
read_only_fields = ['id', 'created_at']
class ColorHistorySyncSerializer(serializers.Serializer):
colors = serializers.ListField(
child=serializers.CharField(max_length=7),
class ColorHistorySyncSerializer(Serializer):
colors = ListField(
child=CharField(max_length=7),
help_text='颜色HEX值列表,如 ["#FF5733", "#33FF57"]'
)
+22 -24
View File
@@ -1,5 +1,5 @@
import re
from rest_framework.views import APIView
from adrf.views import APIView
from rest_framework.response import Response
from rest_framework import status
from rest_framework.permissions import IsAuthenticated
@@ -17,17 +17,17 @@ def validate_hex_color(color):
class ColorHistoryListView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request):
async def get(self, request):
days = int(request.query_params.get('days', 30))
cutoff = timezone.now() - timedelta(days=days)
histories = ColorHistory.objects.filter(
histories = [h async for h in ColorHistory.objects.filter(
user=request.user,
created_at__gte=cutoff
).order_by('-created_at')[:50]
).order_by('-created_at')[:50]]
serializer = ColorHistorySerializer(histories, many=True)
return Response(serializer.data)
def post(self, request):
async def post(self, request):
color = request.data.get('color', '')
if not validate_hex_color(color):
return Response(
@@ -35,16 +35,16 @@ class ColorHistoryListView(APIView):
status=status.HTTP_400_BAD_REQUEST
)
color = color.upper()
existing = ColorHistory.objects.filter(
existing = await ColorHistory.objects.filter(
user=request.user,
color=color
).first()
).afirst()
if existing:
existing.created_at = timezone.now()
existing.save()
await existing.asave()
serializer = ColorHistorySerializer(existing)
return Response(serializer.data, status=status.HTTP_200_OK)
history = ColorHistory.objects.create(
history = await ColorHistory.objects.acreate(
user=request.user,
color=color
)
@@ -55,10 +55,10 @@ class ColorHistoryListView(APIView):
class ColorHistoryDetailView(APIView):
permission_classes = [IsAuthenticated]
def delete(self, request, pk):
async def delete(self, request, pk):
try:
history = ColorHistory.objects.get(pk=pk, user=request.user)
history.delete()
history = await ColorHistory.objects.aget(pk=pk, user=request.user)
await history.adelete()
return Response(status=status.HTTP_204_NO_CONTENT)
except ColorHistory.DoesNotExist:
return Response(
@@ -70,34 +70,32 @@ class ColorHistoryDetailView(APIView):
class ColorHistoryClearView(APIView):
permission_classes = [IsAuthenticated]
def post(self, request):
deleted_count, _ = ColorHistory.objects.filter(user=request.user).delete()
async def post(self, request):
deleted_count, _ = await ColorHistory.objects.filter(user=request.user).adelete()
return Response({'deleted': deleted_count})
class ColorHistorySyncView(APIView):
permission_classes = [IsAuthenticated]
def post(self, request):
async def post(self, request):
serializer = ColorHistorySyncSerializer(data=request.data)
if not serializer.is_valid():
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
colors = serializer.validated_data['colors']
valid_colors = [c.upper() for c in colors if validate_hex_color(c)]
existing_colors = set(
ColorHistory.objects.filter(
existing_colors = set([
c async for c in ColorHistory.objects.filter(
user=request.user,
color__in=valid_colors
).values_list('color', flat=True)
)
])
new_colors = [c for c in valid_colors if c not in existing_colors]
now = timezone.now()
for color in new_colors:
ColorHistory.objects.create(
user=request.user,
color=color,
created_at=now
)
await ColorHistory.objects.abulk_create([
ColorHistory(user=request.user, color=color, created_at=now)
for color in new_colors
])
return Response({
'synced': len(new_colors),
'duplicates': len(valid_colors) - len(new_colors),
+15 -13
View File
@@ -1,15 +1,16 @@
from django.http import JsonResponse
from rest_framework.views import APIView
from adrf.views import APIView
from rest_framework import status
from rest_framework.decorators import permission_classes
from rest_framework.permissions import AllowAny
from asgiref.sync import sync_to_async
from ..models import CompressionHistory
from ..serializers import CompressionHistorySerializer
@permission_classes([AllowAny])
class CompressionHistoryListView(APIView):
def get(self, request):
async def get(self, request):
user = request.user if request.user.is_authenticated else None
session_id = request.query_params.get('session_id')
@@ -29,8 +30,8 @@ class CompressionHistoryListView(APIView):
start = (page - 1) * page_size
end = start + page_size
total = histories.count()
serializer = CompressionHistorySerializer(histories[start:end], many=True)
total = await histories.acount()
serializer = CompressionHistorySerializer([h async for h in histories[start:end]], many=True)
return JsonResponse({
'success': True,
@@ -40,7 +41,7 @@ class CompressionHistoryListView(APIView):
'page_size': page_size
})
def post(self, request):
async def post(self, request):
data = request.data.copy()
if request.user.is_authenticated:
@@ -48,7 +49,8 @@ class CompressionHistoryListView(APIView):
serializer = CompressionHistorySerializer(data=data)
if serializer.is_valid():
serializer.save()
# DRF 序列化器 save() 为同步 ORM 操作,sync_to_async 兜底
await sync_to_async(serializer.save)()
return JsonResponse({
'success': True,
'data': serializer.data
@@ -62,22 +64,22 @@ class CompressionHistoryListView(APIView):
@permission_classes([AllowAny])
class CompressionHistoryDetailView(APIView):
def delete(self, request, pk):
async def delete(self, request, pk):
user = request.user if request.user.is_authenticated else None
session_id = request.query_params.get('session_id')
try:
if user:
history = CompressionHistory.objects.get(pk=pk, user=user)
history = await CompressionHistory.objects.aget(pk=pk, user=user)
elif session_id:
history = CompressionHistory.objects.get(pk=pk, session_id=session_id)
history = await CompressionHistory.objects.aget(pk=pk, session_id=session_id)
else:
return JsonResponse(
{'error': '无权限删除'},
status=status.HTTP_403_FORBIDDEN
)
history.delete()
await history.adelete()
return JsonResponse({'success': True})
except CompressionHistory.DoesNotExist:
return JsonResponse(
@@ -88,14 +90,14 @@ class CompressionHistoryDetailView(APIView):
@permission_classes([AllowAny])
class CompressionHistoryClearView(APIView):
def delete(self, request):
async def delete(self, request):
user = request.user if request.user.is_authenticated else None
session_id = request.query_params.get('session_id')
if user:
count, _ = CompressionHistory.objects.filter(user=user).delete()
count, _ = await CompressionHistory.objects.filter(user=user).adelete()
elif session_id:
count, _ = CompressionHistory.objects.filter(session_id=session_id).delete()
count, _ = await CompressionHistory.objects.filter(session_id=session_id).adelete()
else:
return JsonResponse(
{'error': '无权限清空'},
+1 -1
View File
@@ -3,7 +3,7 @@ import uuid
from PIL import Image
from PIL.ExifTags import TAGS
from django.http import JsonResponse, FileResponse
from rest_framework.views import APIView
from adrf.views import APIView
from rest_framework.parsers import MultiPartParser, FormParser
from rest_framework import status
from rest_framework.decorators import permission_classes
+1 -1
View File
@@ -1,6 +1,6 @@
import difflib
from rest_framework.decorators import permission_classes
from rest_framework.views import APIView
from adrf.views import APIView
from rest_framework.response import Response
from rest_framework import status
from rest_framework.permissions import AllowAny
+61 -46
View File
@@ -1,8 +1,8 @@
from django.http import JsonResponse
from rest_framework.views import APIView
from adrf.views import APIView
from rest_framework.permissions import IsAuthenticated
from rest_framework.parsers import JSONParser
from django.db import transaction
from asgiref.sync import sync_to_async
from ..models import Tool, ToolFavorite
@@ -10,60 +10,75 @@ class ToolFavoriteToggleView(APIView):
permission_classes = [IsAuthenticated]
parser_classes = [JSONParser]
@transaction.atomic
def post(self, request, tool_id):
try:
tool = Tool.objects.get(pk=tool_id, is_enabled=True)
except Tool.DoesNotExist:
async def post(self, request, tool_id):
# 事务段(get_or_create + delete + count)整体放入同步函数,sync_to_async 执行保证原子性
def _toggle_in_txn():
from django.db import transaction
with transaction.atomic():
try:
tool = Tool.objects.get(pk=tool_id, is_enabled=True)
except Tool.DoesNotExist:
return None
favorite, created = ToolFavorite.objects.get_or_create(
user=request.user,
tool=tool
)
if not created:
favorite.delete()
is_favorite = False
message = '已取消收藏'
else:
is_favorite = True
message = '已添加收藏'
favorite_count = ToolFavorite.objects.filter(tool=tool).count()
return {
'tool_id': tool.id,
'is_favorite': is_favorite,
'favorite_count': favorite_count,
'message': message,
}
result = await sync_to_async(_toggle_in_txn)()
if result is None:
return JsonResponse({'success': False, 'error': '工具不存在'}, status=404)
favorite, created = ToolFavorite.objects.get_or_create(
user=request.user,
tool=tool
)
if not created:
favorite.delete()
is_favorite = False
message = '已取消收藏'
else:
is_favorite = True
message = '已添加收藏'
favorite_count = ToolFavorite.objects.filter(tool=tool).count()
return JsonResponse({
'success': True,
'data': {
'id': tool.id,
'is_favorite': is_favorite,
'favorite_count': favorite_count
'id': result['tool_id'],
'is_favorite': result['is_favorite'],
'favorite_count': result['favorite_count']
},
'message': message
'message': result['message']
})
class ToolFavoriteListView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request):
favorites = ToolFavorite.objects.select_related('tool', 'tool__category').filter(
async def get(self, request):
favorites = [fav async for fav in ToolFavorite.objects.select_related(
'tool', 'tool__category'
).filter(
user=request.user,
tool__is_enabled=True
).order_by('-created_at')
).order_by('-created_at')]
data = [{
'id': fav.tool.id,
'name': fav.tool.name,
'description': fav.tool.description,
'icon': fav.tool.icon,
'url_path': fav.tool.url_path,
'color': fav.tool.color,
'category': fav.tool.category.id if fav.tool.category else None,
'category_name': fav.tool.category.name if fav.tool.category else '',
'is_favorite': True,
'favorite_count': ToolFavorite.objects.filter(tool=fav.tool).count(),
'created_at': fav.created_at.isoformat()
} for fav in favorites]
data = []
for fav in favorites:
favorite_count = await ToolFavorite.objects.filter(tool=fav.tool).acount()
data.append({
'id': fav.tool.id,
'name': fav.tool.name,
'description': fav.tool.description,
'icon': fav.tool.icon,
'url_path': fav.tool.url_path,
'color': fav.tool.color,
'category': fav.tool.category.id if fav.tool.category else None,
'category_name': fav.tool.category.name if fav.tool.category else '',
'is_favorite': True,
'favorite_count': favorite_count,
'created_at': fav.created_at.isoformat()
})
return JsonResponse({
'success': True,
@@ -77,13 +92,13 @@ class ToolFavoriteListView(APIView):
class ToolFavoriteStatusView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request, tool_id):
is_favorite = ToolFavorite.objects.filter(
async def get(self, request, tool_id):
is_favorite = await ToolFavorite.objects.filter(
user=request.user,
tool_id=tool_id
).exists()
).aexists()
favorite_count = ToolFavorite.objects.filter(tool_id=tool_id).count()
favorite_count = await ToolFavorite.objects.filter(tool_id=tool_id).acount()
return JsonResponse({
'success': True,
+30 -27
View File
@@ -1,5 +1,5 @@
from django.http import JsonResponse
from rest_framework.views import APIView
from adrf.views import APIView
from rest_framework import status
from rest_framework.permissions import AllowAny, IsAuthenticated
from django.db.models import Count, Q
@@ -19,8 +19,8 @@ class ToolCategoryListView(APIView):
tags=['工具'],
responses={200: success_response}
)
def get(self, request):
categories = ToolCategory.objects.prefetch_related('tools').all()
async def get(self, request):
categories = [c async for c in ToolCategory.objects.prefetch_related('tools').all()]
serializer = ToolCategorySerializer(categories, many=True)
return JsonResponse({
'success': True,
@@ -42,7 +42,7 @@ class ToolListView(APIView):
],
responses={200: success_response}
)
def get(self, request):
async def get(self, request):
category_id = request.query_params.get('category_id')
enabled_only = request.query_params.get('enabled_only', 'true')
ordering = request.query_params.get('ordering', '')
@@ -68,18 +68,19 @@ class ToolListView(APIView):
else:
tools = tools.order_by('sort_order', '-created_at')
serializer = ToolSerializer(tools, many=True)
tool_list = [t async for t in tools]
serializer = ToolSerializer(tool_list, many=True)
data = serializer.data
# 构建 favorites_count 映射
favorites_count_map = {
t.id: t.annotated_favorites_count for t in tools
t.id: t.annotated_favorites_count for t in tool_list
}
if request.user.is_authenticated:
favorite_tool_ids = set(
ToolFavorite.objects.filter(user=request.user).values_list('tool_id', flat=True)
)
favorite_tool_ids = set([
tid async for tid in ToolFavorite.objects.filter(user=request.user).values_list('tool_id', flat=True)
])
for item in data:
item['is_favorited'] = item['id'] in favorite_tool_ids
item['favorites_count'] = favorites_count_map.get(item['id'], 0)
@@ -106,18 +107,18 @@ class ToolDetailView(APIView):
],
responses={200: success_response, 404: not_found_response}
)
def get(self, request, pk):
async def get(self, request, pk):
try:
tool = Tool.objects.select_related('category').get(pk=pk, is_enabled=True)
tool = await Tool.objects.select_related('category').aget(pk=pk, is_enabled=True)
serializer = ToolSerializer(tool)
data = serializer.data
if request.user.is_authenticated:
data['is_favorited'] = ToolFavorite.objects.filter(
data['is_favorited'] = await ToolFavorite.objects.filter(
user=request.user, tool=tool
).exists()
).aexists()
else:
data['is_favorited'] = False
data['favorites_count'] = ToolFavorite.objects.filter(tool=tool).count()
data['favorites_count'] = await ToolFavorite.objects.filter(tool=tool).acount()
return JsonResponse({
'success': True,
'data': data
@@ -141,20 +142,20 @@ class ToolFavoriteToggleView(APIView):
],
responses={200: success_response, 401: unauthorized_response, 404: not_found_response}
)
def post(self, request, pk):
tool = Tool.objects.filter(pk=pk, is_enabled=True).first()
async def post(self, request, pk):
tool = await Tool.objects.filter(pk=pk, is_enabled=True).afirst()
if not tool:
return JsonResponse(
{'success': False, 'error': '工具不存在'},
status=status.HTTP_404_NOT_FOUND
)
fav, created = ToolFavorite.objects.get_or_create(user=request.user, tool=tool)
fav, created = await ToolFavorite.objects.aget_or_create(user=request.user, tool=tool)
if not created:
fav.delete()
await fav.adelete()
is_favorited = False
else:
is_favorited = True
favorites_count = ToolFavorite.objects.filter(tool=tool).count()
favorites_count = await ToolFavorite.objects.filter(tool=tool).acount()
return JsonResponse({
'success': True,
'data': {
@@ -174,17 +175,19 @@ class ToolFavoriteListView(APIView):
tags=['工具'],
responses={200: success_response, 401: unauthorized_response}
)
def get(self, request):
favorites = ToolFavorite.objects.select_related('tool', 'tool__category').filter(
async def get(self, request):
favorites = [fav async for fav in ToolFavorite.objects.select_related(
'tool', 'tool__category'
).filter(
user=request.user,
tool__is_enabled=True
).order_by('-created_at')
).order_by('-created_at')]
result = []
for fav in favorites:
tool_data = ToolSerializer(fav.tool).data
tool_data['is_favorited'] = True
tool_data['favorites_count'] = ToolFavorite.objects.filter(tool=fav.tool).count()
tool_data['favorites_count'] = await ToolFavorite.objects.filter(tool=fav.tool).acount()
result.append(tool_data)
return JsonResponse({
@@ -206,13 +209,13 @@ class ToolUsageIncrementView(APIView):
],
responses={200: success_response, 404: not_found_response}
)
def post(self, request, pk):
async def post(self, request, pk):
try:
tool = Tool.objects.get(pk=pk, is_enabled=True)
tool = await Tool.objects.aget(pk=pk, is_enabled=True)
# 使用F()表达式避免竞态条件
from django.db.models import F
Tool.objects.filter(pk=pk).update(usage_count=F('usage_count') + 1)
tool.refresh_from_db()
await Tool.objects.filter(pk=pk).aupdate(usage_count=F('usage_count') + 1)
await tool.arefresh_from_db()
return JsonResponse({
'success': True,
'data': {