基于 Flask + APScheduler + MySQL 的自动报表系统设计

背景

在日常运维与自动化任务中,定时生成并发送报表是非常常见的场景。比如每天早上汇总前一日任务执行情况、日志统计、业务指标、错误趋势等内容,再通过邮件推送给相关负责人。

本文将介绍一个基于 Flask、Flask-SQLAlchemy、APScheduler、MySQL 组成的轻量级自动报表系统的核心设计原理。

一、系统目标与整体思路

系统的目标非常明确:

通过一个长期运行的 API 服务,接收外部任务上报的数据,并在每天固定时间自动生成邮件报表。

这意味着系统需要同时具备:

  • 持续运行的 HTTP 服务(用于数据接收)
  • 定时任务调度能力(用于报表生成与发送)
  • 可靠的数据存储与查询机制

从架构上看,可以分为三个核心模块:

  1. API 服务器(数据入口)
  2. 数据库层(数据持久化)
  3. 定时调度器(报表生成与发送)

工作机制

  • 外部任务在完成执行后,会构造一个结构化的 JSON 负载,包含任务名称、执行状态(成功/失败)、耗时、日志摘要等关键信息。
  • 通过 HTTP POST 请求将数据发送至预设的 API 端点(例如 /api/report)。
  • API 服务接收到请求后,进行以下处理:
    • 验证请求来源(可选使用 Token 或 IP 白名单);
    • 解析并校验 JSON 数据格式;
    • 将有效数据映射为 ORM 模型对象(如 TaskReport);
    • 利用 Flask-SQLAlchemy 将对象写入 MySQL 数据库,实现持久化存储。

这一设计实现了解耦合 :外部任务只需关心自身逻辑和上报动作,无需了解后续的数据处理流程。同时,API 层提供了标准化接口,便于未来扩展更多类型的任务接入。

系统架构设计

项目采用模块化结构,以 清晰的职责划分 和 易扩展性 为设计核心:

python 复制代码
project/
├── app.py                # 主应用入口,初始化 Flask 与调度器
├── config.py             # 配置文件(数据库、调度、邮件等)
├── models.py             # 数据库模型定义
├── services/
│   └── reporting.py      # 报表生成与发送逻辑
└── requirements.txt

.env 环境变量

python 复制代码
# Flask environment
FLASK_ENV=development
PORT=5000

# Security
SECRET_KEY=heiankey
API_KEY=123456

# Database (MySQL)
DB_USER=task_reporter
DB_PASSWORD=zk33kzd3GFEFTfbE
DB_HOST=43.xx4.xxx.xx
DB_PORT=3306
DB_NAME=task_reporter

# Optionally, override the full SQLAlchemy URI
# DATABASE_URL=mysql+pymysql://user:pass@host:3306/dbname?charset=utf8mb4

# Mail
 MAIL_SERVER=smtp.qq.com
 MAIL_PORT=465
 MAIL_USE_TLS=false
 MAIL_USE_SSL=true
 MAIL_USERNAME=xxxx@qq.com
 MAIL_PASSWORD=tdgekwgjsxxxx
 MAIL_SENDER=xxxx@qq.com
 MAIL_RECIPIENTS=xxxx@qq.com

conifg.py 配置文件

python 复制代码
import os
from urllib.parse import quote_plus

from dotenv import load_dotenv

load_dotenv()


