From 159e8bfd59cb107f86e61cda2dfd1621e5414c25 Mon Sep 17 00:00:00 2001 From: chenhang Date: Mon, 20 Jul 2026 17:44:22 +0800 Subject: [PATCH] fix(chat): migrate legacy character cache identities --- src/data/constants/character_id_aliases.ts | 14 + .../__tests__/local_chat_db_migration.test.ts | 241 ++++++++++++++++++ src/data/storage/chat/local_chat_db.ts | 72 +++++- .../__tests__/navigation_storage.test.ts | 48 ++++ .../storage/navigation/navigation_storage.ts | 31 ++- src/lib/chat/chat_cache_keys.ts | 37 +++ 6 files changed, 435 insertions(+), 8 deletions(-) create mode 100644 src/data/constants/character_id_aliases.ts create mode 100644 src/data/storage/chat/__tests__/local_chat_db_migration.test.ts diff --git a/src/data/constants/character_id_aliases.ts b/src/data/constants/character_id_aliases.ts new file mode 100644 index 00000000..a90dfd0a --- /dev/null +++ b/src/data/constants/character_id_aliases.ts @@ -0,0 +1,14 @@ +const LEGACY_CHARACTER_ID_ALIASES: Readonly> = + Object.freeze({ + character_elio: "elio", + character_maya: "maya-tan", + character_nayeli: "nayeli-cervantes", + }); + +/** + * Converts character IDs persisted by pre-catalog releases to the current + * backend IDs. Unknown and already-current IDs pass through unchanged. + */ +export function normalizeLegacyCharacterId(characterId: string): string { + return LEGACY_CHARACTER_ID_ALIASES[characterId] ?? characterId; +} diff --git a/src/data/storage/chat/__tests__/local_chat_db_migration.test.ts b/src/data/storage/chat/__tests__/local_chat_db_migration.test.ts new file mode 100644 index 00000000..4a8d10c6 --- /dev/null +++ b/src/data/storage/chat/__tests__/local_chat_db_migration.test.ts @@ -0,0 +1,241 @@ +import "fake-indexeddb/auto"; + +import Dexie from "dexie"; +import { afterEach, describe, expect, it } from "vitest"; + +import { + buildChatConversationKey, + buildChatMediaCacheKey, +} from "@/lib/chat/chat_cache_keys"; + +import { + LocalChatDB, + type LocalChatMediaRow, + type LocalMessageRow, +} from "../local_chat_db"; + +const DATABASE_SCHEMA = { + messages: "++dbId, sessionId", + media: + "cacheKey, ownerKey, messageId, kind, remoteUrl, updatedAt, lastAccessedAt", +}; + +const openDatabases: Dexie[] = []; +const databaseNames = new Set(); + +afterEach(async () => { + for (const database of openDatabases.splice(0)) database.close(); + await Promise.all([...databaseNames].map((name) => Dexie.delete(name))); + databaseNames.clear(); +}); + +describe("LocalChatDB v5 migration", () => { + it("moves owner-only and old character messages to current conversations", async () => { + const legacy = await createLegacyDatabase(3); + await legacy.table("messages").bulkAdd([ + messageRow("owner-only", "user:account-1"), + messageRow( + "old-elio", + buildChatConversationKey("user:account-1", "character_elio"), + ), + messageRow( + "old-maya", + buildChatConversationKey("user:account-1", "character_maya"), + ), + messageRow( + "old-nayeli", + buildChatConversationKey("device:guest-1", "character_nayeli"), + ), + messageRow( + "current-elio", + buildChatConversationKey("user:account-2", "elio"), + ), + messageRow( + "unknown-character", + buildChatConversationKey("user:account-2", "character_aria"), + ), + ]); + legacy.close(); + + const upgraded = track(new LocalChatDB(legacy.name)); + await upgraded.open(); + + const rows = await upgraded.messages.orderBy("dbId").toArray(); + expect(rows.map(({ id, sessionId }) => ({ id, sessionId }))).toEqual([ + { + id: "owner-only", + sessionId: buildChatConversationKey("user:account-1", "elio"), + }, + { + id: "old-elio", + sessionId: buildChatConversationKey("user:account-1", "elio"), + }, + { + id: "old-maya", + sessionId: buildChatConversationKey("user:account-1", "maya-tan"), + }, + { + id: "old-nayeli", + sessionId: buildChatConversationKey( + "device:guest-1", + "nayeli-cervantes", + ), + }, + { + id: "current-elio", + sessionId: buildChatConversationKey("user:account-2", "elio"), + }, + { + id: "unknown-character", + sessionId: buildChatConversationKey( + "user:account-2", + "character_aria", + ), + }, + ]); + }); + + it("rekeys media while preserving current data and the newest legacy row", async () => { + const legacy = await createLegacyDatabase(4); + const owner = "user:account-1"; + const currentElioOwner = buildChatConversationKey(owner, "elio"); + const currentElioKey = buildChatMediaCacheKey({ + ownerKey: currentElioOwner, + messageId: "current-wins", + kind: "image", + }); + await legacy.table("media").bulkAdd([ + mediaRow({ + ownerKey: buildChatConversationKey(owner, "character_elio"), + messageId: "current-wins", + kind: "image", + remoteUrl: "https://example.com/legacy-newer.jpg", + updatedAt: 20, + }), + mediaRow({ + ownerKey: currentElioOwner, + messageId: "current-wins", + kind: "image", + remoteUrl: "https://example.com/current.jpg", + updatedAt: 10, + }), + mediaRow({ + ownerKey: owner, + messageId: "newest-legacy-wins", + kind: "audio", + remoteUrl: "https://example.com/older.mp3", + updatedAt: 5, + }), + mediaRow({ + ownerKey: buildChatConversationKey(owner, "character_elio"), + messageId: "newest-legacy-wins", + kind: "audio", + remoteUrl: "https://example.com/newer.mp3", + updatedAt: 15, + }), + mediaRow({ + ownerKey: buildChatConversationKey(owner, "character_maya"), + messageId: "maya-media", + kind: "image", + remoteUrl: "https://example.com/maya.jpg", + updatedAt: 8, + }), + ]); + legacy.close(); + + const upgraded = track(new LocalChatDB(legacy.name)); + await upgraded.open(); + + expect(await upgraded.media.get(currentElioKey)).toMatchObject({ + ownerKey: currentElioOwner, + remoteUrl: "https://example.com/current.jpg", + }); + + const legacyCollisionKey = buildChatMediaCacheKey({ + ownerKey: currentElioOwner, + messageId: "newest-legacy-wins", + kind: "audio", + }); + expect(await upgraded.media.get(legacyCollisionKey)).toMatchObject({ + ownerKey: currentElioOwner, + remoteUrl: "https://example.com/newer.mp3", + }); + + const mayaOwner = buildChatConversationKey(owner, "maya-tan"); + expect( + await upgraded.media.get( + buildChatMediaCacheKey({ + ownerKey: mayaOwner, + messageId: "maya-media", + kind: "image", + }), + ), + ).toMatchObject({ ownerKey: mayaOwner }); + expect(await upgraded.media.count()).toBe(3); + }); + + it("upgrades an existing v4 database and remains idempotent", async () => { + const legacy = await createLegacyDatabase(4); + await legacy.table("messages").add( + messageRow( + "old-elio", + buildChatConversationKey("device:guest-1", "character_elio"), + ), + ); + legacy.close(); + + const upgraded = track(new LocalChatDB(legacy.name)); + await upgraded.open(); + const firstRows = await upgraded.messages.toArray(); + upgraded.close(); + + const reopened = track(new LocalChatDB(legacy.name)); + await reopened.open(); + expect(await reopened.messages.toArray()).toEqual(firstRows); + expect(firstRows[0]?.sessionId).toBe( + buildChatConversationKey("device:guest-1", "elio"), + ); + }); +}); + +async function createLegacyDatabase(version: 3 | 4): Promise { + const name = `cozsweet-chat-v${version}-${crypto.randomUUID()}`; + databaseNames.add(name); + const database = track(new Dexie(name)); + database.version(version).stores(DATABASE_SCHEMA); + await database.open(); + return database; +} + +function track(database: T): T { + openDatabases.push(database); + return database; +} + +function messageRow(id: string, sessionId: string): LocalMessageRow { + return { + id, + role: "assistant", + type: "text", + content: id, + createdAt: "2026-07-17T00:00:00.000Z", + sessionId, + }; +} + +function mediaRow( + input: Pick< + LocalChatMediaRow, + "ownerKey" | "messageId" | "kind" | "remoteUrl" | "updatedAt" + >, +): LocalChatMediaRow { + return { + ...input, + cacheKey: buildChatMediaCacheKey(input), + bytes: new Uint8Array([1, 2, 3]).buffer, + mimeType: input.kind === "image" ? "image/jpeg" : "audio/mpeg", + byteSize: 3, + createdAt: input.updatedAt, + lastAccessedAt: input.updatedAt, + }; +} diff --git a/src/data/storage/chat/local_chat_db.ts b/src/data/storage/chat/local_chat_db.ts index 17e2d740..046a03cf 100644 --- a/src/data/storage/chat/local_chat_db.ts +++ b/src/data/storage/chat/local_chat_db.ts @@ -9,12 +9,16 @@ * 构造时 `dbName` 可注入,便于测试时每个用例用独立 DB 互不污染。 */ -import Dexie, { type Table } from "dexie"; +import Dexie, { type Table, type Transaction } from "dexie"; import type { ChatMediaKind } from "@/data/schemas/chat"; import type { ChatImageData, ChatLockDetailData, } from "@/data/schemas/chat"; +import { + buildChatMediaCacheKey, + migrateLegacyChatConversationKey, +} from "@/lib/chat/chat_cache_keys"; export interface LocalMessageRow { /** Dexie 自增主键(数据库内部使用,不暴露给 LocalMessage 类)。 */ @@ -76,5 +80,71 @@ export class LocalChatDB extends Dexie { messages: "++dbId, sessionId", media: "cacheKey, ownerKey, messageId, kind, remoteUrl, updatedAt, lastAccessedAt", }); + this.version(5) + .stores({ + messages: "++dbId, sessionId", + media: "cacheKey, ownerKey, messageId, kind, remoteUrl, updatedAt, lastAccessedAt", + }) + .upgrade(migrateLegacyChatCache); } } + +async function migrateLegacyChatCache( + transaction: Transaction, +): Promise { + const messages = transaction.table("messages"); + await messages.toCollection().modify((message) => { + message.sessionId = migrateLegacyChatConversationKey(message.sessionId); + }); + + const media = transaction.table("media"); + const rows = await media.toArray(); + const canonicalCacheKeys = new Set( + rows.flatMap((row) => { + const ownerKey = migrateLegacyChatConversationKey(row.ownerKey); + const cacheKey = buildChatMediaCacheKey({ + ownerKey, + messageId: row.messageId, + kind: row.kind, + }); + return ownerKey === row.ownerKey && cacheKey === row.cacheKey + ? [cacheKey] + : []; + }), + ); + + for (const row of rows) { + const ownerKey = migrateLegacyChatConversationKey(row.ownerKey); + const cacheKey = buildChatMediaCacheKey({ + ownerKey, + messageId: row.messageId, + kind: row.kind, + }); + if (ownerKey === row.ownerKey && cacheKey === row.cacheKey) continue; + + const existing = await media.get(cacheKey); + await media.delete(row.cacheKey); + if (existing && canonicalCacheKeys.has(cacheKey)) continue; + if (existing && !isNewerMediaRow(row, existing)) continue; + + await media.put({ + ...row, + cacheKey, + ownerKey, + }); + } +} + +function isNewerMediaRow( + candidate: LocalChatMediaRow, + existing: LocalChatMediaRow, +): boolean { + return ( + candidate.updatedAt > existing.updatedAt || + (candidate.updatedAt === existing.updatedAt && + candidate.lastAccessedAt > existing.lastAccessedAt) || + (candidate.updatedAt === existing.updatedAt && + candidate.lastAccessedAt === existing.lastAccessedAt && + candidate.createdAt > existing.createdAt) + ); +} diff --git a/src/data/storage/navigation/__tests__/navigation_storage.test.ts b/src/data/storage/navigation/__tests__/navigation_storage.test.ts index 817a5fd4..efbde474 100644 --- a/src/data/storage/navigation/__tests__/navigation_storage.test.ts +++ b/src/data/storage/navigation/__tests__/navigation_storage.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { createStorage } from "unstorage"; import memoryDriver from "unstorage/drivers/memory"; +import { StorageKeys } from "@/data/storage/storage_keys"; import { SessionAsyncUtil } from "@/utils/session-storage"; import { NavigationStorage } from "../navigation_storage"; @@ -165,4 +166,51 @@ describe("NavigationStorage", () => { ).resolves.toMatchObject({ characterId: CHARACTER_ID }); }); + it("normalizes old character ids in pending navigation sessions", async () => { + const createdAt = Date.now(); + const legacyPromotion = { + characterId: "character_elio", + promotionType: "image" as const, + lockType: "image_paywall" as const, + clientLockId: "promotion_legacy", + createdAt, + }; + await SessionAsyncUtil.setJson( + StorageKeys.pendingChatPromotion, + legacyPromotion, + ); + await SessionAsyncUtil.setJson(StorageKeys.pendingChatUnlock, { + reason: "single_message_unlock", + characterId: "character_elio", + displayMessageId: "promotion:promotion_legacy", + kind: "image", + lockType: "image_paywall", + clientLockId: "promotion_legacy", + promotion: legacyPromotion, + returnUrl: "/characters/elio/chat", + stage: "payment", + createdAt, + }); + await SessionAsyncUtil.setJson(StorageKeys.pendingChatImageReturn, { + reason: "image_paywall", + characterId: "character_elio", + messageId: "message_legacy", + returnUrl: "/characters/elio/chat?image=message_legacy", + createdAt, + }); + + await expect( + NavigationStorage.consumePendingChatPromotion(CHARACTER_ID), + ).resolves.toMatchObject({ characterId: CHARACTER_ID }); + await expect( + NavigationStorage.peekPendingChatUnlock(CHARACTER_ID), + ).resolves.toMatchObject({ + characterId: CHARACTER_ID, + promotion: { characterId: CHARACTER_ID }, + }); + await expect( + NavigationStorage.consumePendingChatImageReturn(CHARACTER_ID), + ).resolves.toMatchObject({ characterId: CHARACTER_ID }); + }); + }); diff --git a/src/data/storage/navigation/navigation_storage.ts b/src/data/storage/navigation/navigation_storage.ts index 49830de7..3729233a 100644 --- a/src/data/storage/navigation/navigation_storage.ts +++ b/src/data/storage/navigation/navigation_storage.ts @@ -5,6 +5,7 @@ import { z } from "zod"; import { StorageKeys } from "@/data/storage/storage_keys"; import { ChatLockTypeSchema } from "@/data/schemas/chat"; import { getCharacterById } from "@/data/constants/character"; +import { normalizeLegacyCharacterId } from "@/data/constants/character_id_aliases"; import { Result } from "@/utils/result"; import { SessionAsyncUtil } from "@/utils/session-storage"; @@ -138,7 +139,9 @@ export class NavigationStorage { await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock); return null; } - if (value.characterId !== characterId) return null; + if (value.characterId !== normalizeLegacyCharacterId(characterId)) { + return null; + } await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock); return value; } @@ -158,7 +161,9 @@ export class NavigationStorage { if (!value) { await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock); } - return value?.characterId === characterId ? value : null; + return value?.characterId === normalizeLegacyCharacterId(characterId) + ? value + : null; } static async hasPendingChatUnlock(characterId: string): Promise { @@ -206,7 +211,9 @@ export class NavigationStorage { await SessionAsyncUtil.remove(StorageKeys.pendingChatPromotion); return null; } - if (value.characterId !== characterId) return null; + if (value.characterId !== normalizeLegacyCharacterId(characterId)) { + return null; + } await SessionAsyncUtil.remove(StorageKeys.pendingChatPromotion); return value; } @@ -250,7 +257,9 @@ export class NavigationStorage { await SessionAsyncUtil.remove(StorageKeys.pendingChatImageReturn); return null; } - if (value.characterId !== characterId) return null; + if (value.characterId !== normalizeLegacyCharacterId(characterId)) { + return null; + } await SessionAsyncUtil.remove(StorageKeys.pendingChatImageReturn); return value; } @@ -263,13 +272,15 @@ export class NavigationStorage { value: PendingChatUnlock | null, ): PendingChatUnlock | null { if (!value || Date.now() - value.createdAt > MAX_AGE_MS) return null; - if (!getCharacterById(value.characterId)) return null; + const characterId = normalizeLegacyCharacterId(value.characterId); + if (!getCharacterById(characterId)) return null; const promotion = value.promotion ? NavigationStorage.parsePendingChatPromotion(value.promotion) : undefined; if (value.promotion && !promotion) return null; return PendingChatUnlockSchema.parse({ ...value, + characterId, ...(promotion ? { promotion } : {}), }); } @@ -278,14 +289,20 @@ export class NavigationStorage { value: PendingChatImageReturn | null, ): PendingChatImageReturn | null { if (!value || Date.now() - value.createdAt > MAX_AGE_MS) return null; - return getCharacterById(value.characterId) ? value : null; + const characterId = normalizeLegacyCharacterId(value.characterId); + return getCharacterById(characterId) + ? PendingChatImageReturnSchema.parse({ ...value, characterId }) + : null; } private static parsePendingChatPromotion( value: PendingChatPromotion | null, ): PendingChatPromotion | null { if (!value || Date.now() - value.createdAt > MAX_AGE_MS) return null; - return getCharacterById(value.characterId) ? value : null; + const characterId = normalizeLegacyCharacterId(value.characterId); + return getCharacterById(characterId) + ? PendingChatPromotionSchema.parse({ ...value, characterId }) + : null; } } diff --git a/src/lib/chat/chat_cache_keys.ts b/src/lib/chat/chat_cache_keys.ts index 1d740466..de99f04c 100644 --- a/src/lib/chat/chat_cache_keys.ts +++ b/src/lib/chat/chat_cache_keys.ts @@ -1,6 +1,9 @@ import type { ChatMediaKind } from "@/data/schemas/chat"; +import { normalizeLegacyCharacterId } from "@/data/constants/character_id_aliases"; const CHARACTER_NAMESPACE = "::character:"; +const LEGACY_OWNER_KEY_PATTERN = /^(?:user|device):.+$/u; +const LEGACY_DEFAULT_CHARACTER_ID = "elio"; export function buildChatConversationKey( ownerKey: string, @@ -19,3 +22,37 @@ export function buildChatMediaCacheKey(input: { }): string { return `${input.ownerKey}:${input.messageId}:${input.kind}`; } + +/** + * Migrates both pre-character owner keys and keys containing the first + * frontend-only character IDs. This is intentionally idempotent so it can be + * used by database upgrades and compatibility reads. + */ +export function migrateLegacyChatConversationKey(ownerKey: string): string { + const namespaceIndex = ownerKey.lastIndexOf(CHARACTER_NAMESPACE); + if (namespaceIndex < 0) { + return LEGACY_OWNER_KEY_PATTERN.test(ownerKey) + ? buildChatConversationKey(ownerKey, LEGACY_DEFAULT_CHARACTER_ID) + : ownerKey; + } + + const encodedCharacterId = ownerKey.slice( + namespaceIndex + CHARACTER_NAMESPACE.length, + ); + if (encodedCharacterId.length === 0) return ownerKey; + + let characterId: string; + try { + characterId = decodeURIComponent(encodedCharacterId); + } catch { + return ownerKey; + } + + const normalizedCharacterId = normalizeLegacyCharacterId(characterId); + if (normalizedCharacterId === characterId) return ownerKey; + + return buildChatConversationKey( + ownerKey.slice(0, namespaceIndex), + normalizedCharacterId, + ); +}