
简介这套个人博客系统源码面向Java Web初学者、课程实践者与毕业设计参考人群基于SSH2三大框架和Oracle数据库开发可用于理解分层架构、请求流转与持久化映射也适合作为小型博客站点搭建练习项目。系统围绕博主与访客两类角色展开包含首页日志展示、分类分页浏览、评论互动、登录验证码校验以及后台日志查询、新增、修改、删除和分页管理等流程模块划分清晰便于按业务链路阅读源码。压缩包共159个文件约1.51MB涵盖Java源文件、JSP页面、XML配置、class编译文件以及GIF与PNG图片、JAR依赖、TLD标签库和属性配置等资源。已有1164人学习下载适合对照模块梳理数据库交互、页面跳转和配置关系并在此基础上扩展分类管理、评论审核或部署调试等实践。1. 自托管博客源码先想清楚「内容归谁」前年帮朋友迁移他写了六年的技术笔记导出时才发现文章结构被托管服务锁成私有格式图片链接还带防盗链一搬家就掉一层皮。那次之后我给的建议很直接要么纯 Markdown 存本地要么直接拿一套个人博客系统源码自己部署。这类博客源码的价值不在界面多花哨而在于内容层和数据层是否对你完全透明——文章存成什么格式、数据库几张表、渲染链路能不能改、评论和搜索是否自己可控。它适合想长期写技术笔记、又要自己掌握发布节奏的从业者也适合拿来做二次开发练手。下面按我拆包时的顺序走一遍从骨架、跑通、配置部署到最容易翻车的几个地方。2. 拆开博客源码的骨架一次页面请求要过几道手拿到一份博客系统源码我不会急着点运行按钮而是先看它怎么组织请求。这一步决定了后面改模板、加字段、接搜索时你是顺手还是抓瞎。常见做法是应用工厂 蓝图 服务层三层工厂负责装配配置和扩展蓝图按前台、后台拆路由服务层放 Markdown 渲染、搜索索引这类纯逻辑。这样拆的好处是渲染逻辑能单独跑单元测试不用起整个 Web 服务。2.1 目录分层与模块职责典型目录长这样不同实现细节会有出入但层次基本一致blog-src/ ├── app/ │ ├── __init__.py # 应用工厂装配配置、数据库、蓝图 │ ├── models.py # 文章、标签、评论三张核心模型 │ ├── routes/ │ │ ├── public.py # 首页、文章页、标签页、RSS │ │ └── admin.py # 登录、写作、评论审核 │ ├── services/ │ │ ├── markdown_render.py │ │ └── search.py │ ├── templates/ │ └── static/ ├── content/posts/ # Markdown 源文件和代码分开存 ├── scripts/ │ ├── init_db.py │ └── import_posts.py ├── config.py ├── requirements.txt └── wsgi.py判断一份博客源码是否值得长期用看两点content/是否和app/平级独立以及渲染函数是否能脱离请求上下文单独调用。前者决定你换主题时会不会动到文章数据后者决定你能不能批量重渲染历史文章。很多包把 Markdown 解析硬塞在视图函数里一旦你想加个代码高亮插件就得在路由里动刀后面全是连锁反应。2.2 一篇 Markdown 文章怎么变成 HTML链路是读文件 → 解析 front matter → Markdown 转 HTML → 写入缓存字段 → 模板渲染。关键在第三步和第四步之间加一个内容哈希内容没变就跳过重渲染。下面是服务层里我常用的写法import hashlib import frontmatter import markdown from markdown.extensions.codehilite import CodeHiliteExtension from markdown.extensions.toc import TocExtension MD markdown.Markdown( extensions[ fenced_code, tables, footnotes, CodeHiliteExtension(guess_langFalse, pygments_stylefriendly), TocExtension(permalinkFalse, toc_depth2-3), ], output_formathtml5, ) def render_post(raw: str) - dict: post frontmatter.loads(raw) body post.content html MD.convert(body) MD.reset() # 复用实例必须重置否则脚注和目录会串到下一篇 return { title: post.get(title, 无标题), slug: post.get(slug), summary: post.get(summary, ), tags: post.get(tags, []), published_at: post.get(date), content_md: body, content_html: html, content_hash: hashlib.sha256(body.encode(utf-8)).hexdigest(), }MD.reset()这行是血泪经验Markdown 实例复用时脚注编号和目录锚点会累积第二篇文章的目录里会出现第一篇的标题。guess_langFalse是关掉自动语言猜测否则 Pygments 会把一段普通文本猜成某种语言代码块颜色乱七八糟。toc_depth2-3表示只给二级、三级标题生成锚点文章页右侧目录用这个就够了。front matter 里建议固定四个字段title、slug、date、tags。slug一定要手写并保持唯一别用标题自动转拼音中文标题转出来的 slug 后期改标题就断链这是最常见的后悔药场景。2.3 数据库表与内容模型三张核心表加一张关联表SQLite 就够个人博客用开 WAL 模式后并发读没问题CREATE TABLE posts ( id INTEGER PRIMARY KEY AUTOINCREMENT, slug TEXT NOT NULL UNIQUE, title TEXT NOT NULL, summary TEXT DEFAULT , content_md TEXT NOT NULL, -- 原始 Markdown方便二次编辑 content_html TEXT NOT NULL, -- 渲染结果避免每次请求都解析 content_hash TEXT NOT NULL, -- 判断是否需要重渲染 status TEXT NOT NULL DEFAULT draft, published_at TEXT, updated_at TEXT NOT NULL DEFAULT (datetime(now)) ); CREATE INDEX idx_posts_status_time ON posts(status, published_at DESC); CREATE TABLE tags ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, slug TEXT NOT NULL UNIQUE ); CREATE TABLE post_tags ( post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE, tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE, PRIMARY KEY (post_id, tag_id) ); CREATE TABLE comments ( id INTEGER PRIMARY KEY AUTOINCREMENT, post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE, nickname TEXT NOT NULL, email_hash TEXT NOT NULL, -- 只存哈希不存明文邮箱 content TEXT NOT NULL, status TEXT NOT NULL DEFAULT pending, ip_hash TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime(now)) );正文同时存content_md和content_html看着冗余但换来两个好处后台编辑直接读 Markdown 原文前台展示不用每次跑解析。索引建在(status, published_at DESC)上是因为首页查询永远是「已发布 按时间倒序」这个组合索引能让列表页走覆盖扫描。评论表存email_hash而不是邮箱明文后面做 Gravatar 头像和反垃圾都够用也少一份泄露风险。3. 本地跑通这套博客系统从依赖装到首篇文章上线这一步的目标很明确本机起服务、数据库建好、存量 Markdown 导进去、首页能刷出文章。别跳过虚拟环境博客源码的依赖里常有 Pygments、Markdown 这类版本敏感的库全局装容易和别的东西打架。3.1 依赖安装与虚拟环境python3 -m venv .venv source .venv/bin/activate pip install -U pip pip install -r requirements.txt python -c import flask, markdown, frontmatter; print(deps ok)第二条命令先升级 pip是因为老版本 pip 解析某些带环境标记的依赖会失败。最后一行是自检能打印deps ok说明核心三个库都在。如果requirements.txt里没锁版本建议自己补一个pip freeze requirements.lock部署时用 lock 文件装避免半年后重装环境跑不起来——这类翻车在个人项目里太常见了。3.2 初始化数据库与导入存量文章初始化脚本一般封装成 Flask 命令跑之前确认config.py里的数据库路径是绝对路径相对路径在 systemd 下会指到别处flask --app wsgi init-db python scripts/import_posts.py导入脚本的思路是「按 slug 幂等 upsert」内容哈希没变就跳过import pathlib from app import create_app from app.models import db, Post app create_app() with app.app_context(): for path in sorted(pathlib.Path(content/posts).glob(*.md)): data render_post(path.read_text(encodingutf-8)) row db.session.execute( db.select(Post).filter_by(slugdata[slug]) ).scalar_one_or_none() if row is None: row Post(slugdata[slug]) db.session.add(row) elif row.content_hash data[content_hash]: continue # 内容没变跳过重渲染 row.title data[title] row.content_md data[content_md] row.content_html data[content_html] row.content_hash data[content_hash] row.status published row.published_at data[published_at] db.session.commit()scalar_one_or_none()在查到多条同 slug 记录时会直接抛异常这其实是好事能在导入阶段就把重复 slug 暴露出来而不是等线上 500。哈希对比那行对几百篇的库能省掉大半渲染时间第一次全量导入后后续每次只处理改动过的文章。3.3 启动与路由自检gunicorn -w 2 -b 127.0.0.1:8000 wsgi:app curl -s -o /dev/null -w home%{http_code}\n http://127.0.0.1:8000/ curl -s -o /dev/null -w post%{http_code}\n http://127.0.0.1:8000/p/hello-world curl -s http://127.0.0.1:8000/rss.xml | head -n 5本地用 2 个 worker 就够-w的数量不是越多越好SQLite 写锁在 worker 多的时候反而容易撞database is locked。三个 curl 分别验首页、文章详情、RSS第三个还能顺带确认 XML 声明没被模板转义。如果文章页 404 但数据库里有记录先看路由是/p/slug还是/slug再看 slug 有没有被转小写——这两处对不上是新手最常见的卡点。4. 从本地到公网配置项、评论审核与反向代理本地能跑不等于能上线。这一章处理四件事密钥固定、评论可控、中文搜得到、静态资源不丢。每一条都对应线上真实会遇到的故障。4.1 站点配置与密钥五个必须改的变量import os class Config: SECRET_KEY os.environ[BLOG_SECRET_KEY] SITE_URL os.environ.get(BLOG_SITE_URL, http://127.0.0.1:8000) SITE_TITLE 某开发者的技术笔记 POSTS_PER_PAGE 10 COMMENT_MODERATION True COMMENT_RATE_LIMIT 3 # 同一 IP 十分钟内最多 3 条 SQLALCHEMY_DATABASE_URI sqlite:////srv/blog-src/instance/blog.dbSECRET_KEY用环境变量注入生成方式python -c import secrets; print(secrets.token_urlsafe(32))如果源码里写的是SECRET_KEY os.urandom(24)一定要改掉。每次进程重启密钥都变后果是后台登录态全部失效、CSRF token 校验失败表现就是「登录成功刷新又回登录页」。SITE_URL影响 RSS 和 sitemap 里的绝对链接填错会导致订阅器里点开全是 127.0.0.1。数据库路径用四个斜杠的绝对路径写法三个斜杠是相对instance/目录部署后工作目录一变就找不到库。4.2 评论审核与反垃圾的取舍个人博客的评论不需要重型方案三层过滤就够先默认pending进审核队列再对同 IP 做十分钟限频最后对内容做关键词和链接数量判断。审核队列的意义在于机器人提交的垃圾即使绕过前端校验也进不了公开页面。限频逻辑放在提交入口用内存字典或 SQLite 计数都行单机博客不必上 Redis。要注意的是取 IP 时别直接读request.remote_addr反代之后拿到的是 127.0.0.1得从X-Real-IP取并且只信任自己那层 Nginx 设置的头部否则伪造头就能绕过限频。4.3 中文搜索FTS5 分词器的坑SQLite 的 FTS5 默认unicode61分词器按空白和标点切中文整段会被当成一个词搜「博客」搜不到「个人博客系统」。换成trigram也有问题它按三字符滑窗两个汉字组成的词照样召回不了。可行做法是入库前用 jieba 切好存到独立字段import re import jieba def build_search_text(title: str, body: str) - str: plain re.sub(r.*?, , body, flagsre.S) # 去掉代码块 return .join(jieba.cut_for_search(f{title} {plain}))建虚拟表时用 external content 模式正文不重复存CREATE VIRTUAL TABLE posts_fts USING fts5( title, search_text, contentposts, content_rowidid, tokenizeunicode61 );代码块要先剔掉否则函数名被切碎后会把无关文章顶到搜索结果前面。每次文章更新后同步重建对应行或者简单点导入脚本跑完统一INSERT INTO posts_fts(posts_fts) VALUES(rebuild)。几百篇的库重建也就毫秒级不用纠结增量。4.4 Nginx 反代与 Gunicorn 进程守护server { listen 80; server_name blog.example.com; location /static/ { alias /srv/blog-src/app/static/; expires 7d; add_header Cache-Control public; } 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-Proto $scheme; proxy_read_timeout 30s; } }alias后面必须带斜杠和location /static/的斜杠对应少一个就会出现/static//css/app.css这种双斜杠路径。静态资源交给 Nginx 直接发Flask 那边就不用管缓存头。进程守护用 systemdRestartalways加Environment注入密钥工作目录写绝对路径这三项配好基本不会半夜挂掉。5. 避坑排查五个真实翻车现场下面五条都是我在不同机器、不同版本的博客源码上真踩过的按「现象 → 原因 → 解决」记遇到时可以直接对号。5.1 文章页 404Markdown 文件明明在现象导入脚本显示成功数据库查得到记录访问/p/xxx返回 404。 原因路由注册的是/slug但模板里的链接生成用了/p/前缀或者 slug 在导入时被转成小写而库里存的是原始大小写。 解决统一在一处生成文章 URL模板里只调这个函数导入时对 slug 做lower()并加唯一索引让冲突在写入阶段就报出来而不是等到访问时才发现。5.2 后台登录成功刷新就掉线现象输入账号密码后跳转正常点任意后台页面又回到登录页。 原因SECRET_KEY每次启动随机生成session 签名对不上或者 Gunicorn 开了多 workersession 存在进程内存里第二次请求打到另一个 worker。 解决密钥固定到环境变量session 用签名 cookie 存不要用进程内字典。改完重启再开两个终端分别请求确认登录态能跨请求保持。5.3 代码块把页面撑爆甚至渲染出脚本现象文章里贴了一段带script的示例代码页面布局错乱或者弹出对话框。 原因Markdown 渲染默认放行原始 HTML示例代码被浏览器当标签执行了。 解决渲染时关闭原始 HTML 直通或者在输出后做白名单过滤只保留代码块、表格、链接这些标签。改完拿一篇带尖括号的旧文章重新导入验证别只看新文章。5.4 评论重复入库现象用户手抖点了两次提交后台出现两条一模一样的评论。 原因表单没有幂等 token服务端也没做去重。 解决提交页渲染时下发一次性 token服务端消费后失效同时对(post_id, email_hash, content_hash)建唯一索引兜底。索引这层是最后一道防线前端校验永远可以被绕过。5.5 上线后样式全丢静态资源 404现象首页 HTML 正常但 CSS、JS 全部 404页面裸奔。 原因Nginx 的alias路径和 Flask 的static目录对不上或者反代时没把/static/单独摘出来请求全打到 Gunicorn 又没匹配到路由。 解决先curl -I看静态资源返回码和实际路径再核对 Nginx 配置里location与alias的斜杠是否成对。改完清一次浏览器缓存expires 7d会让旧 404 结果被缓存住不清缓存会以为没修好。6. 进阶把这份博客源码改成能放三年的形态跑到这一步站点能写能发但离「长期维护」还差一层。我的习惯是把内容、代码、部署三件事彻底分开content/posts单独建一个 Git 仓库只放 Markdown 和图片博客源码一个仓库升级主题和依赖时不动内容部署脚本一个仓库记录 Nginx 配置、systemd 单元和环境变量模板。三个仓库各自打 tag回滚时互不牵连。写作流水线我固定成四步本地起服务预览、跑导入脚本、检查 sitemap 和 RSS、推送到服务器触发重载。预览这步别省Markdown 的表格和脚注在渲染器版本升级后最容易出问题本地看一眼比线上发现强。验证方法给一个具体的每次发布后跑一遍下面这段三个返回值都该是 200且文章页的Last-Modified应该等于刚才的发布时间。for u in / /p/latest-post /rss.xml /sitemap.xml; do printf %s - $u curl -s -o /dev/null -w %{http_code}\n https://blog.example.com$u done备份只做两件事SQLite 库每天sqlite3 blog.db .backup一份到对象存储Markdown 仓库本身有 Git 历史不用额外备。恢复演练每季度做一次拿备份库在本地起服务确认文章、标签、评论三张表行数对得上——没演练过的备份等于没有备份。最后说个我自己的教训。早些年我图省事把SECRET_KEY和数据库路径硬编码在config.py里跟着代码走换机器时忘了改后台登录态和评论数据一起出问题排查了大半天才定位到是配置漂移。从那以后我每次部署新实例都强制先跑一遍配置自检密钥是否来自环境变量、数据库路径是否绝对路径、SITE_URL是否和实际域名一致三项全过才继续。希望你少走这一趟。本文还有配套的精品资源点击获取