Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 50 additions & 6 deletions apps/server/src/db/repositories/accountDeletion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,32 @@ import {
* identity の provider_user_id からしかユーザーを解決しないため対象にならない */
const DELETED_USER_DISCORD_ID = "system:deleted-user";

/** 「参加者のいない下書きイベント」の条件 (#244)。誰にも見えず誰も消せない
* 孤児になるため退会の完全削除で消す。
*
* この条件に対する要求は **DELETE と、その前に R2 のキーを集める SELECT が
* 同じ集合を指すこと** (#424)。行が消えるとキーを辿れないので収集は削除より
* 前に走るしかなく、集合がずれた分だけ実体が孤児になる。
*
* **同じ文字列を共有するだけでは足りない。同じ引数で呼べることが要る。**
* 素直に書くと所有者の指定が両者で食い違う:
* - DELETE は (1) の付け替えの**後**なので、本人の下書きは既に ghost 名義。
* - しかも DELETE は本人のイベントに限定されておらず、条件に合う ghost 名義の
* 下書きを**すべて**消す。過去の退会で ghost に移った下書きから後になって
* 第三者の参加者が抜けると、無関係な人の退会でその行が消える。
* - 収集は付け替えの**前**なので、本人名義のものはまだ本人名義。
* 所有者の条件を `created_by IN (本人, ghost)` にすると、両者を**同じ引数**で
* 呼べる。付け替えは status も event_member も触らないので
* 「付け替え後に created_by = ghost」⇔「付け替え前に created_by ∈ {本人, ghost}」
* が成り立ち、2つの集合は似ているのではなく**同一**になる。
* (付け替え前に created_by = 本人 のものは付け替えで ghost になり、
* 既に ghost のものはそのまま。それ以外は ghost にならない)
*
* `?` は順に 本人 / ghost / 「本人以外の参加者」の user_id。 */
const ORPHAN_DRAFT_EVENT_WHERE = `created_by IN (?, ?) AND status = 'draft'
AND NOT EXISTS (SELECT 1 FROM event_member m
WHERE m.event_id = event.id AND m.user_id != ?)`;

/** 退会の一連(申請 → 猶予期間 → 完全削除)と、その手前で使う
* 「利用実績があるか」の判定 (#238)。触る表の一覧は userTables.ts と共有する
* (統合 accountMerge.ts と同じ定義を読む) */
Expand Down Expand Up @@ -93,6 +119,24 @@ export const accountDeletionRepo = {
return row?.n ?? 0;
},

/** `deleteAccount` が消す下書きイベントの id (#424)。R2 の実体を消すために
* **deleteAccount を呼ぶ前に**呼ぶこと(行が消えるとキーを辿れない)。
* 条件も引数も DELETE 側と同一(等しくなる理由は
* ORPHAN_DRAFT_EVENT_WHERE)。ghost 名義に移っている下書きも対象に入る=
* DELETE が消す行を1つ残らず含む */
async listDeletableDraftEventIds(
userId: string,
ghostId: string,
): Promise<string[]> {
const rows = await many<{ id: string }>(
`SELECT id FROM event WHERE ${ORPHAN_DRAFT_EVENT_WHERE}`,
userId,
ghostId,
userId,
);
return rows.map((r) => r.id);
},

/** 退会(アカウント削除) (#244)。単一トランザクション(D1 batch)で
* 「共有コンテンツを『退会済みユーザー』(ghost) に付け替え → 個人データ削除 →
* user 行削除(FK CASCADE で残りが消える)」を行う。
Expand Down Expand Up @@ -154,13 +198,13 @@ export const accountDeletionRepo = {
args: [userId],
});

// (1-d) 参加者のいない下書きイベントは誰にも見えず誰も消せない孤児になるため削除
// (1-d) 参加者のいない下書きイベントは誰にも見えず誰も消せない孤児になるため削除。
// 条件も引数も R2 のキー収集(listDeletableDraftEventIds)と同一にする。
// (1) の付け替え後なので本人名義の分は既に ghost 名義だが、
// `IN (本人, ghost)` なのでどちらでも同じ行に当たる
stmts.push({
sql: `DELETE FROM event
WHERE created_by = ? AND status = 'draft'
AND NOT EXISTS (SELECT 1 FROM event_member m
WHERE m.event_id = event.id AND m.user_id != ?)`,
args: [ghostId, userId],
sql: `DELETE FROM event WHERE ${ORPHAN_DRAFT_EVENT_WHERE}`,
args: [userId, ghostId, userId],
});

// (2) FK RESTRICT の個人資産 live_set は user 削除前に明示削除。
Expand Down
11 changes: 11 additions & 0 deletions apps/server/src/db/repositories/eventMeetPrizes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,17 @@ export const eventMeetPrizesRepo = {
await run("DELETE FROM event_prize WHERE id = ?", id);
},

/** イベント削除時のR2掃除用: そのイベントの景品画像の R2 キー一覧 (#424)。
* キーは乱数入りで D1 にしか無いので、行が消えた後は誰も辿れない。
* event 行を消すと FK CASCADE で一緒に消えるため、**削除の前に**呼ぶこと */
async listImageKeysByEvent(eventId: string): Promise<string[]> {
const rows = await many<{ image_key: string }>(
"SELECT image_key FROM event_prize WHERE event_id = ? AND image_key IS NOT NULL",
eventId,
);
return rows.map((r) => r.image_key);
},

/** 景品画像の R2 キーを差し替える(null で画像なしに戻す) (#434) */
async setImageKey(id: string, imageKey: string | null): Promise<void> {
await run("UPDATE event_prize SET image_key = ? WHERE id = ?", imageKey, id);
Expand Down
19 changes: 19 additions & 0 deletions apps/server/src/db/repositories/eventPhotos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,25 @@ export const eventPhotosRepo = {
}));
},

