内容管线
从创建到发布的完整内容生命周期。
内容状态机
内容共 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 |
回收站保留天数由站点内容策略 recycleKeepDays 控制,默认 30 天;设为 0 时永久保留,不自动清理。恢复只清除 deletedAt 并回到 draft,不会自动清除 archivedAt;归档内容恢复后要先取消归档才能提审或发布。
审核双轨制
站点「内容审核」设置支持两种模式:
- 简单模式(默认):提交后进入「待审核」Tab,审核人直接发布/驳回。
- 工作流模式:
settings.auditMode = 'workflow'。提交审核自动发起工作流实例(bizType=cms_content),流程定义取站点配置或按名称「CMS 内容审核」回退。流程审核期间禁止手动发布/驳回;流程通过 → 自动发布 + 静态化 + 推送(回调前会复验内容仍为待审状态,防止长周期流程覆盖人工操作);驳回 → rejected;撤回 → draft。
工作流模式的内容编辑页保留完整富文本布局,页头显示审批流程名称、状态、当前节点与「查看流程」入口,提供「内容 / 审批流程」页签;切换页签保留未保存的编辑内容。草稿/驳回内容默认展示本次提审预览,站点、栏目、标题变化后重新解析预览;预览不保存内容、不创建流程实例。主操作为「保存并提交审核」,提交成功后留在编辑页并切到实际审批流程。简单模式仍使用「保存并发布」。
内容列表「查看审批」打开与普通流程一致的两栏侧边抽屉,左侧内容资料、右侧实际审批链,可查看流转记录和流程图。「审批轮次」支持选择过去记录;编辑页的当前预览与列表查看的最近实际轮次分别服务编辑和查看场景。过去轮次展示当时的流程记录和当前内容资料,界面明确提示,不将正文视为历史快照。
业务编辑页使用内容权限读取流程上下文;工作流审批里的 ContentApprovalView 使用携带必填 instanceId 的 approvalDetail,服务端校验内容与实例关联及流程参与者可见性。定义不可用时在流程区显示错误,不能自动降级成简单审核。契约与公共容器接法见 业务模块接入工作流。
编辑体验
内容形态(多形态内容类型)
每条内容具有形态(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 页
内容字段
除标题/摘要/正文外,支持副标题、短标题(列表窄位展示)、作者、责任编辑、来源、来源链接、原创标记。原创内容在列表以「原创」标识展示。
标题样式
内容可单独设置标题加粗与标题颜色(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?siteId={siteId}(图片快捷上传也可用 POST /api/cms/upload-image?siteId={siteId}),文件进入素材库后 url 归一为 cms-res://{id} 句柄(见「媒体库与素材句柄」)。
- 附件非空时自动置位
hasAttachment,不再仅依赖正文链接正则推断 - 前台详情页在正文下方渲染「附件下载」区(含类型徽标与体积)
自定义静态路径
内容可指定 staticPath 覆盖默认的「栏目路径 + slug/id」命名,形如 news/2026/hello.html。规范只接受小写字母、数字、中划线、下划线、斜杠和 .html 扩展名,并拒绝 ..、连续斜杠及系统保留路径;路径站内唯一(部分唯一索引 cms_contents_site_static_path_uq,忽略回收站内容),正文分页在扩展名前追加 _N。留空时按栏目 detailPathRule 生成。
内容列表和详情接口同时返回服务端计算的 canonicalUrl 与 previewUrl:前者是站内规范地址(外链内容为外链地址),已发布的站内内容的 previewUrl 使用 /__cms/{siteCode} 前缀;外链型内容直接返回其外链地址。后台和主题不得自行拼接栏目路径、slug、id 或扩展名;草稿、待审和下线内容使用签名预览接口。
图片处理(压缩 / 水印 / 缩略图)
站点设置 →「图片处理」对素材中心上传的图片统一应用(存 cms_sites.settings):超宽等比压缩(imageMaxWidth,默认 1600px,0 = 不限制)、文字水印(九宫格定位 + 透明度 + 字号,默认关闭)、缩略图(thumbWidth 默认 400px,默认关闭)。素材列表另支持非破坏裁剪(POST /api/cms/resources/{id}/crop,另存为新素材,不影响原图引用)。
开启缩略图后,上传封面/图集图片自动生成缩略图并记在素材行上。前台列表与图集九宫格优先使用缩略图,节省流量。
coverThumb 不是数据库列,而是读取时由封面素材派生:因此从媒体库选图同样有缩略图,且替换素材后缩略图自动跟随。封面填的是站外 URL(未登记素材)时无缩略图,回退原图。
写入安全边界
- 正文和单页正文在保存前经过
sanitizeCmsHtml;敏感词/易错词替换只作用于安全 HTML,再次净化后才入库。正式主题、审核预览和开放 API 读取同一份正文值;读取接口不修复已存脏值,导入/更新必须重新走净化管线。 externalLink、栏目链接、来源链接、重定向、友情链接、内链词、广告、主题参数、页面区块和页面部件的 URL 字段均在写入层执行共享 CMS URL policy;按字段用途接受entity:、安全站内路径、http/https或明确允许的mailto/tel,素材字段另限站内绝对路径、http(s)或cms-res://。协议相对地址、反斜杠、点路径、控制字符及javascript/data/vbscript/ftp等协议会被拒绝。- 资源句柄必须是当前站点的
cms-res://{id}。写入或导入时发现其他站点句柄会失败;读取解析按siteId限定,未知句柄解析为空,不把跨站资源带回页面。 - 主题参数和可视化区块的 URL 字段属于配置输入,仍应使用上述安全 URL 约定;区块
richtext的 HTML 由服务端净化,JSON-LD/内联脚本动态值使用脚本安全 JSON 序列化。
并发保护(编辑锁 + 乐观锁)
- 编辑锁(软锁):打开编辑页抢占 Redis 锁(
cms:edit-lock:{id},TTL 120s,前端每 30s 心跳续期,离开自动释放)。他人持锁时页面顶部展示「xx 正在编辑」警示,但不阻断操作。 - 乐观锁(硬保护):内容编辑、发布、下线、回收和归档等会改变公开语义的写入使
cms_contents.version+1。编辑保存时携带expectedVersion,服务端版本不一致返回 409,前端提示刷新后重试;提审/驳回以当前状态原子校验,不接受expectedVersion。二者结合:软锁降低冲突概率,硬锁保证冲突不静默覆盖。 - 持久化合规锁:拥有
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,前端无感。归一化与引用索引重建在同一事务内完成(含文件中心引用的补登记),回滚不会留下孤儿素材行。
- 读取时解析:实体映射(service
mapXxx())、前台 SSR、静态化与 Headless API 输出前统一按站点把句柄还原为真实 URL(批量取数 + 进程内 60s 缓存,无 N+1)。 - HTML 净化白名单:
cms-res已列入sanitizeCmsHtml的 scheme 白名单。站点导入、映射物化、分发同步等链路会把已落库的正文再次送进净化器,若不放行会整个丢掉src/href属性,等于永久删除正文中的全部图片。 - 地址安全校验:素材地址在读取时被直接拼进已净化的 HTML(句柄替换发生在净化之后),因此
cms_resources.url/thumb_url只允许站内绝对路径或 http(s) 绝对 URL,且不含引号、尖括号、反斜杠、空白,也不接受协议相对地址。后台、开放 API、会员/公开提交、站点导入和跨站分发等所有外来写入口都在入库或物化前强制校验。 - 站点隔离:每个站点只引用自己的
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索引查询,治理任务复杂度 O(素材数),不依赖对业务表的全表LIKE扫描 - 存在引用的素材禁止删除
- 素材行带
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) |
openApiPublishEnabled | false | 允许开放 API 直接发布的站点级开关;仍需应用持有 cms:publish scope 且开放授权行开启 canPublish |
操作日志时间线
每条内容的关键操作(创建/更新/提审/发布/驳回/下线/回收/恢复/回滚/归档/移动等)自动落 cms_content_op_logs(随内容级联删除),编辑页「操作记录」抽屉按时间线展示操作人与详情。
内容组织
内容模型(自定义字段)
内容模型提供 12 种类型的自定义字段(值存 extend JSONB),支持选项绑定系统字典、默认值自动填充、 「草稿宽松 / 发布严格」的必填校验分层、列表与详情展示配置,以及站群归属治理(平台共享 / 站点专属)。 完整说明见 内容模型与扩展字段。
绑定关系速览:站点(cms_sites.model_id + 自身 extend)、栏目(决定栏目下内容的扩展字段)、 内容(随主栏目继承,换栏目时按目标模型解释)。
标签
站点级标签(/cms/tags):新建时输入名称自动生成拼音 slug(手改后不再覆盖),支持分组管理; 内容打标后前台输出 /tag/{slug}/ 聚合页,页面搭建的内容列表区块也可按标签跨栏目聚合取数。
一文多栏目
内容除主栏目(channel_id)外可挂多个副栏目(cms_content_channels),栏目列表页自动聚合展示主栏目与副栏目内容。副栏目须为本站点列表型栏目。
相关文章
编辑页可手动指定相关文章(cms_content_relations,按选择顺序排序);前台详情页「相关阅读」区块展示手动关联,不足 5 条时按共同标签自动补齐。
定时发布 / 过期下线 / 置顶到期
scheduledAt:到期自动发布(每分钟检查,Redis 排他锁防多实例重复执行);创建内容时设置计划、修改计划或清除已有计划均要求cms:content:publishexpireAt:到期自动下线(同一周期任务处理),适合活动/公告类时效内容topExpireAt:置顶到期自动取消,配合置顶权重topWeight(数值越大越靠前)实现多条置顶精确排序
后台内容列表对两类计划显性可见:未发布且带计划的内容在状态列显示「定时」徽章、发布时间列显示计划时间(如「定时 08-16 09:00」);已发布且带过期时间的显示「限时」徽章,悬停提示到期时刻。
三者都会自动刷新静态页;发布/下线还会触发 Webhook。
内容成功发布时,发布事务除静态发布 outbox 外还会原子创建 cms-subscription-notify 任务。任务按发布时固化的订阅 cutoff 分批匹配站点、主栏目与标准化作者订阅,复用会员站内通知服务并按 content:{id}:version:{version} 去重;通知失败可在任务中心重试,不回滚也不阻塞内容发布。
归档
已发布/已下线内容可归档(archivedAt):前台详情页保留可访问,但不再出现在栏目列表、首页区块、标签页、相关阅读、上下篇等聚合位。后台「归档」Tab 独立管理,可批量归档/取消归档;归档中的内容禁止状态流转。适合大量历史内容瘦身而不产生死链。
批量操作与导入导出
- 批量状态流转:勾选多条后可批量提交审核 / 发布 / 驳回(带原因)/ 下线(
POST /api/cms/contents/batch-status)。逐条独立事务、按动作校验对应权限,部分成功——失败项返回行级原因(如状态不允许、被锁定),不整批回滚 - 批量属性:移动栏目 / 追加标签 / 设置属性(置顶/推荐/热门/原创)/ 归档、取消归档 / 站群分发 / 回收 / 恢复 / 彻底删除,全部事务保护;批量状态接口返回逐条成功与失败原因。
- 单条复制:操作列「复制」在原栏目创建草稿副本;「复制到其他栏目」可选本站任意栏目(跨站请用站群分发)。两者均置空
slug与staticPath规避唯一约束,标签一并复制,isTop重置;换栏目时modelId跟随目标栏目,扩展字段按目标模型解释 - 站群分发双模式:
- 独立复制(默认):完整拷贝正文与扩展字段,分发后独立编辑,操作日志记录来源
- 映射:目标内容保存完整的目标站点本地快照,
mappingSourceId只保留来源关系供治理与同步;来源变更由带规则 revision、来源 version 和幂等键的异步任务同步,任务完成后目标才更新,期间继续提供上一份快照。映射内容禁止独立编辑正文(编辑页只读展示 + 服务端双重拦截);来源不再满足规则时,未锁定且已发布的目标会下线,其他状态的目标保持原状态,均物化最后快照并解除映射;已锁定目标则保留映射并记录冲突;删除规则时解除映射。
- 导入:Excel 批量导入(首行表头:
标题(必填)/摘要/正文/作者/来源),走任务中心异步执行,行级明细 + 断点续跑 + 幂等提交。入口:内容列表「导入」(需先在栏目树选择目标栏目) - 导出:接入导出中心(entity
cms.contents),权限为cms:content:export。导出复用站点、栏目 ACL、部门数据范围、状态、形态、归档/回收、属性、关键词和创建时间筛选,格式为 xlsx/csv。
栏目运维
栏目管理页采用左侧栏目树 + 右侧编辑区的 Master-Detail 结构:左树点击节点即在右侧内联编辑(无需弹窗), 节点右侧「⋯」菜单提供访问前台 / 添加子栏目 / 授权用户 / 清空栏目 / 删除,节点上直接标注单页、外链、隐藏、停用状态。
栏目管理页提供四项运维能力:
| 操作 | 说明 | 权限 |
|---|---|---|
| 移动 | 编辑栏目改「父栏目」即移动,防环校验 + 子树 path 级联重算 | cms:channel:update |
| 合并 | 多个来源栏目内容(含副栏目绑定)并入目标栏目后删除来源;要求同站点列表栏目且无子栏目 | cms:channel:update |
| 清空 | 栏目下全部内容移入回收站(不含子栏目) | cms:channel:update |
| 批量新增 | 按名称列表一次创建多个栏目,slug 生成策略可选首字母缩写(默认,政务公开 → zwgk)或逐字全拼(→ zheng-wu-gong-kai),每行也可用 名称|slug 显式指定;路径冲突自动加序号。单个新增时名称失焦同样自动填充拼音 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(按 code 引用);entity:channel/45与entity:content/123按站内 id 引用。站点导入时合法唯一的 code 引用保持不变,数值 id 按映射表改写;任一实体目标未随包提供、重复或无法安全改写时导入返回 400 并整体回滚。 - 页面搭建:
content-list区块按props.channelCode取数;props.channelId也可作为站内数值引用,但保存时应优先迁移为 code。 - 开放 API:
GET /api/open/v1/cms/contents?siteCode=&channel=news;开放写入只支持图文基础字段,详见开放能力。
会员投稿
前台会员可通过投稿接口提交内容(memberId 标记来源),提交后直接进入审核流;被驳回可修改后重新提交。