refactor(database): migrate to new modular database layer and unify data access
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 2m44s
Frontend CI / ota-android (push) Successful in 12m51s
Frontend CI / build-android-apk (push) Successful in 1h1m26s

- Remove legacy database.ts, LocalDataSource.ts, and MessageRepository.ts
- Create new src/database/ module with messageRepository, userCacheRepository, conversationRepository, and groupCacheRepository
- Update all consumers to import from @/database instead of services/database
- Add web platform blur handling for modal components to fix focus issues
- Flatten SystemMessageItem and NotificationsScreen styles for consistent design
- Add draggable slider in ChatSettingsScreen and dynamic font size support
- Introduce 9 new chat color themes
- Add profile screens for about, terms, and privacy policy with navigation routes
- Add policy links to login and registration screens
- Fix post share URL format from /posts/ to /post/
This commit is contained in:
lafay
2026-04-04 08:01:45 +08:00
parent 189b977fac
commit 82c2970a85
76 changed files with 3382 additions and 2000 deletions

View File

@@ -0,0 +1,123 @@
import * as SQLite from 'expo-sqlite';
import { ILocalDataSource, DataSourceError } from '@/data/datasources/interfaces';
import { databaseManager } from './core/DatabaseManager';
let writeQueue: Promise<void> = Promise.resolve();
export class LocalDataSource implements ILocalDataSource {
async initialize(userId?: string): Promise<void> {
await databaseManager.initialize(userId);
}
private async ensureDb(): Promise<SQLite.SQLiteDatabase> {
return databaseManager.getDb();
}
async query<T>(sql: string, params?: any[]): Promise<T[]> {
try {
const db = await this.ensureDb();
if (params && params.length > 0) {
return await db.getAllAsync<T>(sql, params as any);
}
return await db.getAllAsync<T>(sql);
} catch (error) {
this.handleError(error, 'QUERY');
}
}
async getFirst<T>(sql: string, params?: any[]): Promise<T | null> {
try {
const db = await this.ensureDb();
if (params && params.length > 0) {
return await db.getFirstAsync<T>(sql, params as any);
}
return await db.getFirstAsync<T>(sql);
} catch (error) {
this.handleError(error, 'GET_FIRST');
}
}
async execute(sql: string, params?: any[]): Promise<void> {
try {
const db = await this.ensureDb();
await db.execAsync(sql);
} catch (error) {
this.handleError(error, 'EXECUTE');
}
}
async run(sql: string, params?: any[]): Promise<{ lastInsertRowId: number; changes: number }> {
try {
const db = await this.ensureDb();
if (params && params.length > 0) {
return await db.runAsync(sql, params as any);
}
return await db.runAsync(sql);
} catch (error) {
this.handleError(error, 'RUN');
}
}
async transaction(operations: () => Promise<void>): Promise<void> {
try {
const db = await this.ensureDb();
await db.withTransactionAsync(async () => {
await operations();
});
} catch (error) {
this.handleError(error, 'TRANSACTION');
}
}
async batch(operations: (db: ILocalDataSource) => Promise<void>): Promise<void> {
try {
await this.transaction(async () => {
await operations(this);
});
} catch (error) {
this.handleError(error, 'BATCH');
}
}
async enqueueWrite<T>(operation: () => Promise<T>): Promise<T> {
const wrappedOperation = async () => {
try {
return await operation();
} catch (error) {
if (this.isRecoverableError(error)) {
console.error('[LocalDataSource] 数据库写入异常,尝试重连后重试:', error);
await databaseManager.initialize(databaseManager.getCurrentUserId());
return operation();
}
throw error;
}
};
const queued = writeQueue.then(wrappedOperation, wrappedOperation);
writeQueue = queued.then(() => undefined, () => undefined);
return queued;
}
private isRecoverableError(error: unknown): boolean {
const message = String(error);
return (
message.includes('NativeDatabase.prepareAsync') ||
message.includes('NullPointerException') ||
message.includes('database is locked') ||
message.includes('finalizing statement') ||
message.includes('cannot create file')
);
}
private handleError(error: unknown, operation: string): never {
const message = error instanceof Error ? error.message : 'Unknown error';
throw new DataSourceError(
`Database ${operation} failed: ${message}`,
'DB_ERROR',
'LocalDataSource',
error instanceof Error ? error : undefined
);
}
}
export const localDataSource = new LocalDataSource();

View File

@@ -0,0 +1,14 @@
export const DB_CONFIG = {
MAX_RETRIES: 4,
WEB_RETRY_DELAY_BASE: 500,
NATIVE_RETRY_DELAY: 100,
WEB_CLOSE_DELAY: 300,
WEB_FINAL_CLOSE_DELAY: 200,
LOCK_PREFIX: 'db-',
DEFAULT_DB_NAME: 'carrot_bbs.db',
DB_NAME_PREFIX: 'carrot_bbs_',
} as const;
export const getDbName = (userId: string | null): string => {
return userId ? `${DB_CONFIG.DB_NAME_PREFIX}${userId}.db` : DB_CONFIG.DEFAULT_DB_NAME;
};

View File

