Compare commits
12 Commits
3aa311b9da
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a06061d80e | ||
|
|
0e318b1c36 | ||
|
|
22e3e09b24 | ||
|
|
8bf9ae8b6c | ||
|
|
6f0eb5f4a5 | ||
|
|
597ff063f9 | ||
|
|
0958c1ae6b | ||
|
|
b1cf94cd23 | ||
|
|
df595c706c | ||
|
|
b3847ed98d | ||
|
|
aba4933a95 | ||
|
|
ce7d39f36c |
164
README.md
164
README.md
@@ -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 地址将被自动封禁。
|
||||
|
||||
@@ -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
|
||||
@@ -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]}..."
|
||||
|
||||
101
core/tasks.py
101
core/tasks.py
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 %}
|
||||
12
core/urls.py
12
core/urls.py
@@ -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'),
|
||||
|
||||
293
core/views.py
293
core/views.py
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
"""调试任务"""
|
||||
|
||||
@@ -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,
|
||||
|
||||
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), # 每小时整点执行
|
||||
},
|
||||
}
|
||||
```
|
||||
73
docs/superpowers/specs/2026-06-07-api-token-auth-design.md
Normal file
73
docs/superpowers/specs/2026-06-07-api-token-auth-design.md
Normal 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 + 函数视图模式
|
||||
- 不涉及数据库迁移
|
||||
|
||||
## 错误处理
|
||||
|
||||
- 401:Token 缺失或错误
|
||||
- 400:必填字段缺失、外键匹配失败
|
||||
- 405:非 POST 请求
|
||||
- 500:服务器内部错误
|
||||
- 所有错误统一返回 `{"success": false, "message": "错误描述"}`
|
||||
|
||||
## 向后兼容
|
||||
|
||||
- 已有 Web 页面功能不受影响
|
||||
- 已有 API 端点加上 Token 鉴权(需同步更新调用方)
|
||||
Reference in New Issue
Block a user