管理后台审核流程与领域状态机
YouTeacher 的管理后台,是人工审核者处理那些不能交给开放自助流程的事情的地方:判定一所学校确实是它自称的那所、把一条雇主记录交给某个所有者、批准招聘者、调整某人的配额、以及隐藏一个职位或编辑一篇不该照原样留着的帖子。真正有意思的地方不是这堆按钮——而是"某个按钮什么时候被允许生效"这条规则,是以值对象状态机的形式活在领域层里的,而不是散落在各个 handler 中。一次审核动作,是传输层校验后向下传递的一个请求;这次状态迁移是否合法,由聚合本身来决定——它会抛错,而不会默默做错事。
雇主验证是一次带守卫的迁移
一个雇主的验证状态是四个值之一:unverified、pending、verified、rejected。VerificationStatus 值对象拥有这个集合——它是这些字符串唯一被允许存在的地方,fromString 对集合之外的任何值都会抛错拒绝。状态不是一个你随手赋值的普通字段;它会回答关于自身的问题。canVerify() 和 canReject() 都只在 unverified 或 pending 时返回 true。所以一个已经验证或已经拒绝的雇主,无法再次被验证或拒绝——在任何持久化发生之前,这扇门就已经在值对象这一层关上了。
Employer 聚合在此之上构建。调用 verify() 会先问 canVerify();如果答案是否,它就抛出一个错误,指明当前状态和它本该处于的状态。成功时它并不就地修改——而是返回一个新的 Employer,状态置为已验证、清空拒绝原因、刷新 updatedAt。reject(reason) 与之对称:先用 canReject() 守卫,再返回一个标记为已拒绝、并存下原因的新雇主。聚合的构造函数是私有的,其 props 是只读的;已有记录通过 reconstitute 工厂方法回填。因此每一次迁移都是不可变的——你从不原地编辑一个雇主,而是推导出下一个——这让一次非法迁移变成单一收口处抛出的错误,而不是一个被污染的字段。
所有权是第二台独立的状态机
验证(这所学校是真的吗?)与所有权(有没有人拥有这条记录?)被分开对待。assignOwner(userId) 由 canAssignOwner() 守卫,无法进行时抛出 "already claimed";成功时返回一个带上 user id、所有权状态为 claimed 的新雇主。removeOwner() 是它的逆操作——同样带守卫,当没有东西可移除时抛出 "not claimed",然后返回一个去掉 user id、所有权回到 unclaimed 的雇主。聚合把 isClaimed / isUnclaimed 作为委托给所有权值对象的计算读值暴露出来,与验证侧的 isPending / isVerified / isRejected 是同一套模式。两台正交的机器,各自拒绝自己的非法动作。
REST 边缘:先校验,再委派
雇主验证的这几条路由,展示了整个管理后台面的形状。有一个分页的 GET 用于待处理雇主(从 query 读取可选的 page 和 limit)、一个 POST 用于验证、一个 POST 用于拒绝。验证和拒绝的 handler 自己几乎什么都不做:它们把请求体按一个 zod schema 解析、调用 use case、把结果发回去。来自领域层的错误——包括那些抛出的非法迁移消息——统一通过一个共享的错误处理器输出,而不是每条路由各自编造响应。
请求契约小而明确
所有管理后台的请求形状都是集中在一处的 zod schema,因此每一个审核动作的词汇一眼可辨:
- 雇主——验证只要一个
employerId;拒绝要一个employerId加一个可选的reason。 - 招聘者——同样的一对:一个 id 用于批准,一个 id 加可选原因用于拒绝。
- 配额——充值(
email、一个正数amount)与设定(email、一个 ≥ 0 的limit和 ≥ 0 的used),两者都带一个quotaType,取值为invitation或jobPublish,默认invitation。两种额度,一个 schema 家族。 - 所有权——指派要一个所有者的
email和可选notes;移除要可选notes;一个合并 schema 取sourceId和targetId,用于把一条雇主记录并入另一条。 - 认领请求——批准(
claimId、可选notes)与拒绝(claimId、可选reason、可选adminNotes),这是有人申请拥有一条既有雇主记录的入口路径。 - 内容——一个帖子更新 schema,其字段(标题、正文及其格式、摘要、封面图、分类、meta 标题与描述)全部可选,其中数个明确可为 null,因此审核者可以编辑或清空任意单个字段而不触碰其余。
- 职位——一个可见性 schema,在
visible与hidden之间翻转一个职位;以及一个只含jobId的 schema,用于仅凭 id 的动作。
贯穿始终的一条线:传输层的职责是证明请求格式良好(一个非空 id、一个合法 email、一个落在区间内的数、一个被允许的枚举值),领域层的职责是证明这次迁移合法。两者都不指望对方替自己完成那一半。