diff --git a/core/urls.py b/core/urls.py index 68ae0ad..1ad1977 100644 --- a/core/urls.py +++ b/core/urls.py @@ -39,6 +39,15 @@ urlpatterns = [ # 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'), diff --git a/core/views.py b/core/views.py index 8f60677..9ef31b5 100644 --- a/core/views.py +++ b/core/views.py @@ -10,6 +10,8 @@ from django.contrib.auth import authenticate, login, logout from django.contrib.auth.decorators import login_required from django.contrib import messages from datetime import timedelta, datetime +from functools import wraps +import json import os import logging from loguru import logger @@ -42,6 +44,7 @@ def is_weasyprint_available(): from .models import ( ReadingRecord, + ReadingType, InsightRecord, Summary, FamilyTask, @@ -51,7 +54,10 @@ from .models import ( SummaryCategory, PublicContentType, PublicContent, - TempMessage + TempMessage, + Priority, + PlanType, + Status ) from .forms import ( ReadingRecordForm, @@ -65,6 +71,41 @@ from .forms import ( TempMessageForm ) +# ==================== API Token 鉴权 ==================== + +def require_api_token(view_func): + @wraps(view_func) + def _wrapped_view(request, *args, **kwargs): + auth_header = request.headers.get('Authorization', '') + if not auth_header.startswith('Bearer '): + return JsonResponse({'success': False, 'message': '缺少认证Token'}, status=401) + token = auth_header[7:] + if token != settings.API_TOKEN: + return JsonResponse({'success': False, 'message': 'Token无效'}, status=401) + return view_func(request, *args, **kwargs) + return _wrapped_view + + +def _get_request_data(request): + content_type = request.content_type or '' + if 'application/json' in content_type: + try: + return json.loads(request.body.decode('utf-8')) + except (json.JSONDecodeError, UnicodeDecodeError): + return {} + return request.POST + + +def _parse_date(date_str): + if not date_str: + return timezone.now().date() + try: + return datetime.strptime(date_str.strip(), '%Y-%m-%d').date() + except (ValueError, AttributeError): + return timezone.now().date() + +# ======================================================= + # 首页视图 @login_required def index(request): @@ -871,6 +912,8 @@ def pdf_list(request): return render(request, 'core/pdf_list.html', context) +@csrf_exempt +@require_api_token def api_submit_summary(request): """API提交汇总记录 - 仅接受指定分类和发言人的记录""" logger.info("API: 收到汇总记录提交请求") @@ -931,6 +974,7 @@ def api_submit_summary(request): return JsonResponse({'success': False, 'message': f"提交失败: {str(e)}"}, status=500) @csrf_exempt +@require_api_token def api_temp_upload(request): """API临时文件上传""" logger.info("API: 收到临时文件上传请求") @@ -1130,3 +1174,149 @@ def delete_public_content(request, pk): context = {'content': content} return render(request, 'core/delete_public_content.html', context) + + +# ==================== API 写入接口 ==================== + +@csrf_exempt +@require_api_token +def api_submit_insight(request): + logger.info("API: 收到感悟记录提交请求") + if request.method != 'POST': + return JsonResponse({'success': False, 'message': '只支持POST请求'}, status=405) + + try: + data = _get_request_data(request) + content = (data.get('content') or '').strip() + speaker_name = (data.get('speaker') or '').strip() + + if not content: + return JsonResponse({'success': False, 'message': '内容不能为空'}, status=400) + if not speaker_name: + return JsonResponse({'success': False, 'message': '发言人不能为空'}, status=400) + + try: + speaker = FamilyMember.objects.get(name=speaker_name) + except FamilyMember.DoesNotExist: + return JsonResponse({'success': False, 'message': f"发言人 '{speaker_name}' 不存在"}, status=400) + + record = InsightRecord.objects.create( + date=_parse_date(data.get('date')), + content=content, + speaker=speaker, + file=request.FILES.get('file') + ) + + logger.info(f"API: 感悟记录创建成功,ID={record.id}") + return JsonResponse({'success': True, 'message': '提交成功', 'id': record.id}) + + except Exception as e: + logger.error(f"API: 提交感悟记录失败: {str(e)}") + return JsonResponse({'success': False, 'message': f"提交失败: {str(e)}"}, status=500) + + +@csrf_exempt +@require_api_token +def api_submit_reading(request): + logger.info("API: 收到阅读记录提交请求") + if request.method != 'POST': + return JsonResponse({'success': False, 'message': '只支持POST请求'}, status=405) + + try: + data = _get_request_data(request) + type_name = (data.get('type') or '').strip() + title = (data.get('title') or '').strip() + + if not type_name: + return JsonResponse({'success': False, 'message': '阅读类型不能为空'}, status=400) + if not title: + return JsonResponse({'success': False, 'message': '标题不能为空'}, status=400) + + try: + reading_type = ReadingType.objects.get(name=type_name) + except ReadingType.DoesNotExist: + return JsonResponse({'success': False, 'message': f"阅读类型 '{type_name}' 不存在"}, status=400) + + record = ReadingRecord.objects.create( + date=_parse_date(data.get('date')), + type=reading_type, + title=title, + source=(data.get('source') or '').strip() or None, + progress=(data.get('progress') or '').strip() or None, + note=(data.get('note') or '').strip() or None, + file=request.FILES.get('file') + ) + + logger.info(f"API: 阅读记录创建成功,ID={record.id}") + return JsonResponse({'success': True, 'message': '提交成功', 'id': record.id}) + + except Exception as e: + logger.error(f"API: 提交阅读记录失败: {str(e)}") + return JsonResponse({'success': False, 'message': f"提交失败: {str(e)}"}, status=500) + + +@csrf_exempt +@require_api_token +def api_submit_plan(request): + logger.info("API: 收到今日计划提交请求") + if request.method != 'POST': + return JsonResponse({'success': False, 'message': '只支持POST请求'}, status=405) + + try: + data = _get_request_data(request) + content = (data.get('content') or '').strip() + + if not content: + return JsonResponse({'success': False, 'message': '内容不能为空'}, status=400) + + speaker_name = (data.get('speaker') or '').strip() + if speaker_name: + try: + speaker = FamilyMember.objects.get(name=speaker_name) + except FamilyMember.DoesNotExist: + return JsonResponse({'success': False, 'message': f"发言人 '{speaker_name}' 不存在"}, status=400) + else: + speaker = FamilyMember.objects.get_or_create(name='机器人')[0] + + priority_name = (data.get('priority') or '').strip() + if priority_name: + try: + priority = Priority.objects.get(name=priority_name) + except Priority.DoesNotExist: + return JsonResponse({'success': False, 'message': f"优先级 '{priority_name}' 不存在"}, status=400) + else: + priority = Priority.objects.get_or_create(name='中')[0] + + type_name = (data.get('type') or '').strip() + if type_name: + try: + plan_type = PlanType.objects.get(name=type_name) + except PlanType.DoesNotExist: + return JsonResponse({'success': False, 'message': f"计划类型 '{type_name}' 不存在"}, status=400) + else: + plan_type = PlanType.objects.get_or_create(name='其他')[0] + + status_name = (data.get('status') or '').strip() + if status_name: + try: + status = Status.objects.get(name=status_name) + except Status.DoesNotExist: + return JsonResponse({'success': False, 'message': f"状态 '{status_name}' 不存在"}, status=400) + else: + status = Status.objects.get_or_create(name='未开始')[0] + + record = TodayPlan.objects.create( + date=_parse_date(data.get('date')), + content=content, + speaker=speaker, + priority=priority, + type=plan_type, + status=status, + ) + + logger.info(f"API: 今日计划创建成功,ID={record.id}") + return JsonResponse({'success': True, 'message': '提交成功', 'id': record.id}) + + except Exception as e: + logger.error(f"API: 提交今日计划失败: {str(e)}") + return JsonResponse({'success': False, 'message': f"提交失败: {str(e)}"}, status=500) diff --git a/diary_family/settings.py b/diary_family/settings.py index 286ddec..61fb29c 100644 --- a/diary_family/settings.py +++ b/diary_family/settings.py @@ -125,6 +125,9 @@ STATIC_URL = 'static/' DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' +# API Token configuration +API_TOKEN = 'diary-family-api-token-2026' + # Login URL configuration LOGIN_URL = '/login/'