Compare commits
14 Commits
a3e9de5af2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a06061d80e | ||
|
|
0e318b1c36 | ||
|
|
22e3e09b24 | ||
|
|
8bf9ae8b6c | ||
|
|
6f0eb5f4a5 | ||
|
|
597ff063f9 | ||
|
|
0958c1ae6b | ||
|
|
b1cf94cd23 | ||
|
|
df595c706c | ||
|
|
b3847ed98d | ||
|
|
aba4933a95 | ||
|
|
ce7d39f36c | ||
| 3aa311b9da | |||
| b4e0fc2a67 |
164
README.md
164
README.md
@@ -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 地址将被自动封禁。
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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]}..."
|
||||||
|
|||||||
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,
|
'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()
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
12
core/urls.py
12
core/urls.py
@@ -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'),
|
||||||
|
|||||||
323
core/views.py
323
core/views.py
@@ -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)
|
||||||
|
|||||||
@@ -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):
|
||||||
"""调试任务"""
|
"""调试任务"""
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
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