Compare commits

...

9 Commits

12 changed files with 764 additions and 147 deletions

164
README.md
View File

@@ -1588,9 +1588,11 @@ sudo systemctl restart gunicorn
--- ---
---
## API接口文档 ## API接口文档
系统提供RESTful API接口用于外部客户端提交汇总记录。 系统提供RESTful API接口支持外部客户端提交汇总记录和临时文件
### 1. 汇总记录提交API ### 1. 汇总记录提交API
@@ -1607,6 +1609,7 @@ sudo systemctl restart gunicorn
| 参数名 | 类型 | 必填 | 说明 | | 参数名 | 类型 | 必填 | 说明 |
|-------|------|------|------| |-------|------|------|------|
| content | string | 是 | 汇总记录内容,最大长度不限 | | content | string | 是 | 汇总记录内容,最大长度不限 |
| source | string | 否 | 来源自动获取客户端主机名和IP |
#### 响应格式 #### 响应格式
@@ -1639,7 +1642,7 @@ import requests
url = "http://your-server/api/v1/summary/submit/" url = "http://your-server/api/v1/summary/submit/"
data = { data = {
"content": "监控报告:系统运行正常CPU使用率15%内存使用率42%" "content": "监控报告:系统运行正常"
} }
response = requests.post(url, data=data) response = requests.post(url, data=data)
@@ -1647,50 +1650,6 @@ result = response.json()
print(result) 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仅接受以下条件的记录 1. **数据验证**API仅接受以下条件的记录
@@ -1702,11 +1661,103 @@ if __name__ == "__main__":
3. **日期自动设置**:记录日期自动设置为提交时的日期。 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初始化 #### 方法一Django Shell初始化
@@ -1738,7 +1789,9 @@ print('初始化完成')
2. 在 **汇总分类** 中添加名为"定期"的分类 2. 在 **汇总分类** 中添加名为"定期"的分类
3. 在 **家庭成员** 中添加名为"机器人"的成员 3. 在 **家庭成员** 中添加名为"机器人"的成员
### 3. 常见问题排查 ---
### 5. 常见问题排查
#### 问题1提交返回"分类 '定期' 不存在" #### 问题1提交返回"分类 '定期' 不存在"
@@ -1748,7 +1801,7 @@ print('初始化完成')
**解决方法**:在数据库中创建该发言人(见上方初始化方法) **解决方法**:在数据库中创建该发言人(见上方初始化方法)
#### 问题3提交返回"内容不能为空" #### 问题3上传返回"内容不能为空"
**解决方法**:确保请求中包含 `content` 参数且不为空 **解决方法**:确保请求中包含 `content` 参数且不为空
@@ -1756,6 +1809,15 @@ print('初始化完成')
**解决方法**确认使用的是POST方法不是GET方法 **解决方法**确认使用的是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 登录保护配置
为了防止暴力破解登录密码,系统集成了 Fail2ban 自动封禁功能。当用户在短时间内多次登录失败时,其 IP 地址将被自动封禁。 为了防止暴力破解登录密码,系统集成了 Fail2ban 自动封禁功能。当用户在短时间内多次登录失败时,其 IP 地址将被自动封禁。

View File

@@ -9,7 +9,8 @@ from .models import (
TodayPlan, TodayPlan,
SystemConfig, SystemConfig,
FamilyMember, FamilyMember,
PublicContent PublicContent,
TempMessage
) )
class ReadingRecordForm(forms.ModelForm): class ReadingRecordForm(forms.ModelForm):
@@ -143,3 +144,20 @@ class TempUploadForm(forms.ModelForm):
class Meta: class Meta:
model = PublicContent model = PublicContent
fields = ['title', 'file'] fields = ['title', 'file']
class TempMessageForm(forms.ModelForm):
"""临时留言表单"""
class Meta:
model = TempMessage
fields = ['username', 'content']
widgets = {
'username': forms.TextInput(attrs={'class': 'form-control', 'maxlength': '20', 'placeholder': '用户名可选最多20字节'}),
'content': forms.Textarea(attrs={'class': 'form-control', 'rows': 3, 'maxlength': '1000', 'placeholder': '说点什么...最多1000字节'}),
}
def clean_content(self):
content = self.cleaned_data.get('content', '')
if len(content.encode('utf-8')) > 1000:
raise forms.ValidationError("内容不能超过1000字节")
return content

