Compare commits

...

12 Commits

12 changed files with 1055 additions and 168 deletions

164
README.md
View File

@@ -1588,9 +1588,11 @@ sudo systemctl restart gunicorn
---
---
## API接口文档
系统提供RESTful API接口用于外部客户端提交汇总记录。
系统提供RESTful API接口支持外部客户端提交汇总记录和临时文件
### 1. 汇总记录提交API
@@ -1607,6 +1609,7 @@ sudo systemctl restart gunicorn
| 参数名 | 类型 | 必填 | 说明 |
|-------|------|------|------|
| content | string | 是 | 汇总记录内容,最大长度不限 |
| source | string | 否 | 来源自动获取客户端主机名和IP |
#### 响应格式
@@ -1639,7 +1642,7 @@ import requests
url = "http://your-server/api/v1/summary/submit/"
data = {
"content": "监控报告:系统运行正常CPU使用率15%内存使用率42%"
"content": "监控报告:系统运行正常"
}
response = requests.post(url, data=data)
@@ -1647,50 +1650,6 @@ result = response.json()
print(result)
```
#### 客户端自动提交示例
创建一个Python脚本用于自动提交系统监控数据
```python
#!/usr/bin/env python3
# submit_summary.py
import socket
import psutil
import requests
from datetime import datetime
def get_system_info():
"""获取系统基本信息"""
cpu_percent = psutil.cpu_percent(interval=1)
memory = psutil.virtual_memory()
disk = psutil.disk_usage('/')
return {
'cpu': cpu_percent,
'memory': memory.percent,
'disk': disk.percent
}
def submit_summary(content):
"""提交汇总记录到家庭日报系统"""
url = "http://your-server/api/v1/summary/submit/"
try:
response = requests.post(url, data={'content': content}, timeout=10)
result = response.json()
if result['success']:
print(f"提交成功记录ID: {result['id']}")
else:
print(f"提交失败: {result['message']}")
except Exception as e:
print(f"请求异常: {str(e)}")
if __name__ == "__main__":
info = get_system_info()
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
content = f"[系统监控 {timestamp}] CPU使用率: {info['cpu']}%, 内存使用率: {info['memory']}%, 磁盘使用率: {info['disk']}%"
submit_summary(content)
```
#### 注意事项
1. **数据验证**API仅接受以下条件的记录
@@ -1702,11 +1661,103 @@ if __name__ == "__main__":
3. **日期自动设置**:记录日期自动设置为提交时的日期。
4. **错误处理**如果分类或发言人不存在API会返回错误信息。
---
### 2. 初始化必需数据
### 2. 临时文件上传API
在使用API提交功能前需要确保数据库中存在以下数据
用于上传临时文件到公开内容支持1小时/1天/7天过期自动删除。
#### 请求信息
- **URL**: `/api/v1/temp-upload/`
- **方法**: `POST`
- **Content-Type**: `multipart/form-data`
- **文件大小限制**: 500MB
#### 请求参数
| 参数名 | 类型 | 必填 | 说明 |
|-------|------|------|------|
| title | string | 是 | 文件标题 |
| file | file | 是 | 上传的文件最大500MB |
| expire_type | string | 是 | 过期时间:`expire_1h`(1小时) / `expire_1d`(1天) / `expire_7d`(7天) |
#### 响应格式
**成功响应** (HTTP 200):
```json
{
"success": true,
"message": "上传成功",
"id": 1,
"file_url": "http://your-server/media/public_files/xxx.pdf",
"file_name": "document.pdf",
"file_size": 1048576,
"expire_at": "2026-05-25T18:30:00Z",
"expire_type": "expire_1d"
}
```
**失败响应** (HTTP 400/500):
```json
{
"success": false,
"message": "错误信息描述"
}
```
#### 调用示例
```bash
# 使用 curl 上传文件
curl -X POST http://your-server/api/v1/temp-upload/ \
-F "file=@document.pdf" \
-F "title=测试文件" \
-F "expire_type=expire_1d"
# Python requests 调用示例
import requests
url = "http://your-server/api/v1/temp-upload/"
files = {'file': open('document.pdf', 'rb')}
data = {
'title': '测试文件',
'expire_type': 'expire_1d'
}
response = requests.post(url, files=files, data=data)
result = response.json()
print(result)
```
---
### 3. 临时发言(页面表单)
在公开内容页面 `/public/` 提供临时留言功能。
#### 功能说明
- 用户名可选最大20字节
- 内容必填最大1000字节
- 留言保留时间10分钟
- 显示用户名可选、内容、时间、来源IP
#### 提交方式
在 `/public/` 页面直接填写表单提交无需API调用。
#### 显示规则
- 只显示10分钟内的留言
- 过期后自动从页面移除
- 后台定时任务定期清理过期数据
---
### 4. 初始化必需数据
在使用API提交汇总记录前需要确保数据库中存在以下数据
#### 方法一Django Shell初始化
@@ -1738,7 +1789,9 @@ print('初始化完成')
2. 在 **汇总分类** 中添加名为"定期"的分类
3. 在 **家庭成员** 中添加名为"机器人"的成员
### 3. 常见问题排查
---
### 5. 常见问题排查
#### 问题1提交返回"分类 '定期' 不存在"
@@ -1748,7 +1801,7 @@ print('初始化完成')
**解决方法**:在数据库中创建该发言人(见上方初始化方法)
#### 问题3提交返回"内容不能为空"
#### 问题3上传返回"内容不能为空"
**解决方法**:确保请求中包含 `content` 参数且不为空
@@ -1756,6 +1809,15 @@ print('初始化完成')
**解决方法**确认使用的是POST方法不是GET方法
#### 问题5文件上传失败413 Request Entity Too Large
**解决方法**检查nginx配置中的 `client_max_body_size` 是否大于500m
```nginx
# 在 /etc/nginx/sites-enabled/diary_family 中添加
client_max_body_size 500m;
```
## Fail2ban 登录保护配置
为了防止暴力破解登录密码,系统集成了 Fail2ban 自动封禁功能。当用户在短时间内多次登录失败时,其 IP 地址将被自动封禁。

View File

@@ -9,7 +9,8 @@ from .models import (
TodayPlan,
SystemConfig,
FamilyMember,
PublicContent
PublicContent,
TempMessage
)
class ReadingRecordForm(forms.ModelForm):
@@ -125,4 +126,38 @@ 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']
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

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="更新时间")
@@ -250,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]}..."

View File

@@ -566,3 +566,104 @@ 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()
}
@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()
}

View File

@@ -1,80 +1,198 @@
{% 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' %}
{% load humanize %}
{% 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: 400px;">
<li>
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="dropdown-header mb-0">临时文件上传 API</h6>
<button class="btn btn-sm btn-outline-primary" onclick="copyApiDocs()">
<i class="bi bi-clipboard me-1"></i>复制
</button>
</div>
<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" id="curlExample"><code>curl -X POST -F "file=@f.pdf" -F "title=文件" -F "expire_type=expire_1d" /api/v1/temp-upload/</code></pre>
</li>
</ul>
</div>
<script>
function copyApiDocs() {
const curlCode = document.getElementById('curlExample').textContent;
navigator.clipboard.writeText(curlCode).then(function() {
alert('已复制到剪贴板!');
}).catch(function(err) {
console.error('复制失败:', err);
});
}
</script>
{% 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>
<!-- 临时发言区域 -->
<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 %}
{% 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 %}
<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>
{% endblock %}

View File

@@ -36,6 +36,18 @@ 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'),
# API - 感悟记录提交
path('api/v1/insight/submit/', views.api_submit_insight, name='api_submit_insight'),
# API - 阅读记录提交
path('api/v1/reading/submit/', views.api_submit_reading, name='api_submit_reading'),
# API - 今日计划提交
path('api/v1/plan/submit/', views.api_submit_plan, name='api_submit_plan'),
# 家庭事项
path('family-tasks/', views.family_tasks, name='family_tasks'),

View File

@@ -10,6 +10,8 @@ from django.contrib.auth import authenticate, login, logout
from django.contrib.auth.decorators import login_required
from django.contrib import messages
from datetime import timedelta, datetime
from functools import wraps
import json
import os
import logging
from loguru import logger
@@ -42,6 +44,7 @@ def is_weasyprint_available():
from .models import (
ReadingRecord,
ReadingType,
InsightRecord,
Summary,
FamilyTask,
@@ -50,7 +53,11 @@ from .models import (
FamilyMember,
SummaryCategory,
PublicContentType,
PublicContent
PublicContent,
TempMessage,
Priority,
PlanType,
Status
)
from .forms import (
ReadingRecordForm,
@@ -59,9 +66,46 @@ from .forms import (
FamilyTaskForm,
TodayPlanForm,
SystemConfigForm,
PublicContentForm
PublicContentForm,
TempUploadForm,
TempMessageForm
)
# ==================== API Token 鉴权 ====================
def require_api_token(view_func):
@wraps(view_func)
def _wrapped_view(request, *args, **kwargs):
auth_header = request.headers.get('Authorization', '')
if not auth_header.startswith('Bearer '):
return JsonResponse({'success': False, 'message': '缺少认证Token'}, status=401)
token = auth_header[7:]
if token != settings.API_TOKEN:
return JsonResponse({'success': False, 'message': 'Token无效'}, status=401)
return view_func(request, *args, **kwargs)
return _wrapped_view
def _get_request_data(request):
content_type = request.content_type or ''
if 'application/json' in content_type:
try:
return json.loads(request.body.decode('utf-8'))
except (json.JSONDecodeError, UnicodeDecodeError):
return {}
return request.POST
def _parse_date(date_str):
if not date_str:
return timezone.now().date()
try:
return datetime.strptime(date_str.strip(), '%Y-%m-%d').date()
except (ValueError, AttributeError):
return timezone.now().date()
# =======================================================
# 首页视图
@login_required
def index(request):
@@ -868,6 +912,8 @@ def pdf_list(request):
return render(request, 'core/pdf_list.html', context)
@csrf_exempt
@require_api_token
def api_submit_summary(request):
"""API提交汇总记录 - 仅接受指定分类和发言人的记录"""
logger.info("API: 收到汇总记录提交请求")
@@ -927,6 +973,71 @@ def api_submit_summary(request):
logger.error(f"API: 提交汇总记录失败: {str(e)}")
return JsonResponse({'success': False, 'message': f"提交失败: {str(e)}"}, status=500)
@csrf_exempt
@require_api_token
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 +1090,43 @@ 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 = {}
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()
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)
# 添加公开内容
@@ -1041,3 +1174,149 @@ def delete_public_content(request, pk):
context = {'content': content}
return render(request, 'core/delete_public_content.html', context)
# ==================== API 写入接口 ====================
@csrf_exempt
@require_api_token
def api_submit_insight(request):
logger.info("API: 收到感悟记录提交请求")
if request.method != 'POST':
return JsonResponse({'success': False, 'message': '只支持POST请求'}, status=405)
try:
data = _get_request_data(request)
content = (data.get('content') or '').strip()
speaker_name = (data.get('speaker') or '').strip()
if not content:
return JsonResponse({'success': False, 'message': '内容不能为空'}, status=400)
if not speaker_name:
return JsonResponse({'success': False, 'message': '发言人不能为空'}, status=400)
try:
speaker = FamilyMember.objects.get(name=speaker_name)
except FamilyMember.DoesNotExist:
return JsonResponse({'success': False, 'message': f"发言人 '{speaker_name}' 不存在"}, status=400)
record = InsightRecord.objects.create(
date=_parse_date(data.get('date')),
content=content,
speaker=speaker,
file=request.FILES.get('file')
)
logger.info(f"API: 感悟记录创建成功ID={record.id}")
return JsonResponse({'success': True, 'message': '提交成功', 'id': record.id})
except Exception as e:
logger.error(f"API: 提交感悟记录失败: {str(e)}")
return JsonResponse({'success': False, 'message': f"提交失败: {str(e)}"}, status=500)
@csrf_exempt
@require_api_token
def api_submit_reading(request):
logger.info("API: 收到阅读记录提交请求")
if request.method != 'POST':
return JsonResponse({'success': False, 'message': '只支持POST请求'}, status=405)
try:
data = _get_request_data(request)
type_name = (data.get('type') or '').strip()
title = (data.get('title') or '').strip()
if not type_name:
return JsonResponse({'success': False, 'message': '阅读类型不能为空'}, status=400)
if not title:
return JsonResponse({'success': False, 'message': '标题不能为空'}, status=400)
try:
reading_type = ReadingType.objects.get(name=type_name)
except ReadingType.DoesNotExist:
return JsonResponse({'success': False, 'message': f"阅读类型 '{type_name}' 不存在"}, status=400)
record = ReadingRecord.objects.create(
date=_parse_date(data.get('date')),
type=reading_type,
title=title,
source=(data.get('source') or '').strip() or None,
progress=(data.get('progress') or '').strip() or None,
note=(data.get('note') or '').strip() or None,
file=request.FILES.get('file')
)
logger.info(f"API: 阅读记录创建成功ID={record.id}")
return JsonResponse({'success': True, 'message': '提交成功', 'id': record.id})
except Exception as e:
logger.error(f"API: 提交阅读记录失败: {str(e)}")
return JsonResponse({'success': False, 'message': f"提交失败: {str(e)}"}, status=500)
@csrf_exempt
@require_api_token
def api_submit_plan(request):
logger.info("API: 收到今日计划提交请求")
if request.method != 'POST':
return JsonResponse({'success': False, 'message': '只支持POST请求'}, status=405)
try:
data = _get_request_data(request)
content = (data.get('content') or '').strip()
if not content:
return JsonResponse({'success': False, 'message': '内容不能为空'}, status=400)
speaker_name = (data.get('speaker') or '').strip()
if speaker_name:
try:
speaker = FamilyMember.objects.get(name=speaker_name)
except FamilyMember.DoesNotExist:
return JsonResponse({'success': False, 'message': f"发言人 '{speaker_name}' 不存在"}, status=400)
else:
speaker = FamilyMember.objects.get_or_create(name='机器人')[0]
priority_name = (data.get('priority') or '').strip()
if priority_name:
try:
priority = Priority.objects.get(name=priority_name)
except Priority.DoesNotExist:
return JsonResponse({'success': False, 'message': f"优先级 '{priority_name}' 不存在"}, status=400)
else:
priority = Priority.objects.get_or_create(name='')[0]
type_name = (data.get('type') or '').strip()
if type_name:
try:
plan_type = PlanType.objects.get(name=type_name)
except PlanType.DoesNotExist:
return JsonResponse({'success': False, 'message': f"计划类型 '{type_name}' 不存在"}, status=400)
else:
plan_type = PlanType.objects.get_or_create(name='其他')[0]
status_name = (data.get('status') or '').strip()
if status_name:
try:
status = Status.objects.get(name=status_name)
except Status.DoesNotExist:
return JsonResponse({'success': False, 'message': f"状态 '{status_name}' 不存在"}, status=400)
else:
status = Status.objects.get_or_create(name='未开始')[0]
record = TodayPlan.objects.create(
date=_parse_date(data.get('date')),
content=content,
speaker=speaker,
priority=priority,
type=plan_type,
status=status,
)
logger.info(f"API: 今日计划创建成功ID={record.id}")
return JsonResponse({'success': True, 'message': '提交成功', 'id': record.id})
except Exception as e:
logger.error(f"API: 提交今日计划失败: {str(e)}")
return JsonResponse({'success': False, 'message': f"提交失败: {str(e)}"}, status=500)

BIN
dairy.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

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,18 @@ 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), # 每小时整点执行
},
'cleanup-expired-messages': {
'task': 'core.tasks.cleanup_expired_messages',
'schedule': crontab(minute='*/5'), # 每5分钟执行
},
}
@app.task(bind=True)
def debug_task(self):
"""调试任务"""

View File

@@ -37,6 +37,7 @@ INSTALLED_APPS = [
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'django.contrib.humanize',
'core',
'django_celery_beat',
]
@@ -124,6 +125,9 @@ STATIC_URL = 'static/'
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
# API Token configuration
API_TOKEN = 'diary-family-api-token-2026'
# Login URL configuration
LOGIN_URL = '/login/'
@@ -190,82 +194,98 @@ 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'
import sys
_is_linux = sys.platform == 'linux'
_syslog_handler = {
'level': 'WARNING',
'class': 'logging.handlers.SysLogHandler',
'address': '/dev/log',
'facility': 'local0',
'formatter': 'syslog',
} if _is_linux else {
'level': 'WARNING',
'class': 'logging.handlers.RotatingFileHandler',
'filename': str(LOG_DIR / 'syslog.log'),
'maxBytes': 1024 * 1024 * 50,
'backupCount': 5,
'formatter': 'standard',
'encoding': 'utf-8',
}
LOGGING = {
'version': 1,
'disable_existing_loggers': False, # 不关闭已存在的日志器
'disable_existing_loggers': False,
'formatters': {
'standard': { # 统一的标准日志格式
'standard': {
'format': '[%(asctime)s] [%(levelname)s] [%(process)d] [%(module)s] %(message)s',
'datefmt': '%Y-%m-%d %H:%M:%S'
},
'syslog': { # syslog格式用于fail2ban检测
'syslog': {
'format': '%(name)s: %(levelname)s %(message)s'
},
},
'handlers': {
'file': { # 日志写入文件的处理器
'level': 'INFO', # 日志级别INFO及以上都记录ERROR/WARNING/INFO
'class': 'logging.handlers.RotatingFileHandler', # 日志轮转,防止文件过大
# ✅ 核心pathlib对象转字符串logging只接收字符串路径必转
'file': {
'level': 'INFO',
'class': 'logging.handlers.RotatingFileHandler',
'filename': str(LOG_DIR / 'all_in_one.log'),
'maxBytes': 1024 * 1024 * 100, # 单个日志文件最大100MB
'backupCount': 10, # 最多保留10个日志备份
'formatter': 'standard', # 使用上面定义的统一格式
'encoding': 'utf-8', # 编码,防止中文乱码
'maxBytes': 1024 * 1024 * 100,
'backupCount': 10,
'formatter': 'standard',
'encoding': 'utf-8',
},
'console': { # 兼容控制台输出(开发调试用,不影响生产)
'console': {
'level': 'INFO',
'class': 'logging.StreamHandler',
'formatter': 'standard'
},
'syslog': { # syslog处理器用于fail2ban检测登录失败
'level': 'WARNING',
'class': 'logging.handlers.SysLogHandler',
'address': '/dev/log', # Linux系统日志socket
'facility': 'local0',
'formatter': 'syslog',
},
'auth_file': { # 认证日志文件处理器(备选方案)
'syslog': _syslog_handler,
'auth_file': {
'level': 'WARNING',
'class': 'logging.handlers.RotatingFileHandler',
'filename': str(LOG_DIR / 'auth.log'),
'maxBytes': 1024 * 1024 * 50, # 50MB
'maxBytes': 1024 * 1024 * 50,
'backupCount': 5,
'formatter': 'standard',
'encoding': 'utf-8',
},
},
# 所有日志器配置和原配置完全一致,无需任何修改
'loggers': {
'django': { # Django核心日志
'django': {
'handlers': ['file'],
'level': 'INFO',
'propagate': True,
},
'django.request': { # Django的请求日志
'django.request': {
'handlers': ['file'],
'level': 'INFO',
'propagate': True,
},
'django.security.login': { # 登录安全日志用于fail2ban
'django.security.login': {
'handlers': ['syslog', 'auth_file'],
'level': 'WARNING',
'propagate': False,
},
'celery': { # Celery客户端日志Django中提交任务的日志
'celery': {
'handlers': ['file'],
'level': 'INFO',
'propagate': True,
},
'utils.tasks': { # Celery邮件任务模块
'utils.tasks': {
'handlers': ['file'],
'level': 'INFO',
'propagate': True,
},
'utils.email_utils': { # 邮件配置模块
'utils.email_utils': {
'handlers': ['file'],
'level': 'INFO',
'propagate': True,

View 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), # 每小时整点执行
},
}
```

