Civitai 图片检索输入类型体系剖析:GetAllImagesInput 与 ImageSearchInput 的逐字段对比与用户上下文扁平化
【免费下载链接】civitaiA repository of models, textual inversions, and more项目地址: https://gitcode.com/GitHub_Trending/ci/civitai
本文基于 docs/type-comparison.md 展开,系统梳理 Civitai 主应用图片无限流(image feed)两条核心输入类型——GetAllImagesInput与ImageSearchInput——的继承关系、字段差异与调用链路上的转换逻辑。读者在读完本文后,将能准确理解"完整会话用户对象"与"扁平化搜索字段"两种设计的分界,掌握getAllImagesIndex内部如何解析游标、抽取user.id与user.isModerator并转发给 Meilisearch 搜索路径,以及 event-engine-common 中ImageQueryInput与主应用类型之间尚存的兼容性缺口。
为什么需要一份类型对比文档
在 Civitai 的图片检索架构中,一条"无限滚动图片流"请求会跨越多个层:
- tRPC / REST 控制器层接收原始查询参数;
getAllImagesIndex作为聚合入口,携带完整会话对象user;getImagesFromSearch等搜索函数只接收扁平化的标量字段(currentUserId、isModerator),不再持有整个会话对象。
这两套输入类型的字段面几乎重合,但"用户"这一维度的承载方式截然不同。如果不把类型继承关系显式固化下来,开发者很容易在某一层误用userId(创建者过滤)与currentUserId(当前浏览者)——正如源码注释中强调的:"passing the wrong one turns this into a check that always passes"(传错字段会让权限检查永远通过)。本文要做的就是沿着 image.service.ts 的真实定义,把这条类型链逐层拆开。
类型层级全景:四层继承链
文档给出的类型关系可以用一张继承链概括(对应源码中 image.service.ts 与 image.service.ts 的实际定义):
baseQuerySchema(基座) └─> GetInfiniteImagesOutput(图片无限流查询输出/输入,最大字段集) └─> GetAllImagesInput(增加会话用户与请求上下文) └─> ImageSearchInput(增加扁平化用户字段与游标分页字段)基座:baseQuerySchema
baseQuerySchema = { browsingLevel: number (default: allBrowsingLevelsFlag) }browsingLevel是 NSFW 浏览级别的位标志(bit flag),默认值allBrowsingLevelsFlag由 event-engine-common 中的常量组合而来(image-feed-types.ts):
export const sfwBrowsingLevelsFlag = NsfwLevel.PG | NsfwLevel.PG13; export const nsfwBrowsingLevelsFlag = NsfwLevel.R | NsfwLevel.X | NsfwLevel.XXX; export const allBrowsingLevelsFlag = sfwBrowsingLevelsFlag | nsfwBrowsingLevelsFlag;其中NsfwLevel是一个数值枚举(PG=1, PG13=2, R=4, X=8, XXX=16, Blocked=32),位运算设计使多个级别可以合并进单个number,这也是整个类型体系中几乎所有过滤条件得以高效传递的基础。
第一层扩展:GetInfiniteImagesOutput
GetInfiniteImagesOutput在基座上叠加了两组字段。第一组来自imagesQueryParamSchema(常规查询参数):
| 字段 | 类型 | 说明 |
|---|---|---|
baseModels? | BaseModel[] | 基础模型过滤 |
collectionId?/collectionTagId? | number | 集合/集合标签过滤 |
hideAutoResources?/hideManualResources? | boolean | 隐藏自动/手动关联资源 |
followed? | boolean | 仅看关注对象 |
fromPlatform? | boolean | 仅看平台内容 |
hidden? | boolean | 仅看隐藏内容 |
limit | number | 每页数量,min: 0, max: 200,默认galleryFilterDefaults.limit |
modelId?/modelVersionId? | number | 模型/模型版本过滤 |
notPublished? | boolean | 仅看未发布内容 |
period | MetricTimeframe | 时间窗口,默认galleryFilterDefaults.period |
periodMode? | PeriodMode | 时间窗口模式 |
postId? | number | 帖子过滤 |
prioritizedUserIds? | number[] | 优先展示的用户 |
reactions? | ReviewReactions[] | 按反应类型过滤 |
scheduled? | boolean | 仅看定时发布内容 |
sort | ImageSort | 排序方式,默认galleryFilterDefaults.sort |
tags?/techniques?/tools? | number[] | 标签/技法/工具过滤 |
types? | MediaType[] | 媒体类型(image/video/audio) |
useIndex? | boolean | 是否走索引 |
userId?/username? | number/string | 按创建者过滤 |
withMeta | boolean | 是否带元数据,默认false |
requiringMeta? | boolean | 仅看必须有元数据的 |
第二组是额外字段,包括游标分页、内容审核与排除类条件:
cursor?: bigint | number | string | Date—— 游标,用于续页;excludedTagIds?/excludedUserIds?—— 排除指定标签/用户;generation?: ImageGenerationProcess[]—— 生成过程类型;ids?/imageId?/postIds?/reviewId?—— 按 ID 直接查询;include: ImageInclude[](默认['cosmetics'])—— 控制服务端返回时附带哪些富化数据(如cosmetics、tags、tagIds、profilePictures、metaSelect等);includeBaseModel?、pending?、skip?、withTags?—— 各类开关;- 混排/去重控制:
remixOfId?、remixesOnly?、nonRemixesOnly?; - POI/未成年内容控制:
disablePoi?、disableMinor?,以及仅审核员可用的poiOnly?、minorOnly?。
第二层扩展:GetAllImagesInput
type GetAllImagesInput = GetInfiniteImagesOutput & { useCombinedNsfwLevel?: boolean; user?: SessionUser; // ← FULL USER OBJECT domain?: DomainColor; // 请求来源颜色,用于选择 New & Upcoming 面板 headers?: Record<string, string>; // 请求头(TODO: 是否必需待定) dbTarget?: 'read' | 'write' | 'datapacket'; // 走读库/写库/数据包副本 signal?: AbortSignal; actor?: string; // 调用者身份,经 buildSearchActor() 构造后传给 Meili X-Search-Actor };与GetInfiniteImagesOutput相比,GetAllImagesInput的关键新增是:
user?: SessionUser—— 完整会话用户对象,内含id、isModerator、username、email、permissions等;useLogicalReplica—— 文档描述为必需字段(当前源码中该字段名已演化为dbTarget?: 'read' | 'write' | 'datapacket',默认'read',读者应以当前实现为准);domain、headers、signal、actor等请求上下文。
这一层是"全量上下文"边界:凡是需要完整用户信息(如enforceBlockedBrowsingTags需要user.username)的逻辑,都在这一层或更上层完成。
第三层扩展:ImageSearchInput
type ImageSearchInput = GetInfiniteImagesOutput & { useCombinedNsfwLevel?: boolean; domain?: DomainColor; currentUserId?: number; // ← EXTRACTED FROM user?.id isModerator?: boolean; // ← EXTRACTED FROM user?.isModerator offset?: number; // ← FOR CURSOR PAGINATION entry?: number; // ← FOR CURSOR PAGINATION blockedFor?: string[]; // ← ADDITIONAL FILTER signal?: AbortSignal; actor?: string; };注意:ImageSearchInput在源码中也是从GetInfiniteImagesOutput直接扩展(而非从GetAllImagesInput扩展),但语义上它是"经过getAllImagesIndex组装后的搜索输入",即文档所述"ImageSearchInput = GetAllImagesInput + 扁平化字段"的意图。它不再携带user对象,而是把用户信息降维成两个标量。
关键差异:谁多谁少
GetAllImagesInput 独有、ImageSearchInput 没有的字段
没有。ImageSearchInput继承了GetAllImagesInput的全部字段(并在语义上额外拥有扁平化字段),因此不存在"前者有、后者无"的字段。
ImageSearchInput 独有、GetAllImagesInput 没有的字段
currentUserId?: number—— 从user?.id抽取的当前浏览者 ID;isModerator?: boolean—— 从user?.isModerator抽取的审核员标志;offset?: number—— 游标解析出的偏移量;entry?: number—— 游标解析出的条目时间戳;blockedFor?: string[]—— 额外的屏蔽原因过滤(如tos、moderated、CSAM等BlockedReason,见 image-feed-types.ts)。
用户处理的本质差异:完整对象 vs 扁平化字段
这是两个类型之间最关键的设计分界:
GetAllImagesInput持有user?: SessionUser,即完整的会话对象(id、isModerator、username、email、permissions、emailVerified、createdAt等);ImageSearchInput只接收两个派生标量:currentUserId(来自user?.id)与isModerator(来自user?.isModerator)。
这种"降维"有明确的工程动机,源码注释给出了两个直接理由:
- 防止 PII 泄漏进日志。
getInfiniteImagesHandler会把整个ctx.user展开进搜索输入用于业务逻辑,如果不剥离,每次搜索报错都会把email、emailVerified、username、createdAt写进日志——生产环境一度每天产生 33.1 万条携带真实账号信息的记录。为此源码专门实现了redactSearchInputForLog(image.service.ts),把user对象整体丢弃(而非逐个删除已知 PII 键,因为黑名单会在下次新增字段时失效),同时保留currentUserId与isModerator这两个搜索路径真正用到的字段。 - 避免
userId语义冲突。userId在搜索输入中已经是"按创建者过滤"的查询条件,若把user.id再映射到同名键,会覆盖掉真实的查询条件、污染日志与过滤逻辑。
转换流程:getAllImagesIndex 中的拆解与组装
文档描述的转换发生在getAllImagesIndex(当前源码位于 image.service.ts)。其核心步骤:
const { include, user } = input; // 1. 取出会话用户 const cursorParsed = input.cursor?.toString().split('|'); // 2. 解析游标 const offset = isNumber(cursorParsed?.[0]) ? Number(cursorParsed?.[0]) : 0; const entry = isNumber(cursorParsed?.[1]) ? Number(cursorParsed?.[1]) : undefined; const currentUserId = user?.id; // 3. 抽取用户 ID const userId = input.userId ?? (input.username ? await getUserIdByUsername(input.username) : undefined); const searchInput = { ...input, // 4. 展开全部查询字段 userId, currentUserId, // ← 扁平化:user?.id isModerator: user?.isModerator, // ← 扁平化:user?.isModerator offset, // ← 游标解析出的偏移量 entry, // ← 游标解析出的条目时间戳 };值得补充的两个源码细节:
- 游标格式:
cursor采用"offset|entryTimestamp"形式,例如"500|1724677401898"——前半是偏移量,后半是条目时间戳(毫秒)。解析失败时offset回退为0、entry为undefined(image.service.ts)。 - 组装后的分发:
searchInput会先尝试feedPrimary路径(由 Flipt 特性开关FEED_SERVICE_PRIMARY控制,按currentUserId或'anonymous'分桶);不可用时才真正调用getImagesFromSearch(searchInput)(image.service.ts)。若 Meilisearch 瞬时过载,会把可重试的瞬时错误重新归类为TRPCError SERVICE_UNAVAILABLE(HTTP 503)而非 408/500,避免请求在事件循环上堆积。
对测试的影响:moderator 场景的测试难点
由于真实实现从会话/认证中取user,再抽取user.id与user.isModerator,因此:
- 真实实现路径:测试必须能构造携带
user.isModerator = true的真实认证会话; - 测试端点路径:若测试端点只接受
isModerator、currentUserId作为查询参数,则无法真正验证 moderator 功能,除非满足二者之一:- 接受带有
user.isModerator = true的真实认证会话;或 - 在内部为开发/测试目的 mock 用户对象。
- 接受带有
这意味着围绕审核权限的测试(如notPublished、poiOnly、minorOnly、pendingReviewOnly等审核专属过滤条件)不能只靠"传个参数"来覆盖,而必须穿透到会话层或 mock 层。仓库中已有不少针对该链路的测试可以佐证这一结论,例如:
- image-infinite-wire.test.ts —— 验证无限流数据结构;
- image.controller.feed-source.test.ts —— 验证 feed 数据源选择;
- image-search.client-ip.test.ts 与 image-search-username-type.test.ts —— 覆盖搜索路径的边界条件。
另外,授权逻辑本身也在服务层做了防呆设计:canRequestUnpublished(image.service.ts)区分"创建者被浏览对象"(targetUserId)与"当前浏览者"(currentUserId),非审核员只有在请求已明确限定到本人时才允许查看未发布内容,缺失targetUserId时宁可拒绝也不默认放行——这正是文档强调"currentUserId与userId不可混淆"在权限层的落点。
event-engine-common 中尚未覆盖的字段
event-engine 的ImageQueryInput(image-feed-types.ts)是为事件引擎/feed 服务移植的类型,目前已经覆盖了大部分常规过滤(排序、NSFW 位标志、用户/内容/资源/元数据/混排/时间/发布状态/审核过滤、include富化选项与enableExistenceCheck特性开关)。但与主应用的GetInfiniteImagesOutput相比,以下字段尚未在ImageQueryInput中处理:
| 分类 | 未处理字段 |
|---|---|
| 集合类 | collectionId、collectionTagId |
| 资源显示类 | hideAutoResources、hideManualResources |
| 可见性/关注类 | hidden、followed |
| 推荐/互动类 | prioritizedUserIds、reactions |
| 单体查询类 | imageId、includeBaseModel、pending、reviewId |
| 分页类 | skip |
| 附加数据类 | withTags |
| 混排控制类 | remixesOnly、nonRemixesOnly |
| POI/未成年控制 | disablePoi、disableMinor、poiOnly(审核)、minorOnly(审核) |
这些字段若要在 event-engine 中实现与主应用完全兼容的查询面,需要按上面清单逐项补齐到ImageQueryInput。对照可见,GetInfiniteImagesOutput是"最大公约数",ImageQueryInput目前是它的一个真子集——这也是 docs/type-comparison.md 落笔时指出的主要兼容性缺口;同理,主应用侧ImageSearchInput中被注释掉的prioritizedUserIds、modelId、reviewId等"Unhandled"字段(image.service.ts)也从侧面印证:类型定义宽于实际查询路径支持,是这套体系需要持续对齐的现实。
小结
把整条链路串起来看:baseQuerySchema提供 NSFW 浏览级别基座 →GetInfiniteImagesOutput展开全部查询与富化字段 →GetAllImagesInput挂上完整会话对象与请求上下文 →getAllImagesIndex在入口处解析游标、把user扁平化为currentUserId/isModerator→ImageSearchInput作为纯标量搜索输入进入 Meilisearch / feed 路径。这一设计在"业务层需要完整用户上下文"与"搜索层只需要最小标量、且不能泄漏 PII"之间划出了清晰边界。对测试与二次开发而言,牢记两点即可少踩坑:userId是创建者过滤、currentUserId是浏览者身份,二者方向相反;审核功能验证必须穿透会话层或显式 mock,仅靠查询参数无法覆盖。
【免费下载链接】civitaiA repository of models, textual inversions, and more项目地址: https://gitcode.com/GitHub_Trending/ci/civitai
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考