936 lines
29 KiB
TypeScript
936 lines
29 KiB
TypeScript
|
|
/**
|
|||
|
|
* 本地数据库服务
|
|||
|
|
* 使用 SQLite 存储聊天记录
|
|||
|
|
* 每个用户使用独立的数据库文件,避免数据串用
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
import * as SQLite from 'expo-sqlite';
|
|||
|
|
import type {
|
|||
|
|
UserDTO,
|
|||
|
|
GroupResponse,
|
|||
|
|
GroupMemberResponse,
|
|||
|
|
ConversationResponse,
|
|||
|
|
ConversationDetailResponse,
|
|||
|
|
} from '../types/dto';
|
|||
|
|
|
|||
|
|
// 数据库实例
|
|||
|
|
let db: SQLite.SQLiteDatabase | null = null;
|
|||
|
|
let writeQueue: Promise<void> = Promise.resolve();
|
|||
|
|
|
|||
|
|
// 当前数据库对应的用户ID
|
|||
|
|
let currentDbUserId: string | null = null;
|
|||
|
|
|
|||
|
|
// 数据库版本,用于迁移
|
|||
|
|
const DB_VERSION = 2;
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 初始化用户数据库
|
|||
|
|
* @param userId 用户ID,用于创建用户专属数据库文件
|
|||
|
|
*/
|
|||
|
|
export const initDatabase = async (userId?: string): Promise<void> => {
|
|||
|
|
try {
|
|||
|
|
// 如果指定了用户ID,使用用户专属数据库
|
|||
|
|
// 否则使用默认数据库(兼容旧版本)
|
|||
|
|
const dbName = userId ? `carrot_bbs_${userId}.db` : 'carrot_bbs.db';
|
|||
|
|
|
|||
|
|
// 如果存在旧的数据库连接且用户ID变化,需要关闭旧连接
|
|||
|
|
if (db && currentDbUserId !== userId) {
|
|||
|
|
console.log(`[Database] 切换用户数据库: ${currentDbUserId} -> ${userId}`);
|
|||
|
|
await closeDatabase();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 如果已经打开了正确的数据库,直接返回
|
|||
|
|
if (db && currentDbUserId === userId) {
|
|||
|
|
console.log(`[Database] 数据库已初始化: ${dbName}`);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
db = await SQLite.openDatabaseAsync(dbName);
|
|||
|
|
currentDbUserId = userId || null;
|
|||
|
|
console.log(`[Database] 打开数据库: ${dbName}`);
|
|||
|
|
|
|||
|
|
// 创建消息表(包含新字段 seq, status, segments)
|
|||
|
|
await db.execAsync(`
|
|||
|
|
CREATE TABLE IF NOT EXISTS messages (
|
|||
|
|
id TEXT PRIMARY KEY NOT NULL,
|
|||
|
|
conversationId TEXT NOT NULL,
|
|||
|
|
senderId TEXT NOT NULL,
|
|||
|
|
content TEXT,
|
|||
|
|
type TEXT DEFAULT 'text',
|
|||
|
|
isRead INTEGER DEFAULT 0,
|
|||
|
|
createdAt TEXT NOT NULL,
|
|||
|
|
seq INTEGER DEFAULT 0,
|
|||
|
|
status TEXT DEFAULT 'normal',
|
|||
|
|
segments TEXT
|
|||
|
|
);
|
|||
|
|
`);
|
|||
|
|
|
|||
|
|
// 创建会话表
|
|||
|
|
await db.execAsync(`
|
|||
|
|
CREATE TABLE IF NOT EXISTS conversations (
|
|||
|
|
id TEXT PRIMARY KEY NOT NULL,
|
|||
|
|
participantId TEXT NOT NULL,
|
|||
|
|
lastMessageId TEXT,
|
|||
|
|
lastSeq INTEGER DEFAULT 0,
|
|||
|
|
unreadCount INTEGER DEFAULT 0,
|
|||
|
|
createdAt TEXT NOT NULL,
|
|||
|
|
updatedAt TEXT NOT NULL
|
|||
|
|
);
|
|||
|
|
`);
|
|||
|
|
|
|||
|
|
// 会话缓存表(完整 JSON,兼容私聊/群聊不同结构)
|
|||
|
|
await db.execAsync(`
|
|||
|
|
CREATE TABLE IF NOT EXISTS conversation_cache (
|
|||
|
|
id TEXT PRIMARY KEY NOT NULL,
|
|||
|
|
data TEXT NOT NULL,
|
|||
|
|
updatedAt TEXT NOT NULL
|
|||
|
|
);
|
|||
|
|
`);
|
|||
|
|
|
|||
|
|
// 会话列表缓存表(仅用于 ChatList,避免被详情缓存污染)
|
|||
|
|
await db.execAsync(`
|
|||
|
|
CREATE TABLE IF NOT EXISTS conversation_list_cache (
|
|||
|
|
id TEXT PRIMARY KEY NOT NULL,
|
|||
|
|
data TEXT NOT NULL,
|
|||
|
|
updatedAt TEXT NOT NULL
|
|||
|
|
);
|
|||
|
|
`);
|
|||
|
|
|
|||
|
|
// 用户缓存表
|
|||
|
|
await db.execAsync(`
|
|||
|
|
CREATE TABLE IF NOT EXISTS users (
|
|||
|
|
id TEXT PRIMARY KEY NOT NULL,
|
|||
|
|
data TEXT NOT NULL,
|
|||
|
|
updatedAt TEXT NOT NULL
|
|||
|
|
);
|
|||
|
|
`);
|
|||
|
|
|
|||
|
|
// 当前登录用户缓存(仅保存 me)
|
|||
|
|
await db.execAsync(`
|
|||
|
|
CREATE TABLE IF NOT EXISTS current_user_cache (
|
|||
|
|
id TEXT PRIMARY KEY NOT NULL,
|
|||
|
|
data TEXT NOT NULL,
|
|||
|
|
updatedAt TEXT NOT NULL
|
|||
|
|
);
|
|||
|
|
`);
|
|||
|
|
|
|||
|
|
// 群组缓存表
|
|||
|
|
await db.execAsync(`
|
|||
|
|
CREATE TABLE IF NOT EXISTS groups (
|
|||
|
|
id TEXT PRIMARY KEY NOT NULL,
|
|||
|
|
data TEXT NOT NULL,
|
|||
|
|
updatedAt TEXT NOT NULL
|
|||
|
|
);
|
|||
|
|
`);
|
|||
|
|
|
|||
|
|
// 群成员缓存表
|
|||
|
|
await db.execAsync(`
|
|||
|
|
CREATE TABLE IF NOT EXISTS group_members (
|
|||
|
|
groupId TEXT NOT NULL,
|
|||
|
|
userId TEXT NOT NULL,
|
|||
|
|
data TEXT NOT NULL,
|
|||
|
|
updatedAt TEXT NOT NULL,
|
|||
|
|
PRIMARY KEY (groupId, userId)
|
|||
|
|
);
|
|||
|
|
`);
|
|||
|
|
|
|||
|
|
// 创建索引
|
|||
|
|
await db.execAsync(`
|
|||
|
|
CREATE INDEX IF NOT EXISTS idx_messages_conversationId ON messages(conversationId);
|
|||
|
|
`);
|
|||
|
|
await db.execAsync(`
|
|||
|
|
CREATE INDEX IF NOT EXISTS idx_group_members_groupId ON group_members(groupId);
|
|||
|
|
`);
|
|||
|
|
|
|||
|
|
// 迁移:检查并添加新列(兼容旧版本数据库)
|
|||
|
|
await migrateDatabase();
|
|||
|
|
|
|||
|
|
// 迁移后再创建依赖新列的索引
|
|||
|
|
await db.execAsync(`
|
|||
|
|
CREATE INDEX IF NOT EXISTS idx_messages_seq ON messages(seq);
|
|||
|
|
`);
|
|||
|
|
await db.execAsync(`
|
|||
|
|
CREATE INDEX IF NOT EXISTS idx_messages_conversation_seq ON messages(conversationId, seq);
|
|||
|
|
`);
|
|||
|
|
|
|||
|
|
console.log('数据库初始化成功');
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('数据库初始化失败:', error);
|
|||
|
|
throw error;
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 关闭数据库连接
|
|||
|
|
*/
|
|||
|
|
export const closeDatabase = async (): Promise<void> => {
|
|||
|
|
if (db) {
|
|||
|
|
try {
|
|||
|
|
await db.closeAsync();
|
|||
|
|
console.log('[Database] 数据库连接已关闭');
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('[Database] 关闭数据库失败:', error);
|
|||
|
|
}
|
|||
|
|
db = null;
|
|||
|
|
currentDbUserId = null;
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 获取当前数据库用户ID
|
|||
|
|
*/
|
|||
|
|
export const getCurrentDbUserId = (): string | null => {
|
|||
|
|
return currentDbUserId;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 数据库迁移
|
|||
|
|
const migrateDatabase = async (): Promise<void> => {
|
|||
|
|
const database = await getDb();
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
// 检查 messages 表是否有 seq 列
|
|||
|
|
const tableInfo = await database.getAllAsync<any>(
|
|||
|
|
`PRAGMA table_info(messages)`
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const columns = tableInfo.map(col => col.name);
|
|||
|
|
|
|||
|
|
// 添加 seq 列
|
|||
|
|
if (!columns.includes('seq')) {
|
|||
|
|
await database.execAsync(`ALTER TABLE messages ADD COLUMN seq INTEGER DEFAULT 0`);
|
|||
|
|
console.log('数据库迁移:添加 seq 列');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 添加 status 列
|
|||
|
|
if (!columns.includes('status')) {
|
|||
|
|
await database.execAsync(`ALTER TABLE messages ADD COLUMN status TEXT DEFAULT 'normal'`);
|
|||
|
|
console.log('数据库迁移:添加 status 列');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 添加 segments 列(用于存储消息的 segments JSON)
|
|||
|
|
if (!columns.includes('segments')) {
|
|||
|
|
await database.execAsync(`ALTER TABLE messages ADD COLUMN segments TEXT`);
|
|||
|
|
console.log('数据库迁移:添加 segments 列');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 检查 conversations 表是否有 lastSeq 列
|
|||
|
|
const convTableInfo = await database.getAllAsync<any>(
|
|||
|
|
`PRAGMA table_info(conversations)`
|
|||
|
|
);
|
|||
|
|
const convColumns = convTableInfo.map(col => col.name);
|
|||
|
|
|
|||
|
|
if (!convColumns.includes('lastSeq')) {
|
|||
|
|
await database.execAsync(`ALTER TABLE conversations ADD COLUMN lastSeq INTEGER DEFAULT 0`);
|
|||
|
|
console.log('数据库迁移:添加 lastSeq 列');
|
|||
|
|
}
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('数据库迁移失败:', error);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 获取数据库实例
|
|||
|
|
const getDb = async (): Promise<SQLite.SQLiteDatabase> => {
|
|||
|
|
if (!db) {
|
|||
|
|
throw new Error('数据库未初始化,请先调用 initDatabase(userId)');
|
|||
|
|
}
|
|||
|
|
return db;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const isRecoverableDbError = (error: unknown): boolean => {
|
|||
|
|
const message = String(error);
|
|||
|
|
return (
|
|||
|
|
message.includes('NativeDatabase.prepareAsync') ||
|
|||
|
|
message.includes('NullPointerException')
|
|||
|
|
);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const withDbRead = async <T>(operation: (database: SQLite.SQLiteDatabase) => Promise<T>): Promise<T> => {
|
|||
|
|
let database = await getDb();
|
|||
|
|
try {
|
|||
|
|
return await operation(database);
|
|||
|
|
} catch (error) {
|
|||
|
|
if (!isRecoverableDbError(error)) {
|
|||
|
|
throw error;
|
|||
|
|
}
|
|||
|
|
console.error('数据库读取异常,尝试重连后重试:', error);
|
|||
|
|
db = null;
|
|||
|
|
database = await getDb();
|
|||
|
|
return operation(database);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const enqueueWrite = async <T>(operation: () => Promise<T>): Promise<T> => {
|
|||
|
|
const wrappedOperation = async () => {
|
|||
|
|
try {
|
|||
|
|
return await operation();
|
|||
|
|
} catch (error) {
|
|||
|
|
if (!isRecoverableDbError(error)) {
|
|||
|
|
throw error;
|
|||
|
|
}
|
|||
|
|
console.error('数据库写入异常,尝试重连后重试:', error);
|
|||
|
|
db = null;
|
|||
|
|
return operation();
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
const queued = writeQueue.then(wrappedOperation, wrappedOperation);
|
|||
|
|
writeQueue = queued.then(() => undefined, () => undefined);
|
|||
|
|
return queued;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// ==================== 消息类型定义 ====================
|
|||
|
|
|
|||
|
|
export interface CachedMessage {
|
|||
|
|
id: string;
|
|||
|
|
conversationId: string;
|
|||
|
|
senderId: string;
|
|||
|
|
content?: string;
|
|||
|
|
type?: string;
|
|||
|
|
isRead: boolean;
|
|||
|
|
createdAt: string;
|
|||
|
|
seq: number;
|
|||
|
|
status: string;
|
|||
|
|
segments?: any;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ==================== 消息操作 ====================
|
|||
|
|
|
|||
|
|
// 保存单条消息
|
|||
|
|
export const saveMessage = async (message: CachedMessage): Promise<void> => {
|
|||
|
|
await enqueueWrite(async () => {
|
|||
|
|
const database = await getDb();
|
|||
|
|
await database.runAsync(
|
|||
|
|
`INSERT OR REPLACE INTO messages (id, conversationId, senderId, content, type, isRead, createdAt, seq, status, segments)
|
|||
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|||
|
|
[
|
|||
|
|
message.id,
|
|||
|
|
message.conversationId,
|
|||
|
|
message.senderId,
|
|||
|
|
message.content || '',
|
|||
|
|
message.type || 'text',
|
|||
|
|
message.isRead ? 1 : 0,
|
|||
|
|
message.createdAt,
|
|||
|
|
message.seq || 0,
|
|||
|
|
message.status || 'normal',
|
|||
|
|
message.segments ? JSON.stringify(message.segments) : null,
|
|||
|
|
]
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 批量保存消息
|
|||
|
|
export const saveMessagesBatch = async (messages: CachedMessage[]): Promise<void> => {
|
|||
|
|
if (!messages || messages.length === 0) return;
|
|||
|
|
|
|||
|
|
await enqueueWrite(async () => {
|
|||
|
|
const database = await getDb();
|
|||
|
|
for (const message of messages) {
|
|||
|
|
await database.runAsync(
|
|||
|
|
`INSERT OR REPLACE INTO messages (id, conversationId, senderId, content, type, isRead, createdAt, seq, status, segments)
|
|||
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|||
|
|
[
|
|||
|
|
message.id,
|
|||
|
|
message.conversationId,
|
|||
|
|
message.senderId,
|
|||
|
|
message.content || '',
|
|||
|
|
message.type || 'text',
|
|||
|
|
message.isRead ? 1 : 0,
|
|||
|
|
message.createdAt,
|
|||
|
|
message.seq || 0,
|
|||
|
|
message.status || 'normal',
|
|||
|
|
message.segments ? JSON.stringify(message.segments) : null,
|
|||
|
|
]
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 获取会话的消息(默认只加载最近的20条,避免卡顿)
|
|||
|
|
export const getMessagesByConversation = async (
|
|||
|
|
conversationId: string,
|
|||
|
|
limit: number = 20
|
|||
|
|
): Promise<CachedMessage[]> => {
|
|||
|
|
return withDbRead(async (database) => {
|
|||
|
|
const result = await database.getAllAsync<any>(
|
|||
|
|
`SELECT * FROM messages
|
|||
|
|
WHERE conversationId = ?
|
|||
|
|
ORDER BY seq DESC
|
|||
|
|
LIMIT ?`,
|
|||
|
|
[conversationId, limit]
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
// 反转以保持时间正序(最新的在底部)
|
|||
|
|
return result.reverse().map(msg => ({
|
|||
|
|
...msg,
|
|||
|
|
isRead: msg.isRead === 1,
|
|||
|
|
segments: msg.segments ? JSON.parse(msg.segments) : undefined,
|
|||
|
|
}));
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 获取会话的最大消息序号
|
|||
|
|
export const getMaxSeq = async (conversationId: string): Promise<number> => {
|
|||
|
|
return withDbRead(async (database) => {
|
|||
|
|
const result = await database.getFirstAsync<{ maxSeq: number }>(
|
|||
|
|
`SELECT MAX(seq) as maxSeq FROM messages WHERE conversationId = ?`,
|
|||
|
|
[conversationId]
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
return result?.maxSeq || 0;
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 获取会话的最小消息序号
|
|||
|
|
export const getMinSeq = async (conversationId: string): Promise<number> => {
|
|||
|
|
return withDbRead(async (database) => {
|
|||
|
|
const result = await database.getFirstAsync<{ minSeq: number }>(
|
|||
|
|
`SELECT MIN(seq) as minSeq FROM messages WHERE conversationId = ?`,
|
|||
|
|
[conversationId]
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
return result?.minSeq || 0;
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 获取指定 seq 之前的消息(用于加载历史)
|
|||
|
|
export const getMessagesBeforeSeq = async (conversationId: string, beforeSeq: number, limit: number = 20): Promise<CachedMessage[]> => {
|
|||
|
|
return withDbRead(async (database) => {
|
|||
|
|
const result = await database.getAllAsync<any>(
|
|||
|
|
`SELECT * FROM messages
|
|||
|
|
WHERE conversationId = ? AND seq < ?
|
|||
|
|
ORDER BY seq DESC
|
|||
|
|
LIMIT ?`,
|
|||
|
|
[conversationId, beforeSeq, limit]
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
return result.map(msg => ({
|
|||
|
|
...msg,
|
|||
|
|
isRead: msg.isRead === 1,
|
|||
|
|
segments: msg.segments ? JSON.parse(msg.segments) : undefined,
|
|||
|
|
})).reverse(); // 反转以保持时间正序
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 获取本地消息数量(用于判断是否还有更多历史)
|
|||
|
|
export const getLocalMessageCountBeforeSeq = async (conversationId: string, beforeSeq: number): Promise<number> => {
|
|||
|
|
const database = await getDb();
|
|||
|
|
|
|||
|
|
const result = await database.getFirstAsync<{ count: number }>(
|
|||
|
|
`SELECT COUNT(*) as count FROM messages WHERE conversationId = ? AND seq < ?`,
|
|||
|
|
[conversationId, beforeSeq]
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
return result?.count || 0;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 获取消息数量
|
|||
|
|
export const getMessageCount = async (conversationId: string): Promise<number> => {
|
|||
|
|
const database = await getDb();
|
|||
|
|
|
|||
|
|
const result = await database.getFirstAsync<{ count: number }>(
|
|||
|
|
`SELECT COUNT(*) as count FROM messages WHERE conversationId = ?`,
|
|||
|
|
[conversationId]
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
return result?.count || 0;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 标记消息为已读
|
|||
|
|
export const markMessageAsRead = async (messageId: string): Promise<void> => {
|
|||
|
|
await enqueueWrite(async () => {
|
|||
|
|
const database = await getDb();
|
|||
|
|
await database.runAsync(
|
|||
|
|
`UPDATE messages SET isRead = 1 WHERE id = ?`,
|
|||
|
|
[messageId]
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 标记会话的所有消息为已读
|
|||
|
|
export const markConversationAsRead = async (conversationId: string): Promise<void> => {
|
|||
|
|
await enqueueWrite(async () => {
|
|||
|
|
const database = await getDb();
|
|||
|
|
await database.runAsync(
|
|||
|
|
`UPDATE messages SET isRead = 1 WHERE conversationId = ?`,
|
|||
|
|
[conversationId]
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 删除单条消息
|
|||
|
|
export const deleteMessage = async (messageId: string): Promise<void> => {
|
|||
|
|
await enqueueWrite(async () => {
|
|||
|
|
const database = await getDb();
|
|||
|
|
await database.runAsync(
|
|||
|
|
`DELETE FROM messages WHERE id = ?`,
|
|||
|
|
[messageId]
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 更新消息状态(如撤回)
|
|||
|
|
export const updateMessageStatus = async (messageId: string, status: string): Promise<void> => {
|
|||
|
|
await enqueueWrite(async () => {
|
|||
|
|
const database = await getDb();
|
|||
|
|
await database.runAsync(
|
|||
|
|
`UPDATE messages SET status = ? WHERE id = ?`,
|
|||
|
|
[status, messageId]
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 清空会话的所有消息
|
|||
|
|
export const clearConversationMessages = async (conversationId: string): Promise<void> => {
|
|||
|
|
await enqueueWrite(async () => {
|
|||
|
|
const database = await getDb();
|
|||
|
|
await database.runAsync(
|
|||
|
|
`DELETE FROM messages WHERE conversationId = ?`,
|
|||
|
|
[conversationId]
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// ==================== 会话操作 ====================
|
|||
|
|
|
|||
|
|
// 保存或更新会话
|
|||
|
|
export const saveConversation = async (conversation: {
|
|||
|
|
id: string;
|
|||
|
|
participantId: string;
|
|||
|
|
lastMessageId?: string;
|
|||
|
|
lastSeq?: number;
|
|||
|
|
unreadCount?: number;
|
|||
|
|
createdAt: string;
|
|||
|
|
updatedAt: string;
|
|||
|
|
}): Promise<void> => {
|
|||
|
|
await enqueueWrite(async () => {
|
|||
|
|
const database = await getDb();
|
|||
|
|
await database.runAsync(
|
|||
|
|
`INSERT OR REPLACE INTO conversations (id, participantId, lastMessageId, lastSeq, unreadCount, createdAt, updatedAt)
|
|||
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|||
|
|
[
|
|||
|
|
conversation.id,
|
|||
|
|
conversation.participantId,
|
|||
|
|
conversation.lastMessageId || null,
|
|||
|
|
conversation.lastSeq || 0,
|
|||
|
|
conversation.unreadCount || 0,
|
|||
|
|
conversation.createdAt,
|
|||
|
|
conversation.updatedAt,
|
|||
|
|
]
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 获取所有会话
|
|||
|
|
export const getAllConversations = async (): Promise<any[]> => {
|
|||
|
|
const database = await getDb();
|
|||
|
|
|
|||
|
|
const result = await database.getAllAsync<any>(
|
|||
|
|
`SELECT * FROM conversations ORDER BY updatedAt DESC`
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
return result;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 更新会话未读数
|
|||
|
|
export const updateConversationUnreadCount = async (conversationId: string, count: number): Promise<void> => {
|
|||
|
|
await enqueueWrite(async () => {
|
|||
|
|
const database = await getDb();
|
|||
|
|
await database.runAsync(
|
|||
|
|
`UPDATE conversations SET unreadCount = ? WHERE id = ?`,
|
|||
|
|
[count, conversationId]
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 更新会话最后消息
|
|||
|
|
export const updateConversationLastMessage = async (
|
|||
|
|
conversationId: string,
|
|||
|
|
lastMessageId: string,
|
|||
|
|
lastSeq: number,
|
|||
|
|
updatedAt: string
|
|||
|
|
): Promise<void> => {
|
|||
|
|
await enqueueWrite(async () => {
|
|||
|
|
const database = await getDb();
|
|||
|
|
await database.runAsync(
|
|||
|
|
`UPDATE conversations SET lastMessageId = ?, lastSeq = ?, updatedAt = ? WHERE id = ?`,
|
|||
|
|
[lastMessageId, lastSeq, updatedAt, conversationId]
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 删除会话(包含所有消息)
|
|||
|
|
export const deleteConversation = async (conversationId: string): Promise<void> => {
|
|||
|
|
await enqueueWrite(async () => {
|
|||
|
|
const database = await getDb();
|
|||
|
|
await database.runAsync(
|
|||
|
|
`DELETE FROM messages WHERE conversationId = ?`,
|
|||
|
|
[conversationId]
|
|||
|
|
);
|
|||
|
|
await database.runAsync(
|
|||
|
|
`DELETE FROM conversations WHERE id = ?`,
|
|||
|
|
[conversationId]
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// ==================== 搜索 ====================
|
|||
|
|
|
|||
|
|
// 搜索消息
|
|||
|
|
export const searchMessages = async (keyword: string): Promise<CachedMessage[]> => {
|
|||
|
|
const database = await getDb();
|
|||
|
|
|
|||
|
|
const result = await database.getAllAsync<any>(
|
|||
|
|
`SELECT * FROM messages
|
|||
|
|
WHERE content LIKE ?
|
|||
|
|
ORDER BY createdAt DESC
|
|||
|
|
LIMIT 50`,
|
|||
|
|
[`%${keyword}%`]
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
return result.map(msg => ({
|
|||
|
|
...msg,
|
|||
|
|
isRead: msg.isRead === 1,
|
|||
|
|
segments: msg.segments ? JSON.parse(msg.segments) : undefined,
|
|||
|
|
}));
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// ==================== 工具函数 ====================
|
|||
|
|
|
|||
|
|
// 获取数据库统计信息
|
|||
|
|
export const getDatabaseStats = async (): Promise<{
|
|||
|
|
totalMessages: number;
|
|||
|
|
totalConversations: number;
|
|||
|
|
}> => {
|
|||
|
|
const database = await getDb();
|
|||
|
|
|
|||
|
|
const msgCount = await database.getFirstAsync<{ count: number }>(
|
|||
|
|
`SELECT COUNT(*) as count FROM messages`
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const convCount = await database.getFirstAsync<{ count: number }>(
|
|||
|
|
`SELECT COUNT(*) as count FROM conversations`
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
totalMessages: msgCount?.count || 0,
|
|||
|
|
totalConversations: convCount?.count || 0,
|
|||
|
|
};
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 清空所有数据(用于调试或用户退出登录)
|
|||
|
|
export const clearAllData = async (): Promise<void> => {
|
|||
|
|
await enqueueWrite(async () => {
|
|||
|
|
const database = await getDb();
|
|||
|
|
await database.execAsync(`DELETE FROM messages`);
|
|||
|
|
await database.execAsync(`DELETE FROM conversations`);
|
|||
|
|
await database.execAsync(`DELETE FROM conversation_cache`);
|
|||
|
|
await database.execAsync(`DELETE FROM conversation_list_cache`);
|
|||
|
|
await database.execAsync(`DELETE FROM users`);
|
|||
|
|
await database.execAsync(`DELETE FROM current_user_cache`);
|
|||
|
|
await database.execAsync(`DELETE FROM groups`);
|
|||
|
|
await database.execAsync(`DELETE FROM group_members`);
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// ==================== 通用 JSON 缓存辅助 ====================
|
|||
|
|
|
|||
|
|
const safeParseJson = <T>(value: string): T | null => {
|
|||
|
|
try {
|
|||
|
|
return JSON.parse(value) as T;
|
|||
|
|
} catch {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// ==================== 用户缓存 ====================
|
|||
|
|
|
|||
|
|
export const saveUserCache = async (user: UserDTO): Promise<void> => {
|
|||
|
|
await enqueueWrite(async () => {
|
|||
|
|
const database = await getDb();
|
|||
|
|
await database.runAsync(
|
|||
|
|
`INSERT OR REPLACE INTO users (id, data, updatedAt) VALUES (?, ?, ?)`,
|
|||
|
|
[String(user.id), JSON.stringify(user), new Date().toISOString()]
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
export const saveUsersCache = async (users: UserDTO[]): Promise<void> => {
|
|||
|
|
if (!users || users.length === 0) return;
|
|||
|
|
await enqueueWrite(async () => {
|
|||
|
|
const database = await getDb();
|
|||
|
|
for (const user of users) {
|
|||
|
|
await database.runAsync(
|
|||
|
|
`INSERT OR REPLACE INTO users (id, data, updatedAt) VALUES (?, ?, ?)`,
|
|||
|
|
[String(user.id), JSON.stringify(user), new Date().toISOString()]
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
export const getUserCache = async (userId: string): Promise<UserDTO | null> => {
|
|||
|
|
return withDbRead(async (database) => {
|
|||
|
|
const row = await database.getFirstAsync<{ data: string }>(
|
|||
|
|
`SELECT data FROM users WHERE id = ?`,
|
|||
|
|
[String(userId)]
|
|||
|
|
);
|
|||
|
|
if (!row?.data) return null;
|
|||
|
|
return safeParseJson<UserDTO>(row.data);
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
export const getLatestUserCache = async (): Promise<UserDTO | null> => {
|
|||
|
|
const database = await getDb();
|
|||
|
|
const row = await database.getFirstAsync<{ data: string }>(
|
|||
|
|
`SELECT data FROM users ORDER BY updatedAt DESC LIMIT 1`
|
|||
|
|
);
|
|||
|
|
if (!row?.data) return null;
|
|||
|
|
return safeParseJson<UserDTO>(row.data);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
export const saveCurrentUserCache = async (user: UserDTO): Promise<void> => {
|
|||
|
|
await enqueueWrite(async () => {
|
|||
|
|
const database = await getDb();
|
|||
|
|
await database.runAsync(
|
|||
|
|
`INSERT OR REPLACE INTO current_user_cache (id, data, updatedAt) VALUES (?, ?, ?)`,
|
|||
|
|
['me', JSON.stringify(user), new Date().toISOString()]
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
export const getCurrentUserCache = async (): Promise<UserDTO | null> => {
|
|||
|
|
return withDbRead(async (database) => {
|
|||
|
|
const row = await database.getFirstAsync<{ data: string }>(
|
|||
|
|
`SELECT data FROM current_user_cache WHERE id = 'me'`
|
|||
|
|
);
|
|||
|
|
if (!row?.data) return null;
|
|||
|
|
return safeParseJson<UserDTO>(row.data);
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
export const clearCurrentUserCache = async (): Promise<void> => {
|
|||
|
|
await enqueueWrite(async () => {
|
|||
|
|
const database = await getDb();
|
|||
|
|
await database.runAsync(`DELETE FROM current_user_cache WHERE id = 'me'`);
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// ==================== 群组缓存 ====================
|
|||
|
|
|
|||
|
|
export const saveGroupCache = async (group: GroupResponse): Promise<void> => {
|
|||
|
|
await enqueueWrite(async () => {
|
|||
|
|
const database = await getDb();
|
|||
|
|
await database.runAsync(
|
|||
|
|
`INSERT OR REPLACE INTO groups (id, data, updatedAt) VALUES (?, ?, ?)`,
|
|||
|
|
[String(group.id), JSON.stringify(group), new Date().toISOString()]
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
export const saveGroupsCache = async (groups: GroupResponse[]): Promise<void> => {
|
|||
|
|
if (!groups || groups.length === 0) return;
|
|||
|
|
await enqueueWrite(async () => {
|
|||
|
|
const database = await getDb();
|
|||
|
|
for (const group of groups) {
|
|||
|
|
await database.runAsync(
|
|||
|
|
`INSERT OR REPLACE INTO groups (id, data, updatedAt) VALUES (?, ?, ?)`,
|
|||
|
|
[String(group.id), JSON.stringify(group), new Date().toISOString()]
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
export const getGroupCache = async (groupId: string): Promise<GroupResponse | null> => {
|
|||
|
|
return withDbRead(async (database) => {
|
|||
|
|
const row = await database.getFirstAsync<{ data: string }>(
|
|||
|
|
`SELECT data FROM groups WHERE id = ?`,
|
|||
|
|
[String(groupId)]
|
|||
|
|
);
|
|||
|
|
if (!row?.data) return null;
|
|||
|
|
return safeParseJson<GroupResponse>(row.data);
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
export const getAllGroupsCache = async (): Promise<GroupResponse[]> => {
|
|||
|
|
return withDbRead(async (database) => {
|
|||
|
|
const rows = await database.getAllAsync<{ data: string }>(
|
|||
|
|
`SELECT data FROM groups ORDER BY updatedAt DESC`
|
|||
|
|
);
|
|||
|
|
return rows
|
|||
|
|
.map(row => safeParseJson<GroupResponse>(row.data))
|
|||
|
|
.filter((item): item is GroupResponse => Boolean(item));
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// ==================== 群成员缓存 ====================
|
|||
|
|
|
|||
|
|
export const saveGroupMembersCache = async (
|
|||
|
|
groupId: string,
|
|||
|
|
members: GroupMemberResponse[]
|
|||
|
|
): Promise<void> => {
|
|||
|
|
await enqueueWrite(async () => {
|
|||
|
|
const database = await getDb();
|
|||
|
|
const now = new Date().toISOString();
|
|||
|
|
await database.runAsync(`DELETE FROM group_members WHERE groupId = ?`, [String(groupId)]);
|
|||
|
|
for (const member of members) {
|
|||
|
|
await database.runAsync(
|
|||
|
|
`INSERT OR REPLACE INTO group_members (groupId, userId, data, updatedAt) VALUES (?, ?, ?, ?)`,
|
|||
|
|
[String(groupId), String(member.user_id), JSON.stringify(member), now]
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
export const getGroupMembersCache = async (groupId: string): Promise<GroupMemberResponse[]> => {
|
|||
|
|
return withDbRead(async (database) => {
|
|||
|
|
const rows = await database.getAllAsync<{ data: string }>(
|
|||
|
|
`SELECT data FROM group_members WHERE groupId = ? ORDER BY updatedAt DESC`,
|
|||
|
|
[String(groupId)]
|
|||
|
|
);
|
|||
|
|
return rows
|
|||
|
|
.map(row => safeParseJson<GroupMemberResponse>(row.data))
|
|||
|
|
.filter((item): item is GroupMemberResponse => Boolean(item));
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// ==================== 会话缓存 ====================
|
|||
|
|
|
|||
|
|
type ConversationCacheData = ConversationResponse | ConversationDetailResponse;
|
|||
|
|
|
|||
|
|
export const saveConversationCache = async (conversation: ConversationCacheData): Promise<void> => {
|
|||
|
|
await enqueueWrite(async () => {
|
|||
|
|
const database = await getDb();
|
|||
|
|
const cacheUpdatedAt = ('updated_at' in conversation && conversation.updated_at)
|
|||
|
|
? String(conversation.updated_at)
|
|||
|
|
: new Date().toISOString();
|
|||
|
|
await database.runAsync(
|
|||
|
|
`INSERT OR REPLACE INTO conversation_cache (id, data, updatedAt) VALUES (?, ?, ?)`,
|
|||
|
|
[String(conversation.id), JSON.stringify(conversation), cacheUpdatedAt]
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
export const saveConversationsCache = async (conversations: ConversationResponse[]): Promise<void> => {
|
|||
|
|
if (!conversations || conversations.length === 0) return;
|
|||
|
|
await enqueueWrite(async () => {
|
|||
|
|
const database = await getDb();
|
|||
|
|
for (const conversation of conversations) {
|
|||
|
|
await database.runAsync(
|
|||
|
|
`INSERT OR REPLACE INTO conversation_list_cache (id, data, updatedAt) VALUES (?, ?, ?)`,
|
|||
|
|
[
|
|||
|
|
String(conversation.id),
|
|||
|
|
JSON.stringify(conversation),
|
|||
|
|
conversation.updated_at || new Date().toISOString(),
|
|||
|
|
]
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
export const getConversationCache = async (conversationId: string): Promise<ConversationCacheData | null> => {
|
|||
|
|
return withDbRead(async (database) => {
|
|||
|
|
const row = await database.getFirstAsync<{ data: string }>(
|
|||
|
|
`SELECT data FROM conversation_cache WHERE id = ?`,
|
|||
|
|
[String(conversationId)]
|
|||
|
|
);
|
|||
|
|
if (!row?.data) return null;
|
|||
|
|
return safeParseJson<ConversationCacheData>(row.data);
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
export const getConversationListCache = async (): Promise<ConversationResponse[]> => {
|
|||
|
|
return withDbRead(async (database) => {
|
|||
|
|
let rows = await database.getAllAsync<{ data: string }>(
|
|||
|
|
`SELECT data FROM conversation_list_cache ORDER BY updatedAt DESC`
|
|||
|
|
);
|
|||
|
|
// 兼容旧版本:首次升级时尝试从旧表读取一次
|
|||
|
|
if (rows.length === 0) {
|
|||
|
|
rows = await database.getAllAsync<{ data: string }>(
|
|||
|
|
`SELECT data FROM conversation_cache ORDER BY updatedAt DESC`
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
const list = rows
|
|||
|
|
.map(row => safeParseJson<ConversationResponse>(row.data))
|
|||
|
|
.filter((item): item is ConversationResponse => Boolean(item))
|
|||
|
|
.filter(item => typeof item.id !== 'undefined' && typeof item.type !== 'undefined');
|
|||
|
|
return list.sort((a, b) => {
|
|||
|
|
const aPinned = a.is_pinned ? 1 : 0;
|
|||
|
|
const bPinned = b.is_pinned ? 1 : 0;
|
|||
|
|
if (aPinned !== bPinned) {
|
|||
|
|
return bPinned - aPinned;
|
|||
|
|
}
|
|||
|
|
const aTime = new Date(a.last_message_at || a.updated_at || 0).getTime();
|
|||
|
|
const bTime = new Date(b.last_message_at || b.updated_at || 0).getTime();
|
|||
|
|
return bTime - aTime;
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 局部更新 conversation_list_cache 中的某条会话(如 last_message、last_message_at、unread_count 等)
|
|||
|
|
* 用于:发送消息后立即更新缓存中的最后一条消息,避免 SWR 返回旧数据
|
|||
|
|
*/
|
|||
|
|
export const updateConversationListCacheEntry = async (
|
|||
|
|
conversationId: string,
|
|||
|
|
updates: Partial<ConversationResponse>
|
|||
|
|
): Promise<void> => {
|
|||
|
|
await enqueueWrite(async () => {
|
|||
|
|
const database = await getDb();
|
|||
|
|
const row = await database.getFirstAsync<{ data: string }>(
|
|||
|
|
`SELECT data FROM conversation_list_cache WHERE id = ?`,
|
|||
|
|
[String(conversationId)]
|
|||
|
|
);
|
|||
|
|
if (!row?.data) return;
|
|||
|
|
const conv = safeParseJson<ConversationResponse>(row.data);
|
|||
|
|
if (!conv) return;
|
|||
|
|
const updated = { ...conv, ...updates };
|
|||
|
|
const updatedAt = updates.last_message_at || updated.last_message_at || new Date().toISOString();
|
|||
|
|
await database.runAsync(
|
|||
|
|
`UPDATE conversation_list_cache SET data = ?, updatedAt = ? WHERE id = ?`,
|
|||
|
|
[JSON.stringify(updated), updatedAt, String(conversationId)]
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 更新会话 SWR 缓存中的未读数(标记已读后立即同步清零,避免下次从缓存加载时仍显示旧红点)
|
|||
|
|
*/
|
|||
|
|
export const updateConversationCacheUnreadCount = async (
|
|||
|
|
conversationId: string,
|
|||
|
|
count: number
|
|||
|
|
): Promise<void> => {
|
|||
|
|
await enqueueWrite(async () => {
|
|||
|
|
const database = await getDb();
|
|||
|
|
|
|||
|
|
// 更新 conversation_list_cache
|
|||
|
|
const listRow = await database.getFirstAsync<{ data: string }>(
|
|||
|
|
`SELECT data FROM conversation_list_cache WHERE id = ?`,
|
|||
|
|
[String(conversationId)]
|
|||
|
|
);
|
|||
|
|
if (listRow?.data) {
|
|||
|
|
const conv = safeParseJson<ConversationResponse>(listRow.data);
|
|||
|
|
if (conv) {
|
|||
|
|
conv.unread_count = count;
|
|||
|
|
await database.runAsync(
|
|||
|
|
`UPDATE conversation_list_cache SET data = ? WHERE id = ?`,
|
|||
|
|
[JSON.stringify(conv), String(conversationId)]
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 更新 conversation_cache
|
|||
|
|
const cacheRow = await database.getFirstAsync<{ data: string }>(
|
|||
|
|
`SELECT data FROM conversation_cache WHERE id = ?`,
|
|||
|
|
[String(conversationId)]
|
|||
|
|
);
|
|||
|
|
if (cacheRow?.data) {
|
|||
|
|
const conv = safeParseJson<Record<string, unknown>>(cacheRow.data);
|
|||
|
|
if (conv) {
|
|||
|
|
conv.unread_count = count;
|
|||
|
|
await database.runAsync(
|
|||
|
|
`UPDATE conversation_cache SET data = ? WHERE id = ?`,
|
|||
|
|
[JSON.stringify(conv), String(conversationId)]
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
};
|