feat: 관리자 계정 식별자와 카카오 영구 교체 지원

관리자 승인과 사용자 OAuth 증명을 분리하고 기존 Kakao stable ID를 영구 폐기한다. 로그인 ID와 닉네임 변경은 현재 장수에 revision 기반으로 투영하며 과거 기록은 당시 이름으로 보존한다.
This commit is contained in:
2026-08-24 23:25:49 +00:00
parent e8dc7a65aa
commit 10cd1ed6d4
34 changed files with 1496 additions and 143 deletions
+3
View File
@@ -24,6 +24,7 @@ export interface GatewayUserInfo {
id: string;
username: string;
displayName: string;
identityRevision?: string;
roles: string[];
picture?: string;
imageServer?: number;
@@ -102,6 +103,8 @@ export const parseGameSessionTokenPayload = (value: unknown): GameSessionTokenPa
typeof user.id !== 'string' ||
typeof user.username !== 'string' ||
typeof user.displayName !== 'string' ||
(user.identityRevision !== undefined &&
(typeof user.identityRevision !== 'string' || !isCanonicalIsoTimestamp(user.identityRevision))) ||
!Array.isArray(user.roles) ||
(user.picture !== undefined && typeof user.picture !== 'string') ||
(user.imageServer !== undefined && (!Number.isSafeInteger(user.imageServer) || user.imageServer < 0)) ||
+19
View File
@@ -348,6 +348,13 @@ export type TurnDaemonCommand =
iconRevision: string;
enforceCooldown?: boolean;
}
| {
type: 'adjustGeneralIdentity';
requestId?: string;
userId: string;
displayName: string;
identityRevision: string;
}
| {
type: 'joinCreateGeneral';
requestId?: string;
@@ -757,6 +764,18 @@ export type TurnDaemonCommandResult =
reason: string;
availableAt?: string;
}
| {
type: 'adjustGeneralIdentity';
ok: true;
generalId: number | null;
updated: boolean;
}
| {
type: 'adjustGeneralIdentity';
ok: false;
code: 'CONFLICT' | 'PRECONDITION_FAILED';
reason: string;
}
| {
type: 'joinCreateGeneral';
ok: true;
@@ -0,0 +1,23 @@
ALTER TABLE "app_user"
ADD COLUMN "identity_revision" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "auth_revision" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "session_revoked_before" TIMESTAMP(3),
ADD COLUMN "kakao_replacement_approved_until" TIMESTAMP(3),
ADD COLUMN "kakao_replacement_approved_by_user_id" TEXT,
ADD COLUMN "kakao_replacement_reason" TEXT;
CREATE TABLE "retired_kakao_identity" (
"id" TEXT NOT NULL,
"oauth_id" TEXT NOT NULL,
"former_user_id" TEXT NOT NULL,
"approved_by_user_id" TEXT NOT NULL,
"reason" TEXT NOT NULL,
"retired_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "retired_kakao_identity_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "retired_kakao_identity_oauth_id_key"
ON "retired_kakao_identity"("oauth_id");
CREATE INDEX "retired_kakao_identity_former_user_id_retired_at_idx"
ON "retired_kakao_identity"("former_user_id", "retired_at");
+67 -49
View File
@@ -79,45 +79,63 @@ enum GatewaySourceMode {
}
model AppUser {
id String @id @default(uuid())
loginId String @unique @map("login_id")
displayName String @unique @map("display_name")
passwordHash String @map("password_hash")
passwordSalt String @map("password_salt")
passwordResetRequired Boolean @default(false) @map("password_reset_required")
roles Json @default(dbgenerated("'[]'::jsonb"))
sanctions Json @default(dbgenerated("'{}'::jsonb"))
oauthType OAuthType @default(NONE) @map("oauth_type")
oauthId String? @unique @map("oauth_id")
email String? @unique
oauthInfo Json @default(dbgenerated("'{}'::jsonb")) @map("oauth_info")
picture String @default("default.jpg")
imageServer Int @default(0) @map("image_server")
iconUpdatedAt DateTime? @map("icon_updated_at")
iconRevision DateTime? @map("icon_revision")
profileIconResetAt DateTime? @map("profile_icon_reset_at")
iconRetiredAt DateTime? @map("icon_retired_at")
thirdPartyUse Boolean @default(true) @map("third_party_use")
termsAcceptedAt DateTime? @map("terms_accepted_at")
privacyAcceptedAt DateTime? @map("privacy_accepted_at")
kakaoVerifiedAt DateTime? @map("kakao_verified_at")
kakaoTalkVerifiedUntil DateTime? @map("kakao_talk_verified_until")
kakaoGraceStartedAt DateTime @default(now()) @map("kakao_grace_started_at")
kakaoGraceUntil DateTime? @map("kakao_grace_until")
deleteAfter DateTime? @map("delete_after")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
lastLoginAt DateTime? @map("last_login_at")
legacyData Json @default(dbgenerated("'{}'::jsonb")) @map("legacy_data")
icons UserIcon[]
specialAccessGrants SpecialAccountAccessGrant[]
webPushSubscriptions WebPushSubscription[]
webPushPreferences WebPushPreference[]
webPushNotifications WebPushNotification[]
id String @id @default(uuid())
loginId String @unique @map("login_id")
displayName String @unique @map("display_name")
passwordHash String @map("password_hash")
passwordSalt String @map("password_salt")
passwordResetRequired Boolean @default(false) @map("password_reset_required")
roles Json @default(dbgenerated("'[]'::jsonb"))
sanctions Json @default(dbgenerated("'{}'::jsonb"))
oauthType OAuthType @default(NONE) @map("oauth_type")
oauthId String? @unique @map("oauth_id")
email String? @unique
oauthInfo Json @default(dbgenerated("'{}'::jsonb")) @map("oauth_info")
identityRevision DateTime @default(now()) @map("identity_revision")
authRevision Int @default(0) @map("auth_revision")
sessionRevokedBefore DateTime? @map("session_revoked_before")
kakaoReplacementApprovedUntil DateTime? @map("kakao_replacement_approved_until")
kakaoReplacementApprovedByUserId String? @map("kakao_replacement_approved_by_user_id")
kakaoReplacementReason String? @map("kakao_replacement_reason")
picture String @default("default.jpg")
imageServer Int @default(0) @map("image_server")
iconUpdatedAt DateTime? @map("icon_updated_at")
iconRevision DateTime? @map("icon_revision")
profileIconResetAt DateTime? @map("profile_icon_reset_at")
iconRetiredAt DateTime? @map("icon_retired_at")
thirdPartyUse Boolean @default(true) @map("third_party_use")
termsAcceptedAt DateTime? @map("terms_accepted_at")
privacyAcceptedAt DateTime? @map("privacy_accepted_at")
kakaoVerifiedAt DateTime? @map("kakao_verified_at")
kakaoTalkVerifiedUntil DateTime? @map("kakao_talk_verified_until")
kakaoGraceStartedAt DateTime @default(now()) @map("kakao_grace_started_at")
kakaoGraceUntil DateTime? @map("kakao_grace_until")
deleteAfter DateTime? @map("delete_after")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
lastLoginAt DateTime? @map("last_login_at")
legacyData Json @default(dbgenerated("'{}'::jsonb")) @map("legacy_data")
icons UserIcon[]
specialAccessGrants SpecialAccountAccessGrant[]
webPushSubscriptions WebPushSubscription[]
webPushPreferences WebPushPreference[]
webPushNotifications WebPushNotification[]
@@map("app_user")
}
model RetiredKakaoIdentity {
id String @id @default(uuid())
oauthId String @unique @map("oauth_id")
formerUserId String @map("former_user_id")
approvedByUserId String @map("approved_by_user_id")
reason String
retiredAt DateTime @default(now()) @map("retired_at")
@@index([formerUserId, retiredAt])
@@map("retired_kakao_identity")
}
model SpecialAccountAccessGrant {
id String @id @default(uuid())
userId String @map("user_id")
@@ -244,18 +262,18 @@ model GatewayProfile {
}
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")
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])
@@ -328,11 +346,11 @@ model WebPushDelivery {
}
model WebPushProfileCursor {
profileName String @id @map("profile_name")
profileName String @id @map("profile_name")
status String
preopenAt DateTime? @map("preopen_at")
openAt DateTime? @map("open_at")
updatedAt DateTime @updatedAt @map("updated_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("web_push_profile_cursor")
}