fix(mobile): purge uploaded audio when deleting history or meetings

This commit is contained in:
Yun Chan 2026-09-28 02:16:24 +09:00
parent 5b1621d314
commit 65320288d5
4 changed files with 507 additions and 0 deletions

View file

@ -0,0 +1,210 @@
import type { AudioFileUploadStatus, D3roSupabaseClient } from '@d3ro/api-client'
import { supabase } from '../../lib/supabase'
/**
* 기록·회의를 지울 때 거기에 연결된 원본 음성(audio 버킷 객체 + audio_files 행)을 함께 정리한다.
*
* audio_files.history_id / meeting_id 는 ON DELETE SET NULL 이라 부모 행만 지우면 음성이
* 고아가 되어 계정 삭제 전까지 클라우드에 남는다. 그래서 부모를 지우기 "전에" 연결된 음성을
* 스냅샷해 두고, 부모 삭제가 성공한 "뒤에만" 정리한다(리비전 충돌로 삭제가 거부되면 음성은
* 건드리지 않는다).
*
* 정리 순서: (1) 여전히 부모가 없는 행만 'deleted' 로 선점 → (2) 저장소 객체 삭제 →
* (3) 행 삭제. 선점 덕분에 같은 파일 재가져오기(sha256 재사용)가 지워질 행을 집어 가지 않고,
* 그 사이 다른 기록/회의에 다시 연결된 행은 선점 조건에서 빠진다. 저장소 삭제가 실패하면
* 선점을 원래 상태로 되돌려 재가져오기 경로가 막히지 않게 한다. 행 저장 키는 결정적
* (`<uid>/imports/<sha>/<name>`)이라 'deleted' 행이 남으면 같은 파일을 다시 올릴 수 없기 때문이다.
*/
export type AudioOwnerColumn = 'history_id' | 'meeting_id'
export interface LinkedAudioRef {
id: string
storage_key: string
upload_status: AudioFileUploadStatus
}
/** audio_files / audio 버킷 IO 포트. 서비스는 이 인터페이스에만 의존한다. */
export interface LinkedAudioStore {
/** 부모에 연결되어 있고 아직 삭제 표시되지 않은 음성 행. */
findLinked(userId: string, column: AudioOwnerColumn, ownerId: string): Promise<LinkedAudioRef[]>
/** 여전히 어떤 부모에도 연결되지 않은 행만 'deleted' 로 표시하고, 표시된 행을 돌려준다. */
claimUnlinked(userId: string, ids: string[]): Promise<LinkedAudioRef[]>
/** 주어진 키 중 다른(삭제 표시되지 않은) 행이 아직 참조하는 저장 키. */
findReferencedKeys(userId: string, keys: string[], excludeIds: string[]): Promise<string[]>
removeObjects(keys: string[]): Promise<void>
deleteRows(userId: string, ids: string[]): Promise<void>
restoreStatus(userId: string, id: string, status: AudioFileUploadStatus): Promise<void>
}
export interface LinkedAudioCleanupResult {
removedKeys: string[]
deletedRowIds: string[]
failed: boolean
}
/** 정리 대상 저장 키 선택(순수 정책): 본인 경로이고, 다른 행이 참조하지 않는 키만, 중복 없이. */
export function selectRemovableStorageKeys(
userId: string,
claimed: readonly LinkedAudioRef[],
stillReferenced: readonly string[],
): string[] {
const referenced = new Set(stillReferenced)
const prefix = `${userId}/`
const keys = new Set<string>()
for (const row of claimed) {
const key = row.storage_key
if (key.length <= prefix.length || !key.startsWith(prefix)) continue
if (key.includes('..')) continue
if (referenced.has(key)) continue
keys.add(key)
}
return [...keys]
}
/** 부모 삭제 전에 호출한다. 읽기 실패는 그대로 던져 부모 삭제도 하지 않게 한다. */
export async function snapshotLinkedAudio(
store: LinkedAudioStore,
userId: string,
column: AudioOwnerColumn,
ownerId: string,
): Promise<LinkedAudioRef[]> {
return store.findLinked(userId, column, ownerId)
}
/**
* 부모 삭제가 성공한 뒤 호출한다. 부모는 이미 지워졌으므로 이 단계의 실패는 삭제 자체를
* 실패로 돌리지 않는다(best effort) — 결과의 failed 로만 알린다.
*/
export async function purgeUnlinkedAudio(
store: LinkedAudioStore,
userId: string,
snapshot: readonly LinkedAudioRef[],
): Promise<LinkedAudioCleanupResult> {
const result: LinkedAudioCleanupResult = { removedKeys: [], deletedRowIds: [], failed: false }
const ids = [...new Set(snapshot.map((row) => row.id))]
if (ids.length === 0) return result
let claimed: LinkedAudioRef[]
try {
claimed = await store.claimUnlinked(userId, ids)
} catch {
result.failed = true
return result
}
if (claimed.length === 0) return result
const previousStatus = new Map(snapshot.map((row) => [row.id, row.upload_status]))
const claimedIds = claimed.map((row) => row.id)
try {
const referenced = await store.findReferencedKeys(
userId,
[...new Set(claimed.map((row) => row.storage_key))],
claimedIds,
)
const keys = selectRemovableStorageKeys(userId, claimed, referenced)
if (keys.length > 0) await store.removeObjects(keys)
result.removedKeys = keys
} catch {
result.failed = true
for (const row of claimed) {
const status = previousStatus.get(row.id)
if (status === undefined || status === 'deleted') continue
try {
await store.restoreStatus(userId, row.id, status)
} catch {
// 되돌리기 실패 시 행은 'deleted' 로 남아 이후 정리 대상임을 표시한다.
}
}
return result
}
try {
await store.deleteRows(userId, claimedIds)
result.deletedRowIds = claimedIds
} catch {
// 객체는 이미 지워졌고 행은 'deleted' 로 남아 UI·재사용 조회에서 제외된다.
result.failed = true
}
return result
}
function database(): D3roSupabaseClient {
return supabase as unknown as D3roSupabaseClient
}
const AUDIO_BUCKET = 'audio'
function toRefs(rows: ReadonlyArray<{ id: string; storage_key: string; upload_status: AudioFileUploadStatus }> | null): LinkedAudioRef[] {
return (rows ?? []).map((row) => ({
id: row.id,
storage_key: row.storage_key,
upload_status: row.upload_status,
}))
}
/** Supabase 어댑터. RLS(audio_files_*_own, audio_*_own)가 본인 행·경로로 한정한다. */
export function createSupabaseLinkedAudioStore(): LinkedAudioStore {
return {
async findLinked(userId, column, ownerId) {
const { data, error } = await database()
.from('audio_files')
.select('id, storage_key, upload_status')
.eq('user_id', userId)
.eq(column, ownerId)
.neq('upload_status', 'deleted')
if (error !== null) throw error
return toRefs(data)
},
async claimUnlinked(userId, ids) {
const { data, error } = await database()
.from('audio_files')
.update({ upload_status: 'deleted' })
.eq('user_id', userId)
.in('id', ids)
.is('history_id', null)
.is('meeting_id', null)
.neq('upload_status', 'deleted')
.select('id, storage_key, upload_status')
if (error !== null) throw error
return toRefs(data)
},
async findReferencedKeys(userId, keys, excludeIds) {
if (keys.length === 0) return []
const { data, error } = await database()
.from('audio_files')
.select('id, storage_key')
.eq('user_id', userId)
.in('storage_key', keys)
.neq('upload_status', 'deleted')
if (error !== null) throw error
const excluded = new Set(excludeIds)
return (data ?? [])
.filter((row) => !excluded.has(row.id))
.map((row) => row.storage_key)
},
async removeObjects(keys) {
const { error } = await supabase.storage.from(AUDIO_BUCKET).remove(keys)
if (error !== null) throw error
},
async deleteRows(userId, ids) {
const { error } = await database()
.from('audio_files')
.delete()
.eq('user_id', userId)
.in('id', ids)
.eq('upload_status', 'deleted')
if (error !== null) throw error
},
async restoreStatus(userId, id, status) {
const { error } = await database()
.from('audio_files')
.update({ upload_status: status })
.eq('user_id', userId)
.eq('id', id)
.eq('upload_status', 'deleted')
if (error !== null) throw error
},
}
}

