diff --git a/core/forms.py b/core/forms.py index a7eff5c..35e1ef4 100644 --- a/core/forms.py +++ b/core/forms.py @@ -9,7 +9,8 @@ from .models import ( TodayPlan, SystemConfig, FamilyMember, - PublicContent + PublicContent, + TempMessage ) class ReadingRecordForm(forms.ModelForm): @@ -142,4 +143,21 @@ class TempUploadForm(forms.ModelForm): class Meta: model = PublicContent - fields = ['title', 'file'] \ No newline at end of file + fields = ['title', 'file'] + + +class TempMessageForm(forms.ModelForm): + """临时留言表单""" + class Meta: + model = TempMessage + fields = ['username', 'content'] + widgets = { + 'username': forms.TextInput(attrs={'class': 'form-control', 'maxlength': '20', 'placeholder': '用户名(可选,最多20字节)'}), + 'content': forms.Textarea(attrs={'class': 'form-control', 'rows': 3, 'maxlength': '1000', 'placeholder': '说点什么...(最多1000字节)'}), + } + + def clean_content(self): + content = self.cleaned_data.get('content', '') + if len(content.encode('utf-8')) > 1000: + raise forms.ValidationError("内容不能超过1000字节") + return content \ No newline at end of file diff --git a/core/models.py b/core/models.py index 1f765f4..2945397 100644 --- a/core/models.py +++ b/core/models.py @@ -262,3 +262,20 @@ class PublicContent(models.Model): def __str__(self): return f"{self.type.name} - {self.title}" + + +class TempMessage(models.Model): + """临时留言""" + username = models.CharField(max_length=20, blank=True, null=True, verbose_name="用户名") + content = models.CharField(max_length=1000, verbose_name="内容") + ip_address = models.GenericIPAddressField(blank=True, null=True, verbose_name="来源IP") + created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间") + expire_at = models.DateTimeField(blank=True, null=True, verbose_name="过期时间") + + class Meta: + verbose_name = "临时留言" + verbose_name_plural = "临时留言" + ordering = ['-created_at'] + + def __str__(self): + return f"{self.username or '匿名'} - {self.content[:20]}..." diff --git a/core/tasks.py b/core/tasks.py index 648dcbf..efadaaf 100644 --- a/core/tasks.py +++ b/core/tasks.py @@ -621,3 +621,49 @@ def cleanup_expired_temp_files(self): 'error': error_msg, 'timestamp': timezone.now().isoformat() } + + +@shared_task(bind=True) +def cleanup_expired_messages(self): + """ + 清理过期的临时留言 + 每5分钟执行一次,删除已过期的留言 + """ + task_id = self.request.id if hasattr(self, 'request') else 'unknown' + logger.info(f"[任务 {task_id}] 开始执行清理过期留言任务") + + try: + from core.models import TempMessage + + expired_messages = TempMessage.objects.filter(expire_at__lte=timezone.now()) + + deleted_count = 0 + for message in expired_messages: + try: + msg_content = message.content[:20] + message.delete() + deleted_count += 1 + logger.info(f"[任务 {task_id}] 已删除过期留言: {msg_content}...") + + except Exception as e: + logger.error(f"[任务 {task_id}] 删除留言失败: {str(e)}") + + logger.success(f"[任务 {task_id}] 清理完成,共删除 {deleted_count} 条过期留言") + return { + 'status': 'success', + 'task_id': task_id, + 'deleted_count': deleted_count, + 'timestamp': timezone.now().isoformat() + } + + except Exception as e: + error_msg = str(e) + error_traceback = traceback.format_exc() + logger.error(f"[任务 {task_id}] 清理过期留言失败: {error_msg}") + logger.error(f"[任务 {task_id}] 错误详情:\n{error_traceback}") + return { + 'status': 'failed', + 'task_id': task_id, + 'error': error_msg, + 'timestamp': timezone.now().isoformat() + } diff --git a/core/templates/core/public_content.html b/core/templates/core/public_content.html index b48c402..9a9fefe 100644 --- a/core/templates/core/public_content.html +++ b/core/templates/core/public_content.html @@ -1,6 +1,140 @@ {% extends 'core/base.html' %} +{% load humanize %} {% block content %} + + + + +
+
API 说明
+
+ API 说明 + +
+
+

端点:

+ POST /api/v1/temp-upload/ +

参数:

+ +

cURL:

+
curl -X POST \
+  -F "file=@f.pdf" \
+  -F "title=文件" \
+  -F "expire_type=expire_1d" \
+  /api/v1/temp-upload/
+
+
+ + +

@@ -35,6 +169,64 @@

+ +
+
+
+ 临时发言 + 留言仅保留10分钟 +
+
+
+
+ {% csrf_token %} +
+
+ +
+
+ +
+
+ +
+
+
+ + {% if temp_messages %} +
+ {% for message in temp_messages %} +
+
+
+
+ {{ message.username|default:"匿名" }} + + {{ message.created_at|naturaltime }} + +
+

{{ message.content }}

+ + IP: {{ message.ip_address|default:"未知" }} + +
+ + {{ message.created_at|naturaltime }} + +
+
+ {% endfor %} +
+ {% else %} +

+ 暂无留言 +

+ {% endif %} +
+
+ {% if content_by_type %} {% for type_name, contents in content_by_type.items %}
@@ -71,7 +263,6 @@ {% endif %} {% if content.is_temp_file and content.expire_at %} - {% load humanize %} {{ content.expire_at|naturaltime }}过期 diff --git a/core/views.py b/core/views.py index 3dbff2a..8f60677 100644 --- a/core/views.py +++ b/core/views.py @@ -50,7 +50,8 @@ from .models import ( FamilyMember, SummaryCategory, PublicContentType, - PublicContent + PublicContent, + TempMessage ) from .forms import ( ReadingRecordForm, @@ -60,7 +61,8 @@ from .forms import ( TodayPlanForm, SystemConfigForm, PublicContentForm, - TempUploadForm + TempUploadForm, + TempMessageForm ) # 首页视图 @@ -1044,6 +1046,24 @@ def user_logout(request): def public_content(request): """公开内容页面 - 无需登录""" logger.info("用户访问公开内容页面") + + if request.method == 'POST': + form = TempMessageForm(request.POST) + if form.is_valid(): + message = form.save(commit=False) + x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') + if x_forwarded_for: + message.ip_address = x_forwarded_for.split(',')[0].strip() + else: + message.ip_address = request.META.get('REMOTE_ADDR') + from datetime import timedelta + message.expire_at = timezone.now() + timedelta(minutes=10) + message.save() + logger.info(f"临时留言: {message.username or '匿名'} - {message.content[:20]}...") + return redirect('public_content') + else: + form = TempMessageForm() + public_contents = PublicContent.objects.filter(is_published=True) content_by_type = {} @@ -1054,10 +1074,13 @@ def public_content(request): content_by_type[type_name].append(content) temp_upload_form = TempUploadForm() + temp_messages = TempMessage.objects.filter(expire_at__gt=timezone.now()) context = { 'content_by_type': content_by_type, 'temp_upload_form': temp_upload_form, + 'temp_messages': temp_messages, + 'temp_message_form': form, } return render(request, 'core/public_content.html', context) diff --git a/dairy.png b/dairy.png new file mode 100644 index 0000000..baaaead Binary files /dev/null and b/dairy.png differ diff --git a/diary_family/celery.py b/diary_family/celery.py index 00bbadc..a807ef3 100644 --- a/diary_family/celery.py +++ b/diary_family/celery.py @@ -21,6 +21,10 @@ app.conf.beat_schedule = { 'task': 'core.tasks.cleanup_expired_temp_files', 'schedule': crontab(minute=0), # 每小时整点执行 }, + 'cleanup-expired-messages': { + 'task': 'core.tasks.cleanup_expired_messages', + 'schedule': crontab(minute='*/5'), # 每5分钟执行 + }, } @app.task(bind=True) diff --git a/docs/superpowers/specs/2026-05-25-temp-file-upload-design.md b/docs/superpowers/specs/2026-05-25-temp-file-upload-design.md new file mode 100644 index 0000000..529b94e --- /dev/null +++ b/docs/superpowers/specs/2026-05-25-temp-file-upload-design.md @@ -0,0 +1,145 @@ +# 临时文件上传功能设计 + +**日期**: 2026-05-25 +**状态**: 已批准 + +## 概述 + +在现有 `/public/` 公开内容页面中增加临时文件上传功能,允许任何人上传文件并自动在指定时间后删除。 + +## 功能需求 + +1. 上传临时文件作为 PublicContent 类型发布 +2. 上传者选择过期时间:1小时 / 1天 / 7天 +3. 上传文件最大500MB +4. 过期文件自动从存储中删除 +5. 上传表单对人类用户视觉隐藏(1px宽度),但agent可访问 +6. 提供REST API方式上传文件 + +## 技术方案 + +### 1. 模型改动 + +**文件**: `core/models.py` + +在 `PublicContent` 模型中增加字段: + +```python +is_temp_file = models.BooleanField(default=False, verbose_name='临时文件') +expire_type = models.CharField( + max_length=10, + choices=[ + ('expire_1h', '1小时'), + ('expire_1d', '1天'), + ('expire_7d', '7天'), + ], + blank=True, null=True, + verbose_name='过期类型' +) +expire_at = models.DateTimeField(blank=True, null=True, verbose_name='过期时间') +``` + +### 2. 表单改动 + +**文件**: `core/forms.py` + +扩展 `PublicContentForm`,增加过期类型选择字段。 + +### 3. 上传表单HTML + +**文件**: `core/templates/core/public_content.html` + +在页面底部添加上传表单,设置 `style="width: 1px"` 使人类用户难以阅读,但agent可解析。 + +```html +
+ {% csrf_token %} + {{ temp_upload_form }} + +
+``` + +### 4. API端点 + +**文件**: `core/urls.py` 和 `core/views.py` + +新增 API 端点 `POST /api/v1/temp-upload/`: + +- 路径: `api/v1/temp-upload/` +- 方法: POST +- 参数: + - `file`: 文件 (必填, 最大500MB) + - `title`: 文件标题 (必填) + - `expire_type`: 过期类型 `expire_1h` | `expire_1d` | `expire_7d` (必填) +- 返回: JSON `{ "success": true, "file_url": "...", "expire_at": "..." }` + +### 5. 定时清理任务 + +**文件**: `core/tasks.py` + +新增 Celery 任务 `cleanup_expired_temp_files`: + +```python +@shared_task +def cleanup_expired_temp_files(): + expired_files = PublicContent.objects.filter( + is_temp_file=True, + expire_at__lte=timezone.now() + ) + for file in expired_files: + # 删除物理文件 + if file.file: + file.file.delete() + file.delete() +``` + +配置 Celery Beat 每小时执行。 + +### 6. API文档(写在公开内容页面) + +```markdown +## 临时文件上传 API + +### 上传文件 +POST /api/v1/temp-upload/ + +**参数**: +- `file`: 文件 (multipart/form-data, 最大500MB) +- `title`: 文件标题 (string) +- `expire_type`: 过期时间 (`expire_1h` | `expire_1d` | `expire_7d`) + +**响应**: +```json +{ + "success": true, + "file_url": "/media/temp_files/xxx.pdf", + "expire_at": "2026-05-25T18:30:00Z", + "file_name": "document.pdf", + "file_size": 1048576 +} +``` + +**示例**: +```bash +curl -X POST -F "file=@document.pdf" -F "title=测试文件" -F "expire_type=expire_1d" http://localhost:8000/api/v1/temp-upload/ +``` + +## 数据库迁移 + +```bash +python manage.py makemigrations core --name add_temp_file_fields +python manage.py migrate +``` + +## Celery Beat 配置 + +在 `diary_family/celery.py` 中添加周期任务: + +```python +CELERY_BEAT_SCHEDULE = { + 'cleanup-expired-temp-files': { + 'task': 'core.tasks.cleanup_expired_temp_files', + 'schedule': crontab(minute=0), # 每小时整点执行 + }, +} +``` \ No newline at end of file