@@ -0,0 +1,196 @@
import * as SQLite from 'expo-sqlite';
import { LockManager } from './LockManager';
import { DB_CONFIG, getDbName } from './DatabaseConfig';
import { runMigrations } from '../schema/MigrationManager';
type SQLiteDatabase = SQLite.SQLiteDatabase;
class DatabaseManager {
private static instance: DatabaseManager;
private db: SQLiteDatabase | null = null;
private currentUserId: string | null = null;
private initPromise: Promise<void> | null = null;
private initTargetUserId: string | null = null;
private isInitialized = false;
static getInstance(): DatabaseManager {
if (!DatabaseManager.instance) {
DatabaseManager.instance = new DatabaseManager();
}
return DatabaseManager.instance;
}
async initialize(userId?: string | null): Promise<void> {
const targetUserId = userId || null;
const callId = Date.now();
console.log(`[DatabaseManager ${callId}] initialize 开始, userId=${targetUserId}, 当前db=${!!this.db}, 当前userId=${this.currentUserId}`);
if (this.db && this.currentUserId === targetUserId) {
console.log(`[DatabaseManager ${callId}] 已是目标数据库,直接返回`);
this.isInitialized = true;
return;
}
if (this.initPromise && this.initTargetUserId === targetUserId) {
console.log(`[DatabaseManager ${callId}] 等待进行中的初始化`);
await this.initPromise;
console.log(`[DatabaseManager ${callId}] 初始化完成`);
return;
}
if (this.initPromise) {
console.log(`[DatabaseManager ${callId}] 等待其他初始化完成`);
await this.initPromise.catch(() => {});
}
if (this.db && this.currentUserId === targetUserId) {
console.log(`[DatabaseManager ${callId}] 其他初始化已完成目标,直接返回`);
this.isInitialized = true;
return;
}
this.initTargetUserId = targetUserId;
console.log(`[DatabaseManager ${callId}] 开始实际初始化`);
this.initPromise = LockManager.withMutex(async () => {
const mutexId = callId;
console.log(`[DatabaseManager ${mutexId}] 获得互斥锁`);
if (this.db && this.currentUserId === targetUserId) {
console.log(`[DatabaseManager ${mutexId}] 互斥锁内检查:已是目标数据库`);
this.isInitialized = true;
return;
}
const isWeb = typeof window !== 'undefined';
if (this.db) {
console.log(`[DatabaseManager ${mutexId}] 关闭旧数据库连接...`);
try {
await this.db.closeAsync();
console.log(`[DatabaseManager ${mutexId}] 旧连接已关闭`);
} catch (e) {
console.warn(`[DatabaseManager ${mutexId}] 关闭旧连接失败:`, e);
}
this.db = null;
this.currentUserId = null;
this.isInitialized = false;
if (isWeb) {
await new Promise(r => setTimeout(r, DB_CONFIG.WEB_CLOSE_DELAY));
}
}
const dbName = getDbName(targetUserId);
console.log(`[DatabaseManager ${mutexId}] 打开数据库: ${dbName}`);
const database = await this.openDatabaseWithRetry(dbName);
console.log(`[DatabaseManager ${mutexId}] 数据库已打开,执行迁移`);
await runMigrations(database);
console.log(`[DatabaseManager ${mutexId}] 迁移完成`);
this.db = database;
this.currentUserId = targetUserId;
this.isInitialized = true;
console.log(`[DatabaseManager ${mutexId}] 初始化完成`);
}).finally(() => {
console.log(`[DatabaseManager ${callId}] 清理初始化状态`);
this.initPromise = null;
this.initTargetUserId = null;
});
await this.initPromise;
console.log(`[DatabaseManager ${callId}] initialize 返回`);
}
private async openDatabaseWithRetry(dbName: string): Promise<SQLiteDatabase> {
const isWeb = typeof window !== 'undefined';
return LockManager.withWebLock(`${DB_CONFIG.LOCK_PREFIX}${dbName}`, async () => {
for (let attempt = 0; attempt <= DB_CONFIG.MAX_RETRIES; attempt++) {
try {
const database = await SQLite.openDatabaseAsync(dbName);
console.log(`[DatabaseManager] 成功打开数据库 (尝试 ${attempt + 1}/${DB_CONFIG.MAX_RETRIES + 1})`);
return database;
} catch (error) {
const msg = String(error);
console.error(`[DatabaseManager] 打开失败 (尝试 ${attempt + 1}/${DB_CONFIG.MAX_RETRIES + 1}):`, msg);
if (attempt < DB_CONFIG.MAX_RETRIES) {
const delay = isWeb ? DB_CONFIG.WEB_RETRY_DELAY_BASE * (attempt + 1) : DB_CONFIG.NATIVE_RETRY_DELAY;
console.warn(`[DatabaseManager] ${delay}ms 后重试...`);
await new Promise(r => setTimeout(r, delay));
} else {
throw error;
}
}
}
throw new Error('打开数据库失败');
});
}
async close(): Promise<void> {
if (this.initPromise) {
await this.initPromise.catch(() => {});
}
const dbName = getDbName(this.currentUserId);
await LockManager.withWebLock(`${DB_CONFIG.LOCK_PREFIX}${dbName}`, async () => {
await LockManager.withMutex(async () => {
if (!this.db) return;
try {
await this.db.closeAsync();
console.log('[DatabaseManager] 数据库已关闭');
if (typeof window !== 'undefined') {
await new Promise(r => setTimeout(r, DB_CONFIG.WEB_FINAL_CLOSE_DELAY));
}
} catch (e) {
console.warn('[DatabaseManager] 关闭失败:', e);
}
this.db = null;
this.currentUserId = null;
this.isInitialized = false;
});
});
}
async getDb(timeout: number = 10000): Promise<SQLiteDatabase> {
if (this.isInitialized && this.db) return this.db;
if (this.initPromise) {
const timeoutPromise = new Promise<null>((_, reject) =>
setTimeout(() => reject(new Error('等待数据库初始化超时')), timeout)
);
await Promise.race([this.initPromise, timeoutPromise]);
if (this.db) return this.db;
}
throw new Error('数据库未初始化,请先调用 initialize(userId)');
}
getDbSync(): SQLiteDatabase | null {
return this.db;
}
getCurrentUserId(): string | null {
return this.currentUserId;
}
isReady(): boolean {
return this.isInitialized && this.db !== null;
}
async withMutex<T>(fn: () => Promise<T>): Promise<T> {
return LockManager.withMutex(fn);
}
}
export const databaseManager = DatabaseManager.getInstance();
export { DatabaseManager };