class Config:
    """Application configuration loaded from environment variables."""

    SECRET_KEY = os.getenv("SECRET_KEY", "dev-secret-key")
    API_KEY = os.getenv("API_KEY", "change-me")

    DB_USER = os.getenv("DB_USER", "root")
    DB_PASSWORD = os.getenv("DB_PASSWORD", "")
    DB_HOST = os.getenv("DB_HOST", "127.0.0.1")
    DB_PORT = os.getenv("DB_PORT", "3306")
    DB_NAME = os.getenv("DB_NAME", "task_reporter")

    DEFAULT_MYSQL_URI = (
        f"mysql+pymysql://{DB_USER}:{quote_plus(DB_PASSWORD)}@{DB_HOST}:{DB_PORT}/{DB_NAME}?charset=utf8mb4"
    )
    SQLALCHEMY_DATABASE_URI = os.getenv("DATABASE_URL", DEFAULT_MYSQL_URI)
    SQLALCHEMY_TRACK_MODIFICATIONS = False

    # Mail settings
    MAIL_SERVER = os.getenv("MAIL_SERVER", "")
    MAIL_PORT = int(os.getenv("MAIL_PORT", "0") or 0)
    MAIL_USE_TLS = os.getenv("MAIL_USE_TLS", "false").lower() in {"1", "true", "yes"}
    MAIL_USE_SSL = os.getenv("MAIL_USE_SSL", "false").lower() in {"1", "true", "yes"}
    MAIL_USERNAME = os.getenv("MAIL_USERNAME", "") or None
    MAIL_PASSWORD = os.getenv("MAIL_PASSWORD", "") or None
    MAIL_SENDER = os.getenv("MAIL_SENDER", "") or None
    MAIL_RECIPIENTS = [email.strip() for email in os.getenv("MAIL_RECIPIENTS", "").split(",") if email.strip()]


if __name__ == "__main__":
    print(os.getenv("PORT", "task_reporter"))

models.py 数据库模块

python 复制代码
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy import func

# SQLAlchemy instance to be initialized by the application factory
db = SQLAlchemy()


class TaskReport(db.Model):
    """Task execution report entity stored in the database."""

    __tablename__ = "task_reports"

    id = db.Column(db.Integer, primary_key=True)
    task_id = db.Column(db.String(128), nullable=False, index=True)
    task_name = db.Column(db.String(255), nullable=False)
    status = db.Column(db.String(32), nullable=False)
    message = db.Column(db.String(1024), nullable=True)
    details = db.Column(db.Text, nullable=True)
    execution_time = db.Column(db.Float, nullable=True)
    created_at = db.Column(
        db.DateTime,
        nullable=False,
        server_default=func.now(),
        index=True,
    )

    def __repr__(self) -> str:
        return f"<TaskReport id={self.id} task_id={self.task_id} status={self.status}>"

reporting.py 发送处理模块

python 复制代码
"""
任务执行日报生成与邮件发送模块

功能:
1. 从数据库中获取指定时间范围内的任务执行记录。
2. 生成 HTML 格式的日报(成功任务 & 失败任务)。
3. 通过 SMTP 将日报发送到指定邮箱。
"""

from __future__ import annotations

import datetime as dt
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from typing import Iterable, List, Tuple

from flask import current_app
from app import create_app
from models import TaskReport, db


def _get_previous_day_range(now: dt.datetime | None = None) -> Tuple[dt.datetime, dt.datetime]:
    """
    获取前一天的时间范围(本地时间)。
    起始时间为 00:00:00,结束时间为 23:59:59.999999。
    """
    now = now or dt.datetime.now()
    # prev_day = now.date() - dt.timedelta(days=1)
    prev_day= dt.date.fromisoformat("2025-08-14")
    start = dt.datetime.combine(prev_day, dt.time.min)
    end = dt.datetime.combine(prev_day, dt.time.max)
    return start, end


def generate_daily_report(
    start_time: dt.datetime | None = None,
    end_time: dt.datetime | None = None,
) -> str:
    """
    生成任务执行日报的 HTML 内容。

    :param start_time: 起始时间(默认前一天 00:00:00)
    :param end_time:   结束时间(默认前一天 23:59:59.999999)
    :return: HTML 报告字符串
    """
    if not start_time or not end_time:
        start_time, end_time = _get_previous_day_range()

    # 查询时间范围内的任务执行记录
    reports: List[TaskReport] = (
        TaskReport.query
        .filter(TaskReport.created_at >= start_time)
        .filter(TaskReport.created_at <= end_time)
        .order_by(TaskReport.created_at.asc())
        .all()
    )

    # 分类任务记录
    success_items = [r for r in reports if (r.status or "").lower() == "success"]
    failure_items = [r for r in reports if (r.status or "").lower() != "success"]

    return _render_html(success_items, failure_items, start_time, end_time)