View File

@@ -262,3 +262,20 @@ class PublicContent(models.Model):
def __str__(self): def __str__(self):
return f"{self.type.name} - {self.title}" return f"{self.type.name} - {self.title}"
class TempMessage(models.Model):
"""临时留言"""
username = models.CharField(max_length=20, blank=True, null=True, verbose_name="用户名")
content = models.CharField(max_length=1000, verbose_name="内容")
ip_address = models.GenericIPAddressField(blank=True, null=True, verbose_name="来源IP")
created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间")
expire_at = models.DateTimeField(blank=True, null=True, verbose_name="过期时间")
class Meta:
verbose_name = "临时留言"
verbose_name_plural = "临时留言"
ordering = ['-created_at']
def __str__(self):
return f"{self.username or '匿名'} - {self.content[:20]}..."

View File

@@ -621,3 +621,49 @@ def cleanup_expired_temp_files(self):
'error': error_msg, 'error': error_msg,
'timestamp': timezone.now().isoformat() 'timestamp': timezone.now().isoformat()
} }
@shared_task(bind=True)
def cleanup_expired_messages(self):
"""
清理过期的临时留言
每5分钟执行一次删除已过期的留言
"""
task_id = self.request.id if hasattr(self, 'request') else 'unknown'
logger.info(f"[任务 {task_id}] 开始执行清理过期留言任务")
try:
from core.models import TempMessage
expired_messages = TempMessage.objects.filter(expire_at__lte=timezone.now())
deleted_count = 0
for message in expired_messages:
try:
msg_content = message.content[:20]
message.delete()
deleted_count += 1
logger.info(f"[任务 {task_id}] 已删除过期留言: {msg_content}...")
except Exception as e:
logger.error(f"[任务 {task_id}] 删除留言失败: {str(e)}")
logger.success(f"[任务 {task_id}] 清理完成,共删除 {deleted_count} 条过期留言")
return {
'status': 'success',
'task_id': task_id,
'deleted_count': deleted_count,
'timestamp': timezone.now().isoformat()
}
except Exception as e:
error_msg = str(e)
error_traceback = traceback.format_exc()
logger.error(f"[任务 {task_id}] 清理过期留言失败: {error_msg}")
logger.error(f"[任务 {task_id}] 错误详情:\n{error_traceback}")
return {
'status': 'failed',
'task_id': task_id,
'error': error_msg,
'timestamp': timezone.now().isoformat()
}

View File

