feat: 계정 Web Push 전달 기반과 사건 판정을 추가한다

게임 상태 변경과 같은 트랜잭션에 내구성 outbox를 기록하고 Gateway가 계정 설정과 기기 구독으로 fan-out하도록 한다. 전역 기본값은 비활성으로 유지하며 migration과 회귀 테스트를 함께 추가한다.
This commit is contained in:
2026-08-23 13:16:07 +00:00
parent f858806295
commit 7bc3213b03
34 changed files with 1964 additions and 9 deletions
+19
View File
@@ -108,6 +108,25 @@ model ReadModelOutbox {
@@map("read_model_outbox")
}
model WebPushOutbox {
id BigInt @id @default(autoincrement())
eventId String @unique @map("event_id")
eventType String @map("event_type")
userIds String[] @default([]) @map("user_ids")
year Int?
month Int?
attempts Int @default(0)
availableAt DateTime @default(now()) @map("available_at")
lockedAt DateTime? @map("locked_at")
lockOwner String? @map("lock_owner")
deliveredAt DateTime? @map("delivered_at")
lastError String? @map("last_error")
createdAt DateTime @default(now()) @map("created_at")
@@index([deliveredAt, availableAt, id], map: "web_push_outbox_delivered_at_available_at_id_idx")
@@map("web_push_outbox")
}
model ReadModelRevisionMeta {
id Int @id
coverageVersion Int @default(0) @map("coverage_version")
@@ -0,0 +1,103 @@
CREATE TABLE "web_push_subscription" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"user_id" TEXT NOT NULL,
"endpoint" TEXT NOT NULL,
"p256dh" TEXT NOT NULL,
"auth" TEXT NOT NULL,
"expiration_time" TIMESTAMP(3),
"user_agent" TEXT,
"disabled_at" TIMESTAMP(3),
"last_seen_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "web_push_subscription_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "web_push_preference" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"user_id" TEXT NOT NULL,
"profile_name" TEXT NOT NULL,
"event_type" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT false,
"target_year" INTEGER,
"target_month" INTEGER,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "web_push_preference_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "web_push_event_receipt" (
"event_id" TEXT NOT NULL,
"profile_name" TEXT NOT NULL,
"event_type" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "web_push_event_receipt_pkey" PRIMARY KEY ("event_id")
);
CREATE TABLE "web_push_notification" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"dedupe_key" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"profile_name" TEXT NOT NULL,
"event_type" TEXT NOT NULL,
"title" TEXT NOT NULL,
"body" TEXT NOT NULL,
"url" TEXT NOT NULL,
"tag" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "web_push_notification_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "web_push_delivery" (
"id" BIGSERIAL NOT NULL,
"notification_id" UUID NOT NULL,
"subscription_id" UUID NOT NULL,
"status" TEXT NOT NULL DEFAULT 'PENDING',
"attempts" INTEGER NOT NULL DEFAULT 0,
"available_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"locked_at" TIMESTAMP(3),
"lock_owner" TEXT,
"delivered_at" TIMESTAMP(3),
"last_error" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "web_push_delivery_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "web_push_profile_cursor" (
"profile_name" TEXT NOT NULL,
"status" TEXT NOT NULL,
"preopen_at" TIMESTAMP(3),
"open_at" TIMESTAMP(3),
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "web_push_profile_cursor_pkey" PRIMARY KEY ("profile_name")
);
CREATE UNIQUE INDEX "web_push_subscription_endpoint_key" ON "web_push_subscription"("endpoint");
CREATE INDEX "web_push_subscription_user_id_disabled_at_updated_at_idx"
ON "web_push_subscription"("user_id", "disabled_at", "updated_at");
CREATE UNIQUE INDEX "web_push_preference_user_id_profile_name_event_type_key"
ON "web_push_preference"("user_id", "profile_name", "event_type");
CREATE INDEX "web_push_preference_profile_name_event_type_enabled_idx"
ON "web_push_preference"("profile_name", "event_type", "enabled");
CREATE INDEX "web_push_event_receipt_created_at_idx" ON "web_push_event_receipt"("created_at");
CREATE UNIQUE INDEX "web_push_notification_dedupe_key_key" ON "web_push_notification"("dedupe_key");
CREATE INDEX "web_push_notification_user_id_created_at_idx" ON "web_push_notification"("user_id", "created_at");
CREATE UNIQUE INDEX "web_push_delivery_notification_id_subscription_id_key"
ON "web_push_delivery"("notification_id", "subscription_id");
CREATE INDEX "web_push_delivery_status_available_at_id_idx" ON "web_push_delivery"("status", "available_at", "id");
ALTER TABLE "web_push_subscription"
ADD CONSTRAINT "web_push_subscription_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "app_user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "web_push_preference"
ADD CONSTRAINT "web_push_preference_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "app_user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "web_push_notification"
ADD CONSTRAINT "web_push_notification_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "app_user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "web_push_delivery"
ADD CONSTRAINT "web_push_delivery_notification_id_fkey"
FOREIGN KEY ("notification_id") REFERENCES "web_push_notification"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "web_push_delivery"
ADD CONSTRAINT "web_push_delivery_subscription_id_fkey"
FOREIGN KEY ("subscription_id") REFERENCES "web_push_subscription"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+97
View File
@@ -111,6 +111,9 @@ model AppUser {
legacyData Json @default(dbgenerated("'{}'::jsonb")) @map("legacy_data")
icons UserIcon[]
specialAccessGrants SpecialAccountAccessGrant[]
webPushSubscriptions WebPushSubscription[]
webPushPreferences WebPushPreference[]
webPushNotifications WebPushNotification[]
@@map("app_user")
}
@@ -240,6 +243,100 @@ model GatewayProfile {
@@map("gateway_profile")
}
model WebPushSubscription {
id String @id @default(uuid())
userId String @map("user_id")
user AppUser @relation(fields: [userId], references: [id], onDelete: Cascade)
endpoint String @unique @db.Text
p256dh String @db.Text
auth String @db.Text
expirationTime DateTime? @map("expiration_time")
userAgent String? @map("user_agent") @db.Text
disabledAt DateTime? @map("disabled_at")
lastSeenAt DateTime @default(now()) @map("last_seen_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deliveries WebPushDelivery[]
@@index([userId, disabledAt, updatedAt])
@@map("web_push_subscription")
}
model WebPushPreference {
id String @id @default(uuid())
userId String @map("user_id")
user AppUser @relation(fields: [userId], references: [id], onDelete: Cascade)
profileName String @map("profile_name")
eventType String @map("event_type")
enabled Boolean @default(false)
targetYear Int? @map("target_year")
targetMonth Int? @map("target_month")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@unique([userId, profileName, eventType])
@@index([profileName, eventType, enabled])
@@map("web_push_preference")
}
model WebPushEventReceipt {
eventId String @id @map("event_id")
profileName String @map("profile_name")
eventType String @map("event_type")
createdAt DateTime @default(now()) @map("created_at")
@@index([createdAt])
@@map("web_push_event_receipt")
}
model WebPushNotification {
id String @id @default(uuid())
dedupeKey String @unique @map("dedupe_key")
userId String @map("user_id")
user AppUser @relation(fields: [userId], references: [id], onDelete: Cascade)
profileName String @map("profile_name")
eventType String @map("event_type")
title String
body String
url String
tag String
createdAt DateTime @default(now()) @map("created_at")
deliveries WebPushDelivery[]
@@index([userId, createdAt])
@@map("web_push_notification")
}
model WebPushDelivery {
id BigInt @id @default(autoincrement())
notificationId String @map("notification_id")
notification WebPushNotification @relation(fields: [notificationId], references: [id], onDelete: Cascade)
subscriptionId String @map("subscription_id")
subscription WebPushSubscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade)
status String @default("PENDING")
attempts Int @default(0)
availableAt DateTime @default(now()) @map("available_at")
lockedAt DateTime? @map("locked_at")
lockOwner String? @map("lock_owner")
deliveredAt DateTime? @map("delivered_at")
lastError String? @map("last_error")
createdAt DateTime @default(now()) @map("created_at")
@@unique([notificationId, subscriptionId])
@@index([status, availableAt, id])
@@map("web_push_delivery")
}
model WebPushProfileCursor {
profileName String @id @map("profile_name")
status String
preopenAt DateTime? @map("preopen_at")
openAt DateTime? @map("open_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("web_push_profile_cursor")
}
model GatewayRuntimeAction {
id String @id @default(uuid())
profileName String @map("profile_name")
@@ -0,0 +1,20 @@
CREATE TABLE "web_push_outbox" (
"id" BIGSERIAL NOT NULL,
"event_id" TEXT NOT NULL,
"event_type" TEXT NOT NULL,
"user_ids" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
"year" INTEGER,
"month" INTEGER,
"attempts" INTEGER NOT NULL DEFAULT 0,
"available_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"locked_at" TIMESTAMP(3),
"lock_owner" TEXT,
"delivered_at" TIMESTAMP(3),
"last_error" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "web_push_outbox_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "web_push_outbox_event_id_key" ON "web_push_outbox"("event_id");
CREATE INDEX "web_push_outbox_delivered_at_available_at_id_idx"
ON "web_push_outbox"("delivered_at", "available_at", "id");