Civitai 图片检索输入类型体系剖析:GetAllImagesInput 与 ImageSearchInput 的逐字段对比与用户上下文扁平化
2026/9/18 15:19:13 网站建设 项目流程

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)两条核心输入类型——GetAllImagesInputImageSearchInput——的继承关系、字段差异与调用链路上的转换逻辑。读者在读完本文后,将能准确理解"完整会话用户对象"与"扁平化搜索字段"两种设计的分界,掌握getAllImagesIndex内部如何解析游标、抽取user.iduser.isModerator并转发给 Meilisearch 搜索路径,以及 event-engine-common 中ImageQueryInput与主应用类型之间尚存的兼容性缺口。

为什么需要一份类型对比文档

在 Civitai 的图片检索架构中,一条"无限滚动图片流"请求会跨越多个层:

  1. tRPC / REST 控制器层接收原始查询参数;
  2. getAllImagesIndex作为聚合入口,携带完整会话对象user
  3. getImagesFromSearch等搜索函数只接收扁平化的标量字段currentUserIdisModerator),不再持有整个会话对象。

这两套输入类型的字段面几乎重合,但"用户"这一维度的承载方式截然不同。如果不把类型继承关系显式固化下来,开发者很容易在某一层误用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仅看隐藏内容
limitnumber每页数量,min: 0, max: 200,默认galleryFilterDefaults.limit
modelId?/modelVersionId?number模型/模型版本过滤
notPublished?boolean仅看未发布内容
periodMetricTimeframe时间窗口,默认galleryFilterDefaults.period
periodMode?PeriodMode时间窗口模式
postId?number帖子过滤
prioritizedUserIds?number[]优先展示的用户
reactions?ReviewReactions[]按反应类型过滤
scheduled?boolean仅看定时发布内容
sortImageSort排序方式,默认galleryFilterDefaults.sort
tags?/techniques?/tools?number[]标签/技法/工具过滤
types?MediaType[]媒体类型(image/video/audio)
useIndex?boolean是否走索引
userId?/username?number/string按创建者过滤
withMetaboolean是否带元数据,默认false
requiringMeta?boolean仅看必须有元数据的

第二组是额外字段,包括游标分页、内容审核与排除类条件:

  • cursor?: bigint | number | string | Date—— 游标,用于续页;
  • excludedTagIds?/excludedUserIds?—— 排除指定标签/用户;
  • generation?: ImageGenerationProcess[]—— 生成过程类型;
  • ids?/imageId?/postIds?/reviewId?—— 按 ID 直接查询;
  • include: ImageInclude[](默认['cosmetics'])—— 控制服务端返回时附带哪些富化数据(如cosmeticstagstagIdsprofilePicturesmetaSelect等);
  • 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的关键新增是:

  1. user?: SessionUser—— 完整会话用户对象,内含idisModeratorusernameemailpermissions等;
  2. useLogicalReplica—— 文档描述为必需字段(当前源码中该字段名已演化为dbTarget?: 'read' | 'write' | 'datapacket',默认'read',读者应以当前实现为准);
  3. domainheaderssignalactor等请求上下文。

