Compare commits

..

14 Commits

Author SHA1 Message Date
xiaji
a06061d80e feat: API Token鉴权 + 感悟/阅读/今日计划写入接口 2026-06-07 18:28:57 +08:00
xiaji
0e318b1c36 fix: Windows syslog兼容 + API设计文档 2026-06-07 18:09:35 +08:00
xiaji
22e3e09b24 feat: API说明增加一键复制按钮 2026-05-28 18:09:32 +08:00
xiaji
8bf9ae8b6c docs: 更新API文档,添加临时文件上传API和临时发言说明 2026-05-27 21:19:35 +08:00
xiaji
6f0eb5f4a5 style: 移除右侧浮动面板,保留标题栏下拉菜单 2026-05-25 22:14:56 +08:00
xiaji
597ff063f9 style: 移除标题栏的重复API下拉菜单,只保留浮动面板 2026-05-25 22:09:48 +08:00
xiaji
0958c1ae6b style: 移除页面底部重复的API文档卡片 2026-05-25 22:08:13 +08:00
xiaji
b1cf94cd23 feat(公开内容): 添加临时留言功能,留言保留10分钟,显示用户名、内容、时间和来源IP 2026-05-25 22:04:57 +08:00
xiaji
df595c706c fix: 添加django.contrib.humanize支持 2026-05-25 21:49:22 +08:00
xiaji
b3847ed98d style(公开内容): 添加显眼的上传API下拉菜单 2026-05-25 21:15:30 +08:00
xiaji
aba4933a95 feat(celery): 添加定时清理过期临时文件任务 2026-05-25 21:09:43 +08:00
xiaji
ce7d39f36c feat(公开内容): 添加临时文件上传功能,支持1小时/1天/7天过期 2026-05-25 21:08:56 +08:00
3aa311b9da feat(家庭事项): 按到期时间分类显示,一个月内显示详情,超过一个月显示数量
- 修改视图逻辑,将未完成事项分为即将到期(一个月内)和远期事项
- 即将到期的事项显示完整详情表格
- 超过一个月的远期事项只显示数量,不显示详情
- 支持显示已过期的事项(红色标记)
- 合并远程更新
2026-03-16 18:27:52 +08:00
b4e0fc2a67 feat(家庭事项): 按到期时间分类显示,一个月内显示详情,超过一个月显示数量
- 修改视图逻辑,将未完成事项分为即将到期(一个月内)和远期事项
- 即将到期的事项显示完整详情表格
- 超过一个月的远期事项只显示数量,不显示详情
- 支持显示已过期的事项(红色标记)
2026-03-16 18:26:47 +08:00
13 changed files with 1119 additions and 184 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):
@@ -125,4 +126,38 @@ class PublicContentForm(forms.ModelForm):
'file': forms.FileInput(attrs={'class': 'form-control'}), 'file': forms.FileInput(attrs={'class': 'form-control'}),
'url': forms.URLInput(attrs={'class': 'form-control', 'placeholder': '请输入链接地址'}), 'url': forms.URLInput(attrs={'class': 'form-control', 'placeholder': '请输入链接地址'}),
'sort_order': forms.NumberInput(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="链接地址") url = models.URLField(blank=True, null=True, verbose_name="链接地址")
sort_order = models.IntegerField(default=0, verbose_name="排序") sort_order = models.IntegerField(default=0, verbose_name="排序")
is_published = models.BooleanField(default=True, 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="创建时间") created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间")
updated_at = models.DateTimeField(auto_now=True, verbose_name="更新时间") updated_at = models.DateTimeField(auto_now=True, verbose_name="更新时间")
@@ -250,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

@@ -566,3 +566,104 @@ def celery_send_pdf_report_email(self):
'retries': self.request.retries if hasattr(self, 'request') else 0, 'retries': self.request.retries if hasattr(self, 'request') else 0,
'timestamp': timezone.now().isoformat() '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

