想做一款真正属于自己的学习任务管理工具,市面上那些任务清单、打卡软件用了一圈总觉得不够顺手,数据在自己手里、规则也改不了,于是动手做了这个基于 Python 的个人学习任务挑战系统。整体采用 Django 与 Flask 双框架协作,Django 负责核心业务和管理后台,Flask 承担轻量辅助服务和数据聚合接口,前端通过简单页面完成交互。这样一个组合听起来有点“非主流”,但实际跑下来很稳,也解决了单框架在长期维护时的诸多别扭之处。如果你也准备做一个学习助手、任务打卡系统,或者单纯想看看 Django 和 Flask 如何优雅地并存,这篇实战分享应该能帮上忙。
1. 为什么一个学习助手要同时用 Django 和 Flask
先回答很多人看到标题后的第一反应:一个 Python 项目,选 Django 还是 Flask 就够了,为什么要两套一起上?这确实是我开发前纠结最久的问题,但做完之后我个人认为,这种“双框架混编”在某些场景下不是过度设计,而是贴合实际需求的选择。
1.1 单框架的舒适区与别扭处
Django 的优势是“全家桶”式集成,自带 ORM、Admin 后台、表单处理、认证系统,拿来建用户体系和数据模型非常顺手,尤其是我这种习惯在网页端管理数据的场景,Django Admin 几乎不用写代码就能把任务、挑战、打卡记录的后台管理页面直接搭出来。但它也有让人不太舒服的地方,Django 的请求生命周期和中间件体系比较重,如果只是为一个小功能做轻量接口,启动一个完整的 Django View + Serializer 链路总觉得有点“杀鸡用牛刀”。
Flask 恰好可以补上这个位置,轻量、灵活、路径短,写一个几行的服务就能跑起来,特别适合做系统的辅助接口,比如定时统计任务完成率、推送每日提醒、处理外部回调这些逻辑。单独用 Flask 做整个学习系统当然也可以,但用户认证、模型关系、复杂查询这些都得自己去拼,开发效率会明显下降。
所以我的最终方案是:核心业务和主站用 Django,辅助服务和数据聚合用 Flask,两者共享同一个数据库和 Redis 缓存,通过明确的功能边界来避免混乱。
1.2 Django 负责的核心域与 Flask 负责的外围域
为了不让两个框架抢活,我给它们划了一条清晰的分界线:
- Django 负责:用户注册登录、任务模板管理、挑战创建、打卡记录、积分与等级、Admin 管理后台。
- Flask 负责:每日统计汇总接口、学习提醒消息推送、外部 API 桥接、临时性的数据处理脚本服务。
这条划分的依据其实是“数据一致性”的强弱需求。凡是涉及用户核心资产的写操作,比如打卡、修改任务、领取奖励,必须走 Django 的事务机制保证数据可靠;而查询、统计、提醒这类不要求强一致的功能,放在 Flask 里可以用更灵活的 SQL 聚合和异步方式处理,不会拖累主应用的响应速度。
两个服务之间通过数据库表复用和 Redis 键值交换数据。比如 Django 写入一条新的打卡记录后,Flask 的每日统计服务直接从数据库读取最新数据,根本不需要通过 HTTP 互相调用,避免了服务间循环请求的麻烦。
2. 任务挑战系统的数据模型与状态流转设计
这一部分是整个系统能不能长期跑下去的地基。当初设计的时候我看了不少任务管理类工具,发现很多软件初期好用、用一周就废掉,核心原因往往是任务状态设计得太粗糙,只有“未完成”和“已完成”,缺少中间状态和失败后的处理逻辑。
2.1 核心模型拆解:任务、挑战、打卡、积分
先看最基础的任务模型,我把任务分成两种:一种是一次性的短任务,比如“今天读完某篇文章”;另一种是周期性的习惯任务,比如“每周完成三次深度阅读”。两种任务在系统里的处理逻辑差别很大,所以建模时分成了两张表。
# Django models.py 中的核心模型 from django.db import models from django.contrib.auth.models import User class Task(models.Model): """任务模板表""" TASK_TYPE_CHOICES = [ ('once', '一次性任务'), ('habit', '周期性习惯'), ] user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='tasks') title = models.CharField('任务标题', max_length=200) description = models.TextField('任务描述', blank=True) task_type = models.CharField('任务类型', max_length=10, choices=TASK_TYPE_CHOICES) # 习惯任务周期,比如7代表每周 period_days = models.IntegerField('周期天数', default=7) # 单次任务截止时间 due_time = models.DateTimeField('截止时间', null=True, blank=True) # 基础完成奖励积分 reward_points = models.IntegerField('奖励积分', default=10) created_at = models.DateTimeField(auto_now_add=True) def __str__(self): return f'{self.user.username} - {self.title}' class Challenge(models.Model): """挑战活动:把多个任务组合成有目标的阶段比拼""" name = models.CharField('挑战名称', max_length=100) description = models.TextField('挑战描述', blank=True) tasks = models.ManyToManyField(Task, related_name='challenges') # 挑战开始和结束时间 start_date = models.DateField('开始日期') end_date = models.DateField('结束日期') # 通过挑战需要达到的最低完成率,比如 80 表示 80% pass_rate = models.IntegerField('通过标准(百分比)', default=80) created_by = models.ForeignKey(User, on_delete=models.CASCADE, related_name='created_challenges') created_at = models.DateTimeField(auto_now_add=True)这里比较费心思的是“挑战”这个概念。单人学习系统虽然不像团队竞赛那样需要复杂对战逻辑,但“挑战”可以提升仪式感。我设计的 Challenge 表把多个任务打包成一个阶段目标,并设定了一个通过标准,系统会根据挑战时间范围内的平均完成率来自动判定是否通过。
打卡记录表是整个系统里写入最频繁的一张表,它记录每次用户点击“完成”动作的明细:
class TaskRecord(models.Model): """一次打卡/完成记录""" task = models.ForeignKey(Task, on_delete=models.CASCADE, related_name='records') user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='task_records') # 原计划日期,比如习惯任务每周一应该完成 scheduled_date = models.DateField('计划日期') # 实际完成时间 completed_at = models.DateTimeField('完成时间', auto_now_add=True) # 是否在截止时间前完成 is_on_time = models.BooleanField('是否按时', default=False) remark = models.CharField('备注', max_length=500, blank=True) class Meta: unique_together = ('task', 'scheduled_date', 'user')这里有个很容易踩的坑:如果不加 unique_together 约束,同一个任务同一天可能被重复打卡,统计完成率时会出现超过 100% 的诡异数据。我一开始就没加,后面排查了很久才发现问题出在并发重复提交上。后来加上这个联合唯一索引,又在视图层做了重复请求拦截,双保险后才消停。
2.2 任务状态流转:从待开始到过期未完成
任务不是只有“完成/未完成”两个状态,我设计了一套更贴近真实学习节奏的状态机:
- 待开始:任务已创建,但还没到计划开始时间。
- 待完成:在计划周期内,等待用户打卡。
- 已完成:用户已打卡并通过校验。
- 已过期:截止时间已过且没有完成记录。
- 已失败:挑战类任务在挑战结束时仍未完成,且被判定为影响挑战通过。
状态流转的逻辑放在 Django 的 service 层,而不是散落在视图函数里,这样方便维护。比如系统每日凌晨的任务状态更新脚本,会先查出所有处于“待完成”且截止时间早于当前时间的任务,再批量置为“已过期”。这里我建议用 Django 的 update 批量操作,避免逐条 save 造成大量数据库连接开销。
from django.utils import timezone from .models import TaskRecord, Task def check_expired_tasks(): """每日定时任务:把超时未完成的一次性任务置为过期""" now = timezone.now() # 一次性任务:截止时间已过,且没有完成记录 expired_once_tasks = Task.objects.filter( task_type='once', due_time__lt=now, records__isnull=True ) # 这里如果直接对 queryset 做遍历更新,数据量大时很慢 # 我给 Task 增加了一个 status 字段用于标识状态 expired_once_tasks.update(status='expired')实际开发中我发现一个细节:状态字段最好显式保存,而不要依赖“根据记录是否存在”来即时推导。因为后面要频繁做列表展示和统计筛选,if 判断实时推导在代码里会很绕,性能也受影响。为此我在 Task 表里增加了一个 status 字段,维护状态时统一更新。
2.3 打卡校验逻辑的完整链路
打卡不是一个简单的 insert 操作。用户点击“完成”后,服务端要做四件事:
- 校验任务是否属于当前用户,防止越权操作。
- 校验任务是否处于可打卡状态,例如已经过期的任务不能补卡。
- 查询该周期内是否已经打过卡,重复提交要拦截。
- 写入打卡记录,更新任务状态,增加用户积分。
校验过程我用一个享元式的函数封装起来,统一从 Django Service 层调用:
def perform_check_in(task_id, user): from django.db import transaction from .models import Task, TaskRecord, UserProfile with transaction.atomic(): task = Task.objects.select_for_update().get(id=task_id, user=user) today = timezone.localdate() if task.status == 'expired': raise ValueError('任务已过期,无法打卡') if TaskRecord.objects.filter(task=task, scheduled_date=today, user=user).exists(): raise ValueError('今天已经打过卡') record = TaskRecord.objects.create( task=task, user=user, scheduled_date=today, is_on_time=True ) # 更新任务状态 task.status = 'completed' task.save(update_fields=['status']) # 累加积分 profile, _ = UserProfile.objects.get_or_create(user=user) profile.points += task.reward_points profile.save(update_fields=['points']) return record注意这里用到了select_for_update(),事务内锁定目标行,可以有效避免两个请求同时进来导致插入两条打卡记录。单用户场景下这个操作看似多余,但真实使用中浏览器多标签并发提交、移动端和网页端同时操作的情况是真实存在的,别省这个保障。
3. 挑战判定与学习激励:怎么让完成率不变成自欺欺人
系统上线后我发现一个有意思的现象:如果只看“今天是否完成”,用户很容易在疲劳的时候降低标准,把学习内容水过去。要真正起到学习助手的作用,需要引入“挑战判定”和“激励反馈”,让用户对完成质量负责。
3.1 挑战通过率不是简单的平均数
一个挑战活动包含多个任务,每个任务可能持续多天,如果只用“总打卡次数除以总计划次数”来计算完成率,会掩盖时间分布问题。比如用户前三天疯学、后四天完全放弃,最后总体完成率可能仍然及格,但这不符合“持续学习”的目标。
我的做法是引入“按日完成率”加权的思路:先算出挑战时间范围内每一天的完成率,再取这些每日完成率的平均值作为挑战完成率。这样某一天全空,会直接拉低整体分数,更能反映真实的学习节奏。
def calculate_challenge_completion(challenge, user): """计算一个挑战的用户完成率(按日加权)""" from django.db.models import Count from datetime import timedelta from .models import TaskRecord, Task tasks = challenge.tasks.filter(user=user) task_ids = tasks.values_list('id', flat=True) # 挑战时间范围内所有日期 day_count = (challenge.end_date - challenge.start_date).days + 1 total_rate = 0 for offset in range(day_count): day = challenge.start_date + timedelta(days=offset) # 当天计划任务总数 planned_count = tasks.filter( start_date__lte=day, due_time__date__gte=day ).count() if planned_count == 0: continue # 当天实际完成数 done_count = TaskRecord.objects.filter( task_id__in=task_ids, scheduled_date=day ).count() total_rate += done_count / planned_count if day_count == 0: return 0 return round(total_rate / day_count * 100, 2)实际测试中这个算法的表现更合理。一次做完的“爆发式学习”考不了高分,逐日稳步推进反而能拿高分,这才符合学习助手的初衷。
3.2 积分与连续打卡的激励逻辑
积分系统很容易做成“摆设”,因为用户一旦发现攒了几百分也换不来任何变化,就会失去兴趣。所以我在设计积分时绑定了一些实际反馈:
- 连续打卡满 7 天:解锁“周常之星”标识,额外奖励 50 积分。
- 连续打卡满 30 天:解锁“月常王者”标识,奖励 200 积分。
- 挑战通过后:返还挑战创建时设置的奖励池,并记录通关证书。
这些内容存在一个 UserProfile 表和一个 Achievement 表里。
连续天数的计算逻辑要特别小心跨时区问题。我使用的是服务器本地时区每天 0 点作为分割点,统一用timezone.localdate()来判断日期,避免用户端时区不同导致连续天数断裂。遇到跨年、跨月时,日期排序逻辑要基于 date 对象而非字符串,否则'2024-01-31' > '2024-02-01'这种字符串比较会出错。
3.3 防自欺机制:逾期判定与补卡策略
这是我在实际使用中最有体会的部分。早期系统允许用户“补打卡”,结果我发现连续打卡记录里水分很大,用户经常在过了一周后集中补录,完全失去了每日坚持的意义。后来我调整了策略:
- 不允许无理由补卡,只有挑战期间申请“补卡额度”才能补,而补卡额度通过完成额外任务获得。
- 逾期任务状态直接变为过期,用户明确看到任务变红,心理压力会促使第二天更认真。
补卡额度我用一张单独的表记录,每个挑战周期最多提供 3 次补卡机会,每用一次需要消耗 20 积分作为“代价”。这种“有代价的容错”比完全禁止补卡体验好很多,用户不会因为一次意外断了连续打卡而彻底放弃整个挑战。
4. Flask 辅助服务:统计聚合、提醒推送与定时任务的落地
前面说到 Flask 负责外围辅助功能,这一节讲讲具体落地。不可否认,Django 也能做定时任务和统计报表,但真正把这些从主应用中拆出去后,代码维护的清爽程度立竿见影。
4.1 Flask 统计聚合服务如何与 Django 共享数据
两个框架共享同一个 MySQL 数据库,Flask 侧通过 SQLAlchemy 来连接。为了让模型定义不重复维护,我把 Django 的核心表用db_view的方式暴露给 Flask 侧,或者直接在 Flask 里定义轻量映射模型,只挑选统计需要的关键字段。
# Flask 单独定义的统计模型映射 from flask_sqlalchemy import SQLAlchemy from datetime import datetime db = SQLAlchemy() class TaskRecordView(db.Model): """映射 Django 的 task_record 表,只读模型""" __tablename__ = 'task_record' id = db.Column(db.Integer, primary_key=True) task_id = db.Column(db.Integer) user_id = db.Column(db.Integer) scheduled_date = db.Column(db.Date) completed_at = db.Column(db.DateTime) is_on_time = db.Column(db.Boolean)这里有个重要提醒:Flask 侧不要定义与 Django models 不一致的字段约束。比如 Django 的布尔字段可能是tinyint(1),Flask 这边要对应好;否则查询条件传True时可能会生成不兼容的 SQL。我踩过这个坑,明明 Django 里查得好好的,Flask 里is_on_time=True却查不出数据,最后发现是列类型映射问题。
统计接口的核心是每日汇总用户的学习数据:
from flask import Blueprint, jsonify, request from sqlalchemy import func from datetime import date, timedelta stats_bp = Blueprint('stats', __name__) @stats_bp.route('/api/stats/daily', methods=['GET']) def daily_stats(): """查询某用户指定日期范围内的每日完成情况""" user_id = request.args.get('user_id', type=int) days = request.args.get('days', default=7, type=int) end_date = date.today() start_date = end_date - timedelta(days=days - 1) rows = db.session.query( TaskRecordView.scheduled_date, func.count(TaskRecordView.id) ).filter( TaskRecordView.user_id == user_id, TaskRecordView.scheduled_date >= start_date, TaskRecordView.scheduled_date <= end_date ).group_by(TaskRecordView.scheduled_date).all() result = {str(day): count for day, count in rows} return jsonify({'user_id': user_id, 'start_date': str(start_date), 'end_date': str(end_date), 'data': result})这个接口用 Flask 写就非常简洁,Django 侧如果也想实现同样功能,需要走 DRF 的 ViewSet + queryset annotation 流程,代码量至少翻倍。聚合统计这种读多写少、不涉及核心业务逻辑的场景,天生就适合轻框架。
4.2 定时提醒链路:APScheduler 与 Flask 的配合
学习提醒是通过 Flask 应用内的 APScheduler 实现的。每天早上 7 点推送当天任务列表,晚上 9 点推送未完成任务提醒。推送方式先用站内消息记录,后续对接了 Webhook 能发到即时通讯工具。
调度任务的核心代码:
from apscheduler.schedulers.background import BackgroundScheduler from datetime import datetime def send_morning_reminder(): # 查询当天应完成任务 today = date.today() tasks = db.session.query(TaskView).filter( TaskView.user_id.in_(all_user_ids), TaskView.due_date == today, TaskView.status == 'pending' ).all() # 分组推送给不同用户 ... scheduler = BackgroundScheduler(timezone='Asia/Shanghai') scheduler.add_job(send_morning_reminder, 'cron', hour=7, minute=0) scheduler.add_job(send_evening_reminder, 'cron', hour=21, minute=0)如果提醒任务放在 Django 里做,配置 Celery 需要额外启动 worker 和 beat 进程,对单人使用的学习系统来说有点重。APScheduler 嵌入 Flask 应用里,一条命令启动,简单直接。
4.3 定时任务执行时需要注意的时区与状态问题
定时任务容易踩的坑有两个:一个是时区,一个是状态更新后通知的时机。
时区方面,我在 APScheduler 初始化时明确指定了timezone='Asia/Shanghai',同时数据库里的DateTimeField存储的是 UTC 时间。这样每天早上 7 点的调度是由 APScheduler 按本地时间触发,但查询数据库时要将本地时间转换为 UTC 再比较,或者直接按日期边界查询本地日期的记录。
另一个坑是定时任务与用户实际操作的时间竞争。比如用户 23:59 打卡成功,而凌晨 0:00 的统计任务已经跑完,可能当天的完成率漏掉了这次打卡。我的处理方案是统计任务延迟到凌晨 1:00 执行,并在逻辑上显式标记统计截止时间,同时提供手动重算接口来兜底,发现漏算可以一键补算某天的数据。
5. 部署联调过程中的实测教训:三件让我印象最深的事
系统从本地跑通到真正部署到外网,过程里遇到不少问题,挑三个影响最大、也最典型的记录下来,希望后来人不用重复踩坑。
5.1 Django 与 Flask 如何共享登录状态
最早期两个服务是“各登录各的”,结果用户在 Django 网站登录后,访问 Flask 提供的统计页面还要再登录一次,体验非常割裂。
后来我统一了会话策略:Flask 通过读取 Django 的 session 表来认证用户。Django 默认的数据库 session 存在django_session表里,Flask 侧只读取这张表的session_key和session_data,通过 Django 的签名算法反向解析出_auth_user_id,就能识别当前用户。
# Flask 侧解析 Django session 的示例 from django.core.signing import TimestampSigner def parse_django_session(session_key): from django.contrib.sessions.backends.db import SessionStore store = SessionStore(session_key=session_key) # 加载 session 数据 store.load() user_id = store.get('_auth_user_id') return user_id这里要确保两个框架共享同一个SECRET_KEY和SESSION_COOKIE_NAME,否则解析签名会失败。部署时我用环境变量统一管理这些敏感配置,Git 仓库里只留模板,各环境单独配置。
5.2 跨域与反向代理的配置陷阱
由于 Django 和 Flask 跑在不同的端口,前端需要通过反向代理统一入口。我用 Nginx 做了路径分发:/开头的请求转发给 Django,/api/stats开头的请求转发给 Flask。
server { listen 80; server_name your-domain.com; client_max_body_size 10M; location / { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } location /api/stats { proxy_pass http://127.0.0.1:5000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } }跨域问题在本地开发时也存在。Flask 侧需要配置 CORS,允许来自 Django 开发服务器的请求携带 Cookie;而 Django 侧要关闭 session 的CSRF_COOKIE_SAMESITE限制,否则跨端口访问时 cookie 带不过去。这个问题的排查过程比较磨人,因为浏览器控制台不直接提示 cookie 被拦截,需要手动查看请求头里的 Cookie 是否完整。
5.3 从 DEBUG 模式切换到生产模式时被打了脸
本地开发时 Django 的DEBUG=True能显示详细报错,但上线前如果忘记关闭,会直接把代码异常信息暴露给访问者,安全隐患很大。我根据经验整理了上线前的检查清单:
- 关闭 DEBUG,设置合理的
ALLOWED_HOSTS。 - 使用环境变量管理密钥和数据库连接信息。
- 收集静态文件到指定目录,由 Nginx 直接托管。
- 用 gunicorn 启动 Django,而不是
runserver。 - Flask 部分同样禁用调试模式,限制为主机访问。
# 启动命令示例 gunicorn myproject.wsgi:application --bind 127.0.0.1:8000 --workers 2 --timeout 60 gunicorn flask_app:app --bind 127.0.0.1:5000 --workers 1 --timeout 30我实际部署时踩过最尴尬的坑是静态文件 404。Django 在生产模式下不再托管静态文件,必须提前执行collectstatic,并让 Nginx 配置别名指向静态目录。第一次部署时我忘了这一步,页面样式全部丢失,排查半天才发现是静态资源路径问题,好在修改 Nginx 配置后秒级恢复。
6. 跑通核心闭环之后,这个系统还能怎么长出来
系统目前已经从最初的“能记录任务、能打卡、能看统计”进化成一个相对完整的学习辅助工具。回头梳理时发现,当初把 Django 和 Flask 分开设计带来的可扩展性,在后续功能迭代中确实省了不少事。
6.1 可扩展功能一:学习日历与年度回顾
日历模块可以直接复用 Flask 的统计接口,按月返回每天完成的任务数量,前端用日历热力图展示。年度回顾则是在统计接口基础上增加年度汇总字段,比如“今年累计完成 1200 次学习打卡”“最长连续坚持 45 天”“最喜欢在晚上 9 点学习”这类洞察,不需要改动 Django 核心表结构,只扩展 Flask 的聚合查询即可。
6.2 可扩展功能二:任务模板市场与分享
因为这个系统本质上支持多用户,所以我可以做一个“任务模板市场”,让用户把自己创建的优质任务模板公开,其他人可以一键导入到自己的任务列表。Django 这边只需要增加一个模板公开的字段和导入接口,由于核心表设计时已经预留了created_by和is_public相关字段,改造工作量很小。
6.3 可扩展功能三:数据导出与自动化报告
用 Flask 做导出服务再合适不过。我加了一个每周自动生成 PDF 学习报告的功能,通过 Flask 直接读取数据库中的学习统计,用模板引擎渲染成 HTML,再通过无头浏览器把 HTML 转成 PDF。整个过程不涉及 Django 主应用,所以即使导出服务临时出问题,也不影响用户正常打卡。
一个好的学习助手,核心不是堆砌多少酷炫功能,而是真正让你愿意每天打开它、完成一点小任务、看到自己的成长轨迹。这个基于 Python 的双框架系统经过一段时间的实际使用,已经帮我养成了几个持续至今的学习习惯,这比任何技术选型上的完美主义都更有意义。如果你也想搭一个自己的学习管理系统,我建议先别执着于一步到位,把打卡、挑战、统计这个最小闭环跑通,再根据实际使用体验逐步加功能,你会发现这套组合拳越用越顺手。