22 个审核页面怎么拆:Civitai 的模块边界与渐进式迁移复盘
【免费下载链接】civitaiA repository of models, textual inversions, and more项目地址: https://gitcode.com/GitHub_Trending/ci/civitai
在 Civitai 仓库中,22 个 moderator 审核页面(内容审核、扫描器审计、训练审核、CSAM、生成配置五个业务域)被要求搬进一个独立的卫星应用。拆分带着三条硬约束:新应用必须与主应用共享 Postgres / Redis / ClickHouse 连接;不能 fork 整个仓库;页面要能在新应用里直接构建。表面上这是个"怎么拆"的问题,真正的问题是模块边界画在哪里——哪些代码必须跨应用共享,哪些留在各应用内部,哪些要先重构才能动。
边界问题的分量,看三个数字就明白了。主应用最重的审核页src/pages/moderator/images.tsx有 34 个 import;tRPC 客户端~/utils/trpc被全部 22 个页面导入;枚举文件~/shared/utils/prisma/enums被 14 个页面导入。更关键的是底层:src/server/services/image.service.ts 当前实测 8012 行,直接导入 14 个兄弟服务,与post.service双向互导,是主应用内容图的枢纽。任何一个共享包如果不小心拖进这条链,包就不属于 22 个审核页面了,它属于整个主应用。
docs/moderator-app-shared-modules.md(下称"早期分析")是这场拆分的起点文档。它假设用 git submodule 承载共享代码,设想卫星应用是 Next.js。后来的 docs/moderator-app-package-boundary.md、docs/moderator-app-package-extraction-plan.md 与 docs/monorepo-bootstrap-handoff.md 逐条修订了这些假设,最终落地为 apps/moderator/ 这个 SvelteKit 应用。本文按"问题—规则—节奏—落地—方法论"的顺序复盘这个过程。
🧭 问题背景:拆 22 个审核页面的真实成本
早期分析对 22 个页面的全部导入做了闭包梳理,把依赖划成六组,并给每个页面打了可移植性分级:7 个 Easy、11 个 Medium、6 个 Hard。分级背后有一个规律:Hard 页面的难度几乎都来自单一耦合点。auditor页只有 4 个 import,却因useCheckProfanity(要命中服务端端点)被标为 Hard;images页因 PromptHighlight 与 CSAM hook 两处耦合被标为 Hard。耦合解掉后,难度立刻降回 Medium。
这个分级直接决定了迁移策略:不能一把刀切下去,只能按可移植性分批推进。而分批之前,必须先回答"什么代码允许跨应用流动"。六组依赖的裁定,最终收敛为下面三条规则。
| 依赖分组 | 代表 import | 裁定 |
|---|---|---|
| 契约层(枚举/schema/常量) | ~/shared/utils/prisma/enums(14 页)、zod schema | 规则一:共享 |
| 胶水层(tRPC 客户端、auth hooks) | ~/utils/trpc(22 页)、useCurrentUser | 规则二:各自持有 |
| 审核域组件 | Csam/*、Moderation/*、select.store | 规则二:单消费者留应用内 |
| 通用 UI 词汇 | NextLink(16 页)、EdgeMedia/* | 规则二:vendor-copy(供应商式拷贝)先行 |
| 纯工具函数 | string-helpers、date-helpers | 规则二:拷贝 |
| 移植阻塞点 | image.service、dialog 系统 | 规则三:读/写分离拆解 |
📐 边界判定的三条裁决规则
规则一:数据契约必须共享,两个应用要"说同一种话"。枚举(NsfwLevel、BlockedReason、BlocklistType)、稳定常量、持久化 JSON 形状,属于双应用共同读写数据库列的契约,漂移就是数据损坏,必须收敛到单一来源。仓库里的佐证是 src/shared/utils/prisma/enums.ts——它现在只剩三行:
// Re-export shim: this module moved to the @civitai/db-schema package. export * from '@civitai/db-schema/enums';所有 14 个页面的调用点一行不改,枚举实体则住在 packages/civitai-db-schema/src/enums.ts(1258 行,Prisma 生成器产物)。这条规则同时否决了原方案的civitai-schema-commonsubmodule:docs/monorepo-bootstrap-handoff.md 明确记录"五个基础包,不是六个,没有 civitai-schema-common",理由是位标志解释(browsingLevel.constants.ts)这类领域常量不是基础设施,暂留主应用src/shared/,等卫星应用真正需要再评估。
规则二:单消费者代码留在应用内。全部 22 个页面都导入~/utils/trpc,看起来是"最共享"的代码,裁定却是每个应用持有自己的副本——因为 tRPC 客户端绑定的是本应用的 AppRouter 类型,moderator 应用有自己的 router,共享这份胶水只会造成类型错配。同理,useCurrentUser、FeatureFlagsProvider这类 per-app 胶水(应用私有胶水层)直接 vendor-copy。docs/moderator-app-package-extraction-plan.md 把这条规则推向极致:moderator routers、页面、providers、hooks 一律"one consumer →apps/moderator/",强制抽取的共享包被收紧到一个@civitai/domain契约包(8 个整文件移动 + 2 个成员级抽取:ReportEntity并入enums.ts、CacheTTL抽到cache.ts)。这条规则否决了早期分析提出的civitai-moderator-common子模块(组件 + 页面 + 服务端代码三合一),理由正是"单消费者代码进应用,不打包"。
规则三:巨型服务按"读自持、写选路"拆解。这条规则的前置是一个测试,边界文档称之为治理规则(governing rule):导入闭包测试——对每个拟共享文件,问的不是"它算不算 moderator 相关",而是"它完整的传递~/…导入闭包是否也可移动?"只要有一个叶子摸到 zustand store、tRPC 客户端、provider 或image.service,整个候选即被阻塞。用这个测试量image.service,结果是:整体拉出它等于导入主应用的整个内容图(feed 缓存失效、NSFW 重排队、化妆品、游戏),不可行。解法是把审核面的服务端需求拆成两种形态:
- 读队列——自己拥有。审核队列本质是 SQL(
getImageModerationReviewQueue、getDownleveledImages、strikes 榜单、CSAM 分页、训练队列),只需dbRead+ selectors + enums。把查询函数从image.service/report.service机械抽取出来,SQL 一行不改。 - 跨图写入——按动作选路。纯缓存失效的副作用(如
bustCachesForPosts归根到底是失效 Redis 键)→ 复刻同一批@civitai/redis键的失效,包内完成;真实编排(漫画重排队、通知扇出、游戏状态)→ 代理一次主应用 tRPC 调用,接受网络跳数。
六个隔离服务(moderator.service128 行、blocklist.service、training.service、strike.service等,逐一核实过服务间耦合)可以直接原样抽取,构成新应用自有 router 的骨架。
🔪 迁移节奏:Phase 0 到 Phase 6 的推进逻辑
早期分析给出的路线分七个阶段,推进逻辑可以概括为"契约先行、就地重构、先易后难、延迟提升":
| 阶段 | 核心动作 | 阻塞条件 |
|---|---|---|
| Phase 0 | 发布数据契约(枚举/schema/常量) | 契约未就绪则卫星应用无法启动 |
| Phase 1 | 主应用内就地重构 6 个移植阻塞点 | dialog 系统解耦是最高杠杆项 |
| Phase 2 | 建立卫星应用;vendor-copy UI 词汇与工具;先迁 7 个 Easy 页面 | 依赖 Phase 0/1 |
| Phase 3 | 迁移 Medium 页面(11 个) | 阻塞点重构完成 |
| Phase 4 | 迁移 Hard 页面(6 个) | 各页面单一耦合点解除 |
| Phase 5/6 | 通用 UI 词汇提升为共享包 | 漂移真实造成摩擦才启动 |
两个转折时刻值得展开。第一个在 Phase 2:先迁 7 个 Easy 页面(to-ingest、rating-review、downleveled、ingestion-error、comics-review、strikes、training-data/index)作为概念验证(proof-of-concept),用最小成本验证"自有 router + 共享 DB"的技术栈假设,再谈批量迁移。第二个在 Phase 4:6 个 Hard 页面被整体降级处理——auditor只需给useCheckProfanity配一个配套端点;images需要把 PromptHighlight 抽成纯高亮器、CSAM hook 改写成依赖 dialog 接口而非主应用具体 store。更省事的是,复核时发现部分"阻塞点"是误判:PromptHighlight 的闭包实际自包含(静态词表 + 字符串工具),useCheckProfanity只依赖纯函数库,而generation页随ModelVersionFlag.GenerationDisabled位旗标迁移整体移除。"先易后难"在这里不是排期上的偷懒,而是因为 Hard 的成因是点状耦合,解点即降级。
另一个高杠杆项是 dialog 系统解耦:FlaggedModelsList、CsamDetailsForm、UserBanModal、useReportCsamImages四个组件全部被dialogStore+useDialogContext()卡住。方案是把纯 zustand 的dialogStore提为共享、弹窗组件改接收 props、卫星应用自建轻量DialogProvider——一次重构同时解锁四个组件。
🪞 落地实况:文档假设与仓库现实的差距
早期分析写在 monorepo 化之前,它的载体假设(git submodule + Next.js 卫星应用)没有一条被原样采纳。对照当前仓库,修订与验证情况如下:
| 早期判断 | 现状 |
|---|---|
git submodule +civitai-schema-common | 被否决:改为 pnpm workspace,五个基础设施包 +@civitai/db-schema契约层 |
| 卫星应用用 Next.js | 被修订:落地为 SvelteKit(@sveltejs/kit),见 apps/moderator/package.json |
| 六层依赖分级 + 7/11/6 可移植性裁定 | 被验证:分级、分级理由、迁移顺序在后续文档中被直接沿用 |
修订的直接证据在依赖清单里:apps/moderator/package.json 依赖@civitai/auth、@civitai/db、@civitai/db-schema、@civitai/clickhouse、@civitai/redis、@civitai/mod-utils、@civitai/moderation、@civitai/shared、@civitai/ui,全部是workspace:*引用——"不 fork 仓库、共享连接"的目标兑现为一组 monorepo 拆分中的 workspace 包。应用内部还保留了独立的 apps/moderator/prisma/schema.prisma,是 moderation 专属数据(笔记、strikes、帮助请求)从数据库 introspect(内省生成)而来,从不手写。
路由目录显示迁移范围与早期清单高度吻合,apps/moderator/src/routes/ 下已包含 images、articles、models、reports、blocklists、comics-review、audit、users、xguard、abuse 等审核面:
apps/moderator/src/routes/ ├── images/ articles/ models/ reports/ ├── blocklists/ comics-review/ audit/ ├── users/ xguard/ abuse/ admin/ └── 页面访问集中门控于 hooks.server.tsapps/moderator/CLAUDE.md 记录了这套拆分之后长出来的治理规则:页面授权与动作授权是两条独立轴(页面 grant 管"能打开什么",permission 管"能做什么"),权限 id 是持久化值、改一个 id 等于改一列列名,新页面在授权前默认不可达。回头看,这些规则在早期分析里只以五个"开放问题"的形式存在(认证怎么做、CSAM 是否迁移、过渡期页面归属等)——文档留下了问题,落地过程补上了答案。主应用侧的对照也符合渐进式迁移预期:Csam/*、Moderation/*、src/store/select.store.ts、src/server/services/image.service.ts、src/server/common/moderation-helpers.ts 仍以源码存在,"主应用先就地重构、卫星应用按需迁移"的路线在代码层面是可追溯的。
✅ 可复用的方法论清单
从这条分析链里能带走的,是六条可迁移到其他项目的原则:
- 先画依赖地图,再谈拆分。把待迁移页面的全部导入归为契约、胶水、域组件、通用 UI、工具、阻塞点六组,用"多少个页面导入它"量化每项的共享价值。
- 用导入闭包测试做唯一裁决。对每个拟共享文件跑一遍传递导入闭包:只要有一个叶子摸到全局状态、RPC 客户端或 provider,整份候选标记为阻塞,直到叶子被处理。
- 契约按共享数据裁定,代码按消费者数量裁定。双应用读写的枚举、常量、持久化 JSON 形状必须打包成单一来源;单消费者代码即使"看起来相关",也留在应用内。
- 巨型服务用"读队列自持 + 写入逐动作选路"拆解。查询函数机械抽取(SQL 不变);写操作按副作用分类,选"复刻缓存失效键"或"代理一次 RPC",拒绝整体迁移。
- UI 原语 vendor-copy 先行,提升延后。接受早期重复成本,等视觉漂移真实造成摩擦再提升为共享包,避免在拆分窗口内做全应用调用点重写。
- 移动分两步提交。第一步纯
git mv重命名(保持 R100 相似度以保住--follow历史),第二步才加 re-export shim(重导出垫片)与 import 改写,让数百个调用点保持不动。
收尾:这份早期分析真正值钱的地方
git submodule 没了,civitai-schema-common没了,三个子模块收敛成一个@civitai/domain契约包,Next.js 换成了 SvelteKit。但六层依赖分级、7/11/6 的可移植性裁定、导入闭包测试、读/写分离解法,在后续每一份修订文档里都被原样继承,并最终兑现为apps/moderator与一组@civitai/*workspace 包。当 monorepo 面对"代码怎么拆"时,答案很少躺在某一份方案里,而是躺在一串互相修订的文档、以及修订链尽头落定的代码里。
【免费下载链接】civitaiA repository of models, textual inversions, and more项目地址: https://gitcode.com/GitHub_Trending/ci/civitai
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考