content-domain-model

内容域模型

youteacher_content 模块把业务规则放在无法被绕过的地方:域聚合本身内部。一共三个聚合——PostCategoryPage——外加一个共享值对象 Slug。每个聚合都是不可变的:任何会产生变化的方法(updatepublishmoveTo 等)都返回一个全新的实例,而不是原地修改;获得实例的途径只有两条——create(带默认值的全新实体)和 reconstitute(从持久化状态重建)。

Slug——共享的标识符

Slug 是 post 和 category 共享的值对象,有两个入口。

Slug.create(value) 是严格的那个:先转小写、去首尾空格,然后要求结果匹配 ^[\da-z]+(?:-[\da-z]+)*$——只允许小写字母、数字,以及段与段之间的单个连字符,其它一概不行——并把长度上限定在 200 字符。超出范围就抛错。

Slug.fromTitle(title) 是宽容的那个,post 或 category 创建、改名时都走它。它转小写、剥掉所有非 ASCII 字母/数字/空格/连字符的字符,把连续空格折成单个连字符,把连续连字符折成一个,再把首尾多余的连字符去掉。有意思的边界是纯 CJK 或纯符号的标题:经过 ASCII-only 过滤后它会 slug 成空串。此时 fromTitle 不让请求失败,而是回退到一个不透明的生成 id,形如 post- 加上一个十字符的 nanoid(取自同一套小写字母数字字符集)——这样它仍然能通过严格正则。可读的 CJK 标题在 UI 里照常显示,只有 URL 里的 id 是不透明的。最终 slug 被截断到 200 字符。

Post——博客聚合根

一个 Post 携带它的标题、带 contentFormat 的正文(blocknote——默认值——或 htmlmarkdown)、摘要、可选的特色图、可选的 categoryId、SEO meta 字段、反范式化的作者字段(authorIdauthorNameauthorAvatarUrl),以及一组附件。它的 slug 由标题经 Slug.fromTitle 自动生成——create 时生成,update 时只要标题变了就重新生成。

草稿 / 发布,带定时。 刚创建的 post 一定是 draftpublishedAt 为 null。publish() 不带参数即刻发布(publishedAt = now),且是幂等的——对已发布的 post 再发布会原样返回,保留原始日期。publish(未来日期) 是定时路径:post 以 published 状态但一个未来的 publishedAt 被持久化。isPublished 这个 getter 编码了可见性规则——只有当状态为 published publishedAt 非空 publishedAt <= now 时才为真——所以一个定时的 post 在存储里读作「已发布」,但在到点之前对公众不可见。显式日期总是生效,这也是把已上线的 post 重新定时、或把定时的 post 提前的方式。unpublish() 把 post 退回 draft 同时保留 publishedAt,对已是草稿的 post 幂等。

别名 URL。 一个 post 可以在 slug 之外再带一个人工指定的 URL。setAlias 会 trim 输入,拒绝空串或短于三个字符的值;removeAlias 把它清回 null。

附件,有上限。 附件是活在 Post 聚合内部的实体。addAttachment 强制每个 post 最多 10 个附件的硬上限,到顶后抛错。removeAttachment 在 id 不存在时抛错,而不是悄悄什么都不做。

Attachment——带体积守卫的实体

一个 Attachment 记录文件名、url、mime 类型和大小。它的 create 工厂是文件规则所在:文件名和 url 去空格后必须非空,大小必须为正,且不得超过 10MB10 * 1024 * 1024)。reconstitute 从存储重建时不重跑这些守卫。

Category——嵌套到深度 3

Category 构成一棵树,MAX_DEPTH 硬上限为 3。每个 category 存的不只是 parentId,还有两个物化的祖先数组:path(祖先 category id)和 slugPath(祖先 slug),以及一个数值 depthfullSlugPath 把 slugPath 和自身 slug 拼成一个 a/b/c 字符串;isRoot 就是没有父节点。

深度上限在 createmoveTo 两处都检查:一个自身 depth 已达 MAX_DEPTH - 1 的父节点不能再接子节点,因为那个子节点会落到被禁止的层级。moveTo(newParent) 多加一道守卫——它拒绝把一个 category 移到它自己的后代之下(通过检查目标父节点的 path 是否已包含本 category 的 id 来判定),否则就会造出一个环。createmoveTo 都会从父节点重新计算 pathslugPathdepthdetach() 就是 moveTo(null)——把 category 提回根节点,祖先数组清空、depth 归零。

Page——独立的 Puck 页面

Page 是最简单的聚合。它持有一个 slug(一个由调用方提供的普通字符串,不是 Slug 值对象,也不自动生成)、一个标题,以及 puckData——一个装可视化页面构建器布局的不透明 Record,或者 null。它有同样的 draft / published 状态字段,但没有定时publish()unpublish() 只是翻转状态,背后没有任何日期逻辑,isPublished 就是一个纯状态检查。这是它和 Post 有意为之的区别——Page 是被编排的布局,不是一篇带日期的博客条目。

为什么是这个形状

真正要紧的那些不变量——slug 永远是 URL 安全的、post 在到点前绝不对公众可见、category 树永不超过三层也不成环、附件永不超大——全都活在聚合内部,在构造与变更时强制执行。上游没人需要记得去检查它们,因为构建这些对象的唯一途径都要穿过守卫。

about this entry

One of sijie's wiki entries. The AI on this site is grounded in the same corpus and answers in sijie's voice, with citations back to entries like this one — answering costs sijie money, so it waits behind a code: enter an access code →

content-domain-model