def _render_html(
    success_items: Iterable[TaskReport],
    failure_items: Iterable[TaskReport],
    start_time: dt.datetime,
    end_time: dt.datetime,
) -> str:
    """
    渲染 HTML 报表,使用 Bootstrap 美化样式。
    """
    def _row(r: TaskReport) -> str:
        return (
            f"<tr>"
            f"<td>{r.id}</td>"
            f"<td>{r.task_id}</td>"
            f"<td>{r.task_name}</td>"
            f"<td>{r.status}</td>"
            f"<td>{r.execution_time or ''}</td>"
            f"<td>{(r.message or '').replace('<', '&lt;').replace('>', '&gt;')}</td>"
            f"<td>{r.created_at or ''}</td>"
            f"</tr>"
        )

    success_rows = "".join(_row(r) for r in success_items) or (
        "<tr><td colspan=7 class='text-center text-muted'>无成功记录</td></tr>"
    )
    failure_rows = "".join(_row(r) for r in failure_items) or (
        "<tr><td colspan=7 class='text-center text-muted'>无失败记录</td></tr>"
    )

    start_str = start_time.strftime("%Y-%m-%d %H:%M:%S")
    end_str = end_time.strftime("%Y-%m-%d %H:%M:%S")

    return f"""
    <!doctype html>
    <html>
    <head>
        <meta charset="utf-8" />
        <title>任务执行日报</title>
        <!-- 引入 Bootstrap 5 CDN -->
        <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
    </head>
    <body class="p-4">
        <div class="container">
            <h1 class="mb-3">任务执行日报</h1>
            <p class="text-muted">时间范围:{start_str} - {end_str}</p>

            <h3 class="mt-4">✅ 成功任务</h3>
            <table class="table table-bordered table-hover table-sm align-middle">
                <thead class="table-success">
                    <tr>
                        <th>ID</th><th>Task ID</th><th>任务名称</th><th>状态</th>
                        <th>耗时(s)</th><th>信息</th><th>创建时间</th>
                    </tr>
                </thead>
                <tbody>
                    {success_rows}
                </tbody>
            </table>

            <h3 class="mt-4">❌ 失败 / 其他状态任务</h3>
            <table class="table table-bordered table-hover table-sm align-middle">
                <thead class="table-danger">
                    <tr>
                        <th>ID</th><th>Task ID</th><th>任务名称</th><th>状态</th>
                        <th>耗时(s)</th><th>信息</th><th>创建时间</th>
                    </tr>
                </thead>
                <tbody>
                    {failure_rows}
                </tbody>
            </table>
        </div>
    </body>
    </html>
    """


