import { DeleteObjectCommand, type S3Client } from '@aws-sdk/client-s3'; import type { Database } from '@atlas/database'; type CleanupCandidate = { id: string; bucket: string; object_key: string; }; export class MediaUploadCleanup { private running: Promise | null = null; public constructor( private readonly database: Database, private readonly s3: S3Client, ) {} /** * 上传 URL 到期并不代表浏览器一定上传过对象;DeleteObject 对不存在对象也安全, * 所以终态意图统一尝试删除。只在删除成功后写 object_cleaned_at,失败下轮重试。 */ public async run() { if (this.running) return this.running; this.running = this.runOnce().finally(() => { this.running = null; }); return this.running; } private async runOnce() { const workspaces = await this.database.query<{ id: string }>( 'SELECT id FROM atlas.workspaces', ); let expired = 0; let cleaned = 0; for (const workspace of workspaces.rows) { expired += await this.expirePending(workspace.id); const candidates = await this.claimCandidates(workspace.id); for (const candidate of candidates) { try { await this.s3.send( new DeleteObjectCommand({ Bucket: candidate.bucket, Key: candidate.object_key, }), ); await this.markCleaned(workspace.id, candidate.id); cleaned += 1; } catch (error) { console.warn( 'media_upload_cleanup_failed', JSON.stringify({ workspace_id: workspace.id, upload_id: candidate.id, reason: error instanceof Error ? error.message : 'unknown_error', }), ); } } } if (expired || cleaned) { console.info( 'media_upload_cleanup_completed', JSON.stringify({ expired, cleaned }), ); } } private async expirePending(workspaceId: string): Promise { return this.database.workspaceTransaction(workspaceId, async (tx) => { const result = await tx.query( `UPDATE atlas.media_upload_intents SET status = 'expired', error_code = 'UPLOAD_EXPIRED', error_message = '上传意图已过期', completed_at = now() WHERE status = 'pending' AND expires_at <= now()`, ); return result.rowCount ?? 0; }); } private async claimCandidates(workspaceId: string) { return this.database.workspaceTransaction(workspaceId, async (tx) => { const candidates = await tx.query( `SELECT id, bucket, object_key FROM atlas.media_upload_intents WHERE status IN ('failed', 'expired') AND object_cleaned_at IS NULL ORDER BY created_at LIMIT 50`, ); return candidates.rows; }); } private async markCleaned(workspaceId: string, intentId: string) { await this.database.workspaceTransaction(workspaceId, (tx) => tx.query( `UPDATE atlas.media_upload_intents SET object_cleaned_at = now() WHERE id = $1 AND status IN ('failed', 'expired') AND object_cleaned_at IS NULL`, [intentId], ), ); } }