View File

@@ -0,0 +1,73 @@
# API Token 鉴权与数据写入接口设计
## 概述
为家庭日报系统增加 Token 鉴权机制,并通过 API 支持外部写入阅读记录、感悟记录、今日计划。
## Token 鉴权
- Token 存储在 `settings.py``API_TOKEN` 配置项中
- 请求需携带 `Authorization: Bearer <token>`
- 新建 `@require_api_token` 装饰器统一校验,校验失败返回 401
- 已有 API`api_submit_summary``api_temp_upload`)同步加上鉴权(破坏性变更,需评估影响)
## 新增 API 端点
所有端点均为 POST支持 `application/json``multipart/form-data`(文件上传场景)。统一返回格式:
```json
{"success": true/false, "message": "...", "id": ...}
```
### 1. POST /api/v1/insight/submit/ - 写入感悟记录
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| content | string | 是 | 感悟内容 |
| speaker | string | 是 | 发言人姓名,匹配 FamilyMember.name |
| date | string | 否 | 日期 YYYY-MM-DD默认今天 |
| file | file | 否 | 附件 |
### 2. POST /api/v1/reading/submit/ - 写入阅读记录
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| type | string | 是 | 阅读类型,匹配 ReadingType.name |
| title | string | 是 | 标题 |
| source | string | 否 | 来源 |
| progress | string | 否 | 进度 |
| note | string | 否 | 阅读笔记 |
| date | string | 否 | 日期 YYYY-MM-DD默认今天 |
| file | file | 否 | 附件 |
### 3. POST /api/v1/plan/submit/ - 写入今日计划
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| content | string | 是 | 计划内容 |
| speaker | string | 否 | 发言人,默认"机器人" |
| priority | string | 否 | 优先级,匹配 Priority.name默认"中" |
| type | string | 否 | 类型,匹配 PlanType.name默认"其他" |
| status | string | 否 | 状态,匹配 Status.name默认"未开始" |
| date | string | 否 | 日期 YYYY-MM-DD默认今天 |
## 实现范围
- `diary_family/settings.py`:新增 `API_TOKEN` 配置
- `core/views.py`:新增 `require_api_token` 装饰器、3 个 API 视图函数
- `core/urls.py`:新增 3 条路由
- 不引入 DRF沿用项目现有 JsonResponse + 函数视图模式
- 不涉及数据库迁移
## 错误处理
- 401Token 缺失或错误
- 400必填字段缺失、外键匹配失败
- 405非 POST 请求
- 500服务器内部错误
- 所有错误统一返回 `{"success": false, "message": "错误描述"}`
## 向后兼容
- 已有 Web 页面功能不受影响
- 已有 API 端点加上 Token 鉴权(需同步更新调用方)