@@ -13,15 +13,16 @@
</div> </div>
</div> </div>
<div class="card"> <!-- 即将到期的事项(一个月内) -->
<div class="card mb-4">
<div class="card-header bg-primary text-white d-flex justify-content-between align-items-center"> <div class="card-header bg-primary text-white d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0"> <h5 class="card-title mb-0">
<i class="bi bi-house-door me-2"></i>家庭事项列表 <i class="bi bi-house-door me-2"></i>即将到期的事项
</h5> </h5>
<span class="badge bg-light text-primary">{{ tasks|length }} 项</span> <span class="badge bg-light text-primary">{{ upcoming_tasks|length }} 项</span>
</div> </div>
<div class="card-body"> <div class="card-body">
{% if tasks %} {% if upcoming_tasks %}
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover"> <table class="table table-hover">
<thead> <thead>
@@ -35,8 +36,8 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for task in tasks %} {% for task in upcoming_tasks %}
<tr class="{% if task.status == 'completed' %}table-success{% endif %}"> <tr>
<td> <td>
<span class="badge bg-secondary">{{ task.get_type_display }}</span> <span class="badge bg-secondary">{{ task.get_type_display }}</span>
</td> </td>
@@ -49,13 +50,13 @@
</span> </span>
</td> </td>
<td> <td>
<span class="badge {% if task.status == 'completed' %}bg-success{% else %}bg-warning{% endif %}"> <span class="badge bg-warning">
{{ task.get_status_display }} {{ task.get_status_display }}
</span> </span>
</td> </td>
<td> <td>
{% if task.deadline %} {% if task.deadline %}
<span class="{% if task.is_overdue %}text-danger{% else %}text-muted{% endif %}"> <span class="{% if task.deadline < today %}text-danger{% else %}text-muted{% endif %}">
<i class="bi bi-calendar me-1"></i>{{ task.deadline }} <i class="bi bi-calendar me-1"></i>{{ task.deadline }}
</span> </span>
{% else %} {% else %}
@@ -78,6 +79,37 @@
</table> </table>
</div> </div>
{% else %} {% else %}
<div class="text-center py-4">
<i class="bi bi-check-all text-success" style="font-size: 3rem;"></i>
<p class="text-muted mt-2">没有即将到期的家庭事项</p>
</div>
{% endif %}
</div>
</div>
<!-- 远期事项(超过一个月) -->
{% if future_tasks_count > 0 %}
<div class="card">
<div class="card-header bg-info text-white d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0">
<i class="bi bi-calendar-event me-2"></i>远期事项
</h5>
<span class="badge bg-light text-info">{{ future_tasks_count }} 项</span>
</div>
<div class="card-body">
<div class="alert alert-info mb-0">
<i class="bi bi-info-circle-fill me-2"></i>
还有 <strong>{{ future_tasks_count }}</strong> 个将在一个月后到期的家庭事项
<span class="text-muted">(这些事项较远,暂不显示详情)</span>
</div>
</div>
</div>
{% endif %}
<!-- 全部完成的状态 -->
{% if not upcoming_tasks and future_tasks_count == 0 %}
<div class="card">
<div class="card-body">
<div class="text-center py-5"> <div class="text-center py-5">
<i class="bi bi-check-all text-success" style="font-size: 5rem;"></i> <i class="bi bi-check-all text-success" style="font-size: 5rem;"></i>
<h5 class="text-muted mt-3">太棒了!没有未完成的家庭事项</h5> <h5 class="text-muted mt-3">太棒了!没有未完成的家庭事项</h5>
@@ -86,7 +118,7 @@
<i class="bi bi-plus-lg me-1"></i>添加家庭事项 <i class="bi bi-plus-lg me-1"></i>添加家庭事项
</a> </a>
</div> </div>
{% endif %}
</div> </div>
</div> </div>
{% endif %}
{% endblock %} {% endblock %}

View File

