Civitai 未成年人模型哈希检测系统(Minor Model Hash Detection)设计与实现
【免费下载链接】civitaiA repository of models, textual inversions, and more项目地址: https://gitcode.com/GitHub_Trending/ci/civitai
导读
本文基于 docs/features/minor-model-hash-detection.md 展开,系统讲解 Civitai 如何通过SHA256 文件哈希识别"被审核员标记为未成年(minor)的模型文件被再次上传"这一行为。文章完整覆盖该特性的架构设计、种子集定义、两条无人值守检测路径(扫描时钩子与夜间扫描任务)、快照回滚机制、审核工作台、通知链路、Flipt 功能开关与部分索引优化,并结合 minor-hash.service.ts、model.service.ts、model-file-scan.service.ts 等源码给出实现级证据。读完本文,你将掌握这套"以哈希为内容身份、只以人工决策为种子、默认关闭"的重复上传拦截方案的核心设计原则与全部落地细节。
1. 系统概述:用 SHA256 识别重复上传
当审核员将一个模型标记为 minor 时,该决定只作用于一行Model记录——没有任何机制阻止同一文件被包装成新模型再次上传。Minor Model Hash Detection 系统将模型文件的SHA256 哈希视为内容的身份,一旦与"人工 minor 决策"绑定的哈希再次出现,系统按上传者身份分两条路径处理:
- 同一上传者:自动标记 minor,无人介入,之后进入审核队列供复核;
- 不同上传者:绝不自动标记,仅进入审核队列。因为哈希只能证明文件相同,不能证明是同一个人上传的。
两条无人值守路径共用一个 Flipt 功能开关(minor-hash-auto-flag),且默认关闭。该系统与 model-file-scanning.md(扫描钩子的触发位置)和 nsfw-filtering.md(gallerySettings.level语义)紧密相关。
关键文件一览
| 文件 | 职责 |
|---|---|
| minor-hash.service.ts | 全部匹配、扫描、审核队列与回滚逻辑 |
| model-file-scan.service.ts | applyScanOutcome—— 扫描完成时的钩子 |
| model.service.ts | setModelMinor、captureMinorFlagSnapshot、applyModelFlagSideEffects、withoutMinorHashMeta |
| minor-flag-meta.ts | isMinorAutoFlagged、stripMinorHashMeta、filterModelMetaForClient |
| minor-flag.notifications.ts | model-flagged-minor所有者通知 |
| minor-hash-sweep.ts | 夜间扫描任务(45 3 * * *) |
| minor-hash-sweep.ts | 回填 + 回滚端点(WEBHOOK_TOKEN保护) |
| minor-hash-matches.tsx | 审核员工作台(两个页签) |
| moderator/index.ts | moderator.models.*过程 |
| minor-hash.schema.ts | Zod 输入校验 |
| flipt/client.ts | FLIPT_FEATURE_FLAGS.MINOR_HASH_AUTO_FLAG |
2. "标记为 minor"到底做了什么
setModelMinor({ minor: true })(model.service.ts)按如下顺序执行:
- 捕获前置快照到
Model.meta.minorFlagSnapshot(在任何变更发生之前)。 - 写入
minor = true、nsfw = false、sfwOnly = true、gallerySettings.level = sfwBrowsingLevelsFlag。 - 将
['minor','nsfw','sfwOnly'](即MINOR_LOCKED_PROPERTIES,定义于 model.service.ts)追加到lockedProperties,使创作者无法自行改回。 - 写入一条
ModActivity记录(人工标记为setMinor,自动化标记为setMinorAutoHash)。 - 调用
applyModelFlagSideEffects(model.service.ts):刷新模型标签缓存、排队模型搜索索引更新、删除 gallery-settings 的 Redis key、重新摄取模型,并将该模型所有版本的所有帖子中的所有图片一次性置位minor(基于集合的 UPDATE,并将这些图片 ID 排入搜索索引队列)。
需要特别强调的是:minor存在于Model而非ModelVersion——一个匹配的文件会限制该模型的所有版本。每次标记的影响范围都远大于触发它的那个哈希。
取消标记(minor: false)时刻意不动nsfw/sfwOnly/gallerySettings——该模型在被标记之前可能本来就是合法的 SFW-only。恢复这些字段是回滚路径的职责,且必须依据快照而非猜测。
3. 种子集:什么算"已知 minor"
moderatorMinorSeedPredicate(minor-hash.service.ts)是唯一定义,同时被扫描时查询和扫描任务的 CTE 复用:
m.minor AND 'minor' = ANY(m."lockedProperties") AND m.meta->'minorFlagSnapshot'->>'source' IS DISTINCT FROM 'auto'每个子句都有其存在的理由:
- 仅有
minor不够——创作者可以把自己的模型自我声明为 minor。lockedProperties中包含'minor'才能证明是审核员施加的,从而防止创作者靠自我声明把别人的上传也卷进种子集。 - 排除
source='auto'——阻止自动化自我播种。若不加这一条,每个被自动标记的模型都会成为种子,贡献其上的每一个哈希(包括从未被审核员与 minor 内容绑定的哈希),种子集会从机器决策中不断膨胀,dry run 也就无法预测后续轮次会匹配什么。在生产规模克隆库上的实测:dry run 说 300,实际回填写了 302。
审核员自己点 "Set as Minor" 会写入source='manual',仍然会播种。旧版标记(完全没有快照)同样会播种:NULL IS DISTINCT FROM 'auto'为真。而一个被审核员确认的自动标记会被"晋升"为source='manual'(见下文"Keep flagged"),从而开始播种——排除的是未经复核的机器输出,而不是主题本身。
实际后果:种子集只会因人工决策而增长。这正是 dry run 的预测能够成立的原因——一次运行写入的任何东西都不会改变后续运行匹配的内容。
只有ModelFile.type = 'Model'的文件参与(MINOR_HASH_FILE_TYPE,定义见 minor-hash.service.ts),种子侧和候选侧都是如此。ModelFileHash.hash是citext类型,所有存储的 SHA256 均为大写十六进制。
4. 候选模型
minorHashCandidatesCte(minor-hash.service.ts)定义:其type='Model'的 SHA256 哈希出现在种子集中、且NOT m.minor、status <> 'Deleted'、文件未被回滚盖章覆盖(notMinorHashClearedPredicate,见下文)的模型。对每个模型,sameUploader是bool_or——它的匹配哈希中是否存在"携带该哈希的种子属于同一userId"。
标记一个模型会把它从候选集中移除(它变成了minor),这正是扫描任务可恢复且幂等的原因。
5. 路径一:扫描时钩子
applyScanOutcome(model-file-scan.service.ts)在模型文件扫描完成后调用checkMinorHashOnScan,其门槛依次为:文件有 SHA256、file.type === 'Model'、以及 Flipt 开关开启——开关放在最后检查,只为真正可能匹配的文件付出一次开关求值。
applyMinorHashMatch(minor-hash.service.ts)的决策矩阵:
| 情况 | 结果 |
|---|---|
| 匹配中包含被扫描的模型自身 | skipped(它已经是种子) |
| 没有其他模型匹配 | skipped |
某些其他匹配与扫描模型同userId | flagged—— 以系统用户(-1)调用setModelMinor,活动记录setMinorAutoHash |
| 只有不同上传者的匹配 | queued—— 出现在审核页面 |
checkMinorHashOnScan(minor-hash.service.ts)吞掉所有错误(记录到 Axiom,返回skipped)——minor 哈希的失败绝不能导致扫描回调失败。
这是唯一在实时上传时触发的路径。值得注意的实现细节:清除盖章(clear stamp)的查询(isMinorHashClearedForFile)只在flagged写入路径上执行——因为匹配查询中的m是种子侧而非被扫描的模型,扫描路径无法携带该谓词;把这次额外的往返放在"每次写入一次"而不是"每次扫描一次"的位置上。
6. 路径二:夜间扫描任务
minorHashSweep(minor-hash-sweep.ts)在45 3 * * *运行,用于捕捉钩子无法覆盖的情况:在原文件被标记之前就已上传、且扫描早已完成的副本。它与扫描钩子受同一 Flipt 开关控制,因此两条无人值守路径无需部署即可同时停用。
export const minorHashSweep = createJob('minor-hash-sweep', '45 3 * * *', async () => { if (!(await isFlipt(FLIPT_FEATURE_FLAGS.MINOR_HASH_AUTO_FLAG))) return; const swept = await sweepMinorHashMatches({ dryRun: false, limit: 500 }); const accepted = await acceptExpiredMinorAutoFlags({ dryRun: false, limit: 500 }); return { ...swept, ...accepted }; });sweepMinorHashMatches({ dryRun, limit, concurrency })(minor-hash.service.ts)的行为要点:
- 候选拆分统计来自不受 limit 限制的总数查询——这样 dry run 描述的是真实总体,而不是自己的窗口;
- 写入阶段最多并行处理
limit个同上传者标记,concurrency控制并发; - 只标记同上传者匹配;不同上传者只作为计数上报,由人工复核;
- 单模型失败被计数并记录日志,绝不抛出;
- 完成报告在 HTTP 响应之前写入 Axiom——即使网关超时,已提交的写入仍留有记录。
DEFAULT_SWEEP_CONCURRENCY = 5,每个任务约 6~8 次顺序数据库往返,吞吐量受 DB 延迟限制,因此限制并行模型数即可。
此外任务还调用acceptExpiredMinorAutoFlags:未被复核的自动标记在30 天复核窗口(AUTO_FLAG_REVIEW_WINDOW_DAYS = 30,见 minor-hash.service.ts)之后被持久化为"已接受"(写入minorHashAccepted键,保持source='auto'——这样从无人复核过的标记永远不会成为后续自动化标记的种子)。
7. 快照与回滚
Model.meta.minorFlagSnapshot由captureMinorFlagSnapshot(model.service.ts)幂等写入(WHERE NOT (meta ? key)),因此重复标记永远不会覆盖原始的标记前状态:
| 字段 | 含义 |
|---|---|
at | 标记施加的时间 |
source | 'auto'(活动setMinorAutoHash)或'manual' |
prevNsfw、prevSfwOnly、prevGalleryLevel | 标记前的值 |
prevLockedProperties | 完整的标记前数组 |
prevMinorImageIds | 标记前已经是 minor 的图片 |
快照捕获是尽力而为的:丢失它最多阻碍后续回滚,不能阻碍标记本身(失败只记日志不抛出)。
rollbackMinorHashAutoFlags(minor-hash.service.ts)有两种模式:
- Blanket(全量)——
source IS DISTINCT FROM 'manual'且未被人工确认。审核员自己的决定绝不会作为"撤销回填"的附带损伤被回滚。 - Targeted(定向,
modelIds)——精确到这些模型,任何 source,不做确认跳过。指名模型本身就是审慎的决定,这是误点后的逃生通道。
"人工确认"= 一条ModActivity记录(entityType='model'、activity='setMinor')且createdAt > snapshot.at。该排除在SQL 层完成而非事后过滤:被确认的行永远保留 meta 键,若留在ORDER BY id LIMIT n窗口内会永久占据槽位、拖垮排水进度。它们的数量作为skipped单独上报(来自不受限的查询),操作员因此能区分"没有可撤销的了"与"剩下的全是人工决定"。
每次回滚:调用setModelMinor({ minor: false })(复用其搜索索引/缓存/图片机制),然后从快照恢复nsfw/sfwOnly/gallery level/lockedProperties,将快照键替换为清除盖章,并把prevMinorImageIds重新标记为 minor(取消标记清掉了所有图片的 minor,包括那些本来就合法 minor 的)。
清除盖章(clear stamp)——为什么回滚要被记住
删除快照正是让回滚"被遗忘"的原因:模型重新变成普通候选,下一次无人值守运行会把人工刚刚撤销的标记再加回去。因此,与删除快照的同一写入会在Model.meta.minorHashCleared = { at }上盖章,notMinorHashClearedPredicate(minor-hash.service.ts)将其排除:
NOT (m.meta ? 'minorHashCleared') OR mf."createdAt" > (m.meta->'minorHashCleared'->>'at')::timestamptz它以文件时间为作用域而非整体排除模型:回滚发生时已存在的文件被该决定覆盖;之后上传的文件是上传者的新行为,仍可被捕捉。全量排除会让一次回滚永久屏蔽自动化对该模型的识别。
两个候选 CTE(扫描任务与审核队列)都携带该谓词。扫描路径无法携带(匹配查询的m是种子侧),因此applyMinorHashMatch只在标记路径上对照盖章检查被扫描文件(isMinorHashClearedForFile)——该额外往返每次写入只付一次。
清除盖章只约束自动化。审核员随时可以手动标记该模型。
8. 审核员工作台
/moderator/minor-hash-matches(审核员导航菜单中)。两个页签:
Pending review(待复核)——不同上传者匹配(queryMinorHashMatches)。操作:
- Set as Minor(
model.setMinor,人工标记——同样会快照,因此可撤销,但只能定向回滚); - Dismiss(
dismissMinorHashMatch,写入Model.meta.minorHashDismissed;被驳回的模型从队列中排除)。
Auto-flagged(自动标记)——扫描钩子标记、但尚无审核员签字的模型(queryAutoFlaggedMinorModels:source='auto'且未人工确认),最新的在前。操作:
- Keep flagged(
confirmMinorHashAutoFlag,minor-hash.service.ts)——将快照晋升为source='manual'(保留confirmedFrom/confirmedAt/confirmedBy),并记录审核员自己的setMinorModActivity。confirmedFrom写入为COALESCE(已有 confirmedFrom, 当前 source),所以第二次确认读到的是已晋升的'manual',不会抹去"标记始于'auto'"这一事实——所有者告警与通知都依赖该来源。这一次晋升承载了整个决定:该行离开队列、全量回滚不再能撤销它、模型的哈希开始为未来匹配播种。快照的标记前状态被保留,定向撤销仍可行。若快照捕获失败,晋升 no-op,仅靠ModActivity行保护该标记。 - Revert(
revertMinorHashAutoFlag)——对这一个模型做定向回滚,并写入一条rollbackMinorAutoHashModActivity。
两个队列都不分页(limit默认 1000、最大 2000,外加truncated标志)。这是刻意为之:行在处理后离开队列,任何服务端窗口(OFFSET 或 keyset)要么跳过未复核模型,要么把客户端排序限制在已加载子集上。成本由候选 CTE 主导,而非行数。注意:FlaggedModelsList存在 OFFSET 版本的同类缺陷,不要照抄它的分页。
行详情(封面图、上传者模型数/注册日期、匹配的模型、完整哈希)由单独的逐行查询queryMinorHashMatchDetail在展开时获取,列表查询保持轻量。自动标记页签的详情面板复用完全相同的getMinorHashMatchDetail——两个面板"按构造"保持一致,而非靠人工同步。
历史标记来源记录很少——生产约 13.5K 个 minor 锁定模型中只有 2 个有setMinorModActivity行。"Set minor by/at" 几乎总是空白;渲染时必须做条件显示。
9. 告知所有者
自动标记在无人介入的情况下限制了别人的模型,因此所有者会收到两次告知:发生时一条通知,以及每次打开模型页时一条告警。
两者使用同一句话,且刻意模糊:
Your model{name}has been marked as depicting a minor. If you believe this is a mistake, contact support.
(你的模型{name}已被标记为描绘未成年人。若你认为这是误判,请联系支持。)
它不点名哈希、不点名匹配的模型、不点名来源,也不列举被施加的限制。这些细节每一个都会告诉重复上传者要改哪些字节。应用内没有申诉通道:Auto-flagged 页签本身就是对每条自动标记的审核,申诉队列会与之重复——所有者被引导到支持,审核员从该页签处理。
通知——model-flagged-minor(minor-flag.notifications.ts),NotificationCategory.System,toggleable: false。它轮询at晚于任务游标的快照,门槛为:
COALESCE(meta->'minorFlagSnapshot'->>'confirmedFrom', meta->'minorFlagSnapshot'->>'source') = 'auto'用COALESCE而非source——确认自动标记会把source改写为'manual',只轮询source会静默跳过审核员在下一轮运行前确认过的每条标记,而那恰恰是标记永久生效的情形。查询还要求m.minor(快照写在标记落地之前,且在普通取消标记后仍存活,仅凭其存在不能证明模型当前是 minor)并排除userId = -1(deleteUser会重新指派模型)。
它还携带m.meta ? 'minorFlagSnapshot',看似冗余实则不然——见下文"部分索引"一节。
告警——当isCreator && model.minorAutoFlagged时渲染在/models/[id]。服务端在getModelHandler中以isOwner && model.minor && isMinorAutoFlagged(meta)计算minorAutoFlagged,使用脱敏之前的原始 meta。它在服务端做所有者门控,因为model.getById是publicProcedure且页面是服务端渲染的——标记来自自动化还是人工不是访客该关心的事。告警在Keep flagged后持续存在(模型仍为 minor),在Revert后消失(快照被删除)。
isMinorAutoFlagged与resolveMinorFlagged、resolveMinorAppeal都定义在 minor-flag-meta.ts,其中申诉边界(非所有者一律返回 null)被独立封装以保证可测试性。
10. 什么永远不会到达客户端
该系统写入的三个Model.meta键仅限审核使用。minorFlagSnapshot携带prevMinorImageIds——模型上所有已经是 minor 的图片——以及source和时间戳;minorHashDismissed携带审核员的userId。
stripMinorHashMeta(minor-flag-meta.ts)是"哪些键是机密"的唯一定义。filterModelMetaForClient将其与既有的敏感词过滤组合;注意其不对称性——敏感词数据保留给审核员,而 minor-hash 键对所有人剥离,因为审核员 UI 通过自己的过程(queryAutoFlaggedMinorModels、queryMinorHashMatchDetail)读取它们,从不经过model.getById。
脱敏在控制器层与服务层都执行,因为updateModelById调用dbWrite.model.update时不带select,会返回所有列——任何返回其结果的处理函数都会把快照直接交给客户端:
| 站点 | 经由 |
|---|---|
getModelHandler | model.getById |
getModelsPagedSimpleHandler | model.getAllPagedSimple(公开,60s 缓存) |
updateModelById | requestReview、changeMode、reorderVersions、updateGallerySettings、setCollectionShowcase |
upsertModel(两个分支) | model.upsert |
privateModelFromTraining | private-model-from-training 流程 |
getTrainingModelsForModerators | mod.models.queryTraining(一个grant标志,而非isModerator) |
缺少服务层那一半的话,所有者可以在自己自动标记的模型上点击Request review,从响应里读出source: 'auto'、时间戳和prevMinorImageIds——这会让那套模糊文案失去意义。任何把Model行返回给客户端的新功能都应加入此列表。
与剥离方向对称的还有stripModerationOwnedMeta(minor-flag-meta.ts):modelUpsertSchema.meta是looseObject,未知键会存活并随客户端副本合并进Model.meta——若不加这层防护,创作者可以自行铸造minorFlagSnapshot,让申诉流程把一条伪造的键当作自动标记的证据。MODERATION_OWNED_META_KEYS与剥离列表刻意保持一致(加上敏感词键对),"从不安全下发"与"从不安全接收"共用一张清单,两个方向不会漂移。
11. 功能开关:minor-hash-auto-flag
FLIPT_FEATURE_FLAGS.MINOR_HASH_AUTO_FLAG(flipt/client.ts)控制两条无人值守路径。
默认关闭是构造出来的:isFlipt对未知标志或不可达的 Flipt 返回false——没有定义标志时什么都不自动标记,Flipt 宕机时也保持关闭。对于会自动限制他人模型的路径,"不标记"是安全的失败模式——漏报只是让扫描任务之后还能捕捉的重新上传多活一阵,误报却会错误地把他人的模型标记为 minor。
它位于FLIPT_EVAL_CACHE_BYPASS(flipt/client.ts)中,kill 开关在 60s 配置轮询后即可生效,无需再等求值 TTL。求值是进程内的(wasm 引擎),单次扫描的求值开销可忽略。Flipt 仅走 GitOps——创建或启用该标志需要配置推送,而非 API 写入。
管理端点是刻意不设开关门控的:回滚必须在开关被拉下之后仍可用——而那正是最需要它的时候。
通知同样不受开关控制,且没有关闭开关。开关只是停止模型被标记;通知轮询每 60s 照常运行,一旦出现新的source='auto'快照就会触发。开关关闭时这等于无事发生,因为没有写入方——安全性是二阶的,不是一道闸门。两点后果需要明说:
- 运行回填 = 通知它所标记的每一位所有者。不存在"先安静回填、再打开通知"的做法。若要分阶段,可在排水前把
KeyValue行last-sent-notification-model-flagged-minor前移,之后再恢复。 - 管理端点不加门控的
action=sweep可以在开关读作 "off" 时写入自动快照,所有者在一分钟内就会收到通知。
部署本身是安全的:全新通知类型没有游标行,getJobDate会回退到全局last-sent-notifications值(约 1 分钟前)而不是纪元(send-notifications.ts)。已存在的快照at更旧,永远不会触发。
12. 部分索引:Model_minorFlagSnapshot_source_idx
CREATE INDEX CONCURRENTLY "Model_minorFlagSnapshot_source_idx" ON "Model" (((meta->'minorFlagSnapshot'->>'source'))) WHERE meta ? 'minorFlagSnapshot';迁移20260730120000_model_minor_flag_snapshot_index与此仓库的所有迁移一样手工执行。它是部署前置条件,不是后续优化——无论 Flipt 开关是否开启,通知每 60s 轮询一次。
通知查询重复书写m.meta ? 'minorFlagSnapshot',尽管COALESCE(...) = 'auto'已隐含它。这个子句是承重的。要使用部分索引,Postgres 必须证明查询的WHERE蕴含索引谓词,而predicate_implied_by只能推理equal()子句和 btree 操作符族。?是jsonb_exists,属于 GINjsonb_ops,没有 btree 操作符族,所以查询中的其他任何东西都无法推导出它——缺少这个字面子句时predOK为 false,索引会被直接排除出规划。在生产规模克隆库上的实测:
| 带该子句 | 不带 | |
|---|---|---|
| 计划 | Index Scan | Parallel Seq Scan,3 workers |
| Buffers | 1 | 112,491 |
| 执行耗时 | 0.047 ms | 237 ms |
索引键并不服务通知查询:CoalesceExpr永远无法匹配索引表达式,因此COALESCE(...)、at、minor、userId都作为索引后的 Filter 应用。全部收益来自部分谓词把Model裁剪到快照子集。审核员的 Auto-flagged 队列则直接匹配键(它直接过滤source),获得真正的 Index Cond。
13. 管理端点
GET /api/admin/temp/minor-hash-sweep?token=$WEBHOOK_TOKEN&dryRun=true&limit=1000 GET /api/admin/temp/minor-hash-sweep?token=$WEBHOOK_TOKEN&action=rollback&dryRun=true GET /api/admin/temp/minor-hash-sweep?token=$WEBHOOK_TOKEN&action=rollback&dryRun=false&modelIds=123,456参数语义(minor-hash-sweep.ts 中的 Zod schema):
action:默认'sweep'。'sweep'标记同上传者哈希匹配;'rollback'用快照撤销先前的标记,恢复nsfw/sfwOnly/gallery level/lockedProperties与先前 minor 的图片。不带modelIds时只覆盖自动化标记,并跳过审核员已人工确认的。modelIds:仅 rollback 可用(其他 action 传它会返回 400)。逗号分隔的模型 ID,定向撤销——无论标记来源如何(包括审核员的手动 "Set as Minor")都精确回滚这模型,且不做人工确认跳过。这是误点后的逃生通道;全量回滚永远不会触碰手动标记。dryRun:两个 action 默认均为 true。为 true 时什么都不写,报告展示候选拆分与至多 20 行的样本。limit:默认 100、最大 1000。限制每次调用写入的模型数(sweep 为标记数,rollback 为处理数)——sweep 上报的候选拆分始终覆盖全量总体,与 limit 无关,因此任意 limit 下的 dry run 都显示真实总数。concurrency:默认 5、最大 10。并行写入的模型数。
两个 action 都可恢复且幂等:被标记的模型离开 sweep 的候选集,被回滚的模型失去其 meta 键。因此以重复的小调用(如limit=50)排水比一次性大跑更可取——后者有中途网关超时的风险。
补充安全约束:modelIds与action !== 'rollback'组合时返回 400。整个端点由WebhookEndpoint与?token=$WEBHOOK_TOKEN保护。
14. 写入的数据
| 位置 | 值 |
|---|---|
Model.meta.minorFlagSnapshot | 标记前状态 +source;确认后追加confirmedFrom/confirmedAt/confirmedBy |
Model.meta.minorHashCleared | { at }—— 发生过回滚;自动化对早于此的文件不再动作 |
Model.meta.minorHashDismissed | { at, by }—— 将该模型从待复核队列移除 |
Model.meta.minorHashAccepted | { at }—— 30 天窗口过期的未复核自动标记,持久化为"已接受" |
ModActivity | setMinor、unsetMinor、setMinorAutoHash、rollbackMinorAutoHash、dismissMinorHashMatch |
ModActivity以(activity, entityType, entityId)为唯一键,用DO UPDATE SET createdAt = NOW()做 upsert——每个 (模型, 活动) 组合只有一行,确认操作是更新时间戳而非报错。
所有三个(加上minorHashAccepted共四个)Model.meta键都会从所有面向客户端的响应中剥离——见"什么永远不会到达客户端"。
无 schema 变更:一切依托现有的Model.meta与ModActivity列。唯一一次迁移是一个索引(20260730120000_model_minor_flag_snapshot_index),它不新增任何状态。
15. 删除与重新上传
已覆盖。所有面向用户的删除都是软删除——deleteModelById设置status='Deleted';deleteUser设置{deletedAt, status:'Deleted'}或重新指派给userId = -1。哈希存活,所以被删除的 minor 模型仍然播种。只有permaDeleteModelById会销毁种子(CASCADE 到ModelFileHash),且它受审核员门控。
来自新账号的重新上传会落入不同上传者审核队列,而非自动标记——这是正确的,因为哈希证明的是文件身份,而非背后的人是谁。
16. 设计不变量
以下是该设计所持守的性质,以及各自的强制位置。破坏其中任何一条,该特性会从"仅仅是错误"变成"不安全":
| 不变量 | 强制位置 |
|---|---|
| 只有人工决策播种 | moderatorMinorSeedPredicate(lockedProperties+source <> 'auto') |
| 自动化绝不标记不同上传者 | applyMinorHashMatch(queued)、扫描任务WHERE c."sameUploader" |
| dry run 预测 live run 的写入 | 种子集不能因自动化输出而增长 |
| 回滚会被记住 | minorHashCleared+notMinorHashClearedPredicate |
| 回滚不会永久屏蔽自动化 | 清除盖章按ModelFile."createdAt"作用域化 |
| 审核员的标记绝不被机器回滚 | autoFlaggedPredicate+humanConfirmedPredicate |
| 每条标记都可逆 | 任何变更前先captureMinorFlagSnapshot,手动标记亦然 |
| minor 哈希失败绝不导致扫描失败 | checkMinorHashOnScan吞掉并记录 |
| 两条无人值守路径一起停,无需部署 | 一个 Flipt 开关,两处都检查 |
| 快照永不触达客户端 | 控制器与服务层返回处都做stripMinorHashMeta |
| 机器标记模型时所有者被告知 | model-flagged-minor+ 模型页告警 |
| 确认不会抹去"标记始于自动化" | 晋升时COALESCE(confirmedFrom, source) |
| 所有者文案不能让重新上传者知道该改什么 | 两个界面共用一句通用文案 |
17. 注意事项(Caveats)
minor是模型级的。一个匹配文件限制该模型的所有版本。按"被标记模型数"衡量影响会低估它。- 同上传者是唯一的自动标记信号。用新账号的执着重新上传者会落入审核队列——这是设计使然:哈希证明文件身份,而非背后的人。
- 管理端点的
action=sweep不加门控,不只是action=rollback。即使拉下 kill 开关,任何持有WEBHOOK_TOKEN的人仍可运行 live sweep。 - 历史标记来源数据很薄——见审核页备注;不要构建假设
setMinorModActivity行存在的 UI。 - 清除盖章从不清理。模型可能无限期携带
minorHashCleared;它只会收窄自动化的动作范围,因此是惰性而非过期的。 - 通知没有关闭开关——见功能开关一节。回填即通知。
- 驳回是永久且模型级的。
minorHashDismissed只在待复核查询中检查,所以被驳回的模型即使后续出现无关哈希匹配也不会回到该队列——而扫描钩子在同上传者匹配时仍会自动标记它。按匹配作用域化是一个已知的后续改进。 prevMinorImageIds无上界。它只保存标记之前就已是 minor 的图片(实测:中位数 3、p99 64、最大 1,174),但平台上最大的画廊约 226K 张图片,病态模型会把数 MB 的数组放进每次页面加载都会读取的Model.meta行。applyModelFlagSideEffects交叉写入poi和minor。一条 UPDATE 在任一变化时同时写两者,所以标记 minor 会覆盖每张图片独立扫描出的poi,反之亦然,且两者都不在对方的快照中——回滚无法恢复。这是与 poi 路径共享的既有问题。
结语
Minor Model Hash Detection 是一个高度克制的自动化系统:以 SHA256 为内容身份、以"仅人工决策"为种子集的唯一来源、以"同上传者才自动标记"为安全边界、以幂等快照保证每条自动化决策可逆、以清除盖章防止回滚被遗忘、以默认关闭的 Flipt 开关让两条无人值守路径可零部署同时停用。其核心经验——把不可逆操作的失败模式设计为"漏报"而非"误报"、让 dry run 与 live run 描述同一总体、在 SQL 层而非应用层维护不变量——对任何做内容治理与重复上传检测的系统都极具参考价值。相关实现可从 minor-hash.service.ts 与 minor-hash-sweep.ts 入手继续深入。
【免费下载链接】civitaiA repository of models, textual inversions, and more项目地址: https://gitcode.com/GitHub_Trending/ci/civitai
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考