/** イベント削除時のR2掃除用: そのイベントの写真・動画の (id, eventId, kind) 一覧 (#424)。
* listIdsByUser と同じ理由で **共有の SELECT を使わない**。あれは運営が
* 非表示にした写真 (#278) を落とすので、通すと非表示ぶんの R2 実体だけが
* 残る(イベントが消えて誰からも辿れない孤児になる)。
* event 行を消すと FK CASCADE で一緒に消えるため、**削除の前に**呼ぶこと */
async listIdsByEvent(
eventId: string,
): Promise<Array<{ id: string; eventId: string; kind: "photo" | "video" }>> {
const rows = await many<{ id: string; event_id: string; kind: string }>(
"SELECT id, event_id, kind FROM event_photo WHERE event_id = ?",
eventId,
);
return rows.map((r) => ({
id: r.id,
eventId: r.event_id,
kind: toKind(r.kind),
}));
},

/** 公開プロフィール用: ユーザーが公開設定イベントに投稿した写真(ページング #407)。
* 公開範囲は PUBLIC_USER_PHOTO_COND、フィルタは buildUserPhotoWhere 参照。
* コメント数は一覧と同じ COMMENT_COUNT を使う。ここだけ別に書いていたため
Expand Down
104 changes: 104 additions & 0 deletions apps/server/src/lib/mediaCleanup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { getBucket } from "../runtime.js";
import { eventPhotosRepo } from "../db/repositories/eventPhotos.js";
import { eventMeetPrizesRepo } from "../db/repositories/eventMeetPrizes.js";

/**
* D1 の行と R2 の実体の後始末を1本の契約にまとめる (#424)。
*
* ■ 契約(削除する経路はすべてこの順で書く)
* 1. **D1 からキーを集める**(行が消えた後はキーを辿れない=孤児になる)
* 2. D1 の行を消す
* 3. R2 の実体を `deleteObjects` でベストエフォートに消す
*
* ■ なぜこの失敗方向か
* どちらを先にしても失敗はしうるので、「どちらに壊れるか」を選ぶ話になる。
* - R2 が先に成功して D1 が失敗 → **参照はあるのに実体が無い**。
* 一覧に出るのに開けない写真が残り、モデレーションの証跡 (#278) も
* 消えている。行から辿って復元する方法が無い=回復不能。
* - D1 が先に成功して R2 が失敗 → **実体だけが残る(孤児)**。
* 誰からも参照されないので配信はされず、prefix を舐める掃除で後から拾える。
* 回復可能な方(孤児)に倒す。`purgeDeleted.ts` が退会 (#244) で既に採っている
* 順序で、イベント削除・単体削除もこれに揃えた。
*
* ■ なぜ deferBackground に逃がさないか
* 収集は D1 削除より前でなければならない=どのみちインラインになる。残る R2 側は
* まとめて1回の multi-delete(R2 は1回 1000 キー、イベント写真は
* EVENT_PHOTO_LIMIT=50 本=最大 100 キー+景品+表紙1枚)なので、
* サブリクエスト予算 50 に対して余裕がある。インラインなら失敗が
* テストとレスポンスから見える。
*/

