From 83bbcd8ff7006e0089b3d08b7664fac847b62a22 Mon Sep 17 00:00:00 2001 From: xiaji Date: Mon, 19 Jan 2026 21:36:23 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0PDF=E6=8A=A5=E5=91=8A?= =?UTF-8?q?=E9=82=AE=E4=BB=B6=E5=8F=91=E9=80=81=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在core/tasks.py中添加celery_send_pdf_report_email任务,用于生成PDF报告并发送邮件 - 在test_redis_celery.py中添加对应的测试用例test_celery_redis_pdf_email - 更新main函数中的测试计数和结果输出逻辑 --- core/tasks.py | 196 +++++++++++++++++++++++++++++++++++++++++++ test_redis_celery.py | 87 ++++++++++++++++--- 2 files changed, 273 insertions(+), 10 deletions(-) diff --git a/core/tasks.py b/core/tasks.py index c81ec62..767cea6 100644 --- a/core/tasks.py +++ b/core/tasks.py @@ -5,6 +5,9 @@ from django.core.mail.backends.smtp import EmailBackend from django.utils import timezone from datetime import timedelta import traceback +import os +import re +from django.db.models import F @shared_task(bind=True, max_retries=3, default_retry_delay=60) @@ -327,3 +330,196 @@ def celery_send_html_report_email(self, include_attachment=False): 'error': error_msg, 'timestamp': timezone.now().isoformat() } + + +@shared_task(bind=True, max_retries=3, default_retry_delay=60) +def celery_send_pdf_report_email(self): + """ + Celery异步生成PDF报告并发送邮件 + 1. 检查WeasyPrint是否可用 + 2. 生成PDF报告 + 3. 发送包含PDF附件的邮件 + """ + task_id = self.request.id if hasattr(self, 'request') else 'unknown' + logger.info(f"[任务 {task_id}] 开始执行PDF报告邮件发送任务") + + try: + # 检查WeasyPrint是否可用 + weasyprint_available = False + try: + from weasyprint import HTML + weasyprint_available = True + except ImportError: + logger.error("[任务 {task_id}] WeasyPrint库无法导入,PDF功能将不可用") + raise ValueError("WeasyPrint库无法导入,PDF功能将不可用") + + if not weasyprint_available: + raise ValueError("WeasyPrint库不可用,无法生成PDF报告") + + # 从数据库获取邮件配置 + from core.models import SystemConfig, ReadingRecord, InsightRecord, TodayPlan, FamilyTask + from django.conf import settings + from django.template.loader import render_to_string + + config = SystemConfig.get_config() + # 优先使用sender_email,其次使用smtp_username作为发件人 + from_email = config.sender_email or config.smtp_username + if not from_email: + raise ValueError("未配置发件邮箱") + + # 验证发件人邮箱格式 + email_pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' + if not re.match(email_pattern, from_email): + raise ValueError(f"发件人邮箱格式不正确: {from_email}") + + to_email = config.recipient_email or from_email + # 验证收件人邮箱格式 + if not re.match(email_pattern, to_email): + raise ValueError(f"收件人邮箱格式不正确: {to_email}") + recipient_list = [to_email] if isinstance(to_email, str) else to_email + + # 获取SMTP配置 + host = config.smtp_server or 'localhost' + port = config.smtp_port or 587 + username = config.smtp_username or '' + password = config.smtp_password or '' + use_tls = True # 默认使用TLS + + # 准备报告数据 + today = timezone.now().date() + yesterday = today - timedelta(days=1) + + # 获取昨日记录 + yesterday_reading = ReadingRecord.objects.filter(date=yesterday) + yesterday_insight = InsightRecord.objects.filter(date=yesterday) + + # 获取今日计划 + today_plan = TodayPlan.objects.filter(date=today) + + # 获取家庭事项统计 + family_task_stats = FamilyTask.objects.values('type').annotate(count=FamilyTask.objects.values('id').filter(type=F('type')).count()) + + # 准备上下文数据 + context = { + 'today': today, + 'yesterday': yesterday, + 'yesterday_reading': yesterday_reading, + 'yesterday_insight': yesterday_insight, + 'today_plan': today_plan, + 'family_task_stats': family_task_stats, + } + + # 渲染HTML模板 + html_string = render_to_string('core/report_pdf.html', context) + + # 生成PDF + today_str = today.strftime('%Y-%m-%d') + pdf_file = f"report_{today_str}.pdf" + pdf_path = os.path.join(settings.REPORTS_ROOT, pdf_file) + + # 确保报告目录存在 + os.makedirs(settings.REPORTS_ROOT, exist_ok=True) + + # 使用WeasyPrint生成PDF + from weasyprint import HTML + HTML(string=html_string).write_pdf(pdf_path) + + logger.info(f"[任务 {task_id}] PDF报告生成成功: {pdf_path}") + + # 创建邮件后端 + backend = EmailBackend( + host=host, + port=port, + username=username, + password=password, + use_tls=use_tls, + fail_silently=False + ) + + # 创建邮件 + subject = f"[Celery] 家庭日报 {today_str} - PDF报告" + body = f""" + 这是一封包含PDF报告的邮件。 + + 报告日期: {today_str} + 发送时间: {timezone.now().strftime('%Y-%m-%d %H:%M:%S')} + 任务ID: {task_id} + + 报告包含以下内容: + - 昨日阅读记录 ({yesterday_reading.count()} 条) + - 昨日感悟记录 ({yesterday_insight.count()} 条) + - 今日计划 ({today_plan.count()} 项) + - 家庭事项统计 + + 请查看附件获取完整报告。 + + --- + 家庭日报系统 + 自动发送(Celery异步任务) + """ + + email = EmailMessage( + subject=subject, + body=body, + from_email=from_email, + to=recipient_list, + connection=backend + ) + email.content_subtype = 'plain' + email.encoding = 'utf-8' + + # 添加PDF附件 + with open(pdf_path, 'rb') as f: + email.attach(pdf_file, f.read(), 'application/pdf') + + logger.info(f"[任务 {task_id}] 正在发送包含PDF附件的邮件...") + + # 发送邮件 + sent_count = email.send(fail_silently=False) + + if sent_count > 0: + logger.success(f"[任务 {task_id}] 包含PDF附件的邮件发送成功!") + + # 清理生成的PDF文件 + if os.path.exists(pdf_path): + os.remove(pdf_path) + logger.info(f"[任务 {task_id}] 已清理生成的PDF文件: {pdf_path}") + + return { + 'status': 'success', + 'task_id': task_id, + 'type': 'pdf_report', + 'sent_to': len(recipient_list), + 'pdf_file': pdf_file, + 'timestamp': timezone.now().isoformat() + } + else: + raise Exception("邮件发送返回0") + + except Exception as e: + error_msg = str(e) + error_traceback = traceback.format_exc() + + logger.error(f"[任务 {task_id}] PDF报告邮件发送失败: {error_msg}") + logger.error(f"[任务 {task_id}] 错误详情:\n{error_traceback}") + + # 检查是否应该重试 + if hasattr(self, 'request') and self.request.retries < self.max_retries: + logger.info(f"[任务 {task_id}] 准备第 {self.request.retries + 1} 次重试...") + # 重试延迟指数增长 + retry_delay = 60 * (2 ** self.request.retries) + logger.info(f"[任务 {task_id}] {retry_delay}秒后进行重试") + + raise self.retry(exc=e, countdown=retry_delay) + else: + logger.error(f"[任务 {task_id}] 已达到最大重试次数 {self.max_retries},放弃重试") + + # 返回错误信息而不是抛出异常 + return { + 'status': 'failed', + 'task_id': task_id, + 'type': 'pdf_report', + 'error': error_msg, + 'retries': self.request.retries if hasattr(self, 'request') else 0, + 'timestamp': timezone.now().isoformat() + } diff --git a/test_redis_celery.py b/test_redis_celery.py index 00b422a..736fe58 100644 --- a/test_redis_celery.py +++ b/test_redis_celery.py @@ -353,6 +353,65 @@ def test_celery_redis_email(): return False +def test_celery_redis_pdf_email(): + """测试通过Celery和Redis发送包含PDF附件的邮件功能""" + logger.info("开始测试通过Celery和Redis发送包含PDF附件的邮件...") + try: + from core.tasks import celery_send_pdf_report_email + + # 发送PDF报告邮件任务 + logger.info("发送Celery PDF报告邮件测试任务到Redis队列...") + task = celery_send_pdf_report_email.delay() + logger.info(f"PDF报告邮件测试任务已发送,任务ID: {task.id}") + + # 等待任务完成 + logger.info("等待任务执行(最多60秒)...") + result = task.get(timeout=60) + logger.info(f"PDF报告邮件任务执行结果: {result}") + + # 检查结果状态 + if result.get('status') == 'success': + logger.success("通过Celery和Redis发送包含PDF附件的邮件测试成功!") + return True + else: + error_msg = result.get('error', '未知错误') + logger.error(f"包含PDF附件的邮件发送失败: {error_msg}") + + # 提供具体的解决方案 + if "WeasyPrint" in error_msg: + logger.error("📄 WeasyPrint相关错误,可能的原因:") + logger.error("1. WeasyPrint库未安装") + logger.error("2. WeasyPrint依赖项缺失") + logger.error("3. 权限问题导致无法生成PDF") + logger.error("解决方案: 运行 'pip install weasyprint' 安装WeasyPrint库") + + return False + + except Exception as e: + error_msg = str(e) + logger.error(f"通过Celery和Redis发送包含PDF附件的邮件测试失败: {error_msg}") + + # 提供具体的解决方案 + if "No such file or directory" in error_msg: + logger.error("📁 文件路径错误,可能的原因:") + logger.error("1. 报告目录不存在") + logger.error("2. 权限问题导致无法访问目录") + elif "Connection refused" in error_msg: + logger.error("🔌 连接被拒绝,可能的原因:") + logger.error("1. Celery worker未运行") + logger.error("2. Redis服务未运行") + logger.error("3. 防火墙阻止连接") + else: + logger.error("请检查:") + logger.error("1. Celery worker是否运行: sudo supervisorctl status celery_worker") + logger.error("2. Redis服务状态: sudo systemctl status redis-server") + logger.error("3. 系统配置中的邮件设置是否正确") + logger.error("4. SMTP服务器配置是否正确") + logger.error("5. WeasyPrint库是否已正确安装") + + return False + + def check_logs_config(): """检查Gunicorn、Celery、Redis的日志是否写入同一个文件""" logger.info("开始检查日志配置...") @@ -430,7 +489,7 @@ def main(): logger.info("=" * 50) tests_passed = 0 - total_tests = 5 + total_tests = 6 # 测试1: Redis连接 logger.info("\n[测试1] Redis连接测试") @@ -454,8 +513,13 @@ def main(): if test_celery_redis_email(): tests_passed += 1 - # 测试5: 检查日志配置 - logger.info("\n[测试5] 检查日志配置") + # 测试5: 通过Celery和Redis发送包含PDF附件的邮件 + logger.info("\n[测试5] 通过Celery和Redis发送包含PDF附件的邮件测试") + if test_celery_redis_pdf_email(): + tests_passed += 1 + + # 测试6: 检查日志配置 + logger.info("\n[测试6] 检查日志配置") if check_logs_config(): tests_passed += 1 @@ -471,19 +535,21 @@ def main(): logger.info("2. ✅ Celery可以连接到Redis") logger.info("3. ✅ Redis性能满足要求") logger.info("4. ✅ 可以通过Celery和Redis发送邮件") - logger.info("5. ✅ 日志配置检查完成") - logger.info("6. 建议配置Redis持久化和备份") - logger.info("7. 建议监控Redis内存使用情况") - logger.info("8. 确保Gunicorn、Celery、Redis日志写入同一个文件") + logger.info("5. ✅ 可以通过Celery和Redis发送包含PDF附件的邮件") + logger.info("6. ✅ 日志配置检查完成") + logger.info("7. 建议配置Redis持久化和备份") + logger.info("8. 建议监控Redis内存使用情况") + logger.info("9. 确保Gunicorn、Celery、Redis日志写入同一个文件") return 0 - elif tests_passed >= 3: + elif tests_passed >= 4: logger.warning("部分测试通过,生产环境基本可用。") logger.info("\n需要检查:") logger.info("1. 确保Redis服务正常运行") logger.info("2. 检查Celery worker配置") logger.info("3. 参考README中的故障排除指南") logger.info("4. 检查邮件配置是否正确") - logger.info("5. 检查日志配置是否符合要求") + logger.info("5. 检查WeasyPrint库是否已正确安装") + logger.info("6. 检查日志配置是否符合要求") return 1 else: logger.error("多数测试失败,生产环境可能无法正常工作。") @@ -493,7 +559,8 @@ def main(): logger.info("3. ❌ 检查Django settings.py中的Celery配置") logger.info("4. ❌ 检查Celery worker是否运行") logger.info("5. ❌ 检查系统配置中的邮件设置") - logger.info("6. 参考README中的Redis部署章节重新配置") + logger.info("6. ❌ 检查WeasyPrint库是否已正确安装") + logger.info("7. ❌ 参考README中的Redis部署章节重新配置") return 1 if __name__ == "__main__":