这一层是"全量上下文"边界:凡是需要完整用户信息(如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 没有的字段

  1. currentUserId?: number—— 从user?.id抽取的当前浏览者 ID;
  2. isModerator?: boolean—— 从user?.isModerator抽取的审核员标志;
  3. offset?: number—— 游标解析出的偏移量;
  4. entry?: number—— 游标解析出的条目时间戳;
  5. blockedFor?: string[]—— 额外的屏蔽原因过滤(如tosmoderatedCSAMBlockedReason,见 image-feed-types.ts)。

用户处理的本质差异:完整对象 vs 扁平化字段

这是两个类型之间最关键的设计分界

  • GetAllImagesInput持有user?: SessionUser,即完整的会话对象(idisModeratorusernameemailpermissionsemailVerifiedcreatedAt等);
  • ImageSearchInput只接收两个派生标量:currentUserId(来自user?.id)与isModerator(来自user?.isModerator)。

这种"降维"有明确的工程动机,源码注释给出了两个直接理由:

  1. 防止 PII 泄漏进日志getInfiniteImagesHandler会把整个ctx.user展开进搜索输入用于业务逻辑,如果不剥离,每次搜索报错都会把emailemailVerifiedusernamecreatedAt写进日志——生产环境一度每天产生 33.1 万条携带真实账号信息的记录。为此源码专门实现了redactSearchInputForLog(image.service.ts),把user对象整体丢弃(而非逐个删除已知 PII 键,因为黑名单会在下次新增字段时失效),同时保留currentUserIdisModerator这两个搜索路径真正用到的字段。
  2. 避免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回退为0entryundefined(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.iduser.isModerator,因此:

  • 真实实现路径:测试必须能构造携带user.isModerator = true真实认证会话
  • 测试端点路径:若测试端点只接受isModeratorcurrentUserId作为查询参数,则无法真正验证 moderator 功能,除非满足二者之一:
    1. 接受带有user.isModerator = true的真实认证会话;或
    2. 在内部为开发/测试目的 mock 用户对象。

这意味着围绕审核权限的测试(如notPublishedpoiOnlyminorOnlypendingReviewOnly等审核专属过滤条件)不能只靠"传个参数"来覆盖,而必须穿透到会话层或 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时宁可拒绝也不默认放行——这正是文档强调"currentUserIduserId不可混淆"在权限层的落点。

event-engine-common 中尚未覆盖的字段

event-engine 的ImageQueryInput(image-feed-types.ts)是为事件引擎/feed 服务移植的类型,目前已经覆盖了大部分常规过滤(排序、NSFW 位标志、用户/内容/资源/元数据/混排/时间/发布状态/审核过滤、include富化选项与enableExistenceCheck特性开关)。但与主应用的GetInfiniteImagesOutput相比,以下字段尚未在ImageQueryInput中处理

分类未处理字段
集合类collectionIdcollectionTagId
资源显示类hideAutoResourceshideManualResources
可见性/关注类hiddenfollowed
推荐/互动类prioritizedUserIdsreactions
单体查询类imageIdincludeBaseModelpendingreviewId
分页类skip
附加数据类withTags
混排控制类remixesOnlynonRemixesOnly
POI/未成年控制disablePoidisableMinorpoiOnly(审核)、minorOnly(审核)

这些字段若要在 event-engine 中实现与主应用完全兼容的查询面,需要按上面清单逐项补齐到ImageQueryInput。对照可见,GetInfiniteImagesOutput是"最大公约数",ImageQueryInput目前是它的一个真子集——这也是 docs/type-comparison.md 落笔时指出的主要兼容性缺口;同理,主应用侧ImageSearchInput中被注释掉的prioritizedUserIdsmodelIdreviewId等"Unhandled"字段(image.service.ts)也从侧面印证:类型定义宽于实际查询路径支持,是这套体系需要持续对齐的现实。

小结

把整条链路串起来看:baseQuerySchema提供 NSFW 浏览级别基座 →GetInfiniteImagesOutput展开全部查询与富化字段 →GetAllImagesInput挂上完整会话对象与请求上下文 →getAllImagesIndex在入口处解析游标、把user扁平化为currentUserId/isModeratorImageSearchInput作为纯标量搜索输入进入 Meilisearch / feed 路径。这一设计在"业务层需要完整用户上下文"与"搜索层只需要最小标量、且不能泄漏 PII"之间划出了清晰边界。对测试与二次开发而言,牢记两点即可少踩坑:userId是创建者过滤、currentUserId是浏览者身份,二者方向相反审核功能验证必须穿透会话层或显式 mock,仅靠查询参数无法覆盖

【免费下载链接】civitaiA repository of models, textual inversions, and more项目地址: https://gitcode.com/GitHub_Trending/ci/civitai

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询