View file

@ -8,6 +8,13 @@ import type {
ProcessingJob,
} from '@d3ro/api-client'
import { supabase } from '../../lib/supabase'
import {
createSupabaseLinkedAudioStore,
purgeUnlinkedAudio,
snapshotLinkedAudio,
type LinkedAudioRef,
type LinkedAudioStore,
} from '../audio/linked-audio-cleanup'
export type HistoryFilter = 'all' | 'favorites' | 'processing'
@ -306,16 +313,29 @@ export async function updateHistoryRevisionSafe(
}
}
/**
* 리비전이 일치할 때만 기록을 지우고, 연결된 원본 음성(audio_files + audio 버킷)도 정리한다.
* audio_files.history_id 는 ON DELETE SET NULL 이라 기록만 지우면 음성이 고아로 남기 때문에
* 삭제 전에 연결을 스냅샷하고, 삭제가 성공한 뒤에만 정리한다.
*/
export async function deleteHistoryRevisionSafe(
userId: string,
historyId: string,
expectedRevision: number,
audioStore: LinkedAudioStore = createSupabaseLinkedAudioStore(),
): Promise<void> {
requireUserId(userId)
if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 1) {
throw new HistoryServiceError('validation', 'Expected revision is invalid')
}
let linkedAudio: LinkedAudioRef[]
try {
linkedAudio = await snapshotLinkedAudio(audioStore, userId, 'history_id', historyId)
} catch (error) {
throw historyError(error)
}
try {
const { data, error } = await client()
.from('history')
@ -336,6 +356,8 @@ export async function deleteHistoryRevisionSafe(
} catch (error) {
throw historyError(error)
}
await purgeUnlinkedAudio(audioStore, userId, linkedAudio)
}
export async function createAudioPlaybackUrl(

View file

@ -10,6 +10,13 @@ import type {
} from '@d3ro/api-client'
import { supabase } from '../../lib/supabase'
import { createUuidV4 } from '../../lib/random-id'
import {
createSupabaseLinkedAudioStore,
purgeUnlinkedAudio,
snapshotLinkedAudio,
type LinkedAudioRef,
type LinkedAudioStore,
} from '../audio/linked-audio-cleanup'
export interface MeetingListOptions {
userId: string
@ -515,9 +522,14 @@ export async function updateMeetingTitleRevisionSafe(
}
}
/**
* 변경 시각이 일치할 때만 회의를 지우고, 연결된 원본 음성(audio_files + audio 버킷)도 정리한다.
* audio_files.meeting_id 는 ON DELETE SET NULL 이라 회의만 지우면 음성이 고아로 남는다.
*/
export async function deleteMeetingRevisionSafe(
userId: string,
current: Meeting,
audioStore: LinkedAudioStore = createSupabaseLinkedAudioStore(),
): Promise<void> {
requireUuid(userId, 'authenticated user')
assertMeetingRow(current)
@ -525,6 +537,13 @@ export async function deleteMeetingRevisionSafe(
throw new MeetingServiceError('forbidden', 'Only the meeting owner can delete it')
}
let linkedAudio: LinkedAudioRef[]
try {
linkedAudio = await snapshotLinkedAudio(audioStore, userId, 'meeting_id', current.id)
} catch (error) {
throw toMeetingServiceError(error)
}
try {
const { data, error } = await client()
.from('meetings')
@ -544,6 +563,8 @@ export async function deleteMeetingRevisionSafe(
} catch (error) {
throw toMeetingServiceError(error)
}
await purgeUnlinkedAudio(audioStore, userId, linkedAudio)
}
export async function createMeetingMemo(