一个人做个人网站(四):博客内容模块——一篇文章从草稿到发布的完整旅程
博客是这个站的心脏。相册、视频、作品这些模块,后来几乎都是照着博客的范式长出来的:列表分页、详情渲染、后台 CRUD、封面回收。所以这一篇我会把博客模块讲透——它不仅是"一个增删改查",里面藏着一串只有真正写过内容系统才会遇到的问题。
按一篇文章的生命周期,这篇讲七件事:数据模型怎么立、草稿状态机怎么走、Markdown 为什么要存两份、没发布的文章怎么预览、阅读量怎么防刷、列表怎么查、上下篇和相关文章怎么算。
一、模型:三个容易被忽略的字段设计
文章表的核心字段上一篇亮过,这里聚焦三个"看起来不起眼、事后证明很关键"的设计:
class Post(db.Model):
__tablename__ = 'posts'
slug = db.Column(db.String(200), unique=True, nullable=False, index=True)
content_md = db.Column(db.Text, default='') # Markdown 源文
content_html = db.Column(db.Text, default='') # 渲染后的 HTML
status = db.Column(db.String(20), default='published', index=True) # published/draft
views = db.Column(db.Integer, default=0)
created_at = db.Column(db.DateTime, default=datetime.now, index=True)
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)
published_at = db.Column(db.DateTime, index=True) # 草稿阶段为空,首次发布才写入
第一,content_md 和 content_html 同时存。 这是整个模块最重要的决定,下一节专门讲。
第二,created_at、updated_at、published_at 三个时间各司其职。 新手常犯的错是只存一个创建时间,然后用它排序。但一篇草稿可能在编辑箱里躺两周,发布瞬间如果沿用创建时间,它会"出生在两周前",沉到列表最底部——读者永远看不到新文章。所以排序时间必须是首次发布的时刻,它在草稿阶段为 NULL,只在状态第一次变成 published 时写入。
第三,URL 用 slug 而不是 id。 /blog/rainy-day 比 /blog/23 对人友好、对 SEO 友好,但 slug 允许中文标题退化成空串,需要唯一化兜底(见第七节)。
二、Markdown 双端存储:保存时渲染一次,读取时零成本
"文章内容怎么存"有三个候选方案:
- 只存 Markdown,每次请求现场渲染;
- 只存 HTML,放弃源文;
- 源文和 HTML 都存,保存时渲染一次。
我选第三个。原因:
- 读远多于写。一篇文章被阅读成千上万次,但只编辑几次。渲染放在写入侧,访客端拿到 HTML 直接
v-html,详情页接口零渲染开销; - 爬虫 SSR 直接复用同一份 HTML。第 7 篇会讲到,微信爬虫看到的服务端渲染页和访客看到的内容必须字节级一致——数据源只有一份,天然一致;
- 源文必须留。否则下次进编辑器就是"把 HTML 反向转回 Markdown"的噩梦,代码块和表格会被转得面目全非。
渲染器是 Python-Markdown,扩展集合是刻意和前端编辑器对齐过的:
def render_markdown(text):
if not text:
return ''
return md.markdown(
text,
extensions=[
'extra', # 表格、围栏代码块、属性定义
'sane_lists', # 列表解析更符合直觉
'codehilite', # 代码高亮(Pygments 出 <span class="...">)
'toc', # 自动给标题加 id,前端据此生成目录
'pymdownx.tilde', # ~~删除线~~,与 md-editor-v3 编辑器语法对齐
],
extension_configs={'codehilite': {'css_class': 'highlight', 'guess_lang': False}},
)
保存文章时,无论新建还是更新,都重新渲染一次:
post.content_md = data.get('content_md') or ''
post.content_html = render_markdown(post.content_md)
这里有个重要的认知:后台编辑器里的"实时预览"是前端 JavaScript 渲染的,仅用于写作反馈;真正落库、真正给访客和爬虫看的,永远以后端 Python-Markdown 的渲染结果为准。 两套渲染器难免有细微差异,所以扩展集合必须对齐——比如删除线,前端 md-editor-v3 原生支持,后端就得装 pymdownx-extensions 并启用 pymdownx.tilde,否则编辑器里带删除线、发出来变纯文本。第 5 篇讲编辑器时还会回到这个话题。
配套还有一个后台专用的 /api/admin/markdown/preview 接口,让前端在需要"与线上完全一致"的预览时,可以请求后端渲染,而不是只信 JS 的结果。
同文件里还有两个小工具:阅读时长按中文 400 字/分钟、英文 200 词/分钟估算,空文章也至少返回 1 分钟,避免出现"0 分钟读完";slugify 对中文标题会退化成空,交给唯一化函数兜底成 post-2、post-3。
三、草稿状态机:两个状态,但转移规则有四条
状态只有 draft 和 published 两个,看似简单,转移规则却有四条,漏一条就出内容事故:
if data.get('status') in ('published', 'draft'):
new_status = data['status']
# 草稿首次发布:以发布瞬间作为发布时间;
# 发布过再撤稿/重发,保留原发布时间
if new_status == 'published' and post.status != 'published' and not post.published_at:
post.published_at = datetime.now()
post.status = new_status
四条规则是:
- 草稿 → 发布:写
published_at = now,文章出现在列表; - 发布 → 撤稿:状态变 draft,但发布时间保留。撤稿往往是临时修改,重发时应该回到原来的位置,而不是冒充新文刷屏;
- 撤稿 → 重发:因为
published_at还在,不重写时间; - 新建即发布:创建接口里直接
published_at=datetime.now()。
访客侧的所有查询都带 filter_by(status='published'),包括列表、分类计数、标签计数、上下篇、相关文章、sitemap、RSS、爬虫 SSR——"草稿不可见"不是一处过滤,而是一条贯穿所有查询的纪律。后台接口则反过来,能看到全部状态,并支持按状态筛选。
连分类/标签的文章数都有两副面孔:公开接口只数已发布文章,后台接口连草稿一起数(后台看到"这个分类下有 3 篇"点进去却只有 1 篇会很困惑)。
四、未发布的文章怎么给别人看:签名预览链接
写草稿时经常想把文章发给朋友先看一眼,但草稿对所有访客返回 404。为此我做了一个签名预览链接机制:后台点"生成预览链接",后端用 itsdangerous 以 SECRET_KEY 签发一个带文章 id、24 小时有效的 token:
PREVIEW_MAX_AGE = 24 * 3600
_SALT = 'draft-preview-link'
def make_preview_token(secret_key, post_id):
return URLSafeTimedSerializer(secret_key, salt=_SALT).dumps({'id': post_id})
def verify_preview_token(secret_key, token, post_id):
try:
data = URLSafeTimedSerializer(secret_key, salt=_SALT).loads(token, max_age=PREVIEW_MAX_AGE)
except BadData:
return False
return data.get('id') == post_id
访客接口对非发布文章放行这唯一一条路,且预览不计阅读量、不显示上下篇和相关文章:
if post.status != 'published':
token = request.args.get('preview', '')
if not token or not verify_preview_token(SECRET_KEY, token, post.id):
return fail('文章不存在或尚未发布', 404)
data = post.to_dict(with_content=True)
data['preview'] = True
return ok(data) # 预览不增加阅读数
这个设计的好处是:访问控制仍然只认后端签名,前端不需要任何"预览模式"的特殊权限。拿到链接的任何人(包括退出登录的站长自己)24 小时内可看,过期或篡改 token 立即失效;salt 隔离还保证这个 token 即使泄露也不能被拿去做别的签名用途。
五、阅读量去重:一个 cookie 解决刷新刷量
详情页最容易写错的是 views += 1 放哪。如果每次 GET 都加,刷新涨一次、返回再进涨一次,数字很快失真。我的做法是用一个 httpOnly cookie 记录该浏览器 24 小时内已计数的文章 id:
VIEW_COOKIE = 'viewed_posts'
VIEW_COOKIE_AGE = 24 * 60 * 60
viewed = [x for x in request.cookies.get(VIEW_COOKIE, '').split(',') if x]
if str(post.id) not in viewed:
post.views = (post.views or 0) + 1
db.session.commit()
viewed.append(str(post.id))
response = ok(data)
# cookie 只保留最近 200 个 id,避免体积无限增长
response.set_cookie(
VIEW_COOKIE, ','.join(viewed[-200:]),
max_age=VIEW_COOKIE_AGE, httponly=True, samesite='Lax')
else:
response = ok(data)
几个细节:cookie 用 httponly(JS 读不到,XSS 无法伪造已读)、samesite='Lax';只留最近 200 个 id 控制体积;注意计数发生在文章数据组装之后——先读后写,当前这次响应里的 views 是旧值还是新值要心里有数,我的选择是提交后返回的数据里自然带新值。
这套方案防的是"无意的重复计数"和最简单的刷量;真要刷,换 cookie 清浏览器即可。对个人博客,这个精度完全够用——统计口径写在代码注释里,比一个虚假的"精确数字"诚实。
六、列表查询:分页、过滤、排序的组合
博客列表支持分页、分类、标签、关键词四个维度,全部在公开蓝图里:
query = Post.query.filter_by(status='published')
if category:
query = query.join(Category).filter(
db.or_(Category.name == category, Category.id == category))
if tag:
query = query.filter(Post.tags.any(Tag.name == tag))
if keyword:
query = query.filter(db.or_(
Post.title.like(f'%{keyword}%'),
Post.summary.like(f'%{keyword}%'),
Post.content_md.like(f'%{keyword}%')))
query = query.order_by(Post.published_at.desc(), Post.id.desc())
return paginate(query, page, per_page, lambda p: p.to_dict())
排序是 published_at DESC, id DESC 的双键——第二键不是多余的。当两篇文章的发布时间精确到秒相同(批量导入、脚本灌数据时很常见),只用时间排序,数据库不保证同值行的顺序稳定,翻页时可能出现同一篇文章在第 1 页和第 2 页重复出现。加上唯一的 id 做次序,分页结果才确定。这个排序规则被严格复用到后面的"上下篇"计算中,两处必须一致。
分类参数同时接受名称和 id(URL 里用名称友好,后台跳转用 id 方便);分页走第 2 篇的统一 paginate,非法页码静默回退、单页上限 50。
七、上下篇与相关文章:排序一致比算法聪明更重要
详情页底部有"上一篇/下一篇"。它的坑在于:必须和列表排序严格一致,否则读者会遇到"列表里明明是 A 在上面,点下一篇却跳到 B"的错乱。因此相邻文章的查询完整复刻了 published_at DESC, id DESC,并处理了极端历史数据(老文章可能没有发布时间)退化为按 id 相邻:
base = Post.query.filter(Post.status == 'published', Post.id != post.id)
prev_q = base.filter(
pub.isnot(None),
db.or_(pub < post.published_at,
db.and_(pub == post.published_at, Post.id < post.id))) \
.order_by(pub.desc(), Post.id.desc()).first()
# next 对称:pub > 当前 或 (同时间且 id 更大),ASC 取第一条
相关文章用"共享标签数"排序,共享标签越多越相关,不足 3 篇时用最新发布文章补齐:
overlap = func.count(post_tags.c.tag_id)
rows = (db.session.query(Post, overlap.label('overlap'))
.join(post_tags, post_tags.c.post_id == Post.id)
.filter(Post.status == 'published', Post.id != post.id,
post_tags.c.tag_id.in_(tag_ids))
.group_by(Post.id)
.order_by(overlap.desc(), Post.published_at.desc())
.limit(limit).all())
这两个函数都放在 models/content.py 而不是 API 文件里——因为爬虫 SSR 那套模板也要用完全相同的逻辑生成 og: 相邻链接。业务规则单点定义,是"一份内容服务多个视图"原则在查询层的体现。
八、slug 的两个暗坑
坑一:slug 可能恰好是纯数字。 文章标题叫《2026 总结》,slug 就是 "2026"。如果详情路由先按 id 解析,/blog/2026 会被误当成 id=2026,查不到再 404。所以查找函数必须先按 slug 查,查不到且参数是纯数字时才按 id 兜底:
def _find_post_by_key(key):
post = Post.query.filter_by(slug=key).first() # slug 优先
if post:
return post
if str(key).isdigit():
return db.session.get(Post, int(key))
return None
坑二:slug 必须唯一,且改名不能撞车。 标题重复(《周报》《周报》)时,唯一化函数追加递增后缀,更新时还要排除自身:
def _unique_slug(raw, exclude_id=None):
base = slugify(raw) or 'post'
slug, index = base, 2
while True:
query = Post.query.filter_by(slug=slug)
if exclude_id:
query = query.filter(Post.id != exclude_id)
if not query.first():
return slug
slug = f'{base}-{index}'
index += 1
顺带一个内容治理的小设计:标签重命名时如果目标名字已存在,不报错,而是自动合并——把旧标签下的文章关联迁移到已有标签,再删掉旧标签。这比强行制造两个同名标签让用户自己收拾要友好得多。
九、踩过的坑与小结
其他踩过的坑:
- 删除/换封面时的文件回收要先查全局引用。 同一张图可能既是 A 文章封面又是 B 相册封面,
recycle_media()会扫描所有媒体字段确认无引用后才删 OSS 对象;正文内嵌图片因可能被多篇复用,一律不自动删; onupdate=datetime.now让updated_at自动维护,阅读量自增也会刷新它,所以后台文章列表按updated_at排序时,阅读多的文章会自然靠前——这是预期行为而非 bug,但要知道原因;- 草稿预览页前端要显式隐藏导航和相关推荐(
v-if="!isPreview"),否则草稿的存在会通过"下一篇"链接泄露给持链接人; - 编辑器写、Python 渲染、Pygments 高亮这条链路上,代码块样式依赖前端引入一份 highlight 主题 CSS,只装后端扩展不加样式,代码高亮是"上了色但看不见"。
小结一下这个模块的设计哲学:
- 写入时做重活(渲染 Markdown、生成 slug),读取时只做组装;
- 状态用最少的枚举,但把每条转移规则想全,尤其是时间字段在转移中的行为;
- "草稿不可见"是全查询纪律,不是一个接口的判断;
- 业务规则单点定义(排序、相邻、相关),API 和 SSR 共用;
- 内容系统的难点不在 CRUD,在时间、状态、URL 和"谁能看见"。
下一篇讲写作工具本身:怎么把 md-editor-v3 这个功能完备的 Markdown 编辑器接进后台,实现浮动工具栏、图片粘贴/拖拽上传、与后端渲染对齐的实时预览——第 5 篇:Markdown 编辑器,把写作体验做成一种享受。
系列导航:① 开篇与总体架构 → ② Flask 后端骨架 → ③ 登录与安全 → ④ 博客内容模块(本文)→ ⑤ Markdown 编辑器 → ⑥ OSS 直传全攻略 → ⑦ SPA 的 SEO 与微信分享 → ⑧ 打卡热力图与小游戏 → ⑨ AI 网页宠物与收官


