fix(chat): isolate cached data by identity
Scope message and media caches to the active user or guest identity. Preserve identity snapshots across async chat flows and discard legacy unscoped message data during the IndexedDB upgrade.
This commit is contained in:
@@ -3,10 +3,8 @@
|
||||
/**
|
||||
* Dexie 数据库定义
|
||||
*
|
||||
* 唯一表 `messages`,只有 `++dbId` 自增主键,无次级索引(按本轮需求"最简表")。
|
||||
* 未来要按 session 查询可升级到:
|
||||
* this.version(2).stores({ messages: "++dbId, sessionId, createdAt" })
|
||||
* .upgrade(async (tx) => { ... });
|
||||
* `messages.sessionId` 是当前聊天身份的缓存命名空间。所有消息读写必须通过
|
||||
* 该索引限定身份,不能退化为整表操作。
|
||||
*
|
||||
* 构造时 `dbName` 可注入,便于测试时每个用例用独立 DB 互不污染。
|
||||
*/
|
||||
@@ -63,5 +61,15 @@ export class LocalChatDB extends Dexie {
|
||||
messages: "++dbId",
|
||||
media: "cacheKey, ownerKey, messageId, kind, remoteUrl, updatedAt, lastAccessedAt",
|
||||
});
|
||||
this.version(3)
|
||||
.stores({
|
||||
messages: "++dbId, sessionId",
|
||||
media: "cacheKey, ownerKey, messageId, kind, remoteUrl, updatedAt, lastAccessedAt",
|
||||
})
|
||||
.upgrade(async (transaction) => {
|
||||
// v1/v2 always wrote an empty sessionId. Its owner cannot be recovered,
|
||||
// so retaining it would expose one identity's history to another.
|
||||
await transaction.table("messages").clear();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,16 +3,10 @@
|
||||
/**
|
||||
* LocalChatStorage 完整实现(示范 2:IndexedDB via Dexie)
|
||||
*
|
||||
* 对齐 Dart 端 `LocalChatStorage`(lib/data/services/storage/chat/local_chat_storage.dart):
|
||||
* - init / saveMessage / saveMessages / getAllMessages / clearAll / deleteMessage / close
|
||||
* - 额外提供 `getMessageCount`(异步)替代 Dart 的同步 `messageCount` getter
|
||||
* - 额外提供 `getAllMessagesBySession(sessionId)`(filter 模拟,等下一轮加索引后再用 where)
|
||||
*
|
||||
* 单例挂在 class 静态字段。
|
||||
* Dexie 实例可注入,便于测试用 fake-indexeddb 跑真 Dexie。
|
||||
*
|
||||
* 注意:当前表无次级索引,所有过滤(sessionId)都是 `toArray().then(filter)`,O(n)。
|
||||
* 数据量大时考虑升级 schema 加索引。
|
||||
* 所有读取、替换和删除操作都必须携带 sessionId,避免同一浏览器中的游客、
|
||||
* 登录用户及多个账号共享缓存。
|
||||
*/
|
||||
|
||||
import { Result, type Result as ResultT } from "@/utils";
|
||||
@@ -57,6 +51,7 @@ export class LocalChatStorage {
|
||||
|
||||
async saveMessage(message: LocalMessage): Promise<ResultT<void>> {
|
||||
try {
|
||||
assertSessionId(message.sessionId);
|
||||
await this.db.messages.add(message.toRow());
|
||||
return Result.ok(undefined);
|
||||
} catch (e) {
|
||||
@@ -64,14 +59,24 @@ export class LocalChatStorage {
|
||||
}
|
||||
}
|
||||
|
||||
async saveMessages(messages: readonly LocalMessage[]): Promise<ResultT<void>> {
|
||||
async replaceMessagesBySession(
|
||||
sessionId: string,
|
||||
messages: readonly LocalMessage[],
|
||||
): Promise<ResultT<void>> {
|
||||
try {
|
||||
assertSessionId(sessionId);
|
||||
if (messages.some((message) => message.sessionId !== sessionId)) {
|
||||
throw new Error("Cannot write messages from a different chat identity.");
|
||||
}
|
||||
await this.db.transaction(
|
||||
"rw",
|
||||
this.db.messages,
|
||||
async () => {
|
||||
for (const m of messages) {
|
||||
await this.db.messages.add(m.toRow());
|
||||
await this.db.messages.where("sessionId").equals(sessionId).delete();
|
||||
if (messages.length > 0) {
|
||||
await this.db.messages.bulkAdd(
|
||||
messages.map((message) => message.toRow()),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -83,20 +88,12 @@ export class LocalChatStorage {
|
||||
|
||||
// ---- read ----
|
||||
|
||||
async getAllMessages(): Promise<ResultT<LocalMessage[]>> {
|
||||
async getMessageCountBySession(sessionId: string): Promise<ResultT<number>> {
|
||||
try {
|
||||
const rows = await this.db.messages.toArray();
|
||||
// 按 dbId 升序(与 Dart `box.values.toList()` 插入序语义一致)
|
||||
rows.sort((a, b) => (a.dbId ?? 0) - (b.dbId ?? 0));
|
||||
return Result.ok(rows.map((r) => LocalMessage.fromRow(r)));
|
||||
} catch (e) {
|
||||
return Result.err(e);
|
||||
}
|
||||
}
|
||||
|
||||
async getMessageCount(): Promise<ResultT<number>> {
|
||||
try {
|
||||
return Result.ok(await this.db.messages.count());
|
||||
assertSessionId(sessionId);
|
||||
return Result.ok(
|
||||
await this.db.messages.where("sessionId").equals(sessionId).count(),
|
||||
);
|
||||
} catch (e) {
|
||||
return Result.err(e);
|
||||
}
|
||||
@@ -106,8 +103,10 @@ export class LocalChatStorage {
|
||||
sessionId: string,
|
||||
): Promise<ResultT<LocalMessage[]>> {
|
||||
try {
|
||||
assertSessionId(sessionId);
|
||||
const rows = await this.db.messages
|
||||
.filter((r) => r.sessionId === sessionId)
|
||||
.where("sessionId")
|
||||
.equals(sessionId)
|
||||
.toArray();
|
||||
rows.sort((a, b) => (a.dbId ?? 0) - (b.dbId ?? 0));
|
||||
return Result.ok(rows.map((r) => LocalMessage.fromRow(r)));
|
||||
@@ -118,9 +117,10 @@ export class LocalChatStorage {
|
||||
|
||||
// ---- delete ----
|
||||
|
||||
async clearAll(): Promise<ResultT<void>> {
|
||||
async clearMessagesBySession(sessionId: string): Promise<ResultT<void>> {
|
||||
try {
|
||||
await this.db.messages.clear();
|
||||
assertSessionId(sessionId);
|
||||
await this.db.messages.where("sessionId").equals(sessionId).delete();
|
||||
return Result.ok(undefined);
|
||||
} catch (e) {
|
||||
return Result.err(e);
|
||||
@@ -131,14 +131,21 @@ export class LocalChatStorage {
|
||||
* 按数组下标删除(保持 Dart 端语义:`box.values.toList()[index].delete()`)。
|
||||
* O(n) 取下标后单条 delete,与 Dart 行为一致。
|
||||
*/
|
||||
async deleteMessage(index: number): Promise<ResultT<void>> {
|
||||
async deleteMessage(
|
||||
sessionId: string,
|
||||
index: number,
|
||||
): Promise<ResultT<void>> {
|
||||
try {
|
||||
assertSessionId(sessionId);
|
||||
if (!Number.isInteger(index) || index < 0) {
|
||||
return Result.err(
|
||||
new RangeError(`deleteMessage: index ${index} out of range`),
|
||||
);
|
||||
}
|
||||
const rows = await this.db.messages.toArray();
|
||||
const rows = await this.db.messages
|
||||
.where("sessionId")
|
||||
.equals(sessionId)
|
||||
.toArray();
|
||||
if (index >= rows.length) {
|
||||
return Result.err(
|
||||
new RangeError(`deleteMessage: index ${index} out of range`),
|
||||
@@ -161,3 +168,9 @@ export class LocalChatStorage {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertSessionId(sessionId: string): void {
|
||||
if (sessionId.length === 0) {
|
||||
throw new Error("Chat cache identity must not be empty.");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user