Files
cunkebao_v3/Touchkebao/src/utils/db.ts

630 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 数据库工具类 - 使用serverId作为主键的优化架构
*
* 架构设计:
* 1. 使用serverId作为数据库主键直接对应接口返回的id字段
* 2. 保留原始的id字段用于存储接口数据的完整性
* 3. 添加userId字段实现多用户数据隔离
* 4. 统一会话表和联系人表,兼容好友和群聊
*
* 优势:
* - 直接使用服务器ID作为主键避免ID冲突
* - 通过userId实现多用户数据隔离
* - 统一的会话和联系人表结构,兼容好友和群聊
* - 支持复合索引,提高查询性能
* - 支持用户登录记录和自动清理
*/
import Dexie, { Table } from "dexie";
import { getPersistedData, PERSIST_KEYS } from "@/store/persistUtils";
const DB_NAME_PREFIX = "CunkebaoDatabase";
// ==================== 用户登录记录 ====================
export interface UserLoginRecord {
serverId: string; // 主键: user_${userId}
userId: number; // 用户ID
lastLoginTime: string; // 最后登录时间
loginCount: number; // 登录次数
createTime: string; // 首次登录时间
lastActiveTime: string; // 最后活跃时间
}
// ==================== 统一会话表(兼容好友和群聊) ====================
export interface ChatSession {
serverId: string; // 主键
userId: number; // 用户ID数据隔离
id: number; // 原始ID
type: "friend" | "group"; // 类型:好友或群聊
// 通用字段
wechatAccountId: number; // 所属客服账号
nickname: string; // 显示名称
conRemark?: string; // 备注名
avatar: string; // 头像
content: string; // 最新消息内容
lastUpdateTime: string; // 最后更新时间
aiType?: number; // AI类型0=普通1=AI辅助
config: {
unreadCount: number; // 未读数量
top: number; // 是否置顶1=置顶0=非置顶)
};
sortKey: string; // 预计算排序键
// 好友特有字段type='friend'时有效)
wechatFriendId?: number; // 好友ID
wechatId?: string; // 微信号
alias?: string; // 别名
// 群聊特有字段type='group'时有效)
chatroomId?: string; // 群聊ID
chatroomOwner?: string; // 群主
selfDisplayName?: string; // 群内昵称
notice?: string; // 群公告
phone?: string; // 联系人电话
region?: string; // 联系人地区
extendFields?: string; // 扩展字段JSON 字符串)
}
// ==================== 统一联系人表(兼容好友和群聊) ====================
export interface Contact {
serverId: string; // 主键
userId: number; // 用户ID数据隔离
id: number; // 原始ID
type: "friend" | "group"; // 类型:好友或群聊
// 通用字段
wechatAccountId: number; // 所属客服账号
nickname: string; // 显示名称
conRemark?: string; // 备注名
avatar: string; // 头像
lastUpdateTime: string; // 最后更新时间
aiType?: number; // AI类型0=普通1=AI辅助
config?: any; // 配置信息
sortKey: string; // 预计算排序键
searchKey: string; // 预计算搜索键
// 好友特有字段type='friend'时有效)
wechatFriendId?: number; // 好友ID
wechatId?: string; // 微信号
alias?: string; // 别名
gender?: number; // 性别
groupId?: number; // 标签ID分组
region?: string; // 地区
signature?: string; // 个性签名
phone?: string; // 手机号
quanPin?: string; // 全拼
extendFields?: string; // 扩展字段JSON 字符串)
// 群聊特有字段type='group'时有效)
chatroomId?: string; // 群聊ID
chatroomOwner?: string; // 群主
selfDisplayName?: string; // 群内昵称
notice?: string; // 群公告
memberCount?: number; // 群成员数量
}
// ==================== 联系人标签映射表 ====================
export interface ContactLabelMap {
serverId: string; // 主键: ${contactId}_${labelId}
userId: number; // 用户ID数据隔离
labelId: number; // 标签ID
contactId: number; // 联系人ID
contactType: "friend" | "group"; // 联系人类型
sortKey: string; // 预计算排序键
searchKey: string; // 预计算搜索键
// 列表展示必需字段(轻量)
avatar: string;
nickname: string;
conRemark?: string;
unreadCount: number;
lastUpdateTime: string;
}
// 数据库类
class CunkebaoDatabase extends Dexie {
// ==================== 统一表结构 ====================
chatSessions!: Table<ChatSession>; // 统一会话表
contactsUnified!: Table<Contact>; // 统一联系人表
contactLabelMap!: Table<ContactLabelMap>; // 联系人标签映射表
userLoginRecords!: Table<UserLoginRecord>; // 用户登录记录表
constructor(dbName: string) {
super(dbName);
this.version(1).stores({
// 会话表索引:支持按用户、类型、时间、置顶等查询
chatSessions:
"serverId, userId, id, type, wechatAccountId, [userId+type], [userId+wechatAccountId], [userId+lastUpdateTime], [userId+aiType], sortKey, nickname, conRemark, avatar, content, lastUpdateTime, aiType, phone, region",
// 联系人表索引:支持按用户、类型、标签、搜索等查询
contactsUnified:
"serverId, userId, id, type, wechatAccountId, [userId+type], [userId+wechatAccountId], [userId+aiType], sortKey, searchKey, nickname, conRemark, avatar, lastUpdateTime, groupId, aiType, phone, region",
// 联系人标签映射表索引:支持按用户、标签、联系人、类型查询
contactLabelMap:
"serverId, userId, labelId, contactId, contactType, [userId+labelId], [userId+contactId], [userId+labelId+sortKey], sortKey, searchKey, avatar, nickname, conRemark, unreadCount, lastUpdateTime",
// 用户登录记录表索引支持按用户ID、登录时间查询
userLoginRecords:
"serverId, userId, lastLoginTime, loginCount, createTime, lastActiveTime",
});
this.version(2)
.stores({
chatSessions:
"serverId, userId, id, type, wechatAccountId, [userId+type], [userId+wechatAccountId], [userId+lastUpdateTime], [userId+aiType], sortKey, nickname, conRemark, avatar, content, lastUpdateTime, aiType, phone, region, extendFields",
contactsUnified:
"serverId, userId, id, type, wechatAccountId, [userId+type], [userId+wechatAccountId], [userId+aiType], sortKey, searchKey, nickname, conRemark, avatar, lastUpdateTime, groupId, aiType, phone, region, extendFields",
contactLabelMap:
"serverId, userId, labelId, contactId, contactType, [userId+labelId], [userId+contactId], [userId+labelId+sortKey], sortKey, searchKey, avatar, nickname, conRemark, unreadCount, lastUpdateTime",
userLoginRecords:
"serverId, userId, lastLoginTime, loginCount, createTime, lastActiveTime",
})
.upgrade(async tx => {
await tx
.table("chatSessions")
.toCollection()
.modify(session => {
if (!("extendFields" in session) || session.extendFields == null) {
session.extendFields = "{}";
} else if (typeof session.extendFields !== "string") {
session.extendFields = JSON.stringify(session.extendFields);
}
});
await tx
.table("contactsUnified")
.toCollection()
.modify(contact => {
if (!("extendFields" in contact) || contact.extendFields == null) {
contact.extendFields = "{}";
} else if (typeof contact.extendFields !== "string") {
contact.extendFields = JSON.stringify(contact.extendFields);
}
});
});
}
}
class DatabaseManager {
private currentDb: CunkebaoDatabase | null = null;
private currentUserId: number | null = null;
private getDatabaseName(userId: number) {
return `${DB_NAME_PREFIX}_${userId}`;
}
private async openDatabase(dbName: string) {
const instance = new CunkebaoDatabase(dbName);
await instance.open();
return instance;
}
async ensureDatabase(userId: number) {
if (userId === undefined || userId === null) {
throw new Error("Invalid userId provided for database initialization");
}
if (
this.currentDb &&
this.currentUserId === userId &&
this.currentDb.isOpen()
) {
return this.currentDb;
}
await this.closeCurrentDatabase();
const dbName = this.getDatabaseName(userId);
this.currentDb = await this.openDatabase(dbName);
this.currentUserId = userId;
return this.currentDb;
}
getCurrentDatabase(): CunkebaoDatabase {
if (!this.currentDb) {
throw new Error("Database has not been initialized for the current user");
}
return this.currentDb;
}
getCurrentUserId() {
return this.currentUserId;
}
isInitialized(): boolean {
return !!this.currentDb && this.currentDb.isOpen();
}
async closeCurrentDatabase() {
if (this.currentDb) {
try {
this.currentDb.close();
} catch (error) {
console.warn("Failed to close current database:", error);
}
this.currentDb = null;
}
this.currentUserId = null;
}
}
export const databaseManager = new DatabaseManager();
let pendingDatabaseRestore: Promise<CunkebaoDatabase | null> | null = null;
async function restoreDatabaseFromPersistedState() {
if (typeof window === "undefined") {
return null;
}
const persistedData = getPersistedData<string | Record<string, any>>(
PERSIST_KEYS.USER_STORE,
"localStorage",
);
if (!persistedData) {
return null;
}
let parsed: any = persistedData;
if (typeof persistedData === "string") {
try {
parsed = JSON.parse(persistedData);
} catch (error) {
console.warn("Failed to parse persisted user-store value:", error);
return null;
}
}
const state = parsed?.state ?? parsed;
const userId = state?.user?.id;
if (!userId) {
return null;
}
try {
return await databaseManager.ensureDatabase(userId);
} catch (error) {
console.warn("Failed to initialize database from persisted user:", error);
return null;
}
}
export async function initializeDatabaseFromPersistedUser() {
if (databaseManager.isInitialized()) {
return databaseManager.getCurrentDatabase();
}
if (!pendingDatabaseRestore) {
pendingDatabaseRestore = restoreDatabaseFromPersistedState().finally(() => {
pendingDatabaseRestore = null;
});
}
return pendingDatabaseRestore;
}
const dbProxy = new Proxy({} as CunkebaoDatabase, {
get(_target, prop: string | symbol) {
const currentDb = databaseManager.getCurrentDatabase();
const value = (currentDb as any)[prop];
if (typeof value === "function") {
return value.bind(currentDb);
}
return value;
},
});
export const db = dbProxy;
// 简单的数据库操作类
export class DatabaseService<T> {
constructor(private readonly tableAccessor: () => Table<T>) {}
private get table(): Table<T> {
return this.tableAccessor();
}
// 基础 CRUD 操作 - 使用serverId作为主键
async create(data: Omit<T, "serverId">): Promise<string | number> {
return await this.table.add(this.prepareDataForWrite(data) as T);
}
// 创建数据(直接使用接口数据)
// 接口数据的id字段直接作为serverId主键原id字段保留
async createWithServerId(data: any): Promise<string | number> {
const dataToInsert = this.prepareDataForWrite({
...data,
serverId: data.id, // 使用接口的id作为serverId主键
phone: data.phone ?? "",
region: data.region ?? "",
});
return await this.table.add(dataToInsert as T);
}
// 根据原始ID查询用户友好的查询方法
async findById(id: string | number): Promise<T | undefined> {
return await this.table.where("id").equals(id).first();
}
// 根据serverId查询内部主键查询
async findByPrimaryKey(serverId: string | number): Promise<T | undefined> {
return await this.table.get(serverId);
}
async findAll(): Promise<T[]> {
return await this.table.toArray();
}
async update(serverId: string | number, data: Partial<T>): Promise<number> {
return await this.table.update(
serverId,
this.prepareDataForWrite(data) as any,
);
}
async updateMany(
dataList: { serverId: string | number; data: Partial<T> }[],
): Promise<number> {
return await this.table.bulkUpdate(
dataList.map(item => ({
key: item.serverId,
changes: this.prepareDataForWrite(item.data) as any,
})),
);
}
async createMany(
dataList: Omit<T, "serverId">[],
): Promise<(string | number)[]> {
const processed = dataList.map(item => this.prepareDataForWrite(item));
return await this.table.bulkAdd(processed as T[], { allKeys: true });
}
// 批量创建数据(直接使用接口数据)
// 接口数据的id字段直接作为serverId主键
async createManyWithServerId(dataList: any[]): Promise<(string | number)[]> {
// 检查是否存在重复的serverId
const serverIds = dataList.map(item => item.id);
const existingData = await this.table
.where("serverId")
.anyOf(serverIds)
.toArray();
const existingServerIds = new Set(
existingData.map((item: any) => item.serverId),
);
// 过滤掉已存在的数据
const newData = dataList.filter(item => !existingServerIds.has(item.id));
if (newData.length === 0) {
// console.log("所有数据都已存在,跳过插入");
return [];
}
const processedData = newData.map(item =>
this.prepareDataForWrite({
...item,
serverId: item.id, // 使用接口的id作为serverId主键
phone: item.phone ?? "",
region: item.region ?? "",
}),
);
return await this.table.bulkAdd(processedData as T[], { allKeys: true });
}
async delete(serverId: string | number): Promise<void> {
await this.table.delete(serverId);
}
async clear(): Promise<void> {
await this.table.clear();
}
// 条件查询
async findWhere(field: keyof T, value: any): Promise<T[]> {
return await this.table
.where(field as string)
.equals(value)
.toArray();
}
// 根据服务器ID查询兼容性方法
async findByServerId(serverId: any): Promise<T | undefined> {
return await this.table.get(serverId);
}
// 根据原始ID批量查询
async findByIds(ids: (string | number)[]): Promise<T[]> {
return await this.table.where("id").anyOf(ids).toArray();
}
// 多值查询IN 查询)
async findWhereIn(field: keyof T, values: any[]): Promise<T[]> {
return await this.table
.where(field as string)
.anyOf(values)
.toArray();
}
// 范围查询
async findWhereBetween(field: keyof T, min: any, max: any): Promise<T[]> {
return await this.table
.where(field as string)
.between(min, max)
.toArray();
}
// 模糊查询(以指定字符串开头)
async findWhereStartsWith(field: keyof T, prefix: string): Promise<T[]> {
return await this.table
.where(field as string)
.startsWith(prefix)
.toArray();
}
// 不等于查询
async findWhereNot(field: keyof T, value: any): Promise<T[]> {
return await this.table
.where(field as string)
.notEqual(value)
.toArray();
}
// 大于查询
async findWhereGreaterThan(field: keyof T, value: any): Promise<T[]> {
return await this.table
.where(field as string)
.above(value)
.toArray();
}
// 小于查询
async findWhereLessThan(field: keyof T, value: any): Promise<T[]> {
return await this.table
.where(field as string)
.below(value)
.toArray();
}
// 复合条件查询
async findWhereMultiple(
conditions: {
field: keyof T;
operator:
| "equals"
| "notEqual"
| "above"
| "below"
| "aboveOrEqual"
| "belowOrEqual"
| "startsWith"
| "anyOf"
| "notIn"
| "between"
| "contains";
value: any;
value2?: any; // 用于 between 操作符
}[],
): Promise<T[]> {
let collection = this.table.toCollection();
for (const condition of conditions) {
const { field, operator, value, value2 } = condition;
collection = collection.and(item => {
const fieldValue = (item as any)[field];
switch (operator) {
case "equals":
return fieldValue === value;
case "notEqual":
return fieldValue !== value;
case "above":
return fieldValue > value;
case "below":
return fieldValue < value;
case "aboveOrEqual":
return fieldValue >= value;
case "belowOrEqual":
return fieldValue <= value;
case "startsWith":
return (
typeof fieldValue === "string" && fieldValue.startsWith(value)
);
case "contains":
return typeof fieldValue === "string" && fieldValue.includes(value);
case "anyOf":
return Array.isArray(value) && value.includes(fieldValue);
case "notIn":
return Array.isArray(value) && !value.includes(fieldValue);
case "between":
return fieldValue >= value && fieldValue <= (value2 ?? value);
default:
return true;
}
});
}
return await collection.toArray();
}
// 分页查询
async findWithPagination(
page: number = 1,
limit: number = 10,
): Promise<{ data: T[]; total: number; page: number; limit: number }> {
const offset = (page - 1) * limit;
const total = await this.table.count();
const data = await this.table.offset(offset).limit(limit).toArray();
return { data, total, page, limit };
}
// 排序查询
async findAllSorted(
field: keyof T,
direction: "asc" | "desc" = "asc",
): Promise<T[]> {
const collection = this.table.orderBy(field as string);
return direction === "desc"
? await collection.reverse().toArray()
: await collection.toArray();
}
// 统计
async count(): Promise<number> {
return await this.table.count();
}
// 条件统计
async countWhere(field: keyof T, value: any): Promise<number> {
return await this.table
.where(field as string)
.equals(value)
.count();
}
private prepareDataForWrite(data: any) {
if (!data || typeof data !== "object") {
return data;
}
const prepared = { ...data };
if ("extendFields" in prepared) {
const value = prepared.extendFields;
if (typeof value === "string" && value.trim() !== "") {
prepared.extendFields = value;
} else if (value && typeof value === "object") {
prepared.extendFields = JSON.stringify(value);
} else {
prepared.extendFields = "{}";
}
}
return prepared;
}
}
// 创建统一表的服务实例
export const chatSessionService = new DatabaseService<ChatSession>(
() => databaseManager.getCurrentDatabase().chatSessions,
);
export const contactUnifiedService = new DatabaseService<Contact>(
() => databaseManager.getCurrentDatabase().contactsUnified,
);
export const contactLabelMapService = new DatabaseService<ContactLabelMap>(
() => databaseManager.getCurrentDatabase().contactLabelMap,
);
export const userLoginRecordService = new DatabaseService<UserLoginRecord>(
() => databaseManager.getCurrentDatabase().userLoginRecords,
);
// 默认导出数据库实例
export default db;