Compare commits

...

3 Commits

8 changed files with 357 additions and 87 deletions

View File

@@ -125,4 +125,21 @@ class PublicContentForm(forms.ModelForm):
'file': forms.FileInput(attrs={'class': 'form-control'}),
'url': forms.URLInput(attrs={'class': 'form-control', 'placeholder': '请输入链接地址'}),
'sort_order': forms.NumberInput(attrs={'class': 'form-control', 'placeholder': '请输入排序值'}),
}
}
class TempUploadForm(forms.ModelForm):
"""临时文件上传表单"""
expire_type = forms.ChoiceField(
choices=[
('expire_1h', '1小时'),
('expire_1d', '1天'),
('expire_7d', '7天'),
],
widget=forms.Select(attrs={'class': 'form-select'}),
label='过期时间'
)
class Meta:
model = PublicContent
fields = ['title', 'file']

View File

@@ -240,6 +240,18 @@ class PublicContent(models.Model):
url = models.URLField(blank=True, null=True, verbose_name="链接地址")
sort_order = models.IntegerField(default=0, verbose_name="排序")
is_published = models.BooleanField(default=True, verbose_name="是否发布")
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="过期时间")
created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间")
updated_at = models.DateTimeField(auto_now=True, verbose_name="更新时间")

View File

@@ -566,3 +566,58 @@ def celery_send_pdf_report_email(self):
'retries': self.request.retries if hasattr(self, 'request') else 0,
'timestamp': timezone.now().isoformat()
}
@shared_task(bind=True)
def cleanup_expired_temp_files(self):
"""
清理过期的临时文件
每小时执行一次,删除已过期的临时文件及其物理文件
"""
task_id = self.request.id if hasattr(self, 'request') else 'unknown'
logger.info(f"[任务 {task_id}] 开始执行清理过期临时文件任务")
try:
from core.models import PublicContent
expired_files = PublicContent.objects.filter(
is_temp_file=True,
expire_at__lte=timezone.now()
)
deleted_count = 0
for temp_file in expired_files:
try:
if temp_file.file:
file_path = temp_file.file.path
if os.path.exists(file_path):
os.remove(file_path)
logger.info(f"[任务 {task_id}] 已删除物理文件: {file_path}")
file_title = temp_file.title
temp_file.delete()
deleted_count += 1
logger.info(f"[任务 {task_id}] 已删除临时文件记录: {file_title}")
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()
}

View File

