内容管线
从创建到发布的完整内容生命周期。
内容状态机
内容共 5 个状态:draft(草稿)→ pending(待审核)→ published(已发布)/ rejected(已驳回)/ offline(已下线)。
| 操作 | 允许的源状态 | 目标状态 | 权限 |
|---|---|---|---|
| 提交审核 | draft / rejected | pending | cms:content:update |
| 发布 | draft / pending / rejected / offline | published | cms:content:publish |
| 驳回(带原因) | pending | rejected | cms:content:audit |
| 下线 | published | offline | cms:content:publish |
| 移入回收站 | 任意 | offline + deletedAt | cms:content:delete |
| 恢复 | 回收站 | draft | cms:content:delete |
| 彻底删除 | 仅回收站 | —(硬删除) | cms:content:delete |
回收站内容超过 30 天由周期任务自动清理。
审核双轨制
站点「内容审核」设置支持两种模式:
- 简单模式(默认):提交后进入「待审核」Tab,审核人直接发布/驳回。
- 工作流模式:
settings.auditMode = 'workflow'。提交审核自动发起工作流实例(bizType=cms_content),流程定义取站点配置或按名称「CMS 内容审核」回退。流程审核期间禁止手动发布/驳回;流程通过 → 自动发布 + 静态化 + 推送(回调前会复验内容仍为待审状态,防止长周期流程覆盖人工操作);驳回 → rejected;撤回 → draft。
编辑体验
内容形态(P2 多形态内容类型)
每条内容具有形态(contentType,创建后不可变更):
| 形态 | 说明 | 发布前置校验 |
|---|---|---|
article 图文 | 默认形态,富文本正文,支持正文多页分页 | — |
album 图集 | 编辑页多图管理(上传/媒体库/排序/图片说明),前台九宫格展示、点击看大图;正文降级为可选图文说明 | 至少 1 张图片 |
media 音视频 | 媒体地址(可上传)+ 海报 + 时长,前台原生 <video>/<audio> 播放器 | 媒体地址必填 |
link 外链 | 前台列表点击标题新窗口直达外链(rel="noopener nofollow"),不生成详情页 | 外链地址必填 |
图集/音视频的结构化数据存 mediaData JSONB;图集说明纳入全文检索;列表页与后台列表均展示形态角标,后台支持按形态筛选。
正文多页分页
图文正文在编辑器点击「插入分页符」(插入 [分页] 标记,兼容 <!-- pagebreak -->)即可拆分多页:
- 前台 URL:第 1 页
/{channelPath}/{idOrSlug}.html,第 n 页/{channelPath}/{idOrSlug}_{n}.html(slug 不含下划线,无歧义) - 页内分页导航自动渲染;SEO 标题自动附「(第 n 页)」,canonical 指向当前分页
- 静态化逐页生成全部分页,页数收缩时自动清理多余
_n.html(窗口 5 页);草稿预览始终展示第 1 页
内容字段
除标题/摘要/正文外,支持副标题、短标题(列表窄位展示)、作者、责任编辑、来源、来源链接、原创标记(v1.8.0+)。原创内容在列表以「原创」标识展示。
标题样式
内容可单独设置标题加粗与标题颜色(cms_contents.title_style JSONB,{ bold?, color? }),颜色取自预置色板(CMS_TITLE_STYLE_COLORS)。后台内容列表与前台列表/详情页标题同步生效;两项都为空时回落主题默认外观。
正文附件
正文之外可挂载结构化附件列表(cms_contents.attachments JSONB,单条含 name/url/size/ext/sort,上限 50 个)。编辑页「附件」区支持上传、改名、上下移动与删除,文件走 POST /api/cms/resources/upload 入素材库(非图片类型原样入库);url 落库时归一为 cms-res://{id} 句柄(见「媒体库与素材句柄」)。
- 附件非空时自动置位
hasAttachment,不再仅依赖正文链接正则推断 - 前台详情页在正文下方渲染「附件下载」区(含类型徽标与体积)
自定义静态路径
内容可指定 staticPath 覆盖默认的「栏目路径 + slug/id」命名,形如 news/2026/hello.html(支持 .html/.htm/.shtml/.json)。站内唯一(部分唯一索引 cms_contents_site_static_path_uq,忽略回收站内容);正文分页在扩展名前追加 _N。留空时行为不变。
封面缩略图
站点开启缩略图(站点设置 → 图片处理)后,上传封面/图集图片自动生成缩略图并记在素材行上。前台列表与图集九宫格优先使用缩略图,节省流量。
coverThumb 不是数据库列,而是读取时由封面素材派生:因此从媒体库选图同样有缩略图,且替换素材后缩略图自动跟随。封面填的是站外 URL(未登记素材)时无缩略图,回退原图。
并发保护(编辑锁 + 乐观锁)
- 编辑锁(软锁):打开编辑页抢占 Redis 锁(
cms:edit-lock:{id},TTL 120s,前端每 30s 心跳续期,离开自动释放)。他人持锁时页面顶部展示「xx 正在编辑」警示,但不阻断操作。 - 乐观锁(硬保护):
cms_contents.version每次更新 +1。保存时携带expectedVersion,服务端版本不一致返回 409,前端提示刷新后重试。二者结合:软锁降低冲突概率,硬锁保证冲突不静默覆盖。 - 持久化合规锁:拥有
cms:content:lock的管理员可填写原因锁定内容。锁记录lockedAt/lockedBy/lockReason,会取消待执行scheduledAt,并阻止编辑、审核/发布/下线、回收/删除、移动/分发、批量标记及工作流/周期任务回写;读取、预览、历史与操作日志仍可用。该锁必须显式解锁,不受 Redis TTL 影响。
自动保存
草稿/驳回状态的既有内容,编辑有改动时每 30s 静默自动保存一次,标题栏展示「已自动保存 HH:mm:ss」。新建内容(未落库)不自动保存,避免误创建。
草稿预览链接
编辑页「预览」按钮生成签名临时链接(HMAC-SHA256,默认 2 小时有效):
/__cms/{siteCode}/preview/{contentId}?exp={unix}&sig={hmac}免登录访问,可直接分享给审核人;页面顶部注入预览提示条,不缓存、不回写静态文件。预览前有未保存改动会自动落库(保证预览即所见)。
版本快照 / 对比 / 回滚
- 每次更新前自动留档版本快照(每内容保留最近 20 版)
- 「历史版本」抽屉支持对比(
GET /{id}/versions/{versionId}/diff返回字段级差异,前端双栏红绿高亮)与回滚(回滚前自动为当前状态留档)
媒体库与素材句柄
封面图、图集、附件、模型 image/file 字段与页面区块统一引用素材中心(cms_resources)。从文件中心(managed_files)选取的文件在保存时自动登记为本站素材(复用同一 file_id,不复制物理文件),因此不存在「游离在素材中心之外、治理看不见」的媒资。组件:MediaPickerModal。
句柄化存储
素材在正文 HTML、JSONB 与标量列中统一以 cms-res://{id} 句柄存储,而不是裸 URL:
- 写入时归一:服务端把本站素材库中已登记的 URL 精确替换为句柄,未登记的外链地址原样保留。编辑器仍收发真实 URL,前端无感。归一化与引用索引重建在同一事务内完成(含文件中心引用的补登记),回滚不会留下孤儿素材行。
- 读取时解析:DTO 映射、前台 SSR、静态化与 Headless API 输出前统一把句柄还原为真实 URL(批量取数 + 进程内 60s 缓存,无 N+1)。
- HTML 净化白名单:
cms-res已列入sanitizeCmsHtml的 scheme 白名单。站点导入、映射物化、分发同步等链路会把已落库的正文再次送进净化器,若不放行会整个丢掉src/href属性,等于永久删除正文中的全部图片。 - 地址安全校验:素材地址在读取时被直接拼进已净化的 HTML(句柄替换发生在净化之后),因此
cms_resources.url/thumb_url只允许站内绝对路径或 http(s) 绝对 URL,且不含引号、尖括号、反斜杠、空白,也不接受协议相对地址。站点导入包是唯一的外来写入口,在入库前强制校验。 - 站点隔离:每个站点只引用自己的
cms_resources行。跨站复制(站群分发、站点导入)会先把来源素材登记到目标站(按 URL 去重、复用同一file_id不复制物理文件),再改写句柄;否则来源站点删除时会级联带走目标站的引用索引,正文只剩悬空句柄。
带来的能力:
| 能力 | 说明 |
|---|---|
| 替换素材 | 素材列表「替换」上传新文件,保留素材 id,站内所有引用位置自动指向新文件 |
| 迁移不断链 | 换存储、换 CDN、改文件名只改素材行,不产生死图 |
| 精确引用 | 引用关系落在索引表上,不再靠 URL 子串匹配(a.jpg 不会再命中 a.jpg.bak) |
| 站点导入导出 | 导出包含素材库,导入时重建素材并把包内句柄改写为新站 id,不会跨站引用来源站素材 |
| 跨站分发 | 站群分发把来源素材登记到目标站后再改写句柄,两侧引用各自独立,来源站删除不影响目标站 |
引用索引与孤立治理
cms_resource_refs(resource_id + owner_type + owner_id + field 唯一)记录每个素材被谁引用。owner 每次写入都在同一事务内整体重建自己的引用行,因此索引与业务数据强一致。
- 覆盖 owner:站点、栏目、内容、内容版本快照、友情链接、广告、搭建页面、表单
- 素材列表展示「引用数」列,0 即孤立素材
- 孤立判定是一条
NOT EXISTS索引查询;治理任务从原先「逐素材对 9 张表做全表LIKE扫描」的 O(N×M) 降为 O(N) - 存在引用的素材禁止删除
- 素材行带
owns_file标记:false表示文件由文件中心或来源站点持有(从文件中心选图自动登记、站点导入/站群分发复制出的素材都属此类),删除这类素材只删登记行、不动物理文件,避免把其他模块或其他站点正在用的文件删掉;true时还要确认没有别的素材行共用同一file_id才会联动删除 - 素材中心「重建引用索引」提交任务中心任务(
cms-resource-ref-rebuild),逐 owner 类型分阶段执行,用于存量回填与索引修复;正常运行不需要
词库检查(敏感词 + 易错词)
编辑页「内容检查」按钮对标题/副标题/摘要/正文做单次 Aho-Corasick 扫描(POST /api/cms/contents/check-text),同时报告:
- 敏感词命中:区分拦截词(须删除)与替换词(提交时自动替换),仅提示不拦截保存
- 易错词命中:来自「易错词库」(
/cms/error-prone-words,权限cms:word:list|manage),支持单个/全部一键替换为正确写法,替换作用于标题/副标题/摘要/正文
站点还可开启保存时自动替换(站点设置 → 内容策略):
| 开关 | 行为 |
|---|---|
autoReplaceSensitiveWords | 保存时按敏感词库替换标题/摘要/正文;命中拦截词(未配置替换文本)仍抛 400 拒绝保存 |
autoReplaceErrorProneWords | 保存时按易错词库把常见错词替换为正确写法 |
两者共用带 60s 缓存的 AC 自动机,关闭时不加载词库,无额外开销。
站点内容策略
站点设置 →「内容策略」标签页统一维护以下开关,全部存 cms_sites.settings JSONB(缺项回落 CMS_SITE_OPS_DEFAULTS,无需数据迁移):
| 键 | 默认 | 作用 |
|---|---|---|
publishedContentEditable | true | 关闭后编辑已发布内容直接返回 400,须先下线 |
recycleKeepDays | 30 | 回收站保留天数,超期由每日周期任务彻底删除;0 = 永久保留 |
maxPageOnContentPublish | 0 | 单条内容发布时最多重建所属栏目前 N 页列表;0 = 全部重建 |
autoReplaceSensitiveWords | false | 见上 |
autoReplaceErrorProneWords | false | 见上 |
autoCoverFromBody | false | 未填封面时,保存自动提取正文第一张图片作为封面(跳过 data: URI) |
操作日志时间线
每条内容的关键操作(创建/更新/提审/发布/驳回/下线/回收/恢复/回滚/归档/移动等)自动落 cms_content_op_logs(随内容级联删除),编辑页「操作记录」抽屉按时间线展示操作人与详情。
内容组织
内容模型(自定义字段)
模型定义字段元数据(12 种类型:text/textarea/richtext/number/date/datetime/image/file/select/radio/checkbox/switch),值存入 extend JSONB。字段可配置必填、纳入检索(searchable)、列表显示。
模型可绑定到三级对象,均通过各自的 model_id + extend 列承载:
| 绑定级 | 绑定位置 | 值存放 | 用途 |
|---|---|---|---|
| 站点 | cms_sites.model_id | cms_sites.extend | 站点级运营元数据(备案号、客服电话、App 下载地址等),主题上下文通过 site.extend 读取 |
| 栏目 | cms_channels.model_id | 栏目下内容的 extend | 决定该栏目下内容编辑页动态渲染哪些扩展字段 |
| 内容 | cms_contents.model_id | cms_contents.extend | 由所属栏目继承,换栏目时跟随目标栏目 |
选项来源:select / radio / checkbox 三类字段的选项支持两种来源:
- 手动设置(默认):在模型字段编辑区直接维护
{label, value}[] - 系统字典:只填字典编码(如
content_status),选项在读取模型时实时解析自dict_items(仅取启用项,按 sort 排序)。字典维护一处,所有引用它的模型字段自动同步,无需逐个模型改选项
解析结果通过 resolvedOptions 返回给前端,内容编辑页与站点扩展字段区统一按它渲染;options 保留手动来源的原始值。字典编码不存在时 resolvedOptions 为空数组,不影响其他字段渲染。
一文多栏目
内容除主栏目(channel_id)外可挂多个副栏目(cms_content_channels),栏目列表页自动聚合展示主栏目与副栏目内容。副栏目须为本站点列表型栏目。
相关文章
编辑页可手动指定相关文章(cms_content_relations,按选择顺序排序);前台详情页「相关阅读」区块展示手动关联,不足 5 条时按共同标签自动补齐。
定时发布 / 过期下线 / 置顶到期
scheduledAt:到期自动发布(每分钟检查,Redis 排他锁防多实例重复执行);创建内容时设置计划、修改计划或清除已有计划均要求cms:content:publishexpireAt:到期自动下线(同一周期任务处理),适合活动/公告类时效内容topExpireAt:置顶到期自动取消(v1.8.0+),配合置顶权重topWeight(数值越大越靠前)实现多条置顶精确排序
三者都会自动刷新静态页;发布/下线还会触发 Webhook。
内容成功发布时,发布事务除静态发布 outbox 外还会原子创建 cms-subscription-notify 任务。任务按发布时固化的订阅 cutoff 分批匹配站点、主栏目与标准化作者订阅,复用会员站内通知服务并按 content:{id}:version:{version} 去重;通知失败可在任务中心重试,不回滚也不阻塞内容发布。
归档
已发布/已下线内容可归档(archivedAt,v1.8.0+):前台详情页保留可访问,但不再出现在栏目列表、首页区块、标签页、相关阅读、上下篇等聚合位。后台「归档」Tab 独立管理,可批量归档/取消归档;归档中的内容禁止状态流转。适合大量历史内容瘦身而不产生死链。
批量操作与导入导出
- 批量:移动栏目 / 追加标签 / 设置属性(置顶/推荐/热门)/ 归档、取消归档 / 站群分发 / 回收 / 恢复 / 彻底删除,全部事务保护
- 单条复制:操作列「复制」在原栏目创建草稿副本;「复制到其他栏目」可选本站任意栏目(跨站请用站群分发)。两者均置空
slug与staticPath规避唯一约束,标签一并复制,isTop重置;换栏目时modelId跟随目标栏目,扩展字段按目标模型解释 - 站群分发双模式(v1.8.0+):
- 独立复制(默认):完整拷贝正文与扩展字段,分发后独立编辑,操作日志记录来源
- 映射:仅拷贝标题等元数据(
mappingSourceId指向来源),正文/扩展字段运行时透传来源内容,源改动即时生效;映射内容禁止独立编辑正文(编辑页只读展示 + 服务端双重拦截);来源被彻底删除时自动物化为独立内容防失源;映射的映射自动指向原始来源,不产生解析链
- 导入:Excel 批量导入(首行表头:
标题(必填)/摘要/正文/作者/来源),走任务中心异步执行,行级明细 + 断点续跑 + 幂等提交。入口:内容列表「导入」(需先在栏目树选择目标栏目) - 导出:接入导出中心(entity
cms.contents),按当前筛选条件导出 xlsx/csv
栏目运维
栏目管理页采用左侧栏目树 + 右侧编辑区的 Master-Detail 结构:左树点击节点即在右侧内联编辑(无需弹窗), 节点右侧「⋯」菜单提供访问前台 / 添加子栏目 / 授权用户 / 清空栏目 / 删除,节点上直接标注单页、外链、隐藏、停用状态。
栏目管理页提供四项运维能力(v1.8.0+):
| 操作 | 说明 | 权限 |
|---|---|---|
| 移动 | 编辑栏目改「父栏目」即移动,防环校验 + 子树 path 级联重算 | cms:channel:update |
| 合并 | 多个来源栏目内容(含副栏目绑定)并入目标栏目后删除来源;要求同站点列表栏目且无子栏目 | cms:channel:update |
| 清空 | 栏目下全部内容移入回收站(不含子栏目) | cms:channel:update |
| 批量新增 | 按名称列表一次创建多个栏目,slug 自动取拼音(pinyin-pro),路径冲突自动加序号;单个新增时名称失焦也会自动填充拼音 slug | cms:channel:create |
栏目标识(code)与 URL 标识(slug)
栏目有两个标识,职责不同,不要混用:
| 字段 | 用途 | 唯一性 | 可变性 |
|---|---|---|---|
slug | URL 片段,参与 path 级联(/news/notice/) | 同级唯一(经 path 全站唯一) | 随时可改,改了前台 URL 就变(需配 301) |
code | 程序引用标识:内链、页面搭建区块、开放 API、主题模板 | 站点内唯一 | 一经设定不建议再改 |
code 的价值在于稳定:移动栏目、改 slug、站点复制/导入后它都不变,因此按 code 建立的引用不会失效。 新建栏目时留空会自动取 slug,站内冲突自动追加 -2 / -3;显式填写且撞车时直接报错,不会静默改写。
引用方式:
- 内部链接:
entity:channel@news(推荐);entity:channel/45为历史 id 写法,仍然兼容。站点导入时前者无需重映射,后者按 id 映射表改写,映射缺失则置空 - 页面搭建:
content-list区块存props.channelCode;旧页面存的props.channelId继续生效 - 开放 API:
GET /api/open/v1/cms/contents?siteCode=&channel=news;channelId=45保留兼容
会员投稿
前台会员可通过投稿接口提交内容(memberId 标记来源),提交后直接进入审核流;被驳回可修改后重新提交。