feat(公开内容): 添加临时留言功能,留言保留10分钟,显示用户名、内容、时间和来源IP
This commit is contained in:
@@ -9,7 +9,8 @@ from .models import (
|
|||||||
TodayPlan,
|
TodayPlan,
|
||||||
SystemConfig,
|
SystemConfig,
|
||||||
FamilyMember,
|
FamilyMember,
|
||||||
PublicContent
|
PublicContent,
|
||||||
|
TempMessage
|
||||||
)
|
)
|
||||||
|
|
||||||
class ReadingRecordForm(forms.ModelForm):
|
class ReadingRecordForm(forms.ModelForm):
|
||||||
@@ -142,4 +143,21 @@ class TempUploadForm(forms.ModelForm):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = PublicContent
|
model = PublicContent
|
||||||
fields = ['title', '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
|
||||||
@@ -262,3 +262,20 @@ class PublicContent(models.Model):
|
|||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.type.name} - {self.title}"
|
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]}..."
|
||||||
|
|||||||
@@ -621,3 +621,49 @@ def cleanup_expired_temp_files(self):
|
|||||||
'error': error_msg,
|
'error': error_msg,
|
||||||
'timestamp': timezone.now().isoformat()
|
'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()
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,140 @@
|
|||||||
{% extends 'core/base.html' %}
|
{% extends 'core/base.html' %}
|
||||||
|
{% load humanize %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.api-float-panel {
|
||||||
|
position: fixed;
|
||||||
|
top: 80px;
|
||||||
|
right: 16px;
|
||||||
|
width: 280px;
|
||||||
|
z-index: 1050;
|
||||||
|
background: rgba(255, 255, 255, 0.97);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 20px rgba(0,0,0,0.12);
|
||||||
|
transition: transform 0.3s ease, opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
.api-float-panel.collapsed {
|
||||||
|
transform: translateX(260px);
|
||||||
|
}
|
||||||
|
.api-float-panel .panel-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: linear-gradient(135deg, #0d6efd, #6610f2);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 8px 8px 0 0;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.api-float-panel .panel-body {
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.api-float-panel .panel-body code {
|
||||||
|
font-size: 11px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 1px 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
color: #d63384;
|
||||||
|
}
|
||||||
|
.api-float-panel .panel-body pre {
|
||||||
|
font-size: 11px;
|
||||||
|
margin: 4px 0 0;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.api-float-panel .btn-toggle {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 2px;
|
||||||
|
line-height: 1;
|
||||||
|
opacity: 0.8;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
.api-float-panel .btn-toggle:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.api-float-panel .panel-tab {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
right: 0;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
background: linear-gradient(135deg, #0d6efd, #6610f2);
|
||||||
|
color: #fff;
|
||||||
|
padding: 10px 6px;
|
||||||
|
border-radius: 6px 0 0 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
writing-mode: vertical-rl;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
z-index: 1049;
|
||||||
|
box-shadow: -2px 0 10px rgba(0,0,0,0.1);
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
}
|
||||||
|
.api-float-panel.collapsed .panel-tab {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.api-float-panel.collapsed .panel-header,
|
||||||
|
.api-float-panel.collapsed .panel-body {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.api-float-panel {
|
||||||
|
width: 240px;
|
||||||
|
right: 4px;
|
||||||
|
top: 70px;
|
||||||
|
}
|
||||||
|
.api-float-panel.collapsed {
|
||||||
|
transform: translateX(225px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<!-- 浮动API说明面板 -->
|
||||||
|
<div class="api-float-panel" id="apiFloatPanel">
|
||||||
|
<div class="panel-tab" onclick="toggleApiPanel()" title="展开API说明">API 说明</div>
|
||||||
|
<div class="panel-header">
|
||||||
|
<span><i class="bi bi-terminal me-1"></i>API 说明</span>
|
||||||
|
<button class="btn-toggle" onclick="toggleApiPanel()" title="最小化">−</button>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<p class="mb-1"><strong>端点:</strong></p>
|
||||||
|
<code class="d-block mb-2">POST /api/v1/temp-upload/</code>
|
||||||
|
<p class="mb-1"><strong>参数:</strong></p>
|
||||||
|
<ul class="small mb-2 ps-3">
|
||||||
|
<li><code>title</code> 文件标题</li>
|
||||||
|
<li><code>file</code> 文件(≤500MB)</li>
|
||||||
|
<li><code>expire_type</code> expire_1h / 1d / 7d</li>
|
||||||
|
</ul>
|
||||||
|
<p class="mb-1"><strong>cURL:</strong></p>
|
||||||
|
<pre class="bg-dark text-white"><code>curl -X POST \
|
||||||
|
-F "file=@f.pdf" \
|
||||||
|
-F "title=文件" \
|
||||||
|
-F "expire_type=expire_1d" \
|
||||||
|
/api/v1/temp-upload/</code></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function toggleApiPanel() {
|
||||||
|
var panel = document.getElementById('apiFloatPanel');
|
||||||
|
panel.classList.toggle('collapsed');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<!-- 页面标题 -->
|
<!-- 页面标题 -->
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
<h2 class="mb-0">
|
<h2 class="mb-0">
|
||||||
@@ -35,6 +169,64 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 临时发言区域 -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header bg-secondary text-white">
|
||||||
|
<h5 class="card-title mb-0">
|
||||||
|
<i class="bi bi-chat-left-text me-2"></i>临时发言
|
||||||
|
<span class="badge bg-light text-dark ms-2">留言仅保留10分钟</span>
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post" action="{% url 'public_content' %}" class="mb-3">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<input type="text" name="username" class="form-control" maxlength="20" placeholder="用户名(可选)">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-7">
|
||||||
|
<input type="text" name="content" class="form-control" maxlength="1000" placeholder="说点什么...(最多1000字节)" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<button type="submit" class="btn btn-primary w-100">
|
||||||
|
<i class="bi bi-send me-1"></i>发送
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% if temp_messages %}
|
||||||
|
<div class="list-group mt-3">
|
||||||
|
{% for message in temp_messages %}
|
||||||
|
<div class="list-group-item">
|
||||||
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<h6 class="mb-1">
|
||||||
|
<i class="bi bi-person me-1"></i>{{ message.username|default:"匿名" }}
|
||||||
|
<small class="text-muted ms-2">
|
||||||
|
<i class="bi bi-clock me-1"></i>{{ message.created_at|naturaltime }}
|
||||||
|
</small>
|
||||||
|
</h6>
|
||||||
|
<p class="mb-1">{{ message.content }}</p>
|
||||||
|
<small class="text-muted">
|
||||||
|
<i class="bi bi-geo-alt me-1"></i>IP: {{ message.ip_address|default:"未知" }}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<span class="badge bg-secondary">
|
||||||
|
{{ message.created_at|naturaltime }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted text-center mb-0">
|
||||||
|
<i class="bi bi-chat-left me-1"></i>暂无留言
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% if content_by_type %}
|
{% if content_by_type %}
|
||||||
{% for type_name, contents in content_by_type.items %}
|
{% for type_name, contents in content_by_type.items %}
|
||||||
<div class="card mb-4">
|
<div class="card mb-4">
|
||||||
@@ -71,7 +263,6 @@
|
|||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if content.is_temp_file and content.expire_at %}
|
{% if content.is_temp_file and content.expire_at %}
|
||||||
{% load humanize %}
|
|
||||||
<span class="badge bg-secondary">
|
<span class="badge bg-secondary">
|
||||||
<i class="bi bi-clock me-1"></i>{{ content.expire_at|naturaltime }}过期
|
<i class="bi bi-clock me-1"></i>{{ content.expire_at|naturaltime }}过期
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -50,7 +50,8 @@ from .models import (
|
|||||||
FamilyMember,
|
FamilyMember,
|
||||||
SummaryCategory,
|
SummaryCategory,
|
||||||
PublicContentType,
|
PublicContentType,
|
||||||
PublicContent
|
PublicContent,
|
||||||
|
TempMessage
|
||||||
)
|
)
|
||||||
from .forms import (
|
from .forms import (
|
||||||
ReadingRecordForm,
|
ReadingRecordForm,
|
||||||
@@ -60,7 +61,8 @@ from .forms import (
|
|||||||
TodayPlanForm,
|
TodayPlanForm,
|
||||||
SystemConfigForm,
|
SystemConfigForm,
|
||||||
PublicContentForm,
|
PublicContentForm,
|
||||||
TempUploadForm
|
TempUploadForm,
|
||||||
|
TempMessageForm
|
||||||
)
|
)
|
||||||
|
|
||||||
# 首页视图
|
# 首页视图
|
||||||
@@ -1044,6 +1046,24 @@ def user_logout(request):
|
|||||||
def public_content(request):
|
def public_content(request):
|
||||||
"""公开内容页面 - 无需登录"""
|
"""公开内容页面 - 无需登录"""
|
||||||
logger.info("用户访问公开内容页面")
|
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)
|
public_contents = PublicContent.objects.filter(is_published=True)
|
||||||
|
|
||||||
content_by_type = {}
|
content_by_type = {}
|
||||||
@@ -1054,10 +1074,13 @@ def public_content(request):
|
|||||||
content_by_type[type_name].append(content)
|
content_by_type[type_name].append(content)
|
||||||
|
|
||||||
temp_upload_form = TempUploadForm()
|
temp_upload_form = TempUploadForm()
|
||||||
|
temp_messages = TempMessage.objects.filter(expire_at__gt=timezone.now())
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
'content_by_type': content_by_type,
|
'content_by_type': content_by_type,
|
||||||
'temp_upload_form': temp_upload_form,
|
'temp_upload_form': temp_upload_form,
|
||||||
|
'temp_messages': temp_messages,
|
||||||
|
'temp_message_form': form,
|
||||||
}
|
}
|
||||||
|
|
||||||
return render(request, 'core/public_content.html', context)
|
return render(request, 'core/public_content.html', context)
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ app.conf.beat_schedule = {
|
|||||||
'task': 'core.tasks.cleanup_expired_temp_files',
|
'task': 'core.tasks.cleanup_expired_temp_files',
|
||||||
'schedule': crontab(minute=0), # 每小时整点执行
|
'schedule': crontab(minute=0), # 每小时整点执行
|
||||||
},
|
},
|
||||||
|
'cleanup-expired-messages': {
|
||||||
|
'task': 'core.tasks.cleanup_expired_messages',
|
||||||
|
'schedule': crontab(minute='*/5'), # 每5分钟执行
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@app.task(bind=True)
|
@app.task(bind=True)
|
||||||
|
|||||||
145
docs/superpowers/specs/2026-05-25-temp-file-upload-design.md
Normal file
145
docs/superpowers/specs/2026-05-25-temp-file-upload-design.md
Normal file
@@ -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
|
||||||
|
<form method="post" enctype="multipart/form-data" style="width: 1px; position: absolute; left: -9999px;" action="{% url 'temp_upload' %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ temp_upload_form }}
|
||||||
|
<button type="submit">上传</button>
|
||||||
|
</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), # 每小时整点执行
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user