/** R2 のキー。**イベントが持つ prefix はこの4つだけ**(bgm / deck-images /
* live-set-images / avatars / profile-cards / venue-* はユーザーか会場の持ち物)。
* 組み立てをここに集約しているので、掃除する側が形を書き写さずに済む */
export const eventImageR2Key = (eventId: string) => `event-images/${eventId}`;
export const photoR2Key = (eventId: string, photoId: string) =>
`event-photos/${eventId}/${photoId}`;
export const videoR2Key = (eventId: string, videoId: string) =>
`event-videos/${eventId}/${videoId}`;
/** ポスター(サムネイル画像)は本体の兄弟キーに置く (#408) */
export const videoPosterR2Key = (eventId: string, videoId: string) =>
`${videoR2Key(eventId, videoId)}-poster`;

/** 1件の投稿が持つ R2 オブジェクトのキー。動画 (#408) は本体+ポスターの2つ。
* ポスターなしで投稿された動画でも存在しないキーの削除は無害なので分岐しない
* (分岐を増やすと「ポスターだけ残る」取りこぼしが生まれる)。
* **写真・動画のキーを組み立てる経路は必ずここを通すこと** */
export function photoObjectKeys(p: {
eventId: string;
id: string;
kind: "photo" | "video";
}): string[] {
return p.kind === "video"
? [videoR2Key(p.eventId, p.id), videoPosterR2Key(p.eventId, p.id)]
: [photoR2Key(p.eventId, p.id)];
}

/** イベントが持つ R2 オブジェクトのキーを D1 から列挙する (#424)。
* **event 行を消す前に呼ぶこと**(子テーブルは FK CASCADE で一緒に消えるため、
* 後から呼んでもキーは1つも返らない)。
*
* 列挙元を `bucket.list` ではなく D1 にしているのは、何がこのイベントの持ち物かを
* 知っているのは D1 だから。景品画像 (#434) のキーは乱数を含み D1 にしか無い。
* 既にある孤児(この修正より前に消したイベントの残骸)はここでは拾わない
* = prefix を舐める掃除は別件。
*
* 表紙画像は行の有無を見ずに積む。存在しないキーの削除は無害で、
* `event_image` を引く1サブリクエストを節約できる(退会時の `avatarKey` と同じ手) */
export async function collectEventObjects(eventId: string): Promise<string[]> {
const keys = [eventImageR2Key(eventId)];
for (const p of await eventPhotosRepo.listIdsByEvent(eventId)) {
keys.push(...photoObjectKeys(p));
}
keys.push(...(await eventMeetPrizesRepo.listImageKeysByEvent(eventId)));
return keys;
}

/** R2 の multi-delete の上限(1回のリクエストで消せるキー数) */
const MAX_KEYS_PER_DELETE = 1000;

/** R2 の削除(ベストエフォート)。失敗しても throw しない=呼び出し側の
* 削除そのものは成立させる。残骸はログの label で追える。
* 空配列ならサブリクエストを使わずに戻る。
*
* @returns 消費したサブリクエスト数。退会の掃除 (#244) はこれを実行予算に積む。
* 刻み幅を呼び出し側に数え直させると「1000」が2か所に散り、片方を変えたときに
* 予算だけ静かにずれる。分割した本人が数えて返す。
* 失敗しても既に消費済みなので、途中で落ちても投げる予定だった回数を返す
* (=予算は必ず積まれる) */
export async function deleteObjects(
keys: string[],
label: string,
): Promise<number> {
if (keys.length === 0) return 0;
const calls = Math.ceil(keys.length / MAX_KEYS_PER_DELETE);
try {
const bucket = getBucket();
for (let i = 0; i < keys.length; i += MAX_KEYS_PER_DELETE) {
await bucket.delete(keys.slice(i, i + MAX_KEYS_PER_DELETE));
}
} catch (e) {
console.error(`${label} R2 cleanup failed (${keys.length} keys)`, e);
}
return calls;
}
Loading
Loading