@@ -1,80 +1,198 @@
{% 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">
<h2 class="mb-0"> <!-- 页面标题 -->
<i class="bi bi-globe me-2 text-info"></i>公开内容 <div class="d-flex justify-content-between align-items-center mb-4">
</h2> <h2 class="mb-0">
{% if user.is_authenticated %} <i class="bi bi-globe me-2 text-info"></i>公开内容
<div> </h2>
<a href="{% url 'add_public_content' %}" class="btn btn-primary"> <div class="d-flex align-items-center gap-3">
<i class="bi bi-plus-lg me-1"></i>添加内容 <div class="dropdown">
</a> <button class="btn btn-outline-secondary btn-sm dropdown-toggle" type="button" data-bs-toggle="dropdown">
</div> <i class="bi bi-cloud-arrow-up me-1"></i>API上传
{% endif %} </button>
</div> <ul class="dropdown-menu dropdown-menu-end p-3" style="min-width: 400px;">
<li>
{% if content_by_type %} <div class="d-flex justify-content-between align-items-center mb-2">
{% for type_name, contents in content_by_type.items %} <h6 class="dropdown-header mb-0">临时文件上传 API</h6>
<div class="card mb-4"> <button class="btn btn-sm btn-outline-primary" onclick="copyApiDocs()">
<div class="card-header bg-info text-dark d-flex justify-content-between align-items-center"> <i class="bi bi-clipboard me-1"></i>复制
<h5 class="card-title mb-0"> </button>
<i class="bi bi-folder me-2"></i>{{ type_name }} </div>
</h5> <p class="small text-muted mb-2">端点: <code>POST /api/v1/temp-upload/</code></p>
<span class="badge bg-light text-info">{{ contents|length }} 项</span> <hr>
</div> <p class="small mb-1"><strong>参数:</strong></p>
<div class="card-body"> <ul class="small mb-2">
<div class="list-group"> <li><code>title</code> - 文件标题</li>
{% for content in contents %} <li><code>file</code> - 文件(最大500MB)</li>
<div class="list-group-item"> <li><code>expire_type</code> - expire_1h / expire_1d / expire_7d</li>
<div class="d-flex justify-content-between align-items-start"> </ul>
<div class="flex-grow-1"> <p class="small mb-1"><strong>cURL示例:</strong></p>
<h6 class="mb-2"> <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>
{% if content.url %} </li>
<a href="{{ content.url }}" target="_blank" class="text-decoration-none"> </ul>
<i class="bi bi-link-45deg me-1"></i>{{ content.title }} </div>
</a> <script>
{% else %} function copyApiDocs() {
<i class="bi bi-file-earmark-text me-1"></i>{{ content.title }} const curlCode = document.getElementById('curlExample').textContent;
{% endif %} navigator.clipboard.writeText(curlCode).then(function() {
</h6> alert('已复制到剪贴板!');
{% if content.content %} }).catch(function(err) {
<p class="text-muted small mb-2">{{ content.content|truncatechars:200 }}</p> console.error('复制失败:', err);
{% endif %} });
{% if content.file %} }
<a href="{{ content.file.url }}" class="btn btn-sm btn-outline-primary me-2" target="_blank"> </script>
<i class="bi bi-download me-1"></i>下载文件 {% if user.is_authenticated %}
</a> <a href="{% url 'add_public_content' %}" class="btn btn-primary">
{% endif %} <i class="bi bi-plus-lg me-1"></i>添加内容
</div> </a>
{% if user.is_authenticated %} {% endif %}
<div class="btn-group ms-3"> </div>
<a href="{% url 'edit_public_content' content.id %}" class="btn btn-sm btn-warning" title="编辑"> </div>
<i class="bi bi-pencil"></i>
</a> <!-- 临时发言区域 -->
<a href="{% url 'delete_public_content' content.id %}" class="btn btn-sm btn-danger" title="删除"> <div class="card mb-4">
<i class="bi bi-trash"></i> <div class="card-header bg-secondary text-white">
</a> <h5 class="card-title mb-0">
</div> <i class="bi bi-chat-left-text me-2"></i>临时发言
{% endif %} <span class="badge bg-light text-dark ms-2">留言仅保留10分钟</span>
</div> </h5>
</div> </div>
{% endfor %} <div class="card-body">
</div> <form method="post" action="{% url 'public_content' %}" class="mb-3">
</div> {% csrf_token %}
</div> <div class="row g-2">
{% endfor %} <div class="col-md-3">
{% else %} <input type="text" name="username" class="form-control" maxlength="20" placeholder="用户名(可选)">
<div class="text-center py-5"> </div>
<i class="bi bi-inbox text-muted" style="font-size: 5rem;"></i> <div class="col-md-7">
<h5 class="text-muted mt-3">暂无公开内容</h5> <input type="text" name="content" class="form-control" maxlength="1000" placeholder="说点什么...最多1000字节" required>
<p class="text-muted">请稍后再来查看</p> </div>
{% if user.is_authenticated %} <div class="col-md-2">
<a href="{% url 'add_public_content' %}" class="btn btn-primary"> <button type="submit" class="btn btn-primary w-100">
<i class="bi bi-plus-lg me-1"></i>添加内容 <i class="bi bi-send me-1"></i>发送
</a> </button>
{% endif %} </div>
</div> </div>
{% endif %} </form>
{% endblock %}
{% 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 - 汇总记录提交 # API - 汇总记录提交
path('api/v1/summary/submit/', views.api_submit_summary, name='api_submit_summary'), 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'), path('family-tasks/', views.family_tasks, name='family_tasks'),

View File

@@ -1,6 +1,7 @@
from django.shortcuts import render, redirect, get_object_or_404 from django.shortcuts import render, redirect, get_object_or_404
from django.http import HttpResponse, JsonResponse from django.http import HttpResponse, JsonResponse
from django.utils import timezone from django.utils import timezone
from django.db import models
from django.db.models import Count, Q from django.db.models import Count, Q
from django.core.mail import send_mail, EmailMessage from django.core.mail import send_mail, EmailMessage
from django.conf import settings from django.conf import settings
@@ -9,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
@@ -41,6 +44,7 @@ def is_weasyprint_available():
from .models import ( from .models import (
ReadingRecord, ReadingRecord,
ReadingType,
InsightRecord, InsightRecord,
Summary, Summary,
FamilyTask, FamilyTask,
@@ -49,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,
@@ -58,9 +66,46 @@ from .forms import (
FamilyTaskForm, FamilyTaskForm,
TodayPlanForm, TodayPlanForm,
SystemConfigForm, 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 @login_required
def index(request): def index(request):
@@ -402,17 +447,32 @@ def delete_summary(request, pk):
# 家庭事项视图 # 家庭事项视图
@login_required @login_required
def family_tasks(request): def family_tasks(request):
"""家庭事项 - 显示所有未完成的事项非completed状态且未截止""" """家庭事项 - 显示未完成的事项,一个月内显示详情,超过一个月显示数量"""
logger.info("用户访问家庭事项页面") logger.info("用户访问家庭事项页面")
today = timezone.now().date() today = timezone.now().date()
# 排除已完成的事项和已截止的事项 one_month_later = today + timedelta(days=30)
tasks = FamilyTask.objects.exclude(status__name='completed')
tasks = tasks.filter(Q(deadline__gte=today) | Q(deadline__isnull=True)) # 获取所有未完成的事项(排除已完成状态)
all_pending_tasks = FamilyTask.objects.exclude(status__name='completed')
# 一个月内到期的事项(显示详情)
# 包括:有截止日期且在一个月内,或者没有截止日期的事项
upcoming_tasks = all_pending_tasks.filter(
Q(deadline__isnull=True) | Q(deadline__lte=one_month_later)
)
# 超过一个月到期的事项(只显示数量)
future_tasks = all_pending_tasks.filter(deadline__gt=one_month_later)
future_tasks_count = future_tasks.count()
context = { context = {
'tasks': tasks, 'upcoming_tasks': upcoming_tasks,
'future_tasks_count': future_tasks_count,
'total_pending_count': all_pending_tasks.count(),
'today': today,
} }
return render(request, 'core/family_tasks.html', context) return render(request, 'core/family_tasks.html', context)
# 添加家庭事项 # 添加家庭事项
@@ -852,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: 收到汇总记录提交请求")
@@ -911,6 +973,71 @@ def api_submit_summary(request):
logger.error(f"API: 提交汇总记录失败: {str(e)}") logger.error(f"API: 提交汇总记录失败: {str(e)}")
return JsonResponse({'success': False, 'message': f"提交失败: {str(e)}"}, status=500) 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日志记录器用于fail2ban检测
syslog_logger = logging.getLogger('django.security.login') syslog_logger = logging.getLogger('django.security.login')
@@ -963,21 +1090,43 @@ 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 = {}
for content in public_contents: for content in public_contents:
type_name = content.type.name type_name = content.type.name
if type_name not in content_by_type: if type_name not in content_by_type:
content_by_type[type_name] = [] content_by_type[type_name] = []
content_by_type[type_name].append(content) content_by_type[type_name].append(content)
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_messages': temp_messages,
'temp_message_form': form,
} }
return render(request, 'core/public_content.html', context) return render(request, 'core/public_content.html', context)
# 添加公开内容 # 添加公开内容
@@ -1025,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

@@ -1,6 +1,7 @@
import os import os
from celery import Celery from celery import Celery
from django.conf import settings from django.conf import settings
from celery.schedules import crontab
# 设置默认的Django设置模块 # 设置默认的Django设置模块
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'diary_family.settings') 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) 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) @app.task(bind=True)
def debug_task(self): def debug_task(self):
"""调试任务""" """调试任务"""

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/'
@@ -190,82 +194,98 @@ CSRF_TRUSTED_ORIGINS = [
# 如果你将来有域名,也可以在这里加上,例如 "https://yourdomain.com" # 如果你将来有域名,也可以在这里加上,例如 "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_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 鉴权(需同步更新调用方)