def send_email(
    html_content: str,
    subject: str | None = None,
    recipients: Iterable[str] | None = None
) -> None:
    """
    发送 HTML 邮件。

    :param html_content: 邮件 HTML 内容
    :param subject: 邮件主题
    :param recipients: 收件人列表
    """
    app = current_app
    config = app.config

    server = config.get("MAIL_SERVER", "")
    port = int(config.get("MAIL_PORT", 0) or 0)
    use_tls = bool(config.get("MAIL_USE_TLS", False))
    use_ssl = bool(config.get("MAIL_USE_SSL", False))
    username = config.get("MAIL_USERNAME")
    password = config.get("MAIL_PASSWORD")
    sender = config.get("MAIL_SENDER", username)
    recips = list(recipients) if recipients else list(config.get("MAIL_RECIPIENTS", []) or [])

    if not server or not port:
        raise RuntimeError("MAIL_SERVER 和 MAIL_PORT 必须配置")
    if not sender:
        raise RuntimeError("MAIL_SENDER 必须配置(或 MAIL_USERNAME)")
    if not recips:
        raise RuntimeError("收件人为空,请配置 MAIL_RECIPIENTS 或传入 recipients")

    subject = subject or "任务执行日报"

    msg = MIMEMultipart("alternative")
    msg["Subject"] = subject
    msg["From"] = sender
    msg["To"] = ", ".join(recips)
    msg.attach(MIMEText(html_content, "html", "utf-8"))

    if use_ssl:
        smtp = smtplib.SMTP_SSL(server, port)
    else:
        smtp = smtplib.SMTP(server, port)
        if use_tls:
            smtp.starttls()

    try:
        if username and password:
            smtp.login(username, password)
        smtp.sendmail(sender, recips, msg.as_string())
    finally:
        smtp.quit()


def generate_and_send_report(
    start_time: dt.datetime | None = None,
    end_time: dt.datetime | None = None,
    subject: str | None = None,
    recipients: Iterable[str] | None = None,
) -> None:
    """
    生成日报并发送邮件的便捷方法。
    """
    html = generate_daily_report(start_time=start_time, end_time=end_time)
    print(html)
    send_email(html, subject=subject, recipients=recipients)


if __name__ == "__main__":
    with create_app().app_context():
        generate_and_send_report()

app.py 启动入口

python 复制代码
from functools import wraps

from apscheduler.schedulers.background import BackgroundScheduler
from flask import Flask, request

from conifg import Config
from models import db, TaskReport


def create_app():
    app = Flask(__name__)
    app.config.from_object(Config)

    # Initialize SQLAlchemy
    db.init_app(app)

    # 创建一个后台调度器
    scheduler = BackgroundScheduler(timezone="Asia/Shanghai")
    # 添加一个每隔20秒执行一次的定时任务
    # scheduler.add_job(func=send_alert, trigger="interval", seconds=20)
    # 添加一个每天早上9点执行的定时任务
    # scheduler.add_job(func=send_alert, trigger=CronTrigger(hour=9, minute=0))
    # 启动调度器
    # scheduler.start()

    return app


app = create_app()


@app.route("/health")
def health() -> tuple[dict, int]:
    return {"status": "ok"}, 200