@@ -1,80 +1,184 @@
{% extends 'core/base.html' %}
{% block content %}
<!-- 页面标题 -->
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="mb-0">
<i class="bi bi-globe me-2 text-info"></i>公开内容
</h2>
{% if user.is_authenticated %}
<div>
<a href="{% url 'add_public_content' %}" class="btn btn-primary">
<i class="bi bi-plus-lg me-1"></i>添加内容
</a>
</div>
{% endif %}
</div>
{% if content_by_type %}
{% for type_name, contents in content_by_type.items %}
<div class="card mb-4">
<div class="card-header bg-info text-dark d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0">
<i class="bi bi-folder me-2"></i>{{ type_name }}
</h5>
<span class="badge bg-light text-info">{{ contents|length }} 项</span>
</div>
<div class="card-body">
<div class="list-group">
{% for content in contents %}
<div class="list-group-item">
<div class="d-flex justify-content-between align-items-start">
<div class="flex-grow-1">
<h6 class="mb-2">
{% if content.url %}
<a href="{{ content.url }}" target="_blank" class="text-decoration-none">
<i class="bi bi-link-45deg me-1"></i>{{ content.title }}
</a>
{% else %}
<i class="bi bi-file-earmark-text me-1"></i>{{ content.title }}
{% endif %}
</h6>
{% if content.content %}
<p class="text-muted small mb-2">{{ content.content|truncatechars:200 }}</p>
{% endif %}
{% if content.file %}
<a href="{{ content.file.url }}" class="btn btn-sm btn-outline-primary me-2" target="_blank">
<i class="bi bi-download me-1"></i>下载文件
</a>
{% endif %}
</div>
{% if user.is_authenticated %}
<div class="btn-group ms-3">
<a href="{% url 'edit_public_content' content.id %}" class="btn btn-sm btn-warning" title="编辑">
<i class="bi bi-pencil"></i>
</a>
<a href="{% url 'delete_public_content' content.id %}" class="btn btn-sm btn-danger" title="删除">
<i class="bi bi-trash"></i>
</a>
</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
</div>
</div>
{% endfor %}
{% else %}
<div class="text-center py-5">
<i class="bi bi-inbox text-muted" style="font-size: 5rem;"></i>
<h5 class="text-muted mt-3">暂无公开内容</h5>
<p class="text-muted">请稍后再来查看</p>
{% if user.is_authenticated %}
<a href="{% url 'add_public_content' %}" class="btn btn-primary">
<i class="bi bi-plus-lg me-1"></i>添加内容
</a>
{% endif %}
</div>
{% endif %}
{% endblock %}
{% extends 'core/base.html' %}
{% block content %}
<!-- 页面标题 -->
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="mb-0">
<i class="bi bi-globe me-2 text-info"></i>公开内容
</h2>
<div class="d-flex align-items-center gap-3">
<div class="dropdown">
<button class="btn btn-outline-secondary btn-sm dropdown-toggle" type="button" data-bs-toggle="dropdown">
<i class="bi bi-cloud-arrow-up me-1"></i>API上传
</button>
<ul class="dropdown-menu dropdown-menu-end p-3" style="min-width: 350px;">
<li>
<h6 class="dropdown-header">临时文件上传 API</h6>
<p class="small text-muted mb-2">端点: <code>POST /api/v1/temp-upload/</code></p>
<hr>
<p class="small mb-1"><strong>参数:</strong></p>
<ul class="small mb-2">
<li><code>title</code> - 文件标题</li>
<li><code>file</code> - 文件(最大500MB)</li>
<li><code>expire_type</code> - expire_1h / expire_1d / expire_7d</li>
</ul>
<p class="small mb-1"><strong>cURL示例:</strong></p>
<pre class="small bg-dark text-white p-2 rounded"><code>curl -X POST -F "file=@f.pdf" -F "title=文件" -F "expire_type=expire_1d" /api/v1/temp-upload/</code></pre>
</li>
</ul>
</div>
{% if user.is_authenticated %}
<a href="{% url 'add_public_content' %}" class="btn btn-primary">
<i class="bi bi-plus-lg me-1"></i>添加内容
</a>
{% endif %}
</div>
</div>
{% if content_by_type %}
{% for type_name, contents in content_by_type.items %}
<div class="card mb-4">
<div class="card-header bg-info text-dark d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0">
<i class="bi bi-folder me-2"></i>{{ type_name }}
</h5>
<span class="badge bg-light text-info">{{ contents|length }} 项</span>
</div>
<div class="card-body">
<div class="list-group">
{% for content in contents %}
<div class="list-group-item">
<div class="d-flex justify-content-between align-items-start">
<div class="flex-grow-1">
<h6 class="mb-2">
{% if content.url %}
<a href="{{ content.url }}" target="_blank" class="text-decoration-none">
<i class="bi bi-link-45deg me-1"></i>{{ content.title }}
</a>
{% else %}
<i class="bi bi-file-earmark-text me-1"></i>{{ content.title }}
{% endif %}
{% if content.is_temp_file %}
<span class="badge bg-warning text-dark ms-2">临时文件</span>
{% endif %}
</h6>
{% if content.content %}
<p class="text-muted small mb-2">{{ content.content|truncatechars:200 }}</p>
{% endif %}
{% if content.file %}
<a href="{{ content.file.url }}" class="btn btn-sm btn-outline-primary me-2" target="_blank">
<i class="bi bi-download me-1"></i>下载文件
</a>
{% endif %}
{% if content.is_temp_file and content.expire_at %}
{% load humanize %}
<span class="badge bg-secondary">
<i class="bi bi-clock me-1"></i>{{ content.expire_at|naturaltime }}过期
</span>
{% endif %}
</div>
{% if user.is_authenticated %}
<div class="btn-group ms-3">
<a href="{% url 'edit_public_content' content.id %}" class="btn btn-sm btn-warning" title="编辑">
<i class="bi bi-pencil"></i>
</a>
<a href="{% url 'delete_public_content' content.id %}" class="btn btn-sm btn-danger" title="删除">
<i class="bi bi-trash"></i>
</a>
</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
</div>
</div>
{% endfor %}
{% else %}
<div class="text-center py-5">
<i class="bi bi-inbox text-muted" style="font-size: 5rem;"></i>
<h5 class="text-muted mt-3">暂无公开内容</h5>
<p class="text-muted">请稍后再来查看</p>
{% if user.is_authenticated %}
<a href="{% url 'add_public_content' %}" class="btn btn-primary">
<i class="bi bi-plus-lg me-1"></i>添加内容
</a>
{% endif %}
</div>
{% endif %}
<!-- 临时文件上传表单 - 对人类用户视觉隐藏agent可访问 -->
<div style="width: 1px; height: 1px; overflow: hidden; position: absolute; left: -9999px;">
<form method="post" enctype="multipart/form-data" action="{% url 'api_temp_upload' %}">
{% csrf_token %}
<input type="text" name="title" placeholder="文件标题">
<select name="expire_type">
<option value="expire_1h">1小时</option>
<option value="expire_1d">1天</option>
<option value="expire_7d">7天</option>
</select>
<input type="file" name="file">
<button type="submit">上传</button>
</form>
</div>
<!-- API文档 -->
<div class="card mt-4">
<div class="card-header bg-dark text-white">
<h5 class="card-title mb-0">
<i class="bi bi-terminal me-2"></i>临时文件上传 API
</h5>
</div>
<div class="card-body">
<h6 class="text-muted">上传文件</h6>
<p><strong>端点:</strong> <code>POST /api/v1/temp-upload/</code></p>
<h6 class="text-muted mt-3">参数</h6>
<table class="table table-sm table-bordered">
<thead class="table-light">
<tr>
<th>参数名</th>
<th>类型</th>
<th>必填</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr>
<td>title</td>
<td>string</td>
<td></td>
<td>文件标题</td>
</tr>
<tr>
<td>file</td>
<td>file</td>
<td></td>
<td>文件 (最大500MB)</td>
</tr>
<tr>
<td>expire_type</td>
<td>string</td>
<td></td>
<td>过期时间: expire_1h (1小时) / expire_1d (1天) / expire_7d (7天)</td>
</tr>
</tbody>
</table>
<h6 class="text-muted mt-3">响应示例</h6>
<pre class="bg-dark text-white p-3 rounded"><code>{
"success": true,
"message": "上传成功",
"id": 1,
"file_url": "/media/public_files/xxx.pdf",
"file_name": "document.pdf",
"file_size": 1048576,
"expire_at": "2026-05-25T18:30:00Z",
"expire_type": "expire_1d"
}</code></pre>
<h6 class="text-muted mt-3">cURL 示例</h6>
<pre class="bg-dark text-white p-3 rounded"><code>curl -X POST -F "file=@document.pdf" -F "title=测试文件" -F "expire_type=expire_1d" http://localhost:8000/api/v1/temp-upload/</code></pre>
</div>
</div>
{% endblock %}