@@ -1,6 +1,8 @@
{% extends 'core/base.html' %} {% extends 'core/base.html' %}
{% load humanize %}
{% block content %} {% block content %}
<!-- 页面标题 --> <!-- 页面标题 -->
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="mb-0"> <h2 class="mb-0">
@@ -11,9 +13,14 @@
<button class="btn btn-outline-secondary btn-sm dropdown-toggle" type="button" data-bs-toggle="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上传 <i class="bi bi-cloud-arrow-up me-1"></i>API上传
</button> </button>
<ul class="dropdown-menu dropdown-menu-end p-3" style="min-width: 350px;"> <ul class="dropdown-menu dropdown-menu-end p-3" style="min-width: 400px;">
<li> <li>
<h6 class="dropdown-header">临时文件上传 API</h6> <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> <p class="small text-muted mb-2">端点: <code>POST /api/v1/temp-upload/</code></p>
<hr> <hr>
<p class="small mb-1"><strong>参数:</strong></p> <p class="small mb-1"><strong>参数:</strong></p>
@@ -23,10 +30,20 @@
<li><code>expire_type</code> - expire_1h / expire_1d / expire_7d</li> <li><code>expire_type</code> - expire_1h / expire_1d / expire_7d</li>
</ul> </ul>
<p class="small mb-1"><strong>cURL示例:</strong></p> <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> <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> </li>
</ul> </ul>
</div> </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 %} {% if user.is_authenticated %}
<a href="{% url 'add_public_content' %}" class="btn btn-primary"> <a href="{% url 'add_public_content' %}" class="btn btn-primary">
<i class="bi bi-plus-lg me-1"></i>添加内容 <i class="bi bi-plus-lg me-1"></i>添加内容
@@ -35,6 +52,64 @@
</div> </div>
</div> </div>
<!-- 临时发言区域 -->
<div class="card mb-4">
<div class="card-header bg-secondary text-white">
<h5 class="card-title mb-0">
<i class="bi bi-chat-left-text me-2"></i>临时发言
<span class="badge bg-light text-dark ms-2">留言仅保留10分钟</span>
</h5>
</div>
<div class="card-body">
<form method="post" action="{% url 'public_content' %}" class="mb-3">
{% csrf_token %}
<div class="row g-2">
<div class="col-md-3">
<input type="text" name="username" class="form-control" maxlength="20" placeholder="用户名(可选)">
</div>
<div class="col-md-7">
<input type="text" name="content" class="form-control" maxlength="1000" placeholder="说点什么...最多1000字节" required>
</div>
<div class="col-md-2">
<button type="submit" class="btn btn-primary w-100">
<i class="bi bi-send me-1"></i>发送
</button>
</div>
</div>
</form>
{% if temp_messages %}
<div class="list-group mt-3">
{% for message in temp_messages %}
<div class="list-group-item">
<div class="d-flex justify-content-between align-items-start">
<div class="flex-grow-1">
<h6 class="mb-1">
<i class="bi bi-person me-1"></i>{{ message.username|default:"匿名" }}
<small class="text-muted ms-2">
<i class="bi bi-clock me-1"></i>{{ message.created_at|naturaltime }}
</small>
</h6>
<p class="mb-1">{{ message.content }}</p>
<small class="text-muted">
<i class="bi bi-geo-alt me-1"></i>IP: {{ message.ip_address|default:"未知" }}
</small>
</div>
<span class="badge bg-secondary">
{{ message.created_at|naturaltime }}
</span>
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-muted text-center mb-0">
<i class="bi bi-chat-left me-1"></i>暂无留言
</p>
{% endif %}
</div>
</div>
{% if content_by_type %} {% if content_by_type %}
{% for type_name, contents in content_by_type.items %} {% for type_name, contents in content_by_type.items %}
<div class="card mb-4"> <div class="card mb-4">
@@ -71,7 +146,6 @@
</a> </a>
{% endif %} {% endif %}
{% if content.is_temp_file and content.expire_at %} {% if content.is_temp_file and content.expire_at %}
{% load humanize %}
<span class="badge bg-secondary"> <span class="badge bg-secondary">
<i class="bi bi-clock me-1"></i>{{ content.expire_at|naturaltime }}过期 <i class="bi bi-clock me-1"></i>{{ content.expire_at|naturaltime }}过期
</span> </span>
@@ -121,64 +195,4 @@
<button type="submit">上传</button> <button type="submit">上传</button>
</form> </form>
</div> </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 %} {% endblock %}

View File

