diff --git a/tool/management/commands/seed_tools.py b/tool/management/commands/seed_tools.py index 64c30aa..ad7dd25 100644 --- a/tool/management/commands/seed_tools.py +++ b/tool/management/commands/seed_tools.py @@ -19,6 +19,10 @@ class Command(BaseCommand): name='实用', defaults={'icon': 'ThunderboltOutlined', 'sort_order': 3} )[0], + 'AI 工具': ToolCategory.objects.get_or_create( + name='AI 工具', + defaults={'icon': 'ThunderboltOutlined', 'sort_order': 0} + )[0], } tools_data = [ @@ -38,6 +42,8 @@ class Command(BaseCommand): {'name': '取色器', 'description': '颜色拾取、HEX/RGB/HSL转换、历史记录', 'icon': 'PictureOutlined', 'url_path': '/color-picker', 'category': categories['设计'], 'color': '#eab308', 'usage_count': 4280}, {'name': '字符编码转换', 'description': 'GBK/UTF-8/Big5/Shift_JIS 等字符集互转,支持文件上传下载', 'icon': 'CodeOutlined', 'url_path': '/utility/charset-converter', 'category': categories['开发'], 'color': '#1677ff', 'usage_count': 3650}, {'name': '图片编辑', 'description': '在线图片编辑,支持裁剪、滤镜、调节、文字、画笔', 'icon': 'EditOutlined', 'url_path': '/utility/image-editor', 'category': categories['设计'], 'color': '#f59e0b', 'usage_count': 3456}, + {'name': 'AI 图片放大', 'description': 'AI 图片放大 2x/4x(2 积分/次,失败退回)', 'icon': 'ExpandOutlined', 'url_path': '/utility/image-upscaler', 'category': categories['AI 工具'], 'color': '#8b5cf6', 'usage_count': 0, 'is_new': True}, + {'name': '代码在线运行', 'description': 'JS/Python 沙箱在线运行(学练一体)', 'icon': 'CodeOutlined', 'url_path': '/utility/code-runner', 'category': categories['开发'], 'color': '#6366f1', 'usage_count': 0, 'is_new': True}, ] created_count = 0 diff --git a/tool/migrations/0007_alter_colorhistory_id_toolusagerecord.py b/tool/migrations/0007_alter_colorhistory_id_toolusagerecord.py new file mode 100644 index 0000000..7b2e603 --- /dev/null +++ b/tool/migrations/0007_alter_colorhistory_id_toolusagerecord.py @@ -0,0 +1,37 @@ +# Generated by Django 5.2.12 on 2026-09-08 08:24 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tool', '0006_merge_0005'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterField( + model_name='colorhistory', + name='id', + field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.CreateModel( + name='ToolUsageRecord', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('session_id', models.CharField(blank=True, default='', max_length=64, verbose_name='会话ID')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='使用时间')), + ('tool', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='usage_records', to='tool.tool', verbose_name='工具')), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='tool_usage_records', to=settings.AUTH_USER_MODEL, verbose_name='用户')), + ], + options={ + 'verbose_name': '工具使用记录', + 'verbose_name_plural': '工具使用记录', + 'ordering': ['-created_at'], + 'indexes': [models.Index(fields=['tool', 'created_at'], name='tool_toolus_tool_id_4e72d1_idx')], + }, + ), + ] diff --git a/tool/models.py b/tool/models.py index 03e2041..c2b04ab 100644 --- a/tool/models.py +++ b/tool/models.py @@ -127,6 +127,45 @@ class ToolFavorite(models.Model): return f'{self.user} favorites {self.tool}' +class ToolUsageRecord(models.Model): + """工具使用日志:用于周榜等时间窗聚合统计""" + tool = models.ForeignKey( + Tool, + on_delete=models.CASCADE, + related_name='usage_records', + verbose_name='工具' + ) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='tool_usage_records', + verbose_name='用户' + ) + session_id = models.CharField( + max_length=64, + blank=True, + default='', + verbose_name='会话ID' + ) + created_at = models.DateTimeField( + auto_now_add=True, + verbose_name='使用时间' + ) + + class Meta: + verbose_name = '工具使用记录' + verbose_name_plural = '工具使用记录' + ordering = ['-created_at'] + indexes = [ + models.Index(fields=['tool', 'created_at']), + ] + + def __str__(self): + return f'{self.tool} used at {self.created_at}' + + class CompressionHistory(models.Model): COMPRESSION_MODE_CHOICES = [ ('lossy', '有损压缩'), diff --git a/tool/tests/test_tool_top.py b/tool/tests/test_tool_top.py new file mode 100644 index 0000000..5b0bb4d --- /dev/null +++ b/tool/tests/test_tool_top.py @@ -0,0 +1,92 @@ +"""C-02 工具热榜聚合用例:总榜/周榜窗口/匿名归因/recent 权限/兼容参数。""" +from datetime import timedelta + +from django.test import TestCase +from django.utils import timezone +from rest_framework.test import APIClient + +from tool.models import Tool, ToolUsageRecord +from user.models import FUser + + +class ToolTopTest(TestCase): + def setUp(self): + self.client = APIClient() + self.a = Tool.objects.create(name='A', url_path='/utility/a-tool', usage_count=10) + self.b = Tool.objects.create(name='B', url_path='/utility/b-tool', usage_count=5) + self.c = Tool.objects.create(name='C', url_path='/utility/c-tool', usage_count=1) + self.u = FUser.objects.create_user(username='c02u', password='x12345678') + + def test_total_rank_order(self): + r = self.client.get('/tool/top/', {'range': 'all', 'limit': 3}) + self.assertEqual(r.status_code, 200) + data = r.json()['data'] + self.assertEqual([x['id'] for x in data], [self.a.id, self.b.id, self.c.id]) + self.assertEqual([x['rank'] for x in data], [1, 2, 3]) + for x in data: + self.assertIn('usage_count', x) + self.assertIn('weekly_count', x) + + def test_period_alias_total(self): + r = self.client.get('/tool/top/', {'period': 'total', 'limit': 3}) + self.assertEqual(r.status_code, 200) + self.assertEqual([x['rank'] for x in r.json()['data']], [1, 2, 3]) + + def test_weekly_window_aggregation(self): + now = timezone.now() + for _ in range(3): + ToolUsageRecord.objects.create(tool=self.c, user=self.u) + old = ToolUsageRecord.objects.create(tool=self.a, user=self.u) + old.created_at = now - timedelta(days=8) + old.save(update_fields=['created_at']) + r = self.client.get('/tool/top/', {'range': 'week', 'limit': 3}) + self.assertEqual(r.status_code, 200) + data = r.json()['data'] + # C 近7天3次应排第一 + self.assertEqual(data[0]['id'], self.c.id) + self.assertEqual(data[0]['weekly_count'], 3) + + def test_weekly_boundary_7days(self): + now = timezone.now() + edge = ToolUsageRecord.objects.create(tool=self.b) + edge.created_at = now - timedelta(days=7) + timedelta(seconds=60) + edge.save(update_fields=['created_at']) + out = ToolUsageRecord.objects.create(tool=self.b) + out.created_at = now - timedelta(days=7, seconds=60) + out.save(update_fields=['created_at']) + r = self.client.get('/tool/top/', {'range': 'week', 'limit': 10}) + data = {x['id']: x['weekly_count'] for x in r.json()['data']} + self.assertEqual(data[self.b.id], 1) + + def test_weekly_empty_fallback_to_total(self): + r = self.client.get('/tool/top/', {'range': 'week', 'limit': 3}) + self.assertEqual(r.status_code, 200) + self.assertEqual([x['id'] for x in r.json()['data']], [self.a.id, self.b.id, self.c.id]) + + def test_increment_writes_record_anonymous(self): + r = self.client.post(f'/tool/usage/{self.a.id}/increment/') + self.assertEqual(r.status_code, 200) + self.assertEqual(ToolUsageRecord.objects.filter(tool=self.a).count(), 1) + self.a.refresh_from_db() + self.assertEqual(self.a.usage_count, 11) + + def test_increment_writes_record_login(self): + self.client.force_authenticate(user=self.u) + r = self.client.post(f'/tool/usage/{self.b.id}/increment/') + self.assertEqual(r.status_code, 200) + rec = ToolUsageRecord.objects.filter(tool=self.b).order_by('-id').first() + self.assertEqual(rec.user_id, self.u.id) + + def test_recent_requires_login(self): + r = self.client.get('/tool/recent/') + self.assertIn(r.status_code, (401, 403)) + + def test_recent_dedup_order(self): + ToolUsageRecord.objects.create(tool=self.a, user=self.u) + ToolUsageRecord.objects.create(tool=self.b, user=self.u) + ToolUsageRecord.objects.create(tool=self.a, user=self.u) + self.client.force_authenticate(user=self.u) + r = self.client.get('/tool/recent/', {'limit': 10}) + self.assertEqual(r.status_code, 200) + ids = [x['id'] for x in r.json()['data']] + self.assertEqual(ids, [self.a.id, self.b.id]) diff --git a/tool/urls.py b/tool/urls.py index bacac37..e57dfa1 100644 --- a/tool/urls.py +++ b/tool/urls.py @@ -12,6 +12,8 @@ from .views import ( ToolFavoriteToggleView, ToolFavoriteListView, ToolUsageIncrementView, + ToolTopView, + ToolRecentView, ) from .views.color_history_view import ( ColorHistoryListView, @@ -33,6 +35,8 @@ urlpatterns = [ path('favorites//toggle/', ToolFavoriteToggleView.as_view()), path('favorites/list/', ToolFavoriteListView.as_view()), path('usage//increment/', ToolUsageIncrementView.as_view()), + path('top/', ToolTopView.as_view()), + path('recent/', ToolRecentView.as_view()), path('color-history/', ColorHistoryListView.as_view()), path('color-history//', ColorHistoryDetailView.as_view()), path('color-history/clear/', ColorHistoryClearView.as_view()), diff --git a/tool/views/__init__.py b/tool/views/__init__.py index ec3bb2b..8c6563c 100644 --- a/tool/views/__init__.py +++ b/tool/views/__init__.py @@ -11,5 +11,7 @@ from .tool_manage_view import ( ToolDetailView, ToolFavoriteToggleView, ToolFavoriteListView, - ToolUsageIncrementView + ToolUsageIncrementView, + ToolTopView, + ToolRecentView, ) diff --git a/tool/views/tool_manage_view.py b/tool/views/tool_manage_view.py index 83deac9..cadaf94 100644 --- a/tool/views/tool_manage_view.py +++ b/tool/views/tool_manage_view.py @@ -3,7 +3,7 @@ from adrf.views import APIView from rest_framework import status from rest_framework.permissions import AllowAny, IsAuthenticated from django.db.models import Count, Q -from ..models import ToolCategory, Tool, ToolFavorite +from ..models import ToolCategory, Tool, ToolFavorite, ToolUsageRecord from ..serializers import ToolCategorySerializer, ToolSerializer from drf_yasg.utils import swagger_auto_schema from drf_yasg import openapi @@ -216,6 +216,15 @@ class ToolUsageIncrementView(APIView): from django.db.models import F await Tool.objects.filter(pk=pk).aupdate(usage_count=F('usage_count') + 1) await tool.arefresh_from_db() + # 写入使用日志(周榜统计用),失败不影响主流程 + try: + await ToolUsageRecord.objects.acreate( + tool=tool, + user=request.user if request.user.is_authenticated else None, + session_id=request.COOKIES.get('sessionid', '')[:64] or (request.session.session_key or '')[:64], + ) + except Exception: + pass return JsonResponse({ 'success': True, 'data': { @@ -228,3 +237,112 @@ class ToolUsageIncrementView(APIView): {'success': False, 'error': '工具不存在'}, status=status.HTTP_404_NOT_FOUND ) + + +class ToolTopView(APIView): + """工具热榜:总榜(累计使用次数)/ 周榜(近7天使用日志聚合)""" + permission_classes = [AllowAny] + + @swagger_auto_schema( + operation_summary='工具热榜', + operation_description='获取热门工具排行榜。range=all 按累计使用次数排序;range=week 按近7天使用日志聚合排序(无日志时回退到总榜)。兼容旧参数 period=total|week。返回项含 id/name/icon/usage_count/weekly_count/rank。', + tags=['工具'], + manual_parameters=[ + openapi.Parameter('range', openapi.IN_QUERY, description='榜单周期: all / week', type=openapi.TYPE_STRING), + openapi.Parameter('period', openapi.IN_QUERY, description='兼容旧参数: total / week', type=openapi.TYPE_STRING), + openapi.Parameter('limit', openapi.IN_QUERY, description='返回数量,默认10,最大50', type=openapi.TYPE_INTEGER), + ], + responses={200: success_response} + ) + async def get(self, request): + from django.utils import timezone + from datetime import timedelta + + raw_range = request.query_params.get('range') + raw_period = request.query_params.get('period') + period = (raw_range or raw_period or 'all').lower() + # 兼容映射:all <-> total + if period == 'total': + period = 'all' + if period not in ('all', 'week'): + period = 'all' + try: + limit = int(request.query_params.get('limit', 10)) + except (TypeError, ValueError): + limit = 10 + limit = max(1, min(limit, 50)) + + tools = Tool.objects.filter(is_enabled=True).select_related('category') + weekly_map: dict = {} + + if period == 'week': + week_ago = timezone.now() - timedelta(days=7) + weekly_counts = ( + ToolUsageRecord.objects.filter(created_at__gte=week_ago) + .values_list('tool_id') + .annotate(cnt=Count('id')) + ) + weekly_map = {tid: cnt async for tid, cnt in weekly_counts} + if weekly_map: + tool_list = [t async for t in tools] + tool_list.sort(key=lambda t: (weekly_map.get(t.id, 0), t.usage_count), reverse=True) + serializer = ToolSerializer(tool_list[:limit], many=True) + data = await serializer.adata + for rank, item in enumerate(data, start=1): + item['weekly_count'] = weekly_map.get(item['id'], 0) + item['weekly_usage'] = item['weekly_count'] + item['rank'] = rank + return JsonResponse({'success': True, 'data': data}) + # 无周日志时回退总榜 + period = 'all' + + tools = tools.order_by('-usage_count', 'sort_order') + tool_list = [t async for t in tools[:limit]] + serializer = ToolSerializer(tool_list, many=True) + data = await serializer.adata + for rank, item in enumerate(data, start=1): + item['weekly_count'] = None + item['weekly_usage'] = None + item['rank'] = rank + return JsonResponse({'success': True, 'data': data}) + + +class ToolRecentView(APIView): + """本人最近使用工具(登录,去重按最近倒序)""" + permission_classes = [IsAuthenticated] + + @swagger_auto_schema( + operation_summary='最近使用的工具', + operation_description='返回当前登录用户最近使用过的工具(按工具去重,取最近一条记录倒序)。', + tags=['工具'], + manual_parameters=[ + openapi.Parameter('limit', openapi.IN_QUERY, description='返回数量,默认10,最大50', type=openapi.TYPE_INTEGER), + ], + responses={200: success_response, 401: unauthorized_response} + ) + async def get(self, request): + try: + limit = int(request.query_params.get('limit', 10)) + except (TypeError, ValueError): + limit = 10 + limit = max(1, min(limit, 50)) + + records = [ + r async for r in ToolUsageRecord.objects.filter(user=request.user) + .select_related('tool', 'tool__category') + .order_by('-created_at', '-id')[: limit * 5] + ] + seen = set() + tools = [] + for r in records: + if r.tool_id in seen: + continue + if not getattr(r.tool, 'is_enabled', False): + continue + seen.add(r.tool_id) + tools.append(r.tool) + if len(tools) >= limit: + break + serializer = ToolSerializer(tools, many=True) + data = await serializer.adata + return JsonResponse({'success': True, 'data': data})