‹ 返回博客

个人网站(四):博客内容模块——一篇文章从草稿到发布的完整旅程

2026-09-20 · 约 11 分钟读完 · #编程

一个人做个人网站(四):博客内容模块——一篇文章从草稿到发布的完整旅程

博客是这个站的心脏。相册、视频、作品这些模块,后来几乎都是照着博客的范式长出来的:列表分页、详情渲染、后台 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_mdcontent_html 同时存。 这是整个模块最重要的决定,下一节专门讲。

第二,created_atupdated_atpublished_at 三个时间各司其职。 新手常犯的错是只存一个创建时间,然后用它排序。但一篇草稿可能在编辑箱里躺两周,发布瞬间如果沿用创建时间,它会"出生在两周前",沉到列表最底部——读者永远看不到新文章。所以排序时间必须是首次发布的时刻,它在草稿阶段为 NULL,只在状态第一次变成 published 时写入。

第三,URL 用 slug 而不是 id。 /blog/rainy-day/blog/23 对人友好、对 SEO 友好,但 slug 允许中文标题退化成空串,需要唯一化兜底(见第七节)。

二、Markdown 双端存储:保存时渲染一次,读取时零成本

"文章内容怎么存"有三个候选方案:

  1. 只存 Markdown,每次请求现场渲染;
  2. 只存 HTML,放弃源文;
  3. 源文和 HTML 都存,保存时渲染一次

我选第三个。原因:

渲染器是 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-2post-3

三、草稿状态机:两个状态,但转移规则有四条

状态只有 draftpublished 两个,看似简单,转移规则却有四条,漏一条就出内容事故:

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

四条规则是:

  1. 草稿 → 发布:写 published_at = now,文章出现在列表;
  2. 发布 → 撤稿:状态变 draft,但发布时间保留。撤稿往往是临时修改,重发时应该回到原来的位置,而不是冒充新文刷屏;
  3. 撤稿 → 重发:因为 published_at 还在,不重写时间;
  4. 新建即发布:创建接口里直接 published_at=datetime.now()

访客侧的所有查询都带 filter_by(status='published'),包括列表、分类计数、标签计数、上下篇、相关文章、sitemap、RSS、爬虫 SSR——"草稿不可见"不是一处过滤,而是一条贯穿所有查询的纪律。后台接口则反过来,能看到全部状态,并支持按状态筛选。

连分类/标签的文章数都有两副面孔:公开接口只数已发布文章,后台接口连草稿一起数(后台看到"这个分类下有 3 篇"点进去却只有 1 篇会很困惑)。

四、未发布的文章怎么给别人看:签名预览链接

写草稿时经常想把文章发给朋友先看一眼,但草稿对所有访客返回 404。为此我做了一个签名预览链接机制:后台点"生成预览链接",后端用 itsdangerousSECRET_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 即使泄露也不能被拿去做别的签名用途。

详情页最容易写错的是 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

顺带一个内容治理的小设计:标签重命名时如果目标名字已存在,不报错,而是自动合并——把旧标签下的文章关联迁移到已有标签,再删掉旧标签。这比强行制造两个同名标签让用户自己收拾要友好得多。

九、踩过的坑与小结

其他踩过的坑:

小结一下这个模块的设计哲学:

下一篇讲写作工具本身:怎么把 md-editor-v3 这个功能完备的 Markdown 编辑器接进后台,实现浮动工具栏、图片粘贴/拖拽上传、与后端渲染对齐的实时预览——第 5 篇:Markdown 编辑器,把写作体验做成一种享受

系列导航:① 开篇与总体架构 → ② Flask 后端骨架 → ③ 登录与安全 → ④ 博客内容模块(本文)→ ⑤ Markdown 编辑器 → ⑥ OSS 直传全攻略 → ⑦ SPA 的 SEO 与微信分享 → ⑧ 打卡热力图与小游戏 → ⑨ AI 网页宠物与收官