@@ -40,6 +40,15 @@ urlpatterns = [
# API - 临时文件上传 # API - 临时文件上传
path('api/v1/temp-upload/', views.api_temp_upload, name='api_temp_upload'), 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'), path('family-tasks/', views.family_tasks, name='family_tasks'),
path('family-tasks/add/', views.add_family_task, name='add_family_task'), path('family-tasks/add/', views.add_family_task, name='add_family_task'),

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.auth.decorators import login_required
from django.contrib import messages from django.contrib import messages
from datetime import timedelta, datetime from datetime import timedelta, datetime
from functools import wraps
import json
import os import os
import logging import logging
from loguru import logger from loguru import logger
@@ -42,6 +44,7 @@ def is_weasyprint_available():
from .models import ( from .models import (
ReadingRecord, ReadingRecord,
ReadingType,
InsightRecord, InsightRecord,
Summary, Summary,
FamilyTask, FamilyTask,
@@ -50,7 +53,11 @@ from .models import (
FamilyMember, FamilyMember,
SummaryCategory, SummaryCategory,
PublicContentType, PublicContentType,
PublicContent PublicContent,
TempMessage,
Priority,
PlanType,
Status
) )
from .forms import ( from .forms import (
ReadingRecordForm, ReadingRecordForm,
@@ -60,9 +67,45 @@ from .forms import (
TodayPlanForm, TodayPlanForm,
SystemConfigForm, SystemConfigForm,
PublicContentForm, PublicContentForm,
TempUploadForm 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 @login_required
def index(request): def index(request):
@@ -869,6 +912,8 @@ def pdf_list(request):
return render(request, 'core/pdf_list.html', context) return render(request, 'core/pdf_list.html', context)
@csrf_exempt
@require_api_token
def api_submit_summary(request): def api_submit_summary(request):
"""API提交汇总记录 - 仅接受指定分类和发言人的记录""" """API提交汇总记录 - 仅接受指定分类和发言人的记录"""
logger.info("API: 收到汇总记录提交请求") logger.info("API: 收到汇总记录提交请求")
@@ -929,6 +974,7 @@ def api_submit_summary(request):
return JsonResponse({'success': False, 'message': f"提交失败: {str(e)}"}, status=500) return JsonResponse({'success': False, 'message': f"提交失败: {str(e)}"}, status=500)
@csrf_exempt @csrf_exempt
@require_api_token
def api_temp_upload(request): def api_temp_upload(request):
"""API临时文件上传""" """API临时文件上传"""
logger.info("API: 收到临时文件上传请求") logger.info("API: 收到临时文件上传请求")
@@ -1044,6 +1090,24 @@ def user_logout(request):
def public_content(request): def public_content(request):
"""公开内容页面 - 无需登录""" """公开内容页面 - 无需登录"""
logger.info("用户访问公开内容页面") logger.info("用户访问公开内容页面")
if request.method == 'POST':
form = TempMessageForm(request.POST)
if form.is_valid():
message = form.save(commit=False)
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
if x_forwarded_for:
message.ip_address = x_forwarded_for.split(',')[0].strip()
else:
message.ip_address = request.META.get('REMOTE_ADDR')
from datetime import timedelta
message.expire_at = timezone.now() + timedelta(minutes=10)
message.save()
logger.info(f"临时留言: {message.username or '匿名'} - {message.content[:20]}...")
return redirect('public_content')
else:
form = TempMessageForm()
public_contents = PublicContent.objects.filter(is_published=True) public_contents = PublicContent.objects.filter(is_published=True)
content_by_type = {} content_by_type = {}
@@ -1054,10 +1118,13 @@ def public_content(request):
content_by_type[type_name].append(content) content_by_type[type_name].append(content)
temp_upload_form = TempUploadForm() temp_upload_form = TempUploadForm()
temp_messages = TempMessage.objects.filter(expire_at__gt=timezone.now())
context = { context = {
'content_by_type': content_by_type, 'content_by_type': content_by_type,
'temp_upload_form': temp_upload_form, 'temp_upload_form': temp_upload_form,
'temp_messages': temp_messages,
'temp_message_form': form,
} }
return render(request, 'core/public_content.html', context) return render(request, 'core/public_content.html', context)
@@ -1107,3 +1174,149 @@ def delete_public_content(request, pk):
context = {'content': content} context = {'content': content}
return render(request, 'core/delete_public_content.html', context) 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

@@ -21,6 +21,10 @@ app.conf.beat_schedule = {
'task': 'core.tasks.cleanup_expired_temp_files', 'task': 'core.tasks.cleanup_expired_temp_files',
'schedule': crontab(minute=0), # 每小时整点执行 'schedule': crontab(minute=0), # 每小时整点执行
}, },
'cleanup-expired-messages': {
'task': 'core.tasks.cleanup_expired_messages',
'schedule': crontab(minute='*/5'), # 每5分钟执行
},
} }
@app.task(bind=True) @app.task(bind=True)

View File

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