View File

@@ -0,0 +1,34 @@
export class LockManager {
private static mutex: Promise<void> = Promise.resolve();
static async withMutex<T>(fn: () => Promise<T>): Promise<T> {
const prev = LockManager.mutex;
let release: () => void = () => {};
LockManager.mutex = new Promise<void>((r) => { release = r; });
try {
await prev;
return await fn();
} finally {
release();
}
}
static async withWebLock<T>(lockName: string, fn: () => Promise<T>): Promise<T> {
if (typeof navigator !== 'undefined' && navigator.locks) {
return new Promise((resolve, reject) => {
navigator.locks.request(lockName, { mode: 'exclusive' }, async (lock) => {
if (!lock) {
return reject(new Error('无法获取跨标签页锁'));
}
try {
const result = await fn();
resolve(result);
} catch (e) {
reject(e);
}
});
});
}
return fn();
}
}

54
src/database/index.ts Normal file
View File

@@ -0,0 +1,54 @@
import { localDataSource } from './LocalDataSource';
import { databaseManager } from './core/DatabaseManager';
export { databaseManager } from './core/DatabaseManager';
export { DatabaseManager } from './core/DatabaseManager';
export { localDataSource, LocalDataSource } from './LocalDataSource';
export {
messageRepository, MessageRepository,
conversationRepository, ConversationRepository,
userCacheRepository, UserCacheRepository,
groupCacheRepository, GroupCacheRepository,
postCacheRepository, PostCacheRepository,
} from './repositories';
export { CachedMessage } from './types';
export async function clearAllData(): Promise<void> {
await localDataSource.enqueueWrite(async () => {
await localDataSource.execute(`DELETE FROM messages`);
await localDataSource.execute(`DELETE FROM conversations`);
await localDataSource.execute(`DELETE FROM conversation_cache`);
await localDataSource.execute(`DELETE FROM conversation_list_cache`);
await localDataSource.execute(`DELETE FROM users`);
await localDataSource.execute(`DELETE FROM current_user_cache`);
await localDataSource.execute(`DELETE FROM groups`);
await localDataSource.execute(`DELETE FROM group_members`);
await localDataSource.execute(`DELETE FROM posts_cache`);
});
}
export const initDatabase = async (userId?: string | null): Promise<void> => {
await databaseManager.initialize(userId);
};
export const closeDatabase = async (): Promise<void> => {
await databaseManager.close();
};
export const getCurrentDbUserId = (): string | null => {
return databaseManager.getCurrentUserId();
};
export const isDbInitialized = (): boolean => {
return databaseManager.isReady();
};
export const getDbInstance = () => {
return databaseManager.getDbSync();
};
export const getDb = async (timeout?: number) => {
return databaseManager.getDb(timeout);
};

View File