def require_api_key(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        provided = request.headers.get("X-API-KEY")
        if not provided or provided != app.config.get("API_KEY"):
            return {"error": "Unauthorized failed, please check API_KEY"}, 401
        return func(*args, **kwargs)

    return wrapper


@app.route("/api/v1/task-report", methods=["POST"])
@require_api_key
def create_task_report() -> tuple[dict, int]:
    data = request.get_json(silent=True)
    if not isinstance(data, dict):
        return {"error": "Invalid JSON body"}, 400

    required_fields = ["task_id", "task_name", "status"]
    missing_fields = [f for f in required_fields if f not in data]
    if missing_fields:
        return {"error": f"Missing fields: {', '.join(missing_fields)}"}, 400

    execution_time_value = data.get("execution_time")
    try:
        execution_time_parsed = (
            float(execution_time_value) if execution_time_value is not None else None
        )
    except (TypeError, ValueError):
        return {"error": "execution_time must be a number"}, 400

    report = TaskReport(
        task_id=str(data["task_id"]),
        task_name=str(data["task_name"]),
        status=str(data["status"]),
        message=data.get("message"),
        details=data.get("details"),
        execution_time=execution_time_parsed,
    )

    try:
        db.session.add(report)
        db.session.commit()
        return {"success": True, "id": report.id}, 201
    except Exception as e:
        print(e)
        db.session.rollback()
        return {"error": "Database error"}, 500


if __name__ == "__main__":
    app.run(debug=True)

requirements.txt 版本依赖

复制代码
blinker==1.9.0
click==8.2.1
colorama==0.4.6
Flask==3.1.1
itsdangerous==2.2.0
Jinja2==3.1.6
MarkupSafe==3.0.2
python-frontmatter==1.1.0
PyYAML==6.0.2
Werkzeug==3.1.3


Post请求这个接口

http://127.0.0.1:5000/api/v1/task-report

json 复制代码
{
    "task_id": "db_backup_20230815_023000",
    "task_name": "1夜间数据库全量备份",
    "status": "completed",
    "message": "数据库备份成功完成",
    "details": "{\"database\": \"production_primary\", \"backup_type\": \"full\", \"size\": \"24.7GB\", \"tables\": 42, \"location\": \"/backups/db/2023-08-15_0200.sql.gz\", \"checksum\": \"sha256:9a8b7c6d5e4f3g2h1i0j9k8l7m6n5o4p3q2r1s0\"}",
    "execution_time": 186.4
}

调度器设计原理(APScheduler)

Flask 本身是一个 Web 框架,并不具备定时调度能力。

因此我们引入 APScheduler(Advanced Python Scheduler),它能在 Flask 应用内部嵌入一个轻量级调度器,用于周期性任务执行。

1. 调度策略

在应用启动时,APScheduler 会被初始化并注册一个定时任务(CronJob):

每天早上 9:00 自动触发报表生成与邮件发送。

通过 Cron 表达式,可以灵活地配置执行周期(如每天、每周、每小时),并可扩展多个任务。

2. 调度与 Flask 的集成

APScheduler 支持与 Flask 的上下文无缝结合,调度函数在执行时可以直接访问 Flask 应用的配置、数据库连接等资源,从而避免了多进程通信的复杂性。

报表生成与发送原理

1. 数据读取

定时任务触发后,系统会自动执行报表逻辑模块 services/reporting.py 中的核心函数。

该函数通过 SQLAlchemy 查询数据库中**上一周期(如前一天)**的数据,按任务、项目或业务维度进行聚合统计。

2. 报表构建

系统会将统计结果渲染为 HTML 格式报表。

使用 HTML 而非纯文本的好处是显而易见的:

  • 可视化更强(表格、颜色标识、状态图标);
  • 邮件阅读体验更好;
  • 便于直接转发与展示。

3. 邮件发送

报表生成后,通过 Python 内置的 smtplibemail.mime 模块构造 MIME 邮件:

  • 设置主题(如 "每日任务执行报告")
  • 填充 HTML 内容
  • 指定收件人列表
  • 通过企业邮箱 SMTP 服务器发送

发送过程带有重试机制与异常捕获,保证任务的稳定性。

相关推荐
番石榴AI4 小时前
基于机器学习优化的主图选择方法(酒店,景点,餐厅等APP上的主图展示推荐)
图像处理·人工智能·python·机器学习
qq7422349845 小时前
Python操作数据库之pyodbc
开发语言·数据库·python
2401_841495645 小时前
【自然语言处理】轻量版生成式语言模型GPT
人工智能·python·gpt·深度学习·语言模型·自然语言处理·transformer
Dxy12393102166 小时前
MySQL的SUBSTRING函数详解与应用
数据库·mysql
码力引擎6 小时前
【零基础学MySQL】第十二章:DCL详解
数据库·mysql·1024程序员节
云和数据.ChenGuang6 小时前
tensorflow生成随机数和张量
人工智能·python·tensorflow
杨云龙UP6 小时前
【MySQL迁移】MySQL数据库迁移实战(利用mysqldump从Windows 5.7迁至Linux 8.0)
linux·运维·数据库·mysql·mssql
测试老哥7 小时前
python+requests+excel 接口测试
自动化测试·软件测试·python·测试工具·测试用例·excel·接口测试
AI纪元故事会7 小时前
冰泪与雨丝:一个AI的Python挽歌
开发语言·人工智能·python