feat(C-02):工具热榜周榜+最近使用
新增ToolUsageRecord使用日志(tool+created_at索引);/tool/top/总榜按累计排序、周榜按近7天聚合(无日志回退总榜,兼容period旧参);/tool/recent/登录去重倒序;seed新增AI图片放大/代码运行两个工具。
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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', '有损压缩'),
|
||||
|
||||
@@ -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])
|
||||
@@ -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/<int:pk>/toggle/', ToolFavoriteToggleView.as_view()),
|
||||
path('favorites/list/', ToolFavoriteListView.as_view()),
|
||||
path('usage/<int:pk>/increment/', ToolUsageIncrementView.as_view()),
|
||||
path('top/', ToolTopView.as_view()),
|
||||
path('recent/', ToolRecentView.as_view()),
|
||||
path('color-history/', ColorHistoryListView.as_view()),
|
||||
path('color-history/<int:pk>/', ColorHistoryDetailView.as_view()),
|
||||
path('color-history/clear/', ColorHistoryClearView.as_view()),
|
||||
|
||||
@@ -11,5 +11,7 @@ from .tool_manage_view import (
|
||||
ToolDetailView,
|
||||
ToolFavoriteToggleView,
|
||||
ToolFavoriteListView,
|
||||
ToolUsageIncrementView
|
||||
ToolUsageIncrementView,
|
||||
ToolTopView,
|
||||
ToolRecentView,
|
||||
)
|
||||
|
||||
@@ -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})
|
||||
|
||||
Reference in New Issue
Block a user