@@ -0,0 +1,197 @@
import { localDataSource } from '../LocalDataSource';
import type { ILocalDataSource } from '@/data/datasources/interfaces';
import type { ConversationResponse, ConversationDetailResponse } from '@/types/dto';
type ConversationCacheData = ConversationResponse | ConversationDetailResponse;
const safeParseJson = <T>(value: string): T | null => {
try { return JSON.parse(value) as T; } catch { return null; }
};
export interface IConversationRepository {
save(conv: { id: string; participantId: string; lastMessageId?: string; lastSeq?: number; unreadCount?: number; createdAt: string; updatedAt: string }): Promise<void>;
getAll(): Promise<any[]>;
updateUnreadCount(id: string, count: number): Promise<void>;
updateLastMessage(id: string, lastMessageId: string, lastSeq: number, updatedAt: string): Promise<void>;
delete(conversationId: string): Promise<void>;
getCache(id: string): Promise<ConversationCacheData | null>;
getListCache(): Promise<ConversationResponse[]>;
saveCache(conv: ConversationCacheData): Promise<void>;
saveListCache(convs: ConversationResponse[]): Promise<void>;
updateListEntry(id: string, updates: Partial<ConversationResponse>): Promise<void>;
updateCacheUnreadCount(id: string, count: number, myLastReadSeq?: number): Promise<void>;
saveWithRelated(convs: ConversationResponse[], users?: any[], groups?: any[]): Promise<void>;
}
export class ConversationRepository implements IConversationRepository {
constructor(private dataSource: ILocalDataSource = localDataSource) {}
async save(conv: { id: string; participantId: string; lastMessageId?: string; lastSeq?: number; unreadCount?: number; createdAt: string; updatedAt: string }): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
await this.dataSource.run(
`INSERT OR REPLACE INTO conversations (id, participantId, lastMessageId, lastSeq, unreadCount, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[conv.id, conv.participantId, conv.lastMessageId || null, conv.lastSeq || 0, conv.unreadCount || 0, conv.createdAt, conv.updatedAt]
);
});
}
async getAll(): Promise<any[]> {
return this.dataSource.query<any>(`SELECT * FROM conversations ORDER BY updatedAt DESC`);
}
async updateUnreadCount(id: string, count: number): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
await this.dataSource.run(`UPDATE conversations SET unreadCount = ? WHERE id = ?`, [count, id]);
});
}
async updateLastMessage(id: string, lastMessageId: string, lastSeq: number, updatedAt: string): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
await this.dataSource.run(
`UPDATE conversations SET lastMessageId = ?, lastSeq = ?, updatedAt = ? WHERE id = ?`,
[lastMessageId, lastSeq, updatedAt, id]
);
});
}
async delete(conversationId: string): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
await this.dataSource.run(`DELETE FROM messages WHERE conversationId = ?`, [conversationId]);
await this.dataSource.run(`DELETE FROM conversations WHERE id = ?`, [conversationId]);
});
}
async getCache(id: string): Promise<ConversationCacheData | null> {
const r = await this.dataSource.getFirst<{ data: string }>(
`SELECT data FROM conversation_cache WHERE id = ?`, [String(id)]
);
return r?.data ? safeParseJson<ConversationCacheData>(r.data) : null;
}
async getListCache(): Promise<ConversationResponse[]> {
let rows = await this.dataSource.query<{ data: string }>(
`SELECT data FROM conversation_list_cache ORDER BY updatedAt DESC`
);
if (!rows.length) {
rows = await this.dataSource.query<{ data: string }>(
`SELECT data FROM conversation_cache ORDER BY updatedAt DESC`
);
}
const list = rows.map(r => safeParseJson<ConversationResponse>(r.data))
.filter((c): c is ConversationResponse => Boolean(c))
.filter(c => typeof c.id !== 'undefined' && typeof c.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;
});
}
async saveCache(conv: ConversationCacheData): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
const updatedAt = ('updated_at' in conv && conv.updated_at) ? String(conv.updated_at) : new Date().toISOString();
await this.dataSource.run(
`INSERT OR REPLACE INTO conversation_cache (id, data, updatedAt) VALUES (?, ?, ?)`,
[String(conv.id), JSON.stringify(conv), updatedAt]
);
});
}
async saveListCache(convs: ConversationResponse[]): Promise<void> {
if (!convs?.length) return;
await this.dataSource.enqueueWrite(async () => {
for (const c of convs) {
await this.dataSource.run(
`INSERT OR REPLACE INTO conversation_list_cache (id, data, updatedAt) VALUES (?, ?, ?)`,
[String(c.id), JSON.stringify(c), c.updated_at || new Date().toISOString()]
);
}
});
}
async updateListEntry(id: string, updates: Partial<ConversationResponse>): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
const r = await this.dataSource.getFirst<{ data: string }>(
`SELECT data FROM conversation_list_cache WHERE id = ?`, [String(id)]
);
if (!r?.data) return;
const conv = safeParseJson<ConversationResponse>(r.data);
if (!conv) return;
const updated = { ...conv, ...updates };
const updatedAt = updates.last_message_at || updated.last_message_at || new Date().toISOString();
await this.dataSource.run(
`UPDATE conversation_list_cache SET data = ?, updatedAt = ? WHERE id = ?`,
[JSON.stringify(updated), updatedAt, String(id)]
);
});
}
async updateCacheUnreadCount(id: string, count: number, myLastReadSeq?: number): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
const listRow = await this.dataSource.getFirst<{ data: string }>(
`SELECT data FROM conversation_list_cache WHERE id = ?`, [String(id)]
);
if (listRow?.data) {
const conv = safeParseJson<ConversationResponse>(listRow.data);
if (conv) {
conv.unread_count = count;
if (typeof myLastReadSeq === 'number' && !Number.isNaN(myLastReadSeq)) conv.my_last_read_seq = myLastReadSeq;
await this.dataSource.run(
`UPDATE conversation_list_cache SET data = ? WHERE id = ?`,
[JSON.stringify(conv), String(id)]
);
}
}
const cacheRow = await this.dataSource.getFirst<{ data: string }>(
`SELECT data FROM conversation_cache WHERE id = ?`, [String(id)]
);
if (cacheRow?.data) {
const conv = safeParseJson<Record<string, unknown>>(cacheRow.data);
if (conv) {
conv.unread_count = count;
if (typeof myLastReadSeq === 'number' && !Number.isNaN(myLastReadSeq)) conv.my_last_read_seq = myLastReadSeq;
await this.dataSource.run(
`UPDATE conversation_cache SET data = ? WHERE id = ?`,
[JSON.stringify(conv), String(id)]
);
}
}
});
}
async saveWithRelated(convs: ConversationResponse[], users?: any[], groups?: any[]): Promise<void> {
if (!convs?.length) return;
const now = new Date().toISOString();
await this.dataSource.enqueueWrite(async () => {
for (const c of convs) {
await this.dataSource.run(
`INSERT OR REPLACE INTO conversation_list_cache (id, data, updatedAt) VALUES (?, ?, ?)`,
[String(c.id), JSON.stringify(c), c.updated_at || now]
);
}
if (users?.length) {
for (const u of users) {
await this.dataSource.run(
`INSERT OR REPLACE INTO users (id, data, updatedAt) VALUES (?, ?, ?)`,
[String(u.id), JSON.stringify(u), now]
);
}
}
if (groups?.length) {
for (const g of groups) {
await this.dataSource.run(
`INSERT OR REPLACE INTO groups (id, data, updatedAt) VALUES (?, ?, ?)`,
[String(g.id), JSON.stringify(g), now]
);
}
}
});
}
}
export const conversationRepository = new ConversationRepository();

View File

@@ -0,0 +1,78 @@
import { localDataSource } from '../LocalDataSource';
import type { ILocalDataSource } from '@/data/datasources/interfaces';
import type { GroupResponse, GroupMemberResponse } from '@/types/dto';
const safeParseJson = <T>(value: string): T | null => {
try { return JSON.parse(value) as T; } catch { return null; }
};
export interface IGroupCacheRepository {
save(group: GroupResponse): Promise<void>;
saveBatch(groups: GroupResponse[]): Promise<void>;
get(groupId: string): Promise<GroupResponse | null>;
getAll(): Promise<GroupResponse[]>;
saveMembers(groupId: string, members: GroupMemberResponse[]): Promise<void>;
getMembers(groupId: string): Promise<GroupMemberResponse[]>;
}
export class GroupCacheRepository implements IGroupCacheRepository {
constructor(private dataSource: ILocalDataSource = localDataSource) {}
async save(group: GroupResponse): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
await this.dataSource.run(
`INSERT OR REPLACE INTO groups (id, data, updatedAt) VALUES (?, ?, ?)`,
[String(group.id), JSON.stringify(group), new Date().toISOString()]
);
});
}
async saveBatch(groups: GroupResponse[]): Promise<void> {
if (!groups?.length) return;
await this.dataSource.enqueueWrite(async () => {
const now = new Date().toISOString();
for (const g of groups) {
await this.dataSource.run(
`INSERT OR REPLACE INTO groups (id, data, updatedAt) VALUES (?, ?, ?)`,
[String(g.id), JSON.stringify(g), now]
);
}
});
}
async get(groupId: string): Promise<GroupResponse | null> {
const r = await this.dataSource.getFirst<{ data: string }>(
`SELECT data FROM groups WHERE id = ?`, [String(groupId)]
);
return r?.data ? safeParseJson<GroupResponse>(r.data) : null;
}
async getAll(): Promise<GroupResponse[]> {
const rows = await this.dataSource.query<{ data: string }>(
`SELECT data FROM groups ORDER BY updatedAt DESC`
);
return rows.map(r => safeParseJson<GroupResponse>(r.data)).filter((g): g is GroupResponse => Boolean(g));
}
async saveMembers(groupId: string, members: GroupMemberResponse[]): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
const now = new Date().toISOString();
await this.dataSource.run(`DELETE FROM group_members WHERE groupId = ?`, [String(groupId)]);
for (const m of members) {
await this.dataSource.run(
`INSERT OR REPLACE INTO group_members (groupId, userId, data, updatedAt) VALUES (?, ?, ?, ?)`,
[String(groupId), String(m.user_id), JSON.stringify(m), now]
);
}
});
}
async getMembers(groupId: string): Promise<GroupMemberResponse[]> {
const rows = await this.dataSource.query<{ data: string }>(
`SELECT data FROM group_members WHERE groupId = ? ORDER BY updatedAt DESC`, [String(groupId)]
);
return rows.map(r => safeParseJson<GroupMemberResponse>(r.data)).filter((m): m is GroupMemberResponse => Boolean(m));
}
}
export const groupCacheRepository = new GroupCacheRepository();

View File

@@ -0,0 +1,152 @@
import { localDataSource } from '../LocalDataSource';
import { CachedMessage } from '../types';
import type { ILocalDataSource } from '@/data/datasources/interfaces';
export interface IMessageRepository {
saveMessage(message: CachedMessage): Promise<void>;
saveMessagesBatch(messages: CachedMessage[]): Promise<void>;
getByConversation(conversationId: string, limit?: number): Promise<CachedMessage[]>;
getMaxSeq(conversationId: string): Promise<number>;
getMinSeq(conversationId: string): Promise<number>;
getBeforeSeq(conversationId: string, beforeSeq: number, limit?: number): Promise<CachedMessage[]>;
getCount(conversationId: string): Promise<number>;
getCountBeforeSeq(conversationId: string, beforeSeq: number): Promise<number>;
markAsRead(messageId: string): Promise<void>;
markConversationAsRead(conversationId: string): Promise<void>;
delete(messageId: string): Promise<void>;
updateStatus(messageId: string, status: string, clearContent?: boolean): Promise<void>;
clearConversation(conversationId: string): Promise<void>;
search(keyword: string): Promise<CachedMessage[]>;
getStats(): Promise<{ totalMessages: number; totalConversations: number }>;
}
export class MessageRepository implements IMessageRepository {
constructor(private dataSource: ILocalDataSource = localDataSource) {}
async saveMessage(message: CachedMessage): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
await this.dataSource.run(
`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]
);
});
}
async saveMessagesBatch(messages: CachedMessage[]): Promise<void> {
if (!messages?.length) return;
await this.dataSource.enqueueWrite(async () => {
for (const msg of messages) {
await this.dataSource.run(
`INSERT OR REPLACE INTO messages (id, conversationId, senderId, content, type, isRead, createdAt, seq, status, segments)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[msg.id, msg.conversationId, msg.senderId, msg.content || '',
msg.type || 'text', msg.isRead ? 1 : 0, msg.createdAt,
msg.seq || 0, msg.status || 'normal',
msg.segments ? JSON.stringify(msg.segments) : null]
);
}
});
}
async getByConversation(conversationId: string, limit: number = 20): Promise<CachedMessage[]> {
const rows = await this.dataSource.query<any>(
`SELECT * FROM messages WHERE conversationId = ? ORDER BY seq DESC LIMIT ?`,
[conversationId, limit]
);
return rows.reverse().map(r => ({
...r, isRead: r.isRead === 1, segments: r.segments ? JSON.parse(r.segments) : undefined
}));
}
async getMaxSeq(conversationId: string): Promise<number> {
const r = await this.dataSource.getFirst<{ maxSeq: number }>(
`SELECT MAX(seq) as maxSeq FROM messages WHERE conversationId = ?`, [conversationId]
);
return r?.maxSeq || 0;
}
async getMinSeq(conversationId: string): Promise<number> {
const r = await this.dataSource.getFirst<{ minSeq: number }>(
`SELECT MIN(seq) as minSeq FROM messages WHERE conversationId = ?`, [conversationId]
);
return r?.minSeq || 0;
}
async getBeforeSeq(conversationId: string, beforeSeq: number, limit: number = 20): Promise<CachedMessage[]> {
const rows = await this.dataSource.query<any>(
`SELECT * FROM messages WHERE conversationId = ? AND seq < ? ORDER BY seq DESC LIMIT ?`,
[conversationId, beforeSeq, limit]
);
return rows.map(r => ({ ...r, isRead: r.isRead === 1, segments: r.segments ? JSON.parse(r.segments) : undefined })).reverse();
}
async getCount(conversationId: string): Promise<number> {
const r = await this.dataSource.getFirst<{ count: number }>(
`SELECT COUNT(*) as count FROM messages WHERE conversationId = ?`, [conversationId]
);
return r?.count || 0;
}
async getCountBeforeSeq(conversationId: string, beforeSeq: number): Promise<number> {
const r = await this.dataSource.getFirst<{ count: number }>(
`SELECT COUNT(*) as count FROM messages WHERE conversationId = ? AND seq < ?`, [conversationId, beforeSeq]
);
return r?.count || 0;
}
async markAsRead(messageId: string): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
await this.dataSource.run(`UPDATE messages SET isRead = 1 WHERE id = ?`, [messageId]);
});
}
async markConversationAsRead(conversationId: string): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
await this.dataSource.run(`UPDATE messages SET isRead = 1 WHERE conversationId = ?`, [conversationId]);
});
}
async delete(messageId: string): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
await this.dataSource.run(`DELETE FROM messages WHERE id = ?`, [messageId]);
});
}
async updateStatus(messageId: string, status: string, clearContent: boolean = false): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
if (clearContent) {
await this.dataSource.run(
`UPDATE messages SET status = ?, content = '', segments = '[]' WHERE id = ?`,
[status, messageId]
);
} else {
await this.dataSource.run(`UPDATE messages SET status = ? WHERE id = ?`, [status, messageId]);
}
});
}
async clearConversation(conversationId: string): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
await this.dataSource.run(`DELETE FROM messages WHERE conversationId = ?`, [conversationId]);
});
}
async search(keyword: string): Promise<CachedMessage[]> {
const rows = await this.dataSource.query<any>(
`SELECT * FROM messages WHERE content LIKE ? ORDER BY createdAt DESC LIMIT 50`, [`%${keyword}%`]
);
return rows.map(r => ({ ...r, isRead: r.isRead === 1, segments: r.segments ? JSON.parse(r.segments) : undefined }));
}
async getStats(): Promise<{ totalMessages: number; totalConversations: number }> {
const msgCount = await this.dataSource.getFirst<{ count: number }>(`SELECT COUNT(*) as count FROM messages`);
const convCount = await this.dataSource.getFirst<{ count: number }>(`SELECT COUNT(*) as count FROM conversations`);
return { totalMessages: msgCount?.count || 0, totalConversations: convCount?.count || 0 };
}
}
export const messageRepository = new MessageRepository();

View File

@@ -0,0 +1,44 @@
import { localDataSource } from '../LocalDataSource';
import type { ILocalDataSource } from '@/data/datasources/interfaces';
export interface IPostCacheRepository {
save(id: string, data: any): Promise<void>;
get(id: string): Promise<any | null>;
getAll(): Promise<any[]>;
delete(id: string): Promise<void>;
}
export class PostCacheRepository implements IPostCacheRepository {
constructor(private dataSource: ILocalDataSource = localDataSource) {}
async save(id: string, data: any): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
await this.dataSource.run(
`INSERT OR REPLACE INTO posts_cache (id, data, updatedAt) VALUES (?, ?, ?)`,
[String(id), JSON.stringify(data), new Date().toISOString()]
);
});
}
async get(id: string): Promise<any | null> {
const r = await this.dataSource.getFirst<{ data: string }>(
`SELECT data FROM posts_cache WHERE id = ?`, [String(id)]
);
return r?.data ? JSON.parse(r.data) : null;
}
async getAll(): Promise<any[]> {
const rows = await this.dataSource.query<{ data: string }>(
`SELECT data FROM posts_cache ORDER BY updatedAt DESC`
);
return rows.map(r => JSON.parse(r.data));
}
async delete(id: string): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
await this.dataSource.run(`DELETE FROM posts_cache WHERE id = ?`, [String(id)]);
});
}
}
export const postCacheRepository = new PostCacheRepository();

View File

@@ -0,0 +1,81 @@
import { localDataSource } from '../LocalDataSource';
import type { ILocalDataSource } from '@/data/datasources/interfaces';
import type { UserDTO } from '@/types/dto';
const safeParseJson = <T>(value: string): T | null => {
try { return JSON.parse(value) as T; } catch { return null; }
};
export interface IUserCacheRepository {
save(user: UserDTO): Promise<void>;
saveBatch(users: UserDTO[]): Promise<void>;
get(userId: string): Promise<UserDTO | null>;
getLatest(): Promise<UserDTO | null>;
saveCurrent(user: UserDTO): Promise<void>;
getCurrent(): Promise<UserDTO | null>;
clearCurrent(): Promise<void>;
}
export class UserCacheRepository implements IUserCacheRepository {
constructor(private dataSource: ILocalDataSource = localDataSource) {}
async save(user: UserDTO): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
await this.dataSource.run(
`INSERT OR REPLACE INTO users (id, data, updatedAt) VALUES (?, ?, ?)`,
[String(user.id), JSON.stringify(user), new Date().toISOString()]
);
});
}
async saveBatch(users: UserDTO[]): Promise<void> {
if (!users?.length) return;
await this.dataSource.enqueueWrite(async () => {
const now = new Date().toISOString();
for (const u of users) {
await this.dataSource.run(
`INSERT OR REPLACE INTO users (id, data, updatedAt) VALUES (?, ?, ?)`,
[String(u.id), JSON.stringify(u), now]
);
}
});
}
async get(userId: string): Promise<UserDTO | null> {
const r = await this.dataSource.getFirst<{ data: string }>(
`SELECT data FROM users WHERE id = ?`, [String(userId)]
);
return r?.data ? safeParseJson<UserDTO>(r.data) : null;
}
async getLatest(): Promise<UserDTO | null> {
const r = await this.dataSource.getFirst<{ data: string }>(
`SELECT data FROM users ORDER BY updatedAt DESC LIMIT 1`
);
return r?.data ? safeParseJson<UserDTO>(r.data) : null;
}
async saveCurrent(user: UserDTO): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
await this.dataSource.run(
`INSERT OR REPLACE INTO current_user_cache (id, data, updatedAt) VALUES (?, ?, ?)`,
['me', JSON.stringify(user), new Date().toISOString()]
);
});
}
async getCurrent(): Promise<UserDTO | null> {
const r = await this.dataSource.getFirst<{ data: string }>(
`SELECT data FROM current_user_cache WHERE id = 'me'`
);
return r?.data ? safeParseJson<UserDTO>(r.data) : null;
}
async clearCurrent(): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
await this.dataSource.run(`DELETE FROM current_user_cache WHERE id = 'me'`);
});
}
}
export const userCacheRepository = new UserCacheRepository();

View File

@@ -0,0 +1,5 @@
export { IMessageRepository, MessageRepository, messageRepository } from './MessageRepository';
export { IConversationRepository, ConversationRepository, conversationRepository } from './ConversationRepository';
export { IUserCacheRepository, UserCacheRepository, userCacheRepository } from './UserCacheRepository';
export { IGroupCacheRepository, GroupCacheRepository, groupCacheRepository } from './GroupCacheRepository';
export { IPostCacheRepository, PostCacheRepository, postCacheRepository } from './PostCacheRepository';

View File

@@ -0,0 +1,40 @@
import type { SQLiteDatabase } from 'expo-sqlite';
export const CONVERSATIONS_TABLE = 'conversations';
export const CONVERSATION_CACHE_TABLE = 'conversation_cache';
export const CONVERSATION_LIST_CACHE_TABLE = 'conversation_list_cache';
export const CREATE_CONVERSATIONS_TABLE = `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
)`;
export const CREATE_CONVERSATION_CACHE_TABLE = `CREATE TABLE IF NOT EXISTS conversation_cache (
id TEXT PRIMARY KEY NOT NULL,
data TEXT NOT NULL,
updatedAt TEXT NOT NULL
)`;
export const CREATE_CONVERSATION_LIST_CACHE_TABLE = `CREATE TABLE IF NOT EXISTS conversation_list_cache (
id TEXT PRIMARY KEY NOT NULL,
data TEXT NOT NULL,
updatedAt TEXT NOT NULL
)`;
export const MIGRATE_CONVERSATIONS_TABLE = async (db: SQLiteDatabase): Promise<void> => {
try {
const tableInfo = await db.getAllAsync<{ name: string }>(`PRAGMA table_info(conversations)`);
const columns = tableInfo.map(col => col.name);
if (!columns.includes('lastSeq')) {
await db.execAsync(`ALTER TABLE conversations ADD COLUMN lastSeq INTEGER DEFAULT 0`);
}
} catch (error) {
console.error('[ConversationSchema] 迁移失败:', error);
}
};

View File

@@ -0,0 +1,20 @@
export const GROUPS_TABLE = 'groups';
export const GROUP_MEMBERS_TABLE = 'group_members';
export const CREATE_GROUPS_TABLE = `CREATE TABLE IF NOT EXISTS groups (
id TEXT PRIMARY KEY NOT NULL,
data TEXT NOT NULL,
updatedAt TEXT NOT NULL
)`;
export const CREATE_GROUP_MEMBERS_TABLE = `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)
)`;
export const CREATE_GROUP_INDEXES = [
`CREATE INDEX IF NOT EXISTS idx_group_members_groupId ON group_members(groupId)`,
];

View File

@@ -0,0 +1,41 @@
import type { SQLiteDatabase } from 'expo-sqlite';
export const MESSAGES_TABLE = 'messages';
export const CREATE_MESSAGES_TABLE = `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
)`;
export const CREATE_MESSAGES_INDEXES = [
`CREATE INDEX IF NOT EXISTS idx_messages_conversationId ON messages(conversationId)`,
`CREATE INDEX IF NOT EXISTS idx_messages_seq ON messages(seq)`,
`CREATE INDEX IF NOT EXISTS idx_messages_conversation_seq ON messages(conversationId, seq)`,
];
export const MIGRATE_MESSAGES_TABLE = async (db: SQLiteDatabase): Promise<void> => {
try {
const tableInfo = await db.getAllAsync<{ name: string }>(`PRAGMA table_info(messages)`);
const columns = tableInfo.map(col => col.name);
if (!columns.includes('seq')) {
await db.execAsync(`ALTER TABLE messages ADD COLUMN seq INTEGER DEFAULT 0`);
}
if (!columns.includes('status')) {
await db.execAsync(`ALTER TABLE messages ADD COLUMN status TEXT DEFAULT 'normal'`);
}
if (!columns.includes('segments')) {
await db.execAsync(`ALTER TABLE messages ADD COLUMN segments TEXT`);
}
} catch (error) {
console.error('[MessageSchema] 迁移失败:', error);
}
};

View File

@@ -0,0 +1,57 @@
import type { SQLiteDatabase } from 'expo-sqlite';
import {
CREATE_MESSAGES_TABLE,
CREATE_MESSAGES_INDEXES,
MIGRATE_MESSAGES_TABLE,
} from './MessageSchema';
import {
CREATE_CONVERSATIONS_TABLE,
CREATE_CONVERSATION_CACHE_TABLE,
CREATE_CONVERSATION_LIST_CACHE_TABLE,
MIGRATE_CONVERSATIONS_TABLE,
} from './ConversationSchema';
import {
CREATE_USERS_TABLE,
CREATE_CURRENT_USER_CACHE_TABLE,
} from './UserSchema';
import {
CREATE_GROUPS_TABLE,
CREATE_GROUP_MEMBERS_TABLE,
CREATE_GROUP_INDEXES,
} from './GroupSchema';
import {
CREATE_POSTS_CACHE_TABLE,
} from './PostSchema';
export async function runMigrations(db: SQLiteDatabase): Promise<void> {
if (typeof window !== 'undefined') {
try {
await db.execAsync(`PRAGMA locking_mode = EXCLUSIVE;`);
await db.execAsync(`PRAGMA journal_mode = MEMORY;`);
await db.execAsync(`PRAGMA temp_store = MEMORY;`);
console.log('[MigrationManager] Web: 已设置 EXCLUSIVE locking + MEMORY journal/temp 模式');
} catch (e) {
console.warn('[MigrationManager] 设置 PRAGMA 失败:', e);
}
}
await db.execAsync(CREATE_MESSAGES_TABLE);
await db.execAsync(CREATE_CONVERSATIONS_TABLE);
await db.execAsync(CREATE_CONVERSATION_CACHE_TABLE);
await db.execAsync(CREATE_CONVERSATION_LIST_CACHE_TABLE);
await db.execAsync(CREATE_USERS_TABLE);
await db.execAsync(CREATE_CURRENT_USER_CACHE_TABLE);
await db.execAsync(CREATE_GROUPS_TABLE);
await db.execAsync(CREATE_GROUP_MEMBERS_TABLE);
await db.execAsync(CREATE_POSTS_CACHE_TABLE);
for (const indexSql of CREATE_MESSAGES_INDEXES) {
await db.execAsync(indexSql);
}
for (const indexSql of CREATE_GROUP_INDEXES) {
await db.execAsync(indexSql);
}
await MIGRATE_MESSAGES_TABLE(db);
await MIGRATE_CONVERSATIONS_TABLE(db);
}

View File

@@ -0,0 +1,9 @@
export const POSTS_CACHE_TABLE = 'posts_cache';
export const CREATE_POSTS_CACHE_TABLE = `CREATE TABLE IF NOT EXISTS posts_cache (
id TEXT PRIMARY KEY NOT NULL,
data TEXT NOT NULL,
updatedAt TEXT NOT NULL
)`;
export const CREATE_POSTS_INDEXES: string[] = [];

View File

@@ -0,0 +1,16 @@
export const USERS_TABLE = 'users';
export const CURRENT_USER_CACHE_TABLE = 'current_user_cache';
export const CREATE_USERS_TABLE = `CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY NOT NULL,
data TEXT NOT NULL,
updatedAt TEXT NOT NULL
)`;
export const CREATE_CURRENT_USER_CACHE_TABLE = `CREATE TABLE IF NOT EXISTS current_user_cache (
id TEXT PRIMARY KEY NOT NULL,
data TEXT NOT NULL,
updatedAt TEXT NOT NULL
)`;
export const CREATE_USER_INDEXES: string[] = [];

View File

@@ -0,0 +1,6 @@
export * from './MessageSchema';
export * from './ConversationSchema';
export * from './UserSchema';
export * from './GroupSchema';
export * from './PostSchema';
export { runMigrations } from './MigrationManager';

View File

@@ -0,0 +1,14 @@
export interface CachedMessage {
id: string;
conversationId: string;
senderId: string;
content?: string;
type?: string;
isRead: boolean;
createdAt: string;
seq: number;
status: string;
segments?: any;
}
export { CachedMessage as default };

View File

@@ -0,0 +1 @@
export { CachedMessage } from './CachedMessage';