资讯详情 Flask+SQLite博客系统:从架构设计到数据库迁移完整实战
📅 2026/10/8 19:01:42
简介基于Python的个人博客系统设计与实现毕业设计项目完整提供后端源码、数据库脚本与软件说明书面向正在学习Python Web开发或需要完成毕业设计的学生。项目参照主流Python Web框架的典型结构搭建实现文章发布、评论互动、用户注册登录等博客核心功能数据库部分使用SQL配合ORM进行数据管理并给出环境配置与部署运行说明。压缩包共包含7387个文件大小约25.83MB主要文件类型包括Python源代码及其编译文件、网页前端资源html/js/css、数据库脚本、Word使用文档以及若干依赖库文件目录划分清晰便于定位查找。目前已吸引775人学习下载使用该资源可系统梳理博客系统从需求分析、数据模型设计、ORM数据访问、路由配置、用户认证到数据迁移的完整开发流程所有代码和文档配合良好既能作为毕业设计的完整参考也能在此基础之上扩展个性化功能。1. 一个 Python 博客系统 zip 包的价值半天跑通带数据库的毕设项目这个标题背后是一类很常见的交付物基于 Python 的个人博客系统把 Web 代码和数据库一起打成 zip。所谓设计与实现落到代码上就是两件事设计表结构实现用户、文章、评论的增删改查。这类项目能解决的问题很直接——你拿到的是一个“能跑、能演示、能讲清”的博客而不是一个三天打鱼两天晒网的半成品。它适合三类人准备毕设或课设答辩的学生想拿现成项目练手 Web 开发的 Python 学习者以及单纯想给自己搭一个本地博客或笔记站的人。前提是 Python 环境已经装好然后按下面的章节把项目完整跑一遍再按自己的需求去改字段、换数据库。我下面讲的顺序是架构选型、核心功能代码、数据库初始化和备份、报错排查、最后是搜索和迁移 MySQL 的进阶做法。2. 基于 Flask SQLite 的架构选型为什么不是 Django也不是 MySQL2.1 先看清 zip 里的文件结构解压“基于 Python 的个人博客系统包含数据库”这类项目包最常见的长这样blog_system/ ├── app.py # 应用入口Flask 实例和所有路由定义 ├── models.py # 数据库模型User / Post / Comment ├── init_db.py # 初始化数据库脚本一键建表 ├── requirements.txt # Python 依赖清单 ├── blog.db # SQLite 数据库文件单文件、直接携带 ├── templates/ # 页面模板 │ ├── base.html # 母版页导航栏和页脚 │ ├── index.html # 首页文章列表 │ ├── article.html # 文章详情页 │ ├── login.html # 登录页 │ ├── register.html # 注册页 │ └── admin/ │ └── edit.html # 发布 / 编辑文章页 └── static/ # 静态资源 css / js / 图片你拿到包以后要做的第一件事是分清“代码”和“数据”两类文件。app.py、models.py、init_db.py 是代码blog.db 是数据。在动手改代码之前先打开数据库看一眼表结构。用命令行工具执行一行就能列出所有表sqlite3 blog.db .tables如果看到 user、post、comment 三张表那这个项目的功能边界就很清晰用户管理、文章管理、评论管理。如果表更多说明作者把标签、分类做成了独立表功能更完整但代码量和答辩证也会同步变多。先看懂这个文件结构后面每一步才不会抓瞎。2.2 Flask 在这个项目里是“标准答案”我接触过的同类博客系统里Flask SQLite 出现的频率远高于 Django MySQL。原因在于这几种选择的组合刚好卡在“能完整演示增删改查”和“代码量适合讲清”的平衡点上。对比项Flask SQLiteDjango MySQL上手门槛低一个 app.py 能看完全部路由高settings、ORM、Admin 站点都是额外的概念数据库交付单文件 blog.db 直接放 zip 里需要额外提供 sql 导入脚本和建库步骤表单处理手动写表单、手动校验自带 Form 体系封装较重答辩叙事每个视图函数都能对应一个功能点框架自动完成的部分多容易变成“背文档”再加上“包含数据库”这个交付要求如果数据库选 MySQL老师打开你的项目要先装 MySQL 服务、建库、改密码、执行 sql 脚本任一步骤失败项目都跑不起来。用 SQLite 则不存在这个问题Python 自带驱动文件放哪都能跑。SQLite 的另外一个隐性好处在第 4 章会体现备份就是复制一个文件。2.3 三张表支撑整个博客字段设计和两个容易忽略的索引核心表结构通常是这样的表字段userid、username、password_hash、created_atpostid、title、content、summary、category、tags、views、created_at、updated_at、user_idcommentid、post_id、user_id、content、created_at三个字段设计上的细节值得说透。第一password 字段应该存密码哈希而不是明文字段名写成 password_hash 能时刻提醒自己别踩明文存储的坑。第二post 表里冗余了 summary 字段列表页只取 id、title、summary就不需要把整篇 content 从数据库拖出来再截断列表页性能会好很多。第三是索引给 post.user_id 和 comment.post_id 都加上 indexTrue否则数据量到几百条时关联查询还能忍到几千条时文章详情页会明显变慢。数据流向也很固定首页是 post 表按创建时间倒序分页文章详情页通过 post_id 去 comment 表查评论个人中心通过 user_id 反查文章列表。方向永远是从 user、post 这种主表流向 comment 这种从表不要在评论列表里循环查询文章表那是典型的 N1 查询新手在这里翻车的概率极高。3. 用户与文章的增删改查注册登录、发布、编辑、删除的完整代码3.1 注册登录用 werkzeug 管理密码而不是自制加密注册和登录是几乎所有 Web 项目的入口这里最需要注意的是密码处理。自己写一个 hash 函数、或者用 base64 编码一下都是错误的做法。标准做法是使用 Werkzeug 自带的密码工具代码量最少安全性也有保障。from flask import Flask, render_template, request, session, redirect, url_for from flask_sqlalchemy import SQLAlchemy from werkzeug.security import generate_password_hash, check_password_hash app Flask(__name__) app.secret_key dev-secret-key-change-in-production # 部署前改成环境变量 app.config[SQLALCHEMY_DATABASE_URI] sqlite:///blog.db db SQLAlchemy(app) class User(db.Model): id db.Column(db.Integer, primary_keyTrue) username db.Column(db.String(32), uniqueTrue, nullableFalse) password_hash db.Column(db.String(256), nullableFalse) def set_password(self, raw_password): self.password_hash generate_password_hash(raw_password) def check_password(self, raw_password): return check_password_hash(self.password_hash, raw_password) app.route(/register, methods[GET, POST]) def register(): if request.method POST: username request.form.get(username, ).strip() password request.form.get(password, ) if len(username) 3 or len(password) 6: return render_template(register.html, error用户名至少3位密码至少6位) if User.query.filter_by(usernameusername).first(): return render_template(register.html, error用户名已存在) user User(usernameusername) user.set_password(password) # 只存哈希值不存明文 db.session.add(user) db.session.commit() return redirect(url_for(login)) return render_template(register.html) app.route(/login, methods[GET, POST]) def login(): if request.method POST: username request.form.get(username, ) password request.form.get(password, ) user User.query.filter_by(usernameusername).first() if user and user.check_password(password): session[user_id] user.id session[username] user.username return redirect(url_for(index)) return render_template(login.html, error用户名或密码错误) return render_template(login.html)代码逻辑说明generate_password_hash 会自动加随机盐所以同一密码每次生成的哈希值都不同这是正常现象不是 bug。check_password_hash 负责校验。整个过程中数据库里永远只保存哈希后的字符串密码原文只在内存里存活一瞬间。两个请求处理函数都同时支持 GET 和 POSTGET 返回页面POST 处理表单。参数说明username.strip() 是为了去掉首尾空格防止用户注册“admin ”这种带空格的账号密码最小长度限制在 6 位是因为演示密码 123456 正好 6 位太短校验没有意义太长会给演示带来无谓麻烦。3.2 文章发布和编辑一条路由同时处理 GET 和 POST文章的发布和编辑在操作上非常像所以常见做法是用一条路由同时管两件事URL 上没有文章 id 时是新增有 id 时是编辑。这样模板也可以复用同一份 admin/edit.html。app.route(/post/new, methods[GET, POST]) app.route(/post/int:post_id/edit, methods[GET, POST]) def edit_post(post_idNone): if user_id not in session: return redirect(url_for(login)) post None if post_id: # 双条件查询必须同时满足文章 id 和当前登录用户 id post Post.query.filter_by(idpost_id, user_idsession[user_id]).first() if not post: return 这篇文章不存在或无权编辑, 404 if request.method POST: title request.form.get(title, ).strip() content request.form.get(content, ).strip() summary request.form.get(summary, ).strip() if not title or not content: return render_template(admin/edit.html, error标题和正文不能为空, postpost) if post is None: post Post(user_idsession[user_id]) db.session.add(post) post.title title post.content content post.summary summary[:100] if summary else content[:100] db.session.commit() return redirect(url_for(article_detail, post_idpost.id)) return render_template(admin/edit.html, postpost)逻辑说明新增和编辑共用 validate 逻辑前端传过来的 title 和 content 都先 strip 再去判断是否为空避免全空格内容通过校验。post_id 判断的关键在于 filter_by 里同时传了 user_idsession[user_id]这是一层很基础的越权保护你可以编辑自己文章但直接手改 URL 去 edit 别人的文章时查询结果为空返回 404。参数说明summary 字段手动填了就存手动内容没填则截取正文前 100 个字符兜底。这样列表页始终有摘要可显示不需要在模板层再写截断逻辑。3.3 删除、列表分页与评论跨浏览器支持与三个设计细节这三个功能看着简单但细节里藏着最容易在验收时暴露的问题。app.route(/) def index(): page request.args.get(page, 1, typeint) pagination Post.query.order_by(Post.created_at.desc()).paginate(pagepage, per_page5) return render_template(index.html, paginationpagination) app.route(/post/int:post_id) def article_detail(post_id): post Post.query.get_or_404(post_id) comments Comment.query.filter_by(post_idpost_id).order_by(Comment.created_at.asc()).all() return render_template(article.html, postpost, commentscomments) app.route(/post/int:post_id/delete, methods[POST]) def delete_post(post_id): if user_id not in session: return redirect(url_for(login)) post Post.query.filter_by(idpost_id, user_idsession[user_id]).first() if post: Comment.query.filter_by(post_idpost_id).delete() # 先删评论 db.session.delete(post) # 再删文章 db.session.commit() return redirect(url_for(index))逻辑说明分页的 page 参数用 typeint 强转用户把 page 写成 abc 时不会报 500而是回落到默认值 1。per_page5 适合 demo展示列表时刚好一屏一页。删除评论使用批量 delete 而不是循环遍历执行效率更高也能避免评论数量多时逐条删带来的事务长持有。删除操作强制使用 POST 而不是 GET这是很多人不会特意讲、但实际非常关键的一点搜索引擎爬虫和浏览器预取机制可能会自动 GET 一个删除链接如果你用 GET 删除文章可能被“意外”删掉。这个设计细节在答辩时讲出来分量很足。跨浏览器支持方面我一般会强调模板里尽量用 Bootstrap 栅格代替手写 flex表单提交按钮用常规 input typesubmit不要用“点击某个 div 再触发 JS 提交”的花哨写法。评审老师的浏览器版本可能很老学校机房的 IE 兼容模式也未必支持最新 CSS 特性标准 form 提交永远最稳。4. 数据库设计与初始化建表、测试数据、.db 文件的备份恢复4.1 初始化数据库用脚本建表不要手动敲 CREATE TABLE拿到一个带数据库的项目包第一件事是确认 blog.db 里的表结构和 models.py 是否对得上。对不上的情况在改过代码之后特别常见。如果你改动了模型需要重新初始化数据库。# init_db.py from app import app, db from models import User, Post, Comment with app.app_context(): db.drop_all() # 删除所有表仅限开发环境使用 db.create_all() # 按 models 里的定义重新建表 print(数据库初始化完成)逻辑说明drop_all 和 create_all 是成对出现的先清理旧表再创建新表保证模型和表结构完全一致。这条脚本只适合开发阶段反复改模型时使用生产环境一旦有了真实数据绝对不能直接跑 drop_all。create_all 的特性需要特别注意它只会新建不存在的表不会给已有表增加缺失的字段。你给 Post 模型新增一个 category 字段后光跑 create_all 没有任何效果旧表里没有这一列查询时直接报 OperationalError: no such column。这段时期你要处理的其实就是数据库修改结构的问题。SQLite 的常见解法是执行 ALTER TABLE post ADD COLUMN category VARCHAR(32)或者干脆把旧库备份后删掉重建。第一次跑通项目我建议直接重建省事且干净。4.2 造一批能演示的测试数据空表项目交给老师老师还得自己注册账号、慢慢发文章才能看到首页效果体验很差。正确做法是在初始化之后顺手造一批测试数据让首页和详情页打开就有东西看。# seed.py from datetime import datetime, timedelta from app import app, db from models import User, Post, Comment with app.app_context(): demo_user User(usernamedemo) demo_user.set_password(123456) db.session.add(demo_user) admin User(usernameadmin) admin.set_password(admin123) db.session.add(admin) db.session.flush() # 先落库拿到两个用户的 id for i in range(10): post Post( user_iddemo_user.id if i % 2 0 else admin.id, titlef示例文章{i1}Flask 博客开发记录, content这是一段用于演示的正文内容用来让详情页看起来有足够长度。 * 20, summaryf示例文章{i1} 的摘要, created_atdatetime.now() - timedelta(daysi), ) db.session.add(post) db.session.add(Comment(post_id1, user_idadmin.id, content第一条演示评论)) db.session.commit()逻辑说明flush 的作用是把新用户的 id 立刻从数据库取回来后续构造文章时才能正确写入 user_id。如果不 flushSQLAlchemy 也会在最终 commit 时自动处理但显式 flush 对阅读代码的人来说更直观。created_at 用 now 减去 i 天首页按时间倒序排列时10 篇文章的先后次序一眼就能看出来。参数说明密码用 123456 和 admin123是为了现场演示时输入方便正式部署必须改掉。正文用字符串乘法制造长度纯粹是为了让摘要截断效果可见演示完可以替换成真实内容。4.3 .db 文件的备份、恢复和 WAL 三个坑SQLite 的备份在直觉上极其简单把 blog.db 复制走就完了。实际并没那么简单还得看数据库有没有开启 WAL 模式。SQLite 从较新版本开始默认在部分场景下使用 WAL 日志模式数据库目录里会多出 blog.db-wal 和 blog.db-shm 两个伴随文件。这时候只复制主文件最近写入的数据可能还躺在 -wal 文件里备份并不完整。所以备份前先执行 checkpoint把 WAL 内容合并回主库文件sqlite3 blog.db PRAGMA wal_checkpoint(TRUNCATE); cp blog.db /path/to/backup/blog_$(date %F).db逻辑说明wal_checkpoint(TRUNCATE) 有两个动作把 WAL 文件里的数据写回主库然后把 -wal 文件清空。执行后再复制blog.db 才是完整状态。恢复操作就是把备份文件覆盖回项目目录但前提是数据库版本和 models.py 对得上否则会触发 5.1 节那个 no such column 问题。这里还有一个人多遇到过但我必须提的坑别把 .db 文件放到 U 盘、共享目录或云盘同步盘里直接运行。SQLite 依赖操作系统的文件锁机制网络盘和同步盘的锁状态非常不稳定跑着跑着就会报 database is locked。这不是玄学是 SQLite 架构决定的。开发调试就把文件放本地部署才考虑换 MySQL。5. 避坑/常见问题/排查五个让我熬夜的经典报错5.1 no such column改了模型字段旧库没跟上现象给 Post 模型加了 category 字段重启应用访问首页直接报 sqlite3.OperationalError: no such column: post.category。原因create_all 只创建不存在的表绝不修改已有表结构。SQLite 对删除列、改列类型的支持又很弱导致数据库结构落后于代码结构。解决先判断库里的数据是否重要。不重要就备份后删除 blog.db重新执行 init_db.py。有数据就执行 ALTER TABLE post ADD COLUMN category VARCHAR(32) DEFAULT python再用 sqlite3 blog.db PRAGMA table_info(post); 确认列已经存在最后再启动应用。5.2 database is locked单用户博客也逃不过的写锁现象发布文章或提交评论时偶尔报 sqlite3.OperationalError: database is locked。多刷新几个页面之后出现的概率变高。原因SQLite 同一时刻只允许一个写事务。Flask 开发服务器默认开启多线程两个请求几乎同时发起写操作后到的那一个拿不到写锁就直接失败。解决在数据库连接串上加超时参数sqlite:///blog.db?timeout10让连接等待 10 秒而不是立刻报错。同时把写操作尽量收敛到同一个函数里不要在两次表单提交之间开多个 session。如果并发量继续上涨就该准备迁移到 MySQL这是 SQLite 的定位问题不是配置问题。5.3 中文乱码meta charset 和文件编码一起查现象首页标题正常正文里中文变成“鍖呭惈鏁版嵁”这类乱码或者表单提交后中文全部变成问号。原因三层没对齐。模板文件本身不是 UTF-8 编码模板里没有声明 meta charset或者数据库连接的字符集被强行改成了别的编码。SQLite 本身存的就是 UTF-8 字节乱码大概率出在模板层。解决用编辑器把所有模板文件另存为 UTF-8 编码在 base.html 的 head 区域第一行写上 。然后检查应用里有没有手动设置 response 的 Content-Type charsetFlask 默认走 UTF-8不要画蛇添足。5.4 密码明文入库比乱码更严重的一类问题现象打开数据库一看user 表里的 password 字段存的是明文 123456或者一串看似加密但每次登录都不对的值。原因项目作者对密码存储不了解自己写了 base64 或一个简单的自定义加密函数。更糟的是用了随机盐却没有把盐存下来导致校验时永远算不出相同哈希。解决回到 3.1 节的写法用 generate_password_hash 加密、check_password_hash 校验盐由 Werkzeug 内部管理根本不存在“存盐”这件事。如果你接手的是已经明文入库的系统上线前必须让全部用户重置密码或者提供一个一次性改密链接。这个坑不补项目做得再漂亮也是白搭。5.5 模板 404、静态文件 404启动路径不对不是代码问题现象python app.py 启动成功浏览器访问首页却报 jinja2.exceptions.TemplateNotFound: index.html或者 CSS 文件全部 404。原因Flask 实例化时用了相对路径指定模板目录比如 Flask(name, template_foldertemplates)。当你在项目根目录以外的位置启动 app.py 时这个相对路径指向的工作目录里根本没有 templates 文件夹。解决用file拼绝对路径让模板目录只跟 app.py 所在位置有关跟启动命令所在目录无关。import os basedir os.path.abspath(os.path.dirname(__file__)) app Flask(__name__, template_folderos.path.join(basedir, templates), static_folderos.path.join(basedir, static))逻辑说明file是当前文件的完整路径os.path.dirname 拿到 app.py 所在目录再拼上 templates 和 static。这样你在任何目录下执行 python app.pyFlask 都能找到模板和静态文件。遇到 TemplateNotFound 和静态资源 404先检查这段再检查文件是不是真的存在。6. 进阶给博客加搜索并把 SQLite 平滑迁到 MySQL6.1 一条 LIKE 查询完成搜索个人博客的文本量撑不起搜索引擎组件一个 LIKE 查询就够用。app.route(/search) def search(): keyword request.args.get(q, ).strip() if not keyword: return redirect(url_for(index)) results Post.query.filter( db.or_(Post.title.contains(keyword), Post.content.contains(keyword)) ).order_by(Post.created_at.desc()).all() return render_template(search.html, postsresults, keywordkeyword)逻辑说明contains 在 SQLAlchemy 里生成 LIKE %keyword%对几百篇文章的库毫秒级返回。等文章量真到几十万条再谈分词和倒排索引不迟。6.2 SQLite 到 MySQL类型对照与迁移两条路如果部署到云服务器需要换 MySQL先把类型对照记清楚SQLiteMySQLINTEGER PRIMARY KEYINT PRIMARY KEY AUTO_INCREMENTTEXTTEXT 或 LONGTEXTDATETIMEDATETIMEFLOATFLOAT / DOUBLE迁移有两条路。第一写 Python 脚本从 SQLite 逐表读数据再逐条写入 MySQL 连接适合一次性搬迁第二项目本身长期换库可以改用 SQLAlchemy 指向 MySQL 的 URLORM 自动建表后再导数据。注意 SQLite 的 INTEGER PRIMARY KEY 自带自增换成 MySQL 后要改回 AUTO_INCREMENT同时把 sqlite_sequence 表里的当前自增值读出来写进 MySQL 的自增值否则新文章 id 会撞车。市面上那些数据库同步软件大多面向 MySQL 实例之间对这种跨类型的单库迁移帮不上忙老实写脚本才是正路。6.3 三个必须跑通的验证路径交付前跑一遍这三点比任何检查清单都实在。第一注册新用户登录后首页右上角出现用户名刷新登录页自动跳回首页说明 session 生效。第二发布一篇中文标题、正文超过 200 字的文章列表页只显示摘要详情页显示完整正文删除后列表页少一篇。第三在文章详情页发两条评论换一个用户登录确认评论可见然后删除文章确认评论被一并清理。这三个动作分别验证权限控制、增删改查、级联删除也是答辩评委最常上手操作的三件事。项目值不值得交付就看你敢不敢让老师现场点这三下。我在交付这类项目前总会多花半小时把需求清单之外的边界补上删除文章时评论怎么办、密码忘记怎么办、数据库连不上时页面是否给友好提示。这些位置才是真正劝退使用者的地方。希望帮到你。本文还有配套的精品资源点击获取