文章与内容
文章(Article,表 cms_article)是 CmsKit 的核心长内容形态:Markdown 正文、分类(Classify)、频道(Channel)、独立草稿箱(ArticleDraft)、定时发布、置顶/推荐、热门指数,并自动同步到 MeiliSearch 供检索。它是 Content 子域的「生产侧」聚合根,本身不直接处理评论点赞——互动通过 SubjectId 挂上来(见 互动)。
概念与关系
文章在领域模型里通过外键/导航属性与多个概念相连(均来自 Domain/Articles/Article.cs):
- Channel(频道):
ChannelId必填,文章挂载到系统内置技术频道,建表即对ChannelId建索引——频道是「导航分组」而非作者可随意创建的维度。 - Classify(分类专栏):
ClassifyId可选,ClassifySort记录专栏内排序;作者可在专栏内手动排序(UpdateArticleClassifySortAsync)。 - ArticleDraft(草稿箱):
Article.ArticleDraft一对一导航,草稿是独立表cms_article_draft,与正式文章物理分离。 - Tag(标签):经
ArticleTag多对多关联(TagArticles导航)。 - UserLike / Comment / BookmarkItem:均以
SubjectId指向文章,复用互动层。 - MeiliSearch 索引:发布/更新后由后台作业写入对应索引。
设计动机(why)
草稿与正式文章分离
ArticleDraft 是独立表(Domain/Articles/ArticleDraft.cs),Article 通过 ArticleDraft 导航一对一持有草稿。编辑器自动保存只写草稿箱,绝不触碰已发布的 Article。原因很直接:自动保存是高频、易中断的操作,不能让它污染线上内容或触发索引/通知。发布时再由草稿生成或更新正式文章——这让「编辑中」和「线上」两个状态天然隔离。
定时发布走后台作业而非请求链路
文章设置 ScheduledPublishTime(SetScheduledPublish 会校验时间必须晚于当前)后,发布动作并不发生在用户请求里。CmsKitJobService.ScheduledPublishAsync 定时扫描满足 ScheduledPublishTime <= now && !IsDeleted 的文章批量上线:清空 ScheduledPublishTime、写 PublishedTime、(若三条轴都通过)同步 MeiliSearch、给作者发一条系统通知(NotificationAction.System)。这样设计的好处是:发布动作与「作者点击那一刻」解耦——即便应用在发布点短暂不可用,下一轮扫描仍能补发,且不阻塞作者端写请求。
注意该扫描刻意不筛 AuditStatus:任务的职责只是「钟点到了,清掉计时器」,内容能不能露出是审核那条轴的事。若在这里带上 AuditStatus == Approved,一旦审核开启(配置 Article.AuditStatus.Default 改为 Pending),「待审核 + 排了定时」的文章到点后永远捞不出来,会带着一个过期时间烂在定时列表里、作者也收不到反馈。
可见性由三条正交轴决定,Article 表不存发布状态
一行 Article 的存在本身就代表作者已提交发布,所以表上没有「发布状态」列。是否对外可见由三条各归属不同决策主体的轴共同决定:
| 轴 | 字段 | 谁决定 |
|---|---|---|
| 时间 | ScheduledPublishTime(非 null = 排了定时还没到点) | 作者 |
| 审核 | AuditStatus | 平台 |
| 受众 | PrivacyType | 作者 |
正因为定时只是一条独立的轴而不是某个状态值,「取消定时」只需清空时间字段,文章立即回到原本的可公开状态,不需要记录「原来是什么」。
判断可见性请一律走 PublicContentVisibility——匿名列表(RSS/Atom、搜索索引、推荐召回)用 ArticleIsPubliclyListed,不要自己拼条件。历史上各处手写这个组合、漏掉定时或审核,真实漏了 6 处。
内容检索走 MeiliSearch,且以「轮询增量」为主
文章/沸点在创建或更新后,并不依赖实时领域事件写索引,而是由 CmsKitJobService.IncrementalSyncMeiliSearchAsync 每轮扫描 UpdateTime 落在最近 5 分钟窗口、且「公开 + 审核通过 + 未软删」的内容增量同步;软删除的内容则从索引移除。文章索引按 ArticleCategory 拆成 articles_Normal / articles_AiNews,沸点为单一 shortmsgs 索引。选择轮询而非事件驱动,是为了在写入吞吐与检索新鲜度之间取平衡,并避免主写入链路被搜索引擎拖慢;代价是新内容最多有约 5 分钟的检索延迟(到点发布的文章则在发布瞬间直接索引,无此延迟)。
置顶 / 推荐 / 热门榜是三件不同的事
- 置顶
IsStickie:仅作用于「用户主页」排序(SetStickie),不是全站头条。 - 推荐
IsRecommend:由管理端SetRecommend控制,可推到首页(SetRecommendTime记录推荐时间)。 - 热门榜
HotIndex:由ArticleHotIndexService计算热度指数,GetHotRankArticleListAsync支持日/周/月/总榜——是数据驱动的排行,而非人工置顶。
AI 提取 SEO:能力受设置开关约束
ExtractSeoAsync 从正文抽取关键词/摘要/标签,但它依赖 AI.ContentAnalysis 配置(JSON);配置为空时该能力不可用。该接口同时要求作者身份或 CmsKit.Articles.ExtractSeo 权限——SEO 提取是「作者/运营」才有的操作。
业务能力
- 发布/编辑 Markdown 文章,编辑器自动保存草稿不影响线上;
- 定时发布(到点由后台作业异步上线);
- 按频道(必填导航)与分类专栏(可选,支持专栏内排序)组织内容;
- 置顶(用户主页)、首页推荐、数据驱动的热门榜(日/周/月/总);
- AI 提取 SEO(关键词/摘要/标签,受
AI.ContentAnalysis配置开关约束); - HTML→Markdown 导入(
Html2MdService,便于把外部网页/富文本搬进文章); - 发布/更新即同步 MeiliSearch 检索(普通文章 / AI 资讯两套索引)。
开发示例
// 注入 IArticleService 发布文章
var article = await _articleService.CreateAsync(new CreateUpdateArticleReq
{
Title = "Hello CmsKit",
Content = "# Markdown...",
ClassifyId = classifyId,
Commentable = true
});
// 走 MeiliSearch 检索(注入 IArticleMeiliSearchService)
var docs = await _articleMeiliSearchService.SearchArticlesAsync("cmskit", page: 1, pageSize: 20);
// 用户主页置顶
await _articleService.SetStickieAsync(new SetStickieReq { Id = article.Id, Stickie = true });
频道与分类:用户端只读(导航/详情),管理端通过
[KitAuthorize(CmsKitPermissions.Channels.* / Classifies.*)]维护。文章「是否可评论」有全局默认开关Article.Commentable,作者可在内容级按UpdatePrivacyWithCommentable覆盖,且私密文章(仅自己可见)被禁止开放评论——这是实体内的业务一致性约束。