SET search_path TO atlas, public; -- 素材保留审计关系,删除后释放内容摘要,重新上传会创建独立素材和对象路径。 ALTER TABLE media_assets ADD COLUMN deleted_at TIMESTAMPTZ; ALTER TABLE media_assets ADD COLUMN deleted_checksum_sha256 TEXT; ALTER TABLE media_assets ALTER COLUMN checksum_sha256 DROP NOT NULL; ALTER TABLE media_assets ADD CONSTRAINT media_assets_deletion_shape CHECK ((deleted_at IS NULL AND checksum_sha256 IS NOT NULL) OR (deleted_at IS NOT NULL AND checksum_sha256 IS NULL)); ALTER TABLE collection_asset_transfers ADD COLUMN deleted_at TIMESTAMPTZ; CREATE TABLE product_image_deletions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id UUID NOT NULL, product_id UUID NOT NULL, asset_id UUID, collection_image_id UUID, status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'succeeded', 'failed')), objects JSONB NOT NULL DEFAULT '[]', error_code TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), completed_at TIMESTAMPTZ, FOREIGN KEY (workspace_id, product_id) REFERENCES products(workspace_id, id) ON DELETE RESTRICT, FOREIGN KEY (workspace_id, asset_id) REFERENCES media_assets(workspace_id, id) ON DELETE RESTRICT, FOREIGN KEY (collection_image_id) REFERENCES collection_asset_transfers(id) ON DELETE RESTRICT, UNIQUE (workspace_id, id), UNIQUE (workspace_id, asset_id), UNIQUE (workspace_id, collection_image_id), CHECK (asset_id IS NOT NULL OR collection_image_id IS NOT NULL) ); ALTER TABLE product_image_deletions ENABLE ROW LEVEL SECURITY; ALTER TABLE product_image_deletions FORCE ROW LEVEL SECURITY; CREATE POLICY workspace_isolation ON product_image_deletions USING (workspace_id = app_current_workspace_id()) WITH CHECK (workspace_id = app_current_workspace_id()); ALTER TABLE collection_asset_transfers ADD CONSTRAINT collection_asset_transfers_product_identity UNIQUE (workspace_id, id, product_id); ALTER TABLE product_image_deletions ADD CONSTRAINT product_image_deletions_collection_scope FOREIGN KEY (workspace_id, collection_image_id, product_id) REFERENCES collection_asset_transfers(workspace_id, id, product_id); -- 引用写入锁定素材,与删除事务的排他锁串行;历史引用保留,但不能重新选择已删除素材。 CREATE FUNCTION reject_deleted_media_reference() RETURNS trigger LANGUAGE plpgsql SET search_path = atlas, public AS $$ DECLARE column_name TEXT; asset UUID; removed TIMESTAMPTZ; BEGIN FOREACH column_name IN ARRAY TG_ARGV LOOP FOR asset IN SELECT value::uuid FROM jsonb_array_elements_text( CASE WHEN jsonb_typeof(to_jsonb(NEW)->column_name) = 'array' THEN to_jsonb(NEW)->column_name ELSE jsonb_build_array(to_jsonb(NEW)->column_name) END) value WHERE value IS NOT NULL LOOP SELECT deleted_at INTO removed FROM media_assets WHERE id = asset FOR KEY SHARE; IF removed IS NOT NULL THEN RAISE EXCEPTION 'media asset has been deleted' USING ERRCODE = '23514'; END IF; END LOOP; END LOOP; RETURN NEW; END $$; DO $$ DECLARE rel RECORD; BEGIN FOR rel IN SELECT c.conrelid::regclass AS tab, a.attname AS col FROM pg_constraint c JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = ANY(c.conkey) WHERE c.contype = 'f' AND c.confrelid = 'atlas.media_assets'::regclass AND a.attname <> 'workspace_id' AND c.conrelid <> 'atlas.product_image_deletions'::regclass LOOP EXECUTE format('CREATE TRIGGER %I BEFORE INSERT OR UPDATE OF %I ON %s FOR EACH ROW EXECUTE FUNCTION reject_deleted_media_reference(%L)', 'guard_deleted_' || rel.col, rel.col, rel.tab, rel.col); END LOOP; END $$; CREATE TRIGGER guard_deleted_ai_references BEFORE INSERT OR UPDATE OF reference_asset_ids ON media_ai_edits FOR EACH ROW EXECUTE FUNCTION reject_deleted_media_reference('reference_asset_ids');