Civitai CSAM 媒体保留与删除收尾指南:remove-blocked-images 后的八个待办项与源码级剖析
【免费下载链接】civitaiA repository of models, textual inversions, and more项目地址: https://gitcode.com/GitHub_Trending/ci/civitai
本篇技术指南围绕 docs/csam-retention-followups.md 展开,该文档是 Civitai 仓库在remove-blocked-images保留期删除改造(2026-08)落地后沉淀出的跟进清单。你将了解到:remove-blocked-images作业如何以JobQueue中的BlockedImageDelete队列为唯一依据、在封禁后 7 天硬删除被屏蔽媒体(Image行 + S3 对象),以及围绕它遗留的八个待办项——未实现的removeContentForCsamReportsJob、滞留 CSAM 报告的告警缺口、archivedAt字段的双重职责、双份 CSAM 服务实现、批量解封端点与队列/摄入状态的不一致、孤儿阻塞图片的迁移修复、部署期观察清单与审核信息流的分页问题。读完本文,你将能理解这些项为什么被刻意推迟、各自的代码证据落在哪个文件,以及如何在后续迭代中安全地闭环它们。
背景:remove-blocked-images与BlockedImageDelete队列
先建立上下文。remove-blocked-images作业定义在 src/server/jobs/image-ingestion.ts(removeBlockedImages,cron 表达式'0 * * * *',即整点运行,lockExpiration: 20 * 60,即 20 分钟作业锁)。它的行为可以归纳为三点:
- 硬删除:被封禁媒体在封禁满 7 天后同时删除
Image数据库行与 S3 对象; - 只读队列:作业不直接扫描
Image表,而是读取JobQueue中type = BlockedImageDelete、entityType = Image的行——队列是"待删除"的唯一记录; - 当前唯一删除方:目前它是代码库中唯一会删除 CSAM 媒体的执行者。
保留期天数BLOCKED_IMAGE_RETENTION_DAYS定义在 packages/civitai-shared/src/job-queue.ts:
export const BLOCKED_IMAGE_RETENTION_DAYS = 7;其中值得注意的设计细节(可从 src/server/jobs/image-ingestion.ts 的注释与逻辑确认):
- 保留期时钟从"封禁时刻"而非"上传时刻"起算:队列行的
createdAt由trg_blocked_image_delete_queue触发器在ingestion翻转为Blocked时写入,因此createdAt即封禁时间。若改用Image.createdAt,任何在上传一周后才被屏蔽的图片都会在下一轮运行即可被删除——历史上正是这个回归导致 NCMEC 证据在报告发出前就消失。 - CSAM 报告持有(hold)机制:对存在未完结报告的用户(
reportSentAt IS NULL OR archivedAt IS NULL,按用户取MIN(createdAt)),其被屏蔽媒体会被排除在批次之外(entityId: { notIn: heldActive }),且上限CSAM_HOLD_MAX_DAYS = 30(image-ingestion.ts 第 482 行附近)。超过上限的报告不再拦截,证据照删并发出csam-hold-expiredAxiom 告警——这是对"发送/归档管线没有重试上限"的兜底。 - blob 撤回采用正向判别:只有存在
ModActivity中activity IN ('review', 'bulkRemove')且时间晚于封禁的记录(MODERATOR_TAKEDOWN_ACTIVITIES),删除时才会传{ retractPublicBlobs: true }让 image-cache 服务销毁共享存储对象;自动化扫描、CSAM 报告分支、账户级整库屏蔽等路径只删行不撤回字节。
这些行为的回归保护在 src/server/jobs/tests/remove-blocked-images-retention.test.ts 中有完整断言:保留期从封禁起算、过期仍删、开卷报告持有、超期强删并告警、AiNotVerified与已消失行只清队列不删除、非生产库绝不批量删除等。
注意:本文讨论的所有"待办项"是原变更刻意不包含的内容——要么是需要独立测试的行为变更,要么是既有技术债被该变更暴露出来。文档明确要求"在这些变更部署并稳定后再处理"。
1. 最高优先级:补完removeContentForCsamReportsJob
src/server/jobs/process-csam.ts中的removeContentForCsamReportsJob自 2023-12-18 创建以来从未被真正实现,至今仍是占位符:
const removeContentForCsamReportsJob = createJob( 'remove-csam-content', '40 */1 * * *', async () => { if (!isProd) return; const reports = await getCsamsToRemoveContent(); // wait for each process to finish before going to the next for (const report of reports) { // await archiveCsamDataForReport(report); } }, { dedicated: true } ); export const csamJobs = [sendCsamReportsJob, archiveCsamReportDataJob];源码可见两点:
- 循环体内是被注释掉的
// await archiveCsamDataForReport(report)占位(复制自归档作业,且函数名本身就值得警惕——它调用的是归档函数而非删除函数); - 该作业没有加入导出的
csamJobs数组,csamJobs只有sendCsamReportsJob与archiveCsamReportDataJob两个成员。
由于从未运行,"已发送且已归档"的报告的contentRemovedAt字段从未被任何代码写入。文档给出的收尾清单是:
- 为"已发送且已归档"的报告实现删除步骤——
getCsamsToRemoveContent(见 src/server/services/csam.service-new.ts 第 232-242 行)已经精确选择了这个集合:reportSentAt NOT NULL AND archivedAt NOT NULL AND userId NOT NULL AND contentRemovedAt NULL; - 成功后写入
contentRemovedAt; - 将该作业加入
csamJobs; - 确认与
remove-blocked-images的顺序关系:内容删除不得与归档竞争,两个作业当前分别在:40与:00运行。
未实现的后果是实打实的:getCsamReportStats().unremoved(csam.service-new.ts 第 190-205 行的Promise.all三个计数之一)是一个永久增长且无法排空的计数器;而读取它的审核后台列(src/pages/moderator/csam/index.tsx)处于"死"状态。
2. 滞留 CSAM 报告的告警:无重试上限、无退避、无死信
发送/归档管线当前没有重试上限、没有退避、没有死信队列。任何失败都会让reportSentAt保持NULL,然后每小时无限重试。文档点出了三个具体成因,逐一对应到源码:
成因 A:NCMEC 非零响应时静默返回。在 csam.service-new.ts 的processCsamReport中:
export async function processCsamReport(report: CsamReportProps) { const status = await cybertipClient.getStatus(); if (isDev) console.log({ status }); if (status.data.responseCode !== 0) return; ... }responseCode !== 0时直接return——不记录日志、不改任何状态。
成因 B:凭证缺失导致整批滞留。NcmecCaller.getInstance()在 src/server/http/ncmec/ncmec.caller.ts 第 16-19 行:
static getInstance(): NcmecCaller { if (!env.NCMEC_URL) throw new Error('Missing NCMEC_URL env'); if (!env.NCMEC_USERNAME) throw new Error('Missing NCMEC_USERNAME env'); if (!env.NCMEC_PASSWORD) throw new Error('Missing NCMEC_PASSWORD env'); ... }一旦凭证轮换而环境变量未同步,所有报告会同时滞留。
成因 C:reportSentAt写入被if (isProd)包裹。在reportImages/reportGenerationData/reportTrainingData/reportExternalLink四个分支中,reportSentAt的dbWrite.csamReport.update都位于if (isProd)内(如第 620 行if (isProd) { await dbWrite.csamReport.update(...) }),非生产环境不会落状态。
文档给出了历史延迟数据(n=458):发送中位数 0.58h、最大值 645h(27 天);归档中位数 1.3h、最大值 7,802h(325 天);119 份报告(26%)归档耗时超过 7 天。
待办清单:
- 当任何
CsamReport未发送超过 24h、或未归档超过 7 天时发出告警; - 考虑尝试计数器/死信机制,让永久失败的报告可见,而不是静默无限重试。
这一项在保留期改造后权重更高:remove-blocked-images新增的 CSAM 持有(hold)会在用户存在未完结报告时冻结其被屏蔽媒体,上限CSAM_HOLD_MAX_DAYS(30 天)。一份滞留报告意味着证据会在上限处被清除,而唯一痕迹只是 Axiom 告警。
3.archivedAt的双重职责:需要一个"终态但未存储"的状态
archiveCsamDataForReport对userId为空的内部报告(内部报告以userId === -1存储为NULL)也会盖章archivedAt,尽管实际上没有归档任何内容。当前代码在 csam.service-new.ts 第 1483-1498 行:
export async function archiveCsamDataForReport(data: CsamReportProps) { const { userId } = data; if (!userId) { // An internal report (userId === -1 is stored as NULL) has no user-scoped content to // archive, and archiveBaseReportData bails on it too. Returning silently left such a // report re-selected by getCsamsToArchive every hour since 2024. Stamp a terminal state, but // record that nothing was stored — archivedAt alone would read as evidence-complete. await dbWrite.csamReport.update({ where: { id: data.id }, data: { archivedAt: new Date(), details: { ...data.details, archiveSkipped: 'no reported user' }, }, }); return; } ... }这里details.archiveSkipped = 'no reported user'是workaround 而非修复:它至少记录下了"归档被跳过",但archivedAt本身仍被用来表达两种含义——"证据已归档"与"无事可归档"。文档的收尾项是:
- 为"终态但未存储"引入独立状态,而不是复用
archivedAt,使审核 UI 以及任何保留期/完整性声明都能区分这两种情况。
顺带一提,getCsamsToArchive中的orderBy: [{ createdAt: 'asc' }, { id: 'asc' }]保证了批次顺序可复现(可审计性),但注释明确警告:它没有解决头阻塞(head-of-line blocking)问题——一个每次尝试都失败的报告按构造就是最老的匹配行,会钉在每轮批次头部。
4. 双份实现:csam.service.ts与csam.service-new.ts
仓库中同时存在两个 CSAM 服务文件:
- src/server/services/csam.service-new.ts(1907 行,含流式归档、keyset 分页、Flipt 特性开关等新能力);
- src/server/services/csam.service.ts(930 行,旧实现)。
导入关系如下:
- 作业层(
src/server/jobs/process-csam.ts)导入-new; - 入口层仍导入旧版:
src/server/controllers/csam.controller.ts第 10 行import { createCsamReport } from '~/server/services/csam.service';,src/server/routers/csam.router.ts第 15 行同样来自旧文件。
两份副本携带同样的 bug 演化史:(e.message = '...')这类在 catch 内对非对象 rejection 取.message的赋值问题已在两边修复(新版引入了errorMessage(e)防御函数,见 process-csam.ts 第 22-30 行),但旧文件仍保留着:
- 静默的
if (!userId) return;:旧版archiveCsamDataForReport(第 722 行)对空userId直接返回且不盖章任何状态,这正是新版用archiveSkipped修复的"每小时被getCsamsToArchive反复选中"问题; - 无引用的
getCsamsToRemoveContent:旧版虽然也定义了它,但没有任何调用方。
文档的收尾项:
- 删除
csam.service.ts,把剩余两个消费者指向-new,或者完成-new后缀暗示的迁移收尾。"两份 CSAM 逻辑"正是修复被应用到错误副本的温床。
5.unblock-images.ts:解封不清ingestion的批量回填
src/pages/api/mod/unblock-images.ts 是一个仅供版主调用的 GET 端点(ModEndpoint(handler, ['GET']),BATCH_LIMIT = 100分批),其语义是批量回填:把blockedFor实际不在屏蔽态(needsReview为 null、nsfwLevel不在 0 与NsfwLevel.Blocked)的图片的blockedFor置空。
改造后的现状是:
- 它现在会调用
dropBlockedImageDeleteQueue(updatedIds)(第 100 行)清理队列行,因此不再可能在"本意是解封"的更新上误触发trg_blocked_image_delete_queue而武装一次清除——源码注释明确说明这是修复点; - 但它仍然不触碰
ingestion:任何仍停留在ingestion = 'Blocked'的行,其摄入状态保持不变。
文档的收尾项:
- 判断这个端点是否还需要保留——它读起来像一次性清理工具。如果保留,应把真正处于
Blocked的行设置为ingestion = 'Scanned';如果不需要,直接删除。
6. 孤儿阻塞图片:迁移已修复,需验证清零
在20260806130000_blocked_image_delete_queue_completeness迁移之前,有12,689 张处于Blocked、非AiNotVerified但没有队列行的图片——它们会被无限期保留、没有任何机制清除。迁移文件位于 packages/civitai-db-schema/prisma/migrations/20260806130000_blocked_image_delete_queue_completeness/migration.sql,其修复分两部分:
(1)加宽触发器。旧触发器只监听UPDATE OF ingestion,因此两类路径漏网:a) 以已Blocked状态 INSERT 的行;b)blockedFor从AiNotVerified改出但ingestion未变的行。新触发器函数queue_blocked_image_for_delete()覆盖 INSERT 与blockedFor重指:
CREATE OR REPLACE FUNCTION queue_blocked_image_for_delete() RETURNS TRIGGER AS $$ BEGIN IF (NEW.ingestion = 'Blocked' AND (NEW."blockedFor" IS NULL OR NEW."blockedFor" != 'AiNotVerified')) THEN -- OLD is unassigned on INSERT and touching its fields raises, so the branches stay -- separate: plpgsql does not guarantee short-circuit evaluation within one condition. IF (TG_OP = 'INSERT') THEN PERFORM create_job_queue_record(NEW.id, 'Image', 'BlockedImageDelete'); ELSIF (OLD.ingestion IS DISTINCT FROM NEW.ingestion OR OLD."blockedFor" IS DISTINCT FROM NEW."blockedFor") THEN PERFORM create_job_queue_record(NEW.id, 'Image', 'BlockedImageDelete'); END IF; END IF; RETURN NEW; END; $$ LANGUAGE plpgsql;触发器替换放在单事务中(BEGIN; ... COMMIT;),并带SET LOCAL lock_timeout = '5s'——因为CREATE TRIGGER会对Image表取ACCESS EXCLUSIVE锁,宁可快速失败也不阻塞整表写入:
BEGIN; SET LOCAL lock_timeout = '5s'; DROP TRIGGER IF EXISTS trg_blocked_image_delete_queue ON "Image"; CREATE TRIGGER trg_blocked_image_delete_queue AFTER INSERT OR UPDATE OF ingestion, "blockedFor" ON "Image" FOR EACH ROW EXECUTE FUNCTION queue_blocked_image_for_delete(); COMMIT;(2)回填存量。利用create_job_queue_record的ON CONFLICT DO NOTHING语义,且队列行的createdAt就是保留期时钟,因此这批回填从迁移时刻起获得完整窗口,而不是下一轮就被清除(文档明确这是"安全方向"的选择——它们已被无限期保留,错误的时钟会毁掉证据):
INSERT INTO "JobQueue" ("entityId", "entityType", "type") SELECT i.id, 'Image'::"EntityType", 'BlockedImageDelete'::"JobQueueType" FROM "Image" i WHERE i.ingestion = 'Blocked'::"ImageIngestionStatus" AND i."blockedFor" IS DISTINCT FROM 'AiNotVerified' ON CONFLICT DO NOTHING;文档给出的收尾清单:
- 应用迁移后重跑下述计数,确认为 0;
remove-deleted-user-images.ts中的queueBlockedImagesForDelete在加宽触发器生效后变为冗余——目前作为"双保险"保留,若证实无必要则删除;- 以
postgres角色删除孤儿函数(迁移已把触发器重指到新函数,旧函数无调用方):
DROP FUNCTION IF EXISTS blocked_image_delete_queue_trigger();验证计数 SQL(来自原文档):
SELECT count(*) FROM "Image" i WHERE i.ingestion = 'Blocked'::"ImageIngestionStatus" AND i."blockedFor" IS DISTINCT FROM 'AiNotVerified' AND NOT EXISTS ( SELECT 1 FROM "JobQueue" q WHERE q."entityId" = i.id AND q."entityType" = 'Image' AND q.type = 'BlockedImageDelete' );函数所有权的坑(Function ownership footgun)
迁移注释与文档共同揭示了一个隐蔽的所有权问题:blocked_image_delete_queue_trigger()和image_scan_queue_trigger()由postgres拥有(因为对应迁移以超级用户身份应用),而create_job_queue_record()以及Image/JobQueue表由civitai拥有,且civitai不是postgres的成员。后果是:
civitai可以在自己的表上创建触发器、创建新函数;- 但对 postgres 拥有的函数执行
CREATE OR REPLACE FUNCTION会报must be owner of function; - 任何
GRANT都无法解决——替换函数要求拥有权。
因此新迁移采用"创建新函数queue_blocked_image_for_delete()+ 重指触发器"而非CREATE OR REPLACE。文档的收尾项是二选一:
- 统一约定:数据库迁移始终以同一角色应用;或
- 对这两个"遗留"函数执行
ALTER FUNCTION ... OWNER TO civitai(需要超级用户),避免下一位修改image_scan_queue_trigger()的人撞同一堵墙。
7. 部署观察项(Deploy watch items)
文档列出了四类上线后需重点观察的现象:
- 删除量尖峰(预期内):写作时 602,662 个队列行中有 576,255 个已超过 7 天截止线。按
take: 15000每小时处理,以最大删除速率也需要约38 小时排空。这会表现为持续的 S3/DB 删除负载和对 20 分钟作业锁的高占用。这是存量积压的偿还——新时钟比旧时钟严格保守——但看起来会像一次尖峰。 - CSAM 持有在生产环境处于空转:当前没有任何用户存在未完结报告,因此该机制不会被真实流量演练到,单元测试是它唯一的覆盖。需要盯住第一份真实报告走完"发送 → 归档 → 释放"全链路。
- 外部 cron 配置:
remove-csam-content仍未导出到csamJobs,如果仓库外的调度器配置了该条目,该条目会404。无危害(作业什么都不做),但会显示为失败 cron。 image_userid_id_idx:该索引在生产环境早已存在但此前未在 schema 中声明,现已补声明。生产环境无需操作——迁移在那里是 no-op。
8. 审核信息流体验:/moderator/csam/[userId]
最后一个待办项是关于审核员使用体验的。getImagesByUserIdForModeration是一次不分页的findMany,把用户拥有的全部图片一次性拉出并整体渲染。当前缓解手段是?imageId=深链:进入页面即定位并预选中被报告的那张图,覆盖了"报告驱动"的工作流;但审核员冷启动浏览一个大账号时,仍然会加载全部内容。
文档的收尾项:
- 为图片选择器加分页(
CsamImageSelection.tsx中的InViewLoader目前是注释状态)并增加筛选/搜索,使信息流在没有深链的情况下也可用。
小结:一张可执行的收尾路线图
汇总八个待办项,可以按"风险优先级"重新排列:
| 优先级 | 待办项 | 核心证据文件 | 主要风险 |
|---|---|---|---|
| P0 | 实现removeContentForCsamReportsJob并加入csamJobs | src/server/jobs/process-csam.ts、csam.service-new.ts | contentRemovedAt永不写入,unremoved计数只增不减 |
| P0 | 滞留报告告警(>24h 未发送 / >7d 未归档) | csam.service-new.ts、ncmec.caller.ts | 26% 报告归档超 7 天,超期证据被持有机制清除 |
| P1 | archivedAt双职责拆分 | csam.service-new.ts | 终态语义混淆,影响完整性声明 |
| P1 | 删除旧csam.service.ts并迁移两个消费者 | csam.controller.ts、csam.router.ts | 修复打到错误副本 |
| P1 | 决策unblock-images.ts去留 | unblock-images.ts | ingestion与blockedFor状态不一致 |
| P2 | 验证孤儿图片清零 + 收尾函数所有权 | migration.sql | 所有权坑阻碍后续触发器修改 |
| P2 | 部署观察(积压尖峰、CSAM hold 首单、外部 cron、索引声明) | image-ingestion.ts | 现象误判为事故 |
| P3 | 审核图片选择器分页/搜索 | /moderator/csam/[userId]、CsamImageSelection.tsx | 无深链时大账号不可浏览 |
这些收尾项的共同主题是:让"删除 CSAM 媒体"这一行为从"唯一入口、隐含状态"走向"可观测、可审计、状态自洽"。对希望参与贡献的开发者而言,src/server/jobs/tests/remove-blocked-images-retention.test.ts 是理解现有行为契约的最佳入口,而本文列出的每一项都应在改动时配套同等力度的测试。
【免费下载链接】civitaiA repository of models, textual inversions, and more项目地址: https://gitcode.com/GitHub_Trending/ci/civitai
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考