ZhyCMS成长日记:一个企业 CMS 从 1.0 到 3.1.3 的折腾史

2026 年 8 月 10 日,v1.0.0 发布,功能列表一只手数得过来。 2026 年 10 月 15 日,v3.1.3 发布,360 项测试全绿。 中间只隔了 66 天。

这篇文章不讲成功学,讲讲这 66 天里我们是怎么一路踩坑、填坑、再挖坑给自己跳的。 关键位置都贴了当时真正写进仓库的代码------故事可以讲得活泼,代码不能编。

〇、先看一张"体检表"

版本 日期 主题(一句话)
1.0.0 08-10 出生证:栏目、文章、CKEditor、主题
1.1.0 08-17 加了个 Word 导入,喘口气
2.0.0 08-29 成人礼:RBAC/审计/工作流等八大模块
2.1.x 08-29~30 补丁日:N+1、伪静态分页、环境变量改名
2.2.0 08-31 插件优先架构:什么都能做成插件
2.3.0 09-02 i18n 1.0:界面中英双语,表单也插件化
2.4.0 09-04 四大件:Alembic + 全文搜索 + Docker + OSS
2.4.1 09-05 安全审计:默认密钥能伪造管理员会话
2.4.2 09-06 CSP 太严把自己的 CDN 拦了 + Docker 连环坑
2.5.0 09-07 内容级多语言 + Redis
2.5.1/2 09-08/09 多语言搜索 + 一键翻译插件
2.6.0~4 09-10~12 工程化补课:CI、360 测试的雏形、JWT、会员
3.0.0 09-15 可视化拖拽页面构建器
3.1.0 10-10 数据库一键迁移插件
3.1.1 10-11 高危!API 默认免鉴权 + 全站补 CSRF Token
3.1.2 10-13 中危五连:输入校验与上传安全
3.1.3 10-15 性能与并发:设置缓存、浏览量写放大、N+1

66 天里,代码行数翻了几倍,测试从 0 到 360,发版从手抖靠运气变成有 Checklist、有 CI、有门禁。下面按时间线开唠。


一、v1.0:先跑起来,比什么都重要

v1.0.0 的需求朴素得令人感动:栏目分列表页和单页两种类型,文章用 CKEditor 全功能汉化版,单页支持自定义字段直接录,再加上碎片管理、友情链接、自定义表单、多主题模板------齐活。一个典型的"给企业官网用"的内容管理系统,不需要微服务,不需要消息队列,需要的是客户拿到手能填内容、前台能正常显示。

但 v1.0 的 changelog 里藏着两个很有意思的 fix,基本预示了后面两个月的主旋律。

第一个是中文文件名上传被误判为类型不允许 。Flask 推荐的 secure_filename() 出于安全考虑会把非 ASCII 字符洗干净,"产品手册.docx" 洗完可能只剩个 ".docx"------文件名没了,后缀判断直接失灵,上传被拒。后来这条演变成一条铁律:文件类型判断必须从原始文件名提取后缀,不能信清洗后的名字。

第二个是带路径参数的路由分页链接 BuildError 。栏目页路由带 slug 参数,分页 url_for 漏传参数就抛异常,前台直接 500。路由和 url_for 的爱恨情仇,这才刚刚开始------后面伪静态那章还会重逢。

v1.1 很小,只加了 Word(.docx)导入正文。这个版本存在的最大意义可能是:让我们习惯了"小步快跑、语义化版本"的节奏。真正的风暴在两周后。


二、v2.0:19 天后的成人礼

8 月 29 日,距离 v1.0 才 19 天,v2.0.0 带着八大模块砸了下来:RBAC 权限体系(角色/权限点/多角色、栏目级授权、4 个预设角色 + 17 个权限点)、审计日志(全后台操作留痕)、内容工作流(草稿/待审核/已驳回/已发布/已归档状态机 + 版本快照 + 定时发布)、备份与恢复(mysqldump / JSON / PGDump 三种姿势)、登录安全(防暴破锁定、验证码、异地提醒、后台地址前缀可自定义)、上传安全(MIME + 后缀双校验、SHA-256 去重、Pillow 压缩缩略图)、表单消息通知(邮件 / 企业微信)、SEO 与性能(伪静态、sitemap、robots 可视化、页面缓存)。

为什么跳这么大?因为 v1.x 的用户模型是"所有登录用户都是超管"------做内部玩具可以,卖给企业是要出事的。v2.0 本质上是把"玩具"改造成"产品"的一次性总清算。

第一个迁移大坑:数据不是说迁就能迁

v1.x 没有 Alembic(那是 v2.4 才有的奢侈品),v2.0 的表结构变化只能写裸迁移脚本 scripts/upgrade_v2.py。这脚本在紧接着的 v2.1 被连修了两个回填缺陷,每个都是血泪。

坑一:SQL 优先级缺括号,隐藏文章可能被"平反"成已发布。

is_enabled 布尔字段要升级成工作流状态,回填逻辑本意是"启用且满足某条件 → 已发布,否则 → 草稿"。WHERE 子句里 AND/OR 混用又忘了加括号,SQL 的运算符优先级直接让条件串了味。这种 bug 的可怕之处在于:它不报错,它只是悄悄把客户藏起来的文章挂到了首页上。 等客户发现,可能搜索引擎都已经收录了。

坑二:ALTER TABLE ADD COLUMN DEFAULT ... 会立即回填存量行。

在 MySQL 里给已有表加一个带默认值的列,老数据会立刻被填上默认值。本来的迁移计划是"先加列,再根据老字段 is_enabled 逐行映射状态",结果列加完一看------映射依据还在,但默认值已经抢先污染了所有行,好在当时默认值设计得和映射结果撞不了车,才算惊险过关。最后的方案是引入 schema_migrations 版本记录表:每一步迁移只执行一次、记录在案,重跑自动跳过,保证幂等且绝不覆盖运行期数据。

这两个坑后来沉淀成一条原则:迁移脚本默认假设自己会被跑第二遍,也默认假设库里有真实数据。 这条原则在后来的 Alembic 时代依然成立。


三、v2.1:发版当天就打补丁,不丢人

v2.0.0 和 v2.1.0 是同一天(08-29)。听起来很赶,实际是 v2.0 一落地就被实测教育了。

后台用户列表恒显"暂无用户"------模板取值字段写错了,外加每行单独查角色,一个经典的 N+1 bug 套餐。菜单"显示但点了 403"------没权限的菜单应该直接隐藏,而不是摆个按钮逗用户玩。这两个都是"开发者自测用超管账号,怎么点都对"才漏过去的。

更隐蔽的一个问题出在审计日志上。写审计的函数里直接访问了 request 和 current_user,在 HTTP 请求里岁月静好,可一定时任务和命令行脚本调用,没有请求上下文,当场抛异常------而且异常被外层静默吞掉,审计日志就这么无声无息地丢了。v2.1 的修复思路是把"从请求里取信息"变成"有就取、没有就用系统身份":

python 复制代码
# app/models/audit.py
@classmethod
def record(cls, op_type, module, target_id=None, target_name=None, detail=None,
           user=None, ip=None, user_agent=None):
    """无请求上下文(定时任务/命令行)时跳过 request/current_user 兜底,
    保证审计写入不会因此静默丢失。"""
    from flask import has_request_context, request
    from flask_login import current_user as _cu
    try:
        if user is not None:
            u = user
        elif has_request_context():
            u = _cu if _cu.is_authenticated else None
        else:
            u = None                                        # CLI/定时任务:系统身份
        _ip = ip or (request.remote_addr if has_request_context() else '')
        _ua = user_agent
        if _ua is None and has_request_context() and request.user_agent:
            _ua = request.user_agent.string[:255]
        log = cls(user_id=u.id if u else None, username=u.username if u else '',
                  ip=_ip or '', user_agent=_ua or '', ...)

从此审计写入有个铁规矩:任何依赖请求对象的代码,先问 has_request_context()。

伪静态分页:一个正则调教了好几天

v2.1 还做了一个当时看来很小、后来证明很英明的功能:伪静态分页规范化 。规则是:第 1 页用 /{slug}.html,第 N 页用 /{slug}-{N}.html,page<=1 自动 302 回第一页;老的 /column/{slug}?page=N 继续返回 200,向后兼容。

为了让 8 个列表模板在"伪静态开/关"之间无缝切换链接格式,专门抽了一个全局 Jinja 函数,所有模板不许自己拼 URL,只能走它:

