内容域模型
youteacher_content 模块把业务规则放在无法被绕过的地方:域聚合本身内部。一共三个聚合——Post、Category、Page——外加一个共享值对象 Slug。每个聚合都是不可变的:任何会产生变化的方法(update、publish、moveTo 等)都返回一个全新的实例,而不是原地修改;获得实例的途径只有两条——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——默认值——或 html 或 markdown)、摘要、可选的特色图、可选的 categoryId、SEO meta 字段、反范式化的作者字段(authorId、authorName、authorAvatarUrl),以及一组附件。它的 slug 由标题经 Slug.fromTitle 自动生成——create 时生成,update 时只要标题变了就重新生成。
草稿 / 发布,带定时。 刚创建的 post 一定是 draft,publishedAt 为 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 去空格后必须非空,大小必须为正,且不得超过 10MB(10 * 1024 * 1024)。reconstitute 从存储重建时不重跑这些守卫。
Category——嵌套到深度 3
Category 构成一棵树,MAX_DEPTH 硬上限为 3。每个 category 存的不只是 parentId,还有两个物化的祖先数组:path(祖先 category id)和 slugPath(祖先 slug),以及一个数值 depth。fullSlugPath 把 slugPath 和自身 slug 拼成一个 a/b/c 字符串;isRoot 就是没有父节点。
深度上限在 create 和 moveTo 两处都检查:一个自身 depth 已达 MAX_DEPTH - 1 的父节点不能再接子节点,因为那个子节点会落到被禁止的层级。moveTo(newParent) 多加一道守卫——它拒绝把一个 category 移到它自己的后代之下(通过检查目标父节点的 path 是否已包含本 category 的 id 来判定),否则就会造出一个环。create 和 moveTo 都会从父节点重新计算 path、slugPath 和 depth。detach() 就是 moveTo(null)——把 category 提回根节点,祖先数组清空、depth 归零。
Page——独立的 Puck 页面
Page 是最简单的聚合。它持有一个 slug(一个由调用方提供的普通字符串,不是 Slug 值对象,也不自动生成)、一个标题,以及 puckData——一个装可视化页面构建器布局的不透明 Record,或者 null。它有同样的 draft / published 状态字段,但没有定时:publish() 和 unpublish() 只是翻转状态,背后没有任何日期逻辑,isPublished 就是一个纯状态检查。这是它和 Post 有意为之的区别——Page 是被编排的布局,不是一篇带日期的博客条目。
为什么是这个形状
真正要紧的那些不变量——slug 永远是 URL 安全的、post 在到点前绝不对公众可见、category 树永不超过三层也不成环、附件永不超大——全都活在聚合内部,在构造与变更时强制执行。上游没人需要记得去检查它们,因为构建这些对象的唯一途径都要穿过守卫。