View File

@@ -36,6 +36,9 @@ urlpatterns = [
# API - 汇总记录提交
path('api/v1/summary/submit/', views.api_submit_summary, name='api_submit_summary'),
# API - 临时文件上传
path('api/v1/temp-upload/', views.api_temp_upload, name='api_temp_upload'),
# 家庭事项
path('family-tasks/', views.family_tasks, name='family_tasks'),

View File

@@ -59,7 +59,8 @@ from .forms import (
FamilyTaskForm,
TodayPlanForm,
SystemConfigForm,
PublicContentForm
PublicContentForm,
TempUploadForm
)
# 首页视图
@@ -927,6 +928,70 @@ def api_submit_summary(request):
logger.error(f"API: 提交汇总记录失败: {str(e)}")
return JsonResponse({'success': False, 'message': f"提交失败: {str(e)}"}, status=500)
@csrf_exempt
def api_temp_upload(request):
"""API临时文件上传"""
logger.info("API: 收到临时文件上传请求")
if request.method != 'POST':
return JsonResponse({'success': False, 'message': '只支持POST请求'}, status=405)
try:
title = request.POST.get('title', '').strip()
expire_type = request.POST.get('expire_type', '').strip()
file = request.FILES.get('file')
if not title:
return JsonResponse({'success': False, 'message': '标题不能为空'}, status=400)
if not file:
return JsonResponse({'success': False, 'message': '文件不能为空'}, status=400)
if expire_type not in ['expire_1h', 'expire_1d', 'expire_7d']:
return JsonResponse({'success': False, 'message': '无效的过期类型'}, status=400)
try:
public_content_type = PublicContentType.objects.get(name='临时文件')
except PublicContentType.DoesNotExist:
public_content_type = PublicContentType.objects.create(name='临时文件')
expire_delta_map = {
'expire_1h': timedelta(hours=1),
'expire_1d': timedelta(days=1),
'expire_7d': timedelta(days=7),
}
expire_at = timezone.now() + expire_delta_map[expire_type]
temp_content = PublicContent.objects.create(
type=public_content_type,
title=title,
file=file,
is_published=True,
is_temp_file=True,
expire_type=expire_type,
expire_at=expire_at,
)
file_url = request.build_absolute_uri(temp_content.file.url) if temp_content.file else None
file_size = temp_content.file.size if temp_content.file else 0
logger.info(f"API: 临时文件创建成功ID={temp_content.id}, 过期时间={expire_at}")
return JsonResponse({
'success': True,
'message': '上传成功',
'id': temp_content.id,
'file_url': file_url,
'file_name': temp_content.file.name.split('/')[-1] if temp_content.file else None,
'file_size': file_size,
'expire_at': expire_at.isoformat(),
'expire_type': expire_type,
})
except Exception as e:
logger.error(f"API: 临时文件上传失败: {str(e)}")
return JsonResponse({'success': False, 'message': f"上传失败: {str(e)}"}, status=500)
# 获取syslog日志记录器用于fail2ban检测
syslog_logger = logging.getLogger('django.security.login')
@@ -979,21 +1044,22 @@ def user_logout(request):
def public_content(request):
"""公开内容页面 - 无需登录"""
logger.info("用户访问公开内容页面")
# 获取所有已发布的公开内容
public_contents = PublicContent.objects.filter(is_published=True)
# 按类型分组
content_by_type = {}
for content in public_contents:
type_name = content.type.name
if type_name not in content_by_type:
content_by_type[type_name] = []
content_by_type[type_name].append(content)
temp_upload_form = TempUploadForm()
context = {
'content_by_type': content_by_type,
'temp_upload_form': temp_upload_form,
}
return render(request, 'core/public_content.html', context)
# 添加公开内容

View File

@@ -1,6 +1,7 @@
import os
from celery import Celery
from django.conf import settings
from celery.schedules import crontab
# 设置默认的Django设置模块
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'diary_family.settings')
@@ -14,6 +15,14 @@ app.config_from_object('django.conf:settings', namespace='CELERY')
# 自动发现任务
app.autodiscover_tasks(lambda: settings.INSTALLED_APPS)
# Celery Beat 周期任务配置
app.conf.beat_schedule = {
'cleanup-expired-temp-files': {
'task': 'core.tasks.cleanup_expired_temp_files',
'schedule': crontab(minute=0), # 每小时整点执行
},
}
@app.task(bind=True)
def debug_task(self):
"""调试任务"""

View File

@@ -190,6 +190,10 @@ CSRF_TRUSTED_ORIGINS = [
# 如果你将来有域名,也可以在这里加上,例如 "https://yourdomain.com"
]
# 文件上传大小限制 (500MB)
DATA_UPLOAD_MAX_MEMORY_SIZE = 524288000
FILE_UPLOAD_MAX_MEMORY_SIZE = 524288000
CELERY_BROKER_URL = 'redis://:xjjq1234!@localhost:6379/0'
CELERY_RESULT_BACKEND = 'redis://:xjjq1234!@localhost:6379/0'