python 复制代码
# app/frontend/views.py
def frontend_pager_url(column, page):
    """前台列表分页 URL 生成(伪静态适配)。
    - seo_rewrite_enable=on:第一页 /{slug}.html,第 N 页 /{slug}-{N}.html
    - 关闭:/column/{slug}?page=N(动态查询参数,原有行为)
    """
    try:
        page = int(page or 1)
    except (TypeError, ValueError):
        page = 1
    if Setting.get('seo_rewrite_enable') == 'on':
        if page <= 1:
            return f'/{column.slug}.html'
        return f'/{column.slug}-{page}.html'
    return url_for('frontend.column_detail', slug=column.slug, page=page)

真正刺激的是路由解析。slug 里允许带横杠,那 dash-2024-tester-2.html 到底是"名为 dash-2024-tester 的栏目第 2 页",还是"名为 dash-2024-tester-2 的栏目第 1 页"?正则的顺序和贪婪度调了好几轮,最后定下两条铁律:article- 静态前缀规则的优先级必须高于分页规则;含 -数字 的 slug 先整体当 slug 查库,查不到才切分页后缀。 分页缓存也必须按页独立,防止第 1 页和第 2 页串内容。

v2.1.1 干了件"社死但正确"的事:环境变量前缀从项目早期拼错的 ZHOCMS_ 统一改成 ZHYCMS_(ZHYCMS_ENV、ZHYCMS_SECRET_KEY、ZHYCMS_DB_URI)。旧前缀保留兼容回退,但丑话说在前面:未来主版本必删。另外把初始化向导里的数据库选项重排了------MySQL/PostgreSQL 置顶并标注"生产推荐",SQLite 标注"仅开发/测试",旁边红字提示 SQLite 并发写入风险。


四、v2.2:插件优先------把"什么都想加"关进笼子

到 v2.1 为止,所有功能都长在核心里。每接一个客户需求,核心代码就胖一圈。v2.2 做了一个架构层面的决定:插件优先(Plugin First)------除了内容管理最内核的东西,其他一切优先做成插件。

一个插件长什么样

一个插件就是 plugins/<slug>/ 目录 + 一份 manifest.json + 一个继承 PluginBase 的类。基类把插件能声明的东西列得明明白白:

python 复制代码
# app/plugin_api.py
class PluginBase:
    """插件基类:子类按需覆写钩子方法。"""

    # ---- 元数据(与 manifest.json 保持一致,以类属性为准) ----
    slug = ''
    name = ''
    version = '1.0.0'
    min_core_version = ''     # 核心版本低于此值时拒绝启用
    requires = []             # 启用前这些依赖插件必须已启用
    extends = ''              # 父插件 slug(基于谁二次开发)

    # ---- 声明式注册信息 ----
    permissions = []                 # [(code, label, desc)] 启用时幂等种子权限点
    preset_role_grants = {}          # {role_code: [perm_code, ...]} 给预设角色补授权
    audit_modules = []               # [(module_code, label)] 审计筛选下拉聚合

    # ---- 代码钩子(节选) ----
    def get_migration_files(self): ...   # v2.4:插件自带 Alembic 迁移
    def get_storage_drivers(self): ...   # v2.4:注册云存储驱动
    def get_search_provider(self): ...   # v2.5.2:插件内容进全文搜索
    def get_builder_widgets(self): ...   # v3.0:注册页面构建器组件
    def get_builder_layouts(self): ...   # v3.0:贡献默认布局片段
    def get_frontend_guard(self): ...    # v2.6.4:前台访问守卫(会员鉴权)

插件能注册的东西几乎无所不包:蓝图、模板函数、后台菜单、API 端点、数据表、sitemap、演示数据钩子、审计模块。但真正的设计精华是启停机制 ------启动时全量导入所有插件并完成注册,运行时只用一个设置项 enabled_plugins 做门控:

python 复制代码
# app/plugin_system.py
SETTING_KEY = 'enabled_plugins'

def enabled_slugs():
    """读取当前启用插件 slug 集合(带 60s 缓存)。"""
    cached = cache.get(_ENABLED_CACHE_KEY)
    if cached is not None:
        return cached
    raw = Setting.get(SETTING_KEY, '') or ''
    slugs = {s.strip() for s in raw.split(',') if s.strip()}
    try:
        cache.set(_ENABLED_CACHE_KEY, slugs, timeout=_ENABLED_CACHE_TTL)
    except Exception:
        pass                          # 缓存失败不影响功能
    return slugs

门控体现在三个层面:插件的视图函数进去先查启用清单,禁用直接 404;菜单聚合时跳过禁用插件;模板函数包一层守卫,禁用时返回空值------所以前台模板里写着 {{ banner_items() }},禁用轮播图插件后页面也不会报错,只是那块区域安静地消失。

这个设计白送了三个好处:启停只改一个设置值,即时生效、无需重启;天然兼容多 worker(所有进程都读同一份设置,没有进程内状态);禁用是可逆的 ------禁用只移除清单,不删表不丢数据,客户手抖关错了,再开就行。启用时才做三件事:种子权限点 → 给预设角色补授权 → db.create_all() 补建插件表。

模板解析也定了三级链:主题覆盖 → 插件自带 → default 主题兜底,任何一层缺失都不会白页。

上传插件?先过八关

允许上传第三方插件压缩包,等于允许别人往你的服务器上写代码。所以上传校验做成了流水线,任何一步失败,整包拒绝:

markdown 复制代码
1. 扩展名白名单(仅 .zip / .tar.gz / .tgz)
2. 压缩包损坏检测(打不开直接拒)
3. 路径穿越预检(条目名含 ../、绝对路径 → 拒)
4. 解压后 realpath 二次校验(防符号链接绕过预检,symlink 静默跳过)
5. manifest.json 必须是合法 JSON 且含 slug
6. slug 必须符合 ^[a-zA-Z0-9_-]{2,32}$
7. __init__.py 必须存在
8. 禁止覆盖已存在插件(先备份并删除旧目录再传)

全程在临时目录操作,任何失败都不写入 plugins/,finally 里彻底清理临时文件。主题上传是同款思路的 8 步校验,还额外要求必备模板(index/list/article/page/base/404/500.html)全量存在,缺一个都不许启用;8 套内置主题的 manifest 里标了 builtin: true,禁止覆盖、禁止删除。

危险操作(删除插件/主题)还配了独立的一次性图形验证码:弹窗里必须渲染验证码图片和输入框,不先请求验证码端点就提交,必然提示"验证码已过期"------这条经验后来在删除确认弹窗上又救过一次场,那是 v3.1.1 发布日补丁的故事了。

"核心功能插件化"的迁移艺术

v2.2 还干了件容易挨骂的事:把友情链接从核心挪进了插件。后台路由没了?菜单项没了?老站不炸才怪。最后的迁移方案是"四不变"原则:

  • 表名不变 (friend_links)------数据零搬迁;
  • 审计模块代码不变 (friend_link)------历史日志翻译正常;
  • 后台路由路径与端点名不变 (插件蓝图挂同一个 admin_bp)------书签和肌肉记忆零变化;
  • 设置键不变------通知配置、历史数据全部保留。

老站升级后首次启动自动启用一次友链插件,用 friend_link_plugin_migrated 这个 Setting 标记保证只做一次,新装站点不触发。前台模板统一改成调用插件全局函数,核心自动包裹启用守卫。后来 v2.3 的表单插件化用的是同一套配方,再后来 v3.1.0 的数据库迁移、各种核心/插件边界调整,"四不变"成了标准动作。

同期 REST 内容 API 也上线了:/api/v1 蓝本,统一响应包 {code, message, data, meta},带总开关、Token 鉴权和 CORS 白名单。当时觉得鉴权写得挺稳------这个错觉在 v3.1.1 被一个高危 issue 狠狠干碎,后面细说。


五、v2.3:双语世界与"插件独立翻译域"

企业客户有出海需求,v2.3 引入 Flask-Babel 做界面国际化(i18n 1.0,此时只管界面文案,内容数据的多语言是 v2.5 的事)。Locale 选择搞了一条完整的优先级链:URL 的 ?lang=xx(一次性,写回 session)→ session → cookie → Accept-Language 自动匹配 → 默认 zh 兜底。后台有可视化配置页:总开关、默认语种、可用语种清单,改动写审计。

这里有个关键克制:i18n 默认关闭,关闭时全站按中文渲染,与 v2.2 行为完全一致。 做企业软件,"默认行为不变"这六个字值得贴在显示器上------任何新功能的默认状态都应该是"不装不知道、装了吓一跳"的反面:不开就和没有一样。

但真正有意思的工程问题是:插件的文案怎么翻译?

如果所有插件的译文都塞进核心的 messages.po,那插件就无法独立打包分发------第三方开发者做个插件还得给核心提翻译 PR,荒谬。方案是插件独立翻译域:

javascript 复制代码
plugins/<slug>/translations/<lang>/LC_MESSAGES/messages.po
模板里:{{ _p('banner', '轮播图') }}
Python 层:self._('轮播图')

每个插件自带译文目录,核心渲染时按插件 slug 找对应翻译域;插件启停不影响核心译文,打包时译文随目录一起走,装到别的站点上译文立即可用。

同期还加了两套英文主题(default_en、manufacturing_en),并做了一个对成交帮助极大的联动:安装向导里选英文制造业演示数据时,系统会自动开启 i18n(i18n_enable=1、默认 en、可用 zh,en)、自动启用 banner/product/friend_link/form 四个插件,并生成全英文的栏目、文章、产品、轮播图、友链和表单------客户装完第一眼看到的就是一个完整的英文企业站,而不是一堆"请添加内容"的空壳。

这个周期还修了一个 .gitignore 引发的悬案:app/static/uploads/ 整个被 ignore,结果 demo/ 目录下 15 张演示图片进不了仓库,客户装完首页全是裂图。改成 uploads/* 加一行白名单 !uploads/demo/ 才解决。ignore 规则也是代码,也得 review,也会出生产事故。


六、v2.4:四天塞下四大件

9 月 4 日,又一个大版本,一口气上了四个大件。

6.1 Alembic:终于有了正经迁移框架

裸 SQL 迁移脚本的日子过够了,引入 Flask-Migrate(封装 Alembic)。但历史包袱摆在眼前:已经存在的老库怎么办?让客户手写 flask db stamp head?不现实。

方案是启动时自动探测:发现库中没有 alembic_version 表(即 v2.3.0 及更早的库),自动 stamp 一个 baseline(基线迁移 0001 标记的就是 v2.3.0 的完整 schema),此后只执行增量迁移。老站升级零手动操作,新库则从 0001 正常走完全程。插件也不被遗忘:PluginBase.get_migration_files() 把插件 migrations/versions/ 下的脚本自动合并进 Alembic 的 version_locations;没有迁移文件的插件仍由 db.create_all() 兜底建表。考虑到 SQLite 不能直接 ALTER 表的限制,全局开启了 render_as_batch=True。

6.2 全文搜索:纯 Python 方案 + 故障自动降级

默认 Whoosh(纯 Python,零外部服务)+ jieba 中文分词,索引标题、去 HTML 正文、摘要和栏目名,支持相关度排序;大站可选 Meilisearch,后台一键切换。关键设计是降级链:Whoosh 故障时自动退回 SQL LIKE,搜索功能绝不中断 。文章保存/删除时通过统一的 clear_content_cache 钩子更新索引------这个钩子后来被复用了无数次(页面缓存、搜索索引、栏目树缓存、插件索引......),是全项目最忙的函数,v3.1.3 还在给它加戏。

6.3 Docker:多阶段、非 root、健康检查

Dockerfile 采用多阶段构建,builder 阶段装依赖编译轮子,runtime 阶段基于 python:3.12-slim 只拷贝成品,以非 root 用户跑 gunicorn。容器入口 entrypoint.sh 负责一条完整的启动链:等待数据库就绪 → 执行数据库迁移 → 恢复被 volume 挂载遮盖住的演示图片 → 编译 i18n 翻译 → 启动 gunicorn。还新增了 /healthz 健康检查端点(DB ping + JSON),并且特意豁免初始化拦截------容器探针在安装向导都没跑完时也得能探活,不然编排系统会把还没配置的新站反复杀掉。

6.4 OSS:抽象层先行,默认行为不变

对象存储没有直接写进核心,而是先在核心定义了存储驱动协议,再由官方插件 oss_storage 提供阿里云 OSS / 腾讯云 COS / 七牛云 Kodo 三家实现:

python 复制代码
# app/utils/storage.py
class StorageDriver:
    """存储驱动基类。云驱动由 oss_storage 插件实现并注册。"""
    name = ''
    keeps_local_copy = True       # 本地驱动 True,云驱动 False

    def save(self, local_path, key): ...      # 发布文件,返回 URL
    def save_bytes(self, data, key): ...      # 字节流上传(子类可直接对接 SDK)
    def delete(self, key): ...
    def exists(self, key): ...
    def public_url(self, key): ...
    def health_check(self): ...               # 连通性自检 (ok, message)

上传唯一入口 save_upload_file() 改成"先在本地完成校验/压缩/缩略图,再发布到当前驱动"。云 SDK 全部懒加载,没装 oss2 之类的包时配置页给出安装指引,不拖慢不用云存储的站点。三条设计决策值得一说:默认本地驱动,行为与旧版完全一致 ;uploaded_files 表新增 storage 列记录每个文件归属哪个驱动,切换厂商后旧文件 URL 不失效;驱动注册按"归属插件"门控,插件禁用后其驱动自动回退本地,而不是写死某个插件名。


七、v2.4.1:安全审计教我们做人

v2.4 发布第二天,安全审计报告甩在桌上,8 个问题,第一个就是严重(Critical)。

硬编码默认 SECRET_KEY(CWE-798)

app/config.py 里有一个写死的回退密钥,而源码是公开的。Flask 的默认 session 是客户端签名 Cookie------知道密钥就能离线伪造任意管理员会话,不需要爆破,不需要请求,拿着伪造好的 Cookie 直接进后台。

修复后的密钥解析是三级优先,且每一级都不回退到硬编码:

python 复制代码
# app/config.py(节选)
# 1) 环境变量 ZHYCMS_SECRET_KEY 优先
if env_key:
    return env_key
# 2) instance/secret_key 持久化文件
key_path = os.path.join(BASE_DIR, 'instance', 'secret_key')
try:
    if os.path.exists(key_path):
        with open(key_path, 'r', encoding='utf-8') as f:
            key = f.read().strip()
        if key:
            return key
except OSError:
    pass
# 3) 首次启动自动生成并落盘(权限 600)
key = secrets.token_hex(32)
try:
    os.makedirs(os.path.dirname(key_path), exist_ok=True)
    with open(key_path, 'w', encoding='utf-8') as f:
        f.write(key)
    os.chmod(key_path, 0o600)
except OSError:
    # 无法落盘时也使用本次随机密钥(至少不用硬编码值),仅影响重启后会话失效
    pass
return key

代价是存量部署升级后旧会话全部失效,所有人重新登录一次。但这根本不算代价------这是续命。

搜索高亮:转义与包裹的顺序之争

高危问题第二个:搜索结果页存储型 XSS(CWE-79)。文章标题是作者可以随意填的,如果标题里塞 <script>,而搜索高亮过滤器为了把关键词包进 <mark> 直接对原文做字符串替换、模板里再 |safe 渲染,脚本就执行了。修复原则后来被抽象成全项目处理富文本/纯文本的标准动作------先整体转义,再只给转义后的匹配文本包标签,顺序永远不能反过来:

python 复制代码
# app/utils/helpers.py
@app.template_filter('highlight')
def highlight_filter(value, keyword=''):
    """安全修复(v2.4.1):先对原文与关键词做 HTML 转义,
    再仅对转义后的匹配文本包裹 <mark>,杜绝存储型 XSS。"""
    from markupsafe import escape, Markup
    if not value or not keyword:
        return value or ''
    import re as _re
    safe_value = escape(str(value))
    safe_keyword = escape(str(keyword))
    pattern = _re.escape(str(safe_keyword))
    result = _re.sub(
        pattern, f'<mark>{safe_keyword}</mark>', str(safe_value),
        flags=_re.IGNORECASE
    )
    return Markup(result)

开放重定向:// 开头的 URL 不是相对路径

登录和语种切换的 next 参数会原样 302,传 //evil.com 时浏览器会按当前协议跳去 evil.com------钓鱼链接可以长得和你家站点一模一样。修复不长,但条件一个都不能少:

python 复制代码
# app/admin/auth.py
# 拒绝协议相对 URL(如 //evil.com),只允许站内相对路径
next_url = request.args.get('next') or ''
if (not next_url
        or not next_url.startswith('/')
        or next_url.startswith('//')
        or next_url.startswith('/\\')):
    next_url = url_for('admin.dashboard')
return redirect(next_url)

同批修复的还有:run.py 硬编码 debug=True(CWE-489,生产环境开 Werkzeug 调试器约等于裸奔,改为跟随配置类);Cookie 显式声明 SameSite=Lax、生产环境 Secure;全站 after_request 统一注入安全响应头基线(X-Frame-Options、nosniff、Referrer-Policy、CSP 含 frame-ancestors 'self');备份恢复文件强制落在非 Web 目录 instance/backups 且恢复完立即删除;表单导出 Excel 防公式注入(= + - @ 开头的单元格前置单引号,CWE-1236)。

这一天学到的最大一课:安全配置不能有"默认值能跑就行"的心态,默认值必须是安全的(secure by default)。 这句话在 v3.1.1 还要再复习一次,而且是更疼的方式。


八、v2.4.2:安全加固会"反伤",Docker 坑也有连环套

安全策略不是写上去就完事了,v2.4.2 深刻体会了什么叫加固反伤。

CSP 把自己的静态资源全拦了

v2.4.1 上了严格 CSP(script-src/style-src 'self'),第二天后台直接"裸奔":AdminLTE、FontAwesome、jQuery、icheck-bootstrap,外加 6 套前台主题的 Bootstrap,全走 jsdelivr CDN,全被浏览器按 CSP 拦在门外。

团队面临一个选择:放宽 CSP 把 cdn.jsdelivr.net 加白,一劳永逸;或者把资源全部本地化,保持 CSP 严格。最后选了难的那条:新增 app/static/vendor/ 目录,把全部第三方静态资源拉回自己服务器,9 个模板共 36 处外链逐一改成 url_for('static') 本地引用,CSP 一个字符都不放宽。顺带还治了 CDN 抽风断供和 Referer 外带两个隐忧。

CSRF 第二道防线:同源校验

光有 SameSite=Lax 不够------老浏览器根本不认识 SameSite,同站子域场景也有缺口。v2.4.2 增加了 Origin/Referer 同源校验钩子,与 Cookie 属性形成纵深:

python 复制代码
# app/__init__.py
@app.before_request
def _csrf_same_origin_check():
    if request.method not in ('POST', 'PUT', 'PATCH', 'DELETE'):
        return
    # REST 内容 API 使用 X-API-Token 头鉴权(不依赖 Cookie),豁免
    if request.path.startswith('/api/'):
        return
    source = request.headers.get('Origin') or request.referrer
    # 非浏览器客户端(curl/SDK/脚本)无 Origin/Referer:放行------
    # 此类请求无法被跨站页面诱导发起,且已有 SameSite=Lax 兜底
    if not source:
        return
    source_host = (urlparse(source).netloc or '').lower()
    if source_host and source_host != (request.host or '').lower():
        abort(403, description='跨站请求被拒绝(CSRF origin check failed)')

注意"无头请求放行"这个决定:没有 Origin/Referer 的请求来自 curl/SDK 这类非浏览器客户端,跨站页面无法诱导它们发出,硬拦只会误伤正常集成。安全策略的边界要画在"攻击实际发生的路径"上,而不是为了数字好看一刀切。

Docker 连环套

容器化上线后坑是成套出现的。第一坑:PyMySQL 连 MySQL 8 默认的 caching_sha2_password 认证需要 cryptography 库,缺了它 worker 启动即崩,补上依赖只是第一步。第二坑:容器不能以 root 跑应用,entrypoint 要先用 root 修正 volume 挂载目录的属主,再通过 gosu 降权执行。第三坑最有代表性------容器里向导填 localhost 连库失败 :初始化向导让用户填数据库主机,用户很自然地写 localhost,可容器里的 localhost 是容器自己,不是宿主机。修复思路后来成了通用原则:

检测到 ZHYCMS_DB_URI 环境变量时,向导直接隐藏数据库配置区,POST 完全忽略数据库表单字段。环境已经替你决定的事,就别再问用户。

还修了维护模式页面 500:关站模板渲染时漏传 seo 变量,而主题 base.html 的 meta keywords 依赖它,结果一关站所有页面反而报 UndefinedError。上线前要把"每个错误路径"本身也测一遍------503 关站页自己不能 500。


九、v2.5:出海刚需------内容级多语言与 Redis

v2.3 的 i18n 只翻译界面按钮,客户真正要的是"同一篇文章有中文版和英文版"。v2.5 上了内容级多语言:核心新增 article_translations、column_translations、fragment_translations 三张翻译关联表,插件侧产品、表单、招聘、友链四张翻译表跟进。主表保留默认语言字段(冗余但简单,列表查询不用 join),翻译表存非默认语言,查不到时回退主表。

前台模板侧只加一个全局函数 t(obj, field),6 套主题 44 个模板逐一接入。它的实现把所有边界情况都考虑到了:dict 形态的碎片值、i18n 关闭态、目标语言即默认语言、翻译存在但为空......

python 复制代码
# app/utils/i18n_content.py
def t_field(obj, field, locale=None):
    """取对象的多语言字段值;无翻译或为空时 fallback 主表默认语言字段。"""
    if isinstance(obj, dict):                 # Fragment.get_dict() 形态
        return obj.get(field, '')
    if not i18n_enabled():                    # i18n 关闭:直接回主表
        return getattr(obj, field, None)
    target = locale or _safe_current_locale()
    if target == get_default_locale():        # 默认语言:主表即权威
        return getattr(obj, field, None)
    translations = getattr(obj, 'translations', None)
    if translations:
        for tr in list(translations):
            if getattr(tr, 'locale', None) == target:
                val = getattr(tr, field, None)
                if val not in (None, ''):
                    return val
    return getattr(obj, field, None)          # 没翻译?回退默认语言,绝不留空

两个冷静的设计决定值得划重点。第一,slug 不随语言变化 ------URL 永远稳定,切语言只切内容,SEO 和外链不会分裂。第二,页面缓存键必须加 locale ------中文缓存和英文缓存物理隔离,绝不串页。串页缓存这类亏我们已经吃过一次:v2.6.3 之前切换主题不清缓存、缓存键又不含主题标识,旧主题渲染的 HTML 在 TTL 窗口里继续命中,客户切完主题刷新一看还是旧皮肤。后来的规矩是双保险:切主题无条件 cache.clear(),缓存键再追加 |theme=<slug> 纵深防御,多 worker 或清理失败时也不串。

Redis 的接入同样克制,不做运行时开关,启动时探测一次即可:

python 复制代码
# app/__init__.py
def _resolve_redis_url(app):
    """REDIS_URL 有值 → 尝试 ping,可达则启用,不可达回退 SimpleCache。
    无环境变量 → None(SimpleCache + Cookie Session)。"""
    env_url = os.environ.get('REDIS_URL', '').strip()
    if not env_url:
        return None
    try:
        conn = _redis.from_url(env_url, decode_responses=False)
        conn.ping()
        return env_url
    except Exception as e:
        app.logger.warning(f'[Redis] 连接失败({e}),回退 SimpleCache')
        return None

配上 Redis 就启用 RedisCache + Flask-Session 服务端会话(支撑多实例负载均衡,也降低 XSS 窃取会话的风险);不配就回退单机零依赖组合。后台只读状态页展示探测结果,故意不提供手动开关------运行时在两套 session 机制之间横跳,比没有 Redis 危险得多。


十、v2.5.1 ~ v2.5.2:让搜索和翻译也"说人话"

v2.5.1 解决一个细想很合理、不细想会漏掉的问题:开了英文站后,用英文关键词搜不到英文文章。原因是索引只有一套。修复后每个 locale 一份独立索引(Whoosh 按 instance/search_index/<locale>/ 分目录,Meilisearch 按索引名分),每篇文章的每种语言都写一份文档,写入内容经 t_field 解析------有翻译用翻译,没翻译回退默认语言,与前台渲染表现完全一致:未翻译的文章不会因为用户切了语言就"搜不到了"。

v2.5.2 的一键翻译插件支持百度/有道/Google/DeepSeek 四家(DeepSeek 走 OpenAI 兼容协议,长文排版保持最好,也能改地址接通义/智谱/Kimi),长文按块级标签和换行的安全边界自动分片,规避各家单请求长度限制,译文只回填不自动保存,留给人工校对。

这个版本顺带做了两件影响深远的小事。其一,抽象出 SearchProvider 协议:插件通过 get_search_provider() 钩子把自有内容(产品、岗位)送进全站搜索,索引文档用 uid = 类型:id 加 type 字段区分,各类型不再互相覆盖,搜索结果还能按类型生成正确的详情链接。其二,启动时自动编译翻译 :发现 messages.mo 缺失或落后于 .po 就自动编译。此前 .mo 不入库,忘了本地编译就发版,英文站全站回退中文,这种"我本地明明好好的"事故值得用一劳永逸的自动化终结。


十一、v2.6 系列:三天集中还债,测试从 34 到 113

功能一路狂奔到 2.5,回头一看工程基建几乎为零:没有 CI,没有发布检查,常量散落各处,错误处理各写各的。2.6 的四个版本(09-10 到 09-12)就是集中还债。

v2.6.0 先立规矩。 RELEASE.md 发布 Checklist 把"版本一致性 → 迁移 → 翻译 → 文档 → 测试 → tag → 发布后验证"固化成流程,配 scripts/check_release.py 自动校验 CMS_VERSION / CHANGELOG / README / wiki.html 四处版本号一致。pytest 脚手架落地(隔离 SQLite + 临时索引目录 + session 注入登录),GitHub Actions 三 Python 版本矩阵(3.10/3.11/3.12)跑测试、发布检查、常量门禁三个 job。

统一异常处理接管 400/403/404/405/500 以及兜底 Exception,按请求路径分发三种协议------API 调用方要 JSON,后台管理员看后台错误页,访客看主题错误页,绝不能让 API 消费者收到一段 HTML:

python 复制代码
# app/errors.py
def _resolve_protocol():
    """判断当前请求应返回哪种协议:'api' / 'admin' / 'frontend'。"""
    path = request.path
    if path.startswith('/api/'):
        return 'api'
    # X-Requested-With: XMLHttpRequest 或 Accept: application/json
    if (request.headers.get('X-Requested-With') == 'XMLHttpRequest'
            or request.accept_mimetypes.best == 'application/json'):
        return 'api'
    if path.startswith('/admin'):
        return 'admin'
    return 'frontend'

常量治理同样是 v2.6.0 的硬仗:工作流状态、上传扩展名白名单、搜索引擎名、角色预设名......17 处硬编码字面量散落在 models、utils、admin、plugins 里,收编进零依赖的 app/constants.py(Workflow / Upload / Locales / Search / Roles / Pagination 命名空间),再加 scripts/check_constants.py 做 grep 门禁------已收编的裸字面量谁敢写回来,CI 直接红。

v2.6.1 理架构。 蓝图注册从 create_app 抽离;admin 按业务域拆成 content/ 和 system/ 子包;最重要的是引入 service 层------路由只做 HTTP 协议转换,表单校验、DB 写入、审计日志、缓存清理这些业务逻辑进 app/services/,且 service 函数不依赖 request/current_user 全局对象,数据全部参数显式传入,返回 (result, messages) 让调用方 flash。这个决定让后来的单元测试好写了十倍:测业务逻辑不用伪造整个 HTTP 请求。

v2.6.2 暴补测试 ,用例数从 34 跳到 113:RBAC 栏目级权限边界 17 项(无授权/单栏目/多栏目/全站授权矩阵 + 跨栏目数据隔离)、插件全生命周期 20 项(启用→使用→禁用→重启用,验证路由/菜单/模板函数/API 的注册与清理)、Alembic 迁移 9 项(版本图无分叉、up/downgrade 幂等、模拟生产 stamp baseline 再 upgrade)、API 契约 17 项、存储驱动 16 项。期间踩了个很阴的测试坑:flask-login 会在 g 上缓存登录用户,种子数据的 app context 没关干净,导致"明明登录了 A,current_user 却是 B",最后把种子流程移入独立 context 并及时关闭、先建权限点再建角色才根治。

v2.6.3 升级 API 鉴权。 单一静态 Token 升级为 JWT access/refresh 双 token(access 15 分钟、refresh 7 天),支持 token/jwt/both 三种模式双轨兼容,flask-limiter 限流上线(登录 5 次/分钟、只读 120 次/分钟,超限 429)。双轨模式的判断顺序很直白:

python 复制代码
# app/api/views.py
auth_mode = (Setting.get('api_auth_mode') or 'both').lower()
if auth_mode == 'token':
    return _check_api_token()
if auth_mode == 'jwt':
    return _check_jwt()
# both(默认):有 Bearer 就验 JWT,否则回退静态 Token
has_bearer = request.headers.get('Authorization', '').lower().startswith('bearer ')
if has_bearer:
    return _check_jwt()
return _check_api_token()

踩了个 JWT 新手坑:identity 必须是字符串,传整数直接抛 InvalidSubjectError,老老实实 str(user.id),额外信息放 additional_claims。

v2.6.4 上线前台会员体系 (member 插件)和插件依赖治理。会员表 members 与后台管理员完全隔离------独立 session 键 member_id,独立密码体系,两套登录态互不干扰。核心不直接认识"会员",而是通过 PluginBase.get_frontend_guard() 钩子解耦:插件返回 {'is_authenticated': callable, 'login_url': callable},核心才对 member_only 栏目做 302 跳登录;没装插件,核心一行鉴权代码都不执行。插件依赖同时上了 requires/extends/min_core_version 启用前强校验,禁用插件时反向检查依赖者并拒绝------依赖治理的规则顺序(最低版本 → requires → extends)和友好的错误提示,让插件生态第一次有了"包管理"的雏形。


十二、v3.0:可视化页面构建器------最大的一场豪赌

9 月 15 日,v3.0.0。自研拖拽式页面构建器:原生 HTML5 拖拽 + 组件注册表 + iframe 内服务端真实渲染(不是前端模拟个长得像的 DOM),覆盖首页/单页/列表页/文章详情四种全页面类型,24 个 Bootstrap4 组件,草稿/发布/修订回滚、中英双语文案、语言白名单与会员可见性、桌面/平板/手机三端预览、全局页面样式。开发周期一口气新增 139 个测试,总量到 280。

最关键的战略决策是双轨制:存在已发布布局的页面才走构建器渲染,其余全部继续渲染既有手写模板;删除构建记录立即回退,老站升级后零行为变化。这让"上构建器"从不可控的大爆炸变成了可灰度、可回退的开关。

安全上一点没敢放松。布局是用户在浏览器里拼出来的 JSON,服务端必须按白名单严格校验------类型未知、嵌套过深、节点过多、体积过大,统统 400:

python 复制代码
# app/builder/schema.py
def validate_layout(layout, check_widgets=True):
    """校验布局文档。成功返回规整后的 dict,失败抛 LayoutError。"""
    if not isinstance(layout, dict):
        raise LayoutError('布局必须是 JSON 对象')
    root = layout.get('root')
    if not isinstance(root, list):
        raise LayoutError('布局缺少 root 数组')

    payload = json.dumps(layout, ensure_ascii=False).encode('utf-8')
    if len(payload) > _B.MAX_PAYLOAD_BYTES:          # 256KB
        raise LayoutError(f'布局文档超过 {_B.MAX_PAYLOAD_BYTES // 1024}KB 上限')

    valid_types = set(_registry.keys()) if check_widgets else None
    count = 0
    def walk(node, depth, allowed):
        nonlocal count
        count += 1
        if count > _B.MAX_NODES:                     # 300 节点
            raise LayoutError(f'单页节点数超过 {_B.MAX_NODES} 上限')
        if depth > _B.MAX_DEPTH:                     # 8 层
            raise LayoutError(f'嵌套深度超过 {_B.MAX_DEPTH} 层上限')
        ntype = node.get('type')
        if valid_types is not None and ntype not in valid_types:
            raise LayoutError(f'未知组件类型:{ntype}')
        # ... props 白名单、select 双向类型、repeater 子字段、
        #     localized 语言键、visibility 语言/会员可见性逐一校验

预览端点 /admin/builder/preview/<id> 必须登录才可以访问,且只注入真实数据渲染、不产生任何匿名可访问的页面。插件生态也全面接入:插件可通过 get_builder_widgets() 注册自有组件,通过 get_builder_layouts() 向默认布局贡献片段------但片段启用前必须跑同一套 validate_layout,非法片段直接拒绝插件启用;合并快照时撞 id 自动加 owner 前缀重写,单个片段失败隔离跳过;插件卸载前扫描所有页面的布局引用,还有页面用着它的组件就拒绝卸载并列出引用清单。默认布局按"当前启用主题 fixture → 核心回退 → 插件片段"三层合并,而且只做一次性快照------后续主题切换、插件停启,绝不改写用户已有的草稿和已发布页面。


十三、v3.1.0:数据库迁移做成插件,顺便硬扛 MySQL 5.6

"本地 SQLite 开发,上线切 MySQL/PostgreSQL"是刚需,但没人愿意天天在核心里维护迁移逻辑。于是 db_migrator 成为默认不启用的官方插件,权限点默认不授予任何预设角色,只有超管能用。

迁移流程是一条教科书式的数据管道:目标库建表 → 按外键依赖拓扑序逐表复制数据 → PostgreSQL 重置自增序列 → 同步 Alembic 版本号 → 可选热切换引擎免重启。拓扑序和单事务是两个关键:

python 复制代码
# plugins/db_migrator/migrate.py
# 逐表复制:sorted_tables 已按外键依赖拓扑排序(父表先于子表);
# 整批复制在一个事务里,任一表失败整体回滚,目标库不残留半截数据
with src_engine.connect() as sc, tgt_engine.begin() as tc:
    for table in metadata.sorted_tables:
        count = _copy_table(sc, tc, table)          # 内部每批 500 行
        summary.append({'table': table.name, 'rows': count})

目标库非空保护是另一条硬规矩:同名表只要有一行数据就拒绝迁移,必须显式勾选"覆盖目标库已有数据"才先 drop 再建;迁移前还要先测连通性。同步 Alembic 版本号是为了让目标库重启后被识别为"已管理",直接 upgrade head 无增量,避免对着 create_all 建出来的完整 schema 重复执行中间迁移而报错。

MySQL 5.6 的 1709 暴击

这是整个周期最"考古"的 bug。低版本 MySQL 5.6 使用 Antelope 行格式、innodb_large_prefix=OFF,utf8mb4 编码下单列索引前缀上限只有 767 字节。我们照惯例把字符串列建成 255 字符(255×4=1020 字节),凡是带索引或唯一约束的列,建表即报错:

arduino 复制代码
ERROR 1709 (HY000): Index column size too large. The maximum column size is 767 bytes.

修复不是去要求客户改数据库参数,而是在迁移建表前对 metadata 动个小手术------把所有带索引/唯一约束的超长字符串列统一收口到 191(191×4=764,卡线过关):

python 复制代码
# plugins/db_migrator/migrate.py
from sqlalchemy import String, UniqueConstraint
for table in metadata.tables.values():
    indexed_keys = set()
    for idx in table.indexes:
        indexed_keys.update(c.key for c in idx.columns)
    for cons in table.constraints:
        if isinstance(cons, UniqueConstraint):
            indexed_keys.update(c.key for c in cons.columns)
    for col in table.columns:
        if not (col.index or col.unique or col.key in indexed_keys):
            continue
        length = getattr(col.type, 'length', None)
        if isinstance(col.type, String) and length and length > 191:
            col.type.length = 191

都 2026 年了还要兼容 2013 年的数据库默认配置------文档里写多少遍"请使用 MySQL 5.7+",都不如报错弹一次脸印象深刻。能在代码里自动收口的,就不要把负担甩给运维。


十四、v3.1.1:两个高危,脸有点疼

10 月 11 日,安全报告指出两个高危(H1/H2)。其中一个打在我们 v2.2 就上线、一直自以为稳的内容 API 上。

H1:API 默认免鉴权(fail-open)

当年的令牌校验逻辑是:配置了令牌就校验,没配置就匿名放行 。问题是默认安装的 api_token 本来就是空的------两个"合理"的默认值凑在一起,结论荒诞:任何开箱即用的站点,/api/v1/* 全部内容端点对全世界匿名敞开,草稿之外的栏目、文章随便拉。

修复只有一句话,但这句话值得刻在墙上:未配置令牌时一律 401(fail-closed),而不是放行。

python 复制代码
# app/api/views.py
def _check_api_token():
    """静态令牌校验:X-API-Token,恒时比较。
    v3.1.1 fail-closed:未配置令牌时默认拒绝,避免默认安装全 API 匿名可读。"""
    token = Setting.get('api_token') or ''
    provided = request.headers.get('X-API-Token', '')
    if not token:
        if not provided:
            return api_err(401, 'API 未配置访问令牌,请在后台「内容 API」中生成')
        return api_err(401, 'API Token 无效或缺失')
    if not provided or not hmac.compare_digest(token, provided):  # 恒时比较防时序侧信道
        return api_err(401, 'API Token 无效或缺失')
    return None

api_enable=off 时则全端点返回 404------对外的语义是"这功能不存在",而不是"功能在但你没权限",减少暴露面。

H2:全站缺一个和 Session 绑定的 CSRF Token

v2.4.2 的 Origin/Referer 校验有它的覆盖盲区,且本质上依赖浏览器行为。v3.1.1 补上同步 Token 机制:Token 存在用户 Session 里,表单和 AJAX 都必须携带,服务端恒时比较:

python 复制代码
# app/utils/csrf.py
def generate_csrf_token():
    """取当前 Session 的 CSRF Token,没有则生成并写入。"""
    token = session.get('_csrf_token')
    if not token:
        token = secrets.token_urlsafe(32)
        session['_csrf_token'] = token
    return token

def validate_csrf():
    """before_request 钩子:状态变更请求强制校验。"""
    if request.method not in ('POST', 'PUT', 'PATCH', 'DELETE'):
        return
    if request.path.startswith('/api/') or request.path.startswith('/healthz'):
        return                                    # 头鉴权/健康检查豁免
    expected = session.get('_csrf_token')
    if not expected:
        # 首个匿名 POST(如登录表单第一次提交):放行并借机建立 Token
        generate_csrf_token()
        return
    provided = (request.form.get('_csrf_token')
                or request.headers.get('X-CSRF-Token', ''))
    if not provided or not hmac.compare_digest(expected, provided):
        abort(403, description='CSRF 校验失败,请刷新页面重试')

落地工作量在"最后一公里":所有前后台 base 模板注入 <meta name="csrf-token"> 和一段全局捕获脚本,普通表单提交自动加 _csrf_token 隐藏字段,jQuery 的 $.post/$.ajax 全局加 X-CSRF-Token 头;但项目里还有一堆手写 fetch/XHR(Word 导入、CKEditor 图片上传、构建器保存/发布/回滚、一键翻译),这些只能逐处补头,漏一个就是一个 403。与 v2.4.2 的同源校验同时保留,两层机制互为纵深 。测试基建也顺带升级:client fixture 统一自动携带 CSRF Token 和 API Token,后来 v3.1.2 全部安全用例都建立在这个 fixture 上。

发布日补丁与三平台的脾气

v3.1.1 发布当天又追了四个修复,tag 强制移到新提交、三个平台的 Release 删了重建:后台登录 CSRF 要用独立模板;迁移 1709 收口;删除确认弹窗必须渲染验证码图片(不先请求验证码端点,提交必提示"验证码已过期");审计日志清理前后端字段错位------前端下拉传 days,后端读 keep_days,用户选了"清空全部"(0 值特殊逻辑),页面却提示"已清理 90 天前的审计日志,共删除 0 条",修复时新旧参数都兼容、0 值单独走清空分支,并把清理动作本身记成一条 clear_all 审计。

这次操作还摸清了三个代码托管平台 API 的脾气,全记进了备忘录:Gitee 建 Release 的 target_commitish 必须全量哈希,短哈希直接 422;GitHub 上 tag 已存在时创建 Release 不要带 target_commitish;GitCode 的 Release 不能删除也不暴露 id,只能 PATCH 原地更新 body,target 跟随 tag 自动移动。发版流程里每一个平台的坑都值得文档化,下次就不必再用一次失败的 API 调用交学费。


十五、v3.1.2:中危五连,专治"不起眼但能要命"

GitCode issue #2 列了五项(M1-M5),全是代码 review 时一眼扫过去不会停、但扫描器和真实攻击不会客气的地方。

M1:一个裸 int() 引发的 500

文章、栏目、碎片管理的视图里,sort_order、page_size、parent_id、分页 page 这些参数到处直接 int(...)。用户手滑或扫描器提交个 ?page=abc,ValueError 原地爆炸,前台/后台 500。统一收口成一个 8 行的工具函数,再扫遍全仓库替换所有裸调用点,分页类参数外面再套 max(1, ...) 拒收负数:

python 复制代码
# app/utils/helpers.py
def safe_int(value, default=0):
    """安全整数转换(v3.1.2 M1):非数字/空串回退默认值,杜绝 ValueError→500。"""
    if value is None:
        return default
    try:
        return int(str(value).strip())
    except (TypeError, ValueError):
        return default

# 调用点示例:
article.sort_order = safe_int(form_data.get('sort_order'), 0)
page = max(safe_int(request.args.get('page'), 1), 1)

收口范围包括 article_service、column_service、碎片管理、前后台文章列表、搜索页、构建器数据组件------一个都不能漏,因为这类函数的价值就在于"最后一个裸调用点被替换掉"。

M2:上传子目录路径穿越

通用上传接口的 type 参数会被拼进存储路径 os.path.join(UPLOAD_FOLDER, sub_dir, date_dir)。type=../../../tmp 会发生什么,不用多解释。修复是白名单加段正则,而且在上传入口和 save_upload_file 内部各校验一次------纵深防御的意义在于,以后任何人新增上传调用方,都绕不开内部这道关:

python 复制代码
# app/utils/uploads.py
ALLOWED_SUB_DIRS = frozenset({
    'common', 'image', 'file', 'avatar', 'editor', 'article', 'theme',
    'site', 'column', 'fragment',
})
_SAFE_SUB_SEGMENT_RE = re.compile(r'^[a-zA-Z0-9_-]{1,32}$')

def _safe_sub_dir(sub_dir):
    """1. 空值 → 'common'
       2. 剥离两侧 / 与 \\
       3. 含 '..' 段或绝对路径特征 → 'common'
       4. 首段必须在白名单,其余段匹配段格式正则
       5. 任一不通过 → 'common'"""
    if not sub_dir:
        return 'common'
    s = str(sub_dir).strip().replace('\\', '/').strip('/')
    if not s or '..' in s.split('/'):
        return 'common'
    parts = [p for p in s.split('/') if p]
    if not parts or parts[0] not in ALLOWED_SUB_DIRS:
        return 'common'
    if any(not _SAFE_SUB_SEGMENT_RE.match(p) for p in parts[1:]):
        return 'common'
    return '/'.join(parts)

M3:CKEditorFuncNum 反射型 XSS

富文本编辑器上传图片后,服务端要返回一段 <script> 调回父窗口的 CKEditor,回调编号 CKEditorFuncNum 是用户可控的。老代码直接字符串拼接,传入 </script><script>alert(1)</script> 一气呵成。修复双管齐下:编号只接受 1-6 位纯数字,URL 和错误信息进 JS 字符串前全部 HTML 转义:

python 复制代码
# app/admin/upload.py
_CKEDITOR_FUNC_NUM_RE = re.compile(r'^\d{1,6}$')

func_num = (request.args.get('CKEditorFuncNum')
            or request.form.get('CKEditorFuncNum') or '').strip()
if func_num and not _CKEDITOR_FUNC_NUM_RE.match(func_num):
    return jsonify({'error': '参数无效'}), 400

# 失败回调(err 转义后再拼)
if func_num:
    return (
        f'<script>window.parent.CKEDITOR.tools.callFunction('
        f'{func_num}, "", "{html.escape(err, quote=True)}");</script>'
    ), 200
# 成功回调(file_url 同样转义)

M4:SVG------能画矢量图,也能装 JavaScript

LOGO 上传允许 svg 扩展名,而 SVG 本质是 XML,里面可以内嵌 <script>、事件处理器甚至 <foreignObject>,配合当时相对宽松的 CSP,就是一个现成的存储型 XSS 载体。修复打的是组合拳。第一道:默认 upload_allow_svg=off,后台"上传安全"页新增开关,历史上允许 SVG 的白名单表单改为按开关动态决定。第二道,开启后内容嗅探 + 大小上限:

python 复制代码
# app/utils/uploads.py
if ext == 'svg':
    if Setting.get('upload_allow_svg') != 'on':
        return None, None, 'SVG 上传默认关闭,请到「上传安全」开启后再上传'
    SVG_MAX_SIZE = 512 * 1024
    if size > SVG_MAX_SIZE:
        return None, None, f'SVG 文件过大(最大 {SVG_MAX_SIZE // 1024} KB)'
    file_storage.stream.seek(0)
    head = file_storage.stream.read(SVG_MAX_SIZE).decode('utf-8', errors='ignore').lower()
    file_storage.stream.seek(0)
    danger_tokens = ('<script', 'onload=', 'onclick=',
                     'href="javascript:', "href='javascript:",
                     '<foreignobject', 'onerror=', 'onmouseover=')
    if any(tok in head for tok in danger_tokens):
        return None, None, 'SVG 含危险脚本片段,已拒绝上传'
    allowed_exts = list(allowed_exts) + ['svg']

第三道是兜底------哪怕是上线前就存在于服务器上的存量 SVG,响应出去时也要关进沙箱。在全站安全响应头钩子里对 .svg 请求特别处理:

python 复制代码
# app/__init__.py
if req_path.endswith('.svg'):
    response.headers['Content-Type'] = 'image/svg+xml; charset=utf-8'
    response.headers['X-Content-Type-Options'] = 'nosniff'
    response.headers['Content-Security-Policy'] = (
        "sandbox; default-src 'none'; "
        "style-src 'unsafe-inline'; img-src 'self' data:"
    )

M5:验证码形同虚设

登录密码错误的代码分支里没有消费验证码------验证码一旦生成,可以配合不同密码无限重试,防暴破锁定数倒是在涨,可验证码这道人形门槛等于没装。修复简单到只有一句话:无论密码对错,先一次性消费,再比对。

python 复制代码
# app/admin/auth.py
captcha = (request.form.get('captcha') or '').strip().lower()
# 无论比对结果先 pop,杜绝同码复用枚举密码
session_captcha = (session.pop('captcha', None) or '').lower()

这个版本新增 26 项专项测试(tests/test_security_m.py),总量 346。写测试时也有两个实战教训值得一提:SVG 用例必须先把 SVG 检查挪到扩展名白名单判断之前(默认后缀清单里没有 svg,会被提前拒掉,根本走不到嗅探逻辑);中文错误信息在 JSON 响应里是 \uXXXX 转义形态,断言要 resp.get_json()['error'] 取出来再判断,不能直接在原始响应文本里找中文。


十六、v3.1.3:功能都对之后,账要算一算

GitCode issue #3 是四项中危性能/并发问题(M7-M10)。这类问题的共同特点是:功能完全正确,只是系统会在压力下慢慢流血。

M7:一个页面 20+ 次设置查询

Setting.get(key) 的实现是每次一条 SELECT ... WHERE key=? LIMIT 1,而单页渲染链路要读 20 多个设置项------导航要读、SEO 要读、主题要读、缓存开关要读......一页 20 多条几乎一模一样的 SQL。为什么不用跨请求缓存?因为设置项要求改了立刻生效,长 TTL 缓存在多 worker 下必然有一致性麻烦。最后的方案是请求级缓存:用 flask.g 存整表映射,同一请求内首查整表一次,之后零 SQL,请求结束缓存自然消亡,多 worker 下天然无一致性问题:

python 复制代码
# app/models/setting.py(节选两个类方法)
@classmethod
def _request_cache_map(cls):
    """单页渲染链路 Setting.get() 调用 20+ 次,逐次 first() 造成写放大;
    同一请求内首查整表建映射,后续读零 SQL。set() 写后失效当前请求。"""
    if not has_app_context():
        return None                           # CLI / 裸单测:保持逐键查询
    cache_map = g.get('_settings_map')
    if cache_map is None:
        cache_map = {r.key: (r.value or '') for r in cls.query.all()}
        setattr(g, '_settings_map', cache_map)
    return cache_map

@classmethod
def get(cls, key, default=None):
    cache_map = cls._request_cache_map()
    if cache_map is not None:
        if key in cache_map:
            return cache_map[key]
        # 键不在整表映射中:查一次库解析默认值并回填,避免同请求重复落库
        item = cls.query.filter_by(key=key).first()
        if item is None:
            resolved = default if default is not None else cls.DEFAULTS.get(key, '')
        else:
            resolved = item.value or ''
        cache_map[key] = resolved
        return resolved
    ...                                    # 无应用上下文:保持原有逐键查询行为

set() 写入后同时失效数据库和当前请求的映射,保证同请求内"写后立即可见"。

M8:每看一篇文章就 UPDATE + COMMIT

文章详情每被访问一次,老代码就 article.viewed += 1 然后 db.session.commit()。高并发下这是对同一行的持续写锁竞争;更尴尬的是整页缓存命中时视图函数根本不执行------缓存 TTL(默认 900 秒)内浏览量完全不增长,开了缓存反而把统计功能关了。

改造为"缓存累加 + 合并回库"。每次访问只往缓存里的 dict 加一,不碰数据库:

python 复制代码
# app/frontend/views.py
_VIEW_PENDING_KEY = 'article:viewed:pending'
_VIEW_FLUSH_THRESHOLD = 200      # 请求内累计达 200 触发合并(多 worker 无调度器兜底)
_VIEW_PENDING_TTL = 86400        # 兜底 24h,防调度器长期停止时增量滞留

def _incr_view(aid):
    """优先缓存累加(消除每详情请求一次写事务);缓存不可用回落直接写库。"""
    try:
        pending = cache.get(_VIEW_PENDING_KEY)
        pending = dict(pending) if isinstance(pending, dict) else {}
        pending[aid] = pending.get(aid, 0) + 1
        cache.set(_VIEW_PENDING_KEY, pending, timeout=_VIEW_PENDING_TTL)
        if sum(pending.values()) >= _VIEW_FLUSH_THRESHOLD:
            _flush_view_counts()
    except Exception:
        ...                                # 回落旧行为,计数功能不中断

合并任务由 APScheduler 每 60 秒跑一次,用一条增量 UPDATE 把攒下来的数字一次性写回,而不是读出来再写;合并失败就把增量放回缓存等下一轮,不丢数:

python 复制代码
def _flush_view_counts():
    pending = cache.get(_VIEW_PENDING_KEY)
    if not isinstance(pending, dict) or not pending:
        return
    cache.delete(_VIEW_PENDING_KEY)
    try:
        for aid, n in pending.items():
            if not n:
                continue
            db.session.query(Article).filter(Article.id == aid).update(
                {Article.viewed: func.coalesce(Article.viewed, 0) + n},
                synchronize_session=False,
            )
        db.session.commit()
    except Exception:
        db.session.rollback()
        # 合并失败:增量放回缓存(与期间新增量合并),等下一轮
        ...

整页缓存的命中路径也显式调用 _incr_view(),修掉"缓存窗口计数不涨"的老 bug。代价是浏览量从此变成近似值 (误差窗口 ≤ 60 秒或 200 次访问)------这个语义变化我们在 UPGRADE.md 和发版说明里用大字标注了。会改变用户可观测行为的优化,必须明说,让依赖精确计数的用户有选择权。

M9:首页 N+1,9 个区块 20+ 条 SQL

首页要渲染 9 个行业区块,老实现对每个 slug 调一次 _home_column_articles():查栏目、查子栏目、查文章,每块 3 条 SQL,叠加最新文章和导航树,单页 20 多条 SQL。重写后收口为 3 条------目标栏目一次 IN 查全、全部子栏目一次、跨栏目文章一次,在内存里分组并取每组前 N:

python 复制代码
# app/frontend/views.py
def _home_section_data():
    """原实现 9 个 slug 各发 1~3 条 SQL,单页首页约 20+ 条;
    收口为 3 条 SQL:目标栏目一次、子栏目一次、跨栏目文章一次,
    内存分组取前 N,排序语义与原实现一致。"""

导航栏的栏目树同理,加 Column.get_tree_cached() 请求级缓存------导航渲染和上下文处理器对同一棵树只查一次,内容 CRUD 后由 clear_content_cache() 统一失效,同请求内立即读到新树。

M10:并发重名,唯一索引替你拦下了 500

"先 SELECT 查重,不存在再 INSERT"在并发下有竞态窗口:两个同名请求同时穿过查重线,双双走到插入,users.username 唯一索引兜底时抛出 IntegrityError------没人接,冒泡成 500。修复就是老老实实在数据库的最后一道防线处接异常,翻译成业务错误:

python 复制代码
# app/services/user_service.py
try:
    db.session.flush()
except IntegrityError:
    db.session.rollback()
    _flash(_gettext('用户名已存在'), 'danger')
    return None, messages
...
try:
    db.session.commit()
except IntegrityError:              # flush 与 commit 两处都要接
    db.session.rollback()
    _flash(_gettext('用户名已存在'), 'danger')
    return None, messages

应用层查重只是体验优化(能给出友好提示),数据库唯一约束才是最终防线------这条数据库课上的老话,又实锤了一次。

v3.1.3 新增 14 项性能与并发用例(SQL 计数断言、缓存失效、浏览量合并、缓存页计数、竞态回退、端点无 500),全量 360 项通过。测试里也沉淀了两条经验:服务层函数内部用了 i18n 翻译时,裸 app_context 里 select_locale 会访问 request 而 RuntimeError,必须用 app.test_request_context('/') 包裹;SQL 计数断言要用"SELECT 且 FROM 某表"的正则口径,i18n 翻译表惰性加载和建数 INSERT 都会干扰朴素的关键字计数。


十七、66 天,我们到底学会了什么

1. 默认值必须是安全的。 默认密钥、默认放行、默认允许 SVG------每一个"默认能跑就行"最后都会变成安全事故。fail-closed 永远比 fail-open 贵得值。

2. 升级体验是产品的一部分。 四不变原则(表名/路由/设置键/审计代码不变)、自动 stamp baseline、默认关闭的新功能、写得明明白白的 UPGRADE.md。客户不怕你加功能,怕的是升级完首页裂了、数据没了、会话全掉。

3. 缓存是功能,不是优化。 缓存键必须包含一切影响渲染的维度(分页、语言、主题、会员状态);写操作要有统一失效入口(clear_content_cache);引入近似语义要大张旗鼓告诉用户;缓存命中路径也必须执行业务计数。

4. 校验要纵深,要收口。 入口校验一次,内部再校验一次;散落各处的 int() 收口成 safe_int;白名单思维(只允许什么)永远优于黑名单思维(拦截什么);数据库约束是最后防线,应用层校验只是体验。

5. 测试和门禁是发版的胆。 从 0 到 360 项测试、CI 三版本矩阵、常量门禁、版本一致性检查------这些不会让写代码变快,但会让点"发布"按钮的手不再抖。

6. 小步快跑,但每步可回退。 23 个版本里没有"大爆炸重构":插件化有自动迁移,构建器有双轨制,迁移插件默认不启用,Redis 探测不到就回退。能回退的变更才敢频繁发。

7. 报错信息和文档是给未来的自己看的。 "验证码已过期"却不告诉你该先请求图片、选了"清空全部"却提示"删除 0 条"------这类 bug 不崩不炸,但毁掉的是信任。同样,环境已经决定的事不要再问用户(容器里的数据库配置),会自动收口的兼容不要甩给运维(MySQL 191)。


尾声

v1.0 的时候,它只是一个能发文章的小站。

v3.1.3 的时候,它有 RBAC、工作流、插件生态、八大内置主题、可视化构建器、全文搜索、内容级多语言、会员体系、数据库一键迁移、JWT API,以及 360 个替我们值班的测试用例。

但说实话,最让人有成就感的不是功能清单,而是那些没有出现在 changelog 显眼位置的东西:默认安全的配置、升级无感的迁移、失效即清理的缓存、出错时说人话的提示、半夜发版也敢点下去的门禁。

代码还在继续长。下一个版本见。


关于项目 & 共建

ZhyCMS 是一个开源的 Flask 企业内容管理系统,代码全程公开,三个镜像仓库实时同步,国内访问任选:

如果这篇文章里的某个坑你也踩过、某个方案让你觉得"我们项目也该这么改",欢迎:

  • ⭐ 点个 Star,这是对独立开源项目最直接的鼓励;
  • 🐛 到任一仓库的 Issues 提 bug、报安全问题、聊新功能想法------v3.1.x 那一连串安全版本,最早的触发点就是一条用户反馈;
  • 🔧 直接 Pull Request 参与共建:插件、主题、翻译、文档都在欢迎清单里,不一定要写核心代码。
相关推荐
小羊没烦恼!1 小时前
T-SQL查询高级—SQL Server索引中的碎片和填充因子
开发语言·前端·javascript·数据库·后端·python·web安全
定栓2 小时前
Webpack迁到Vite第一节:模块系统迁移:require → import
运维·前端·webpack
小熊在忙fpga2 小时前
N32G430芯片的flash表示
前端·javascript·html
Data analyse4562 小时前
App埋点版本兼容的字段迁移校验
大数据·运维·服务器·前端·数据库·数据分析
todoitbo2 小时前
本来只想给内网开个口子,Natapp 却多给了一道防护
前端·javascript·vue.js·内网穿透·网络防护·natapp
会灭火的程序员2 小时前
SuperMap AgentX Server智能体接入WEB应用技术文档
前端·supermap·agentx·agentx server
CRMEB2 小时前
商品 SKU 设计,从简单规格到多规格踩过什么
java·前端·数据库·开源
Chloe.Zz2 小时前
一篇入门Agent
开发语言·前端·javascript
java1234_小锋2 小时前
【技术专题】HTML5 网页技术 - HTML5 表格标签
前端·html·html5