feat: 支持头像导入

This commit is contained in:
digua
2025-12-19 02:08:21 +08:00
parent ae6e89be4f
commit 145b9416d7
18 changed files with 788 additions and 75 deletions
+16 -7
View File
@@ -70,7 +70,9 @@ function createDatabase(sessionId: string): Database.Database {
name TEXT NOT NULL,
platform TEXT NOT NULL,
type TEXT NOT NULL,
imported_at INTEGER NOT NULL
imported_at INTEGER NOT NULL,
group_id TEXT,
group_avatar TEXT
);
CREATE TABLE IF NOT EXISTS member (
@@ -78,7 +80,8 @@ function createDatabase(sessionId: string): Database.Database {
platform_id TEXT NOT NULL UNIQUE,
account_name TEXT,
group_nickname TEXT,
aliases TEXT DEFAULT '[]'
aliases TEXT DEFAULT '[]',
avatar TEXT
);
CREATE TABLE IF NOT EXISTS member_name_history (
@@ -134,18 +137,20 @@ export function importData(parseResult: ParseResult): string {
try {
const importTransaction = db.transaction(() => {
const insertMeta = db.prepare(`
INSERT INTO meta (name, platform, type, imported_at)
VALUES (?, ?, ?, ?)
INSERT INTO meta (name, platform, type, imported_at, group_id, group_avatar)
VALUES (?, ?, ?, ?, ?, ?)
`)
insertMeta.run(
parseResult.meta.name,
parseResult.meta.platform,
parseResult.meta.type,
Math.floor(Date.now() / 1000)
Math.floor(Date.now() / 1000),
parseResult.meta.groupId || null,
parseResult.meta.groupAvatar || null
)
const insertMember = db.prepare(`
INSERT OR IGNORE INTO member (platform_id, account_name, group_nickname) VALUES (?, ?, ?)
INSERT OR IGNORE INTO member (platform_id, account_name, group_nickname, avatar) VALUES (?, ?, ?, ?)
`)
const getMemberId = db.prepare(`
SELECT id FROM member WHERE platform_id = ?
@@ -154,7 +159,7 @@ export function importData(parseResult: ParseResult): string {
const memberIdMap = new Map<string, number>()
for (const member of parseResult.members) {
insertMember.run(member.platformId, member.accountName || null, member.groupNickname || null)
insertMember.run(member.platformId, member.accountName || null, member.groupNickname || null, member.avatar || null)
const row = getMemberId.get(member.platformId) as { id: number }
memberIdMap.set(member.platformId, row.id)
}
@@ -312,6 +317,8 @@ export function getAllSessions(): AnalysisSession[] {
messageCount,
memberCount,
dbPath,
groupId: meta.group_id || null,
groupAvatar: meta.group_avatar || null,
})
}
@@ -365,6 +372,8 @@ export function getSession(sessionId: string): AnalysisSession | null {
messageCount,
memberCount,
dbPath: getDbPath(sessionId),
groupId: meta.group_id || null,
groupAvatar: meta.group_avatar || null,
}
} finally {
db.close()
+20 -1
View File
@@ -391,11 +391,16 @@ export async function mergeFilesWithTempDb(
if (member.groupNickname) {
existing.groupNickname = member.groupNickname
}
// 头像使用最新的(覆盖更新)
if (member.avatar) {
existing.avatar = member.avatar
}
} else {
memberMap.set(member.platformId, {
platformId: member.platformId,
accountName: member.accountName,
groupNickname: member.groupNickname,
avatar: member.avatar,
})
}
}
@@ -453,6 +458,14 @@ export async function mergeFilesWithTempDb(
const platforms = new Set(parseResults.map((r) => r.meta.platform))
const platform = platforms.size === 1 ? parseResults[0].meta.platform : 'mixed'
// 确定群ID和群头像(仅当所有文件都来自同一个群时保留)
const groupIds = new Set(parseResults.map((r) => r.meta.groupId).filter(Boolean))
const groupId = groupIds.size === 1 ? parseResults.find((r) => r.meta.groupId)?.meta.groupId : undefined
// 如果有唯一群ID,使用最后一个文件的群头像(可能是最新的)
const groupAvatar = groupId
? parseResults.filter((r) => r.meta.groupId === groupId).pop()?.meta.groupAvatar
: undefined
// 构建来源信息
const sources: MergeSource[] = parseResults.map(({ reader, source, meta }) => ({
filename: source,
@@ -463,15 +476,18 @@ export async function mergeFilesWithTempDb(
// 构建 ChatLab 格式
const chatLabData: ChatLabFormat = {
chatlab: {
version: '1.0.0',
version: '0.0.1',
exportedAt: Math.floor(Date.now() / 1000),
generator: 'ChatLab Merge Tool',
description: `合并自 ${parseResults.length} 个文件`,
},
meta: {
name: outputName,
platform: platform as ChatPlatform,
type: parseResults[0].meta.type as ChatType,
sources,
groupId,
groupAvatar,
},
members: Array.from(memberMap.values()),
messages: mergedMessages,
@@ -497,11 +513,14 @@ export async function mergeFilesWithTempDb(
name: chatLabData.meta.name,
platform: chatLabData.meta.platform,
type: chatLabData.meta.type,
groupId: chatLabData.meta.groupId,
groupAvatar: chatLabData.meta.groupAvatar,
},
members: chatLabData.members.map((m) => ({
platformId: m.platformId,
accountName: m.accountName,
groupNickname: m.groupNickname,
avatar: m.avatar,
})),
messages: chatLabData.messages.map((msg) => ({
senderPlatformId: msg.sender,
+16 -9
View File
@@ -58,13 +58,16 @@ export function createTempDatabase(dbPath: string): Database.Database {
CREATE TABLE IF NOT EXISTS meta (
name TEXT NOT NULL,
platform TEXT NOT NULL,
type TEXT NOT NULL
type TEXT NOT NULL,
group_id TEXT,
group_avatar TEXT
);
CREATE TABLE IF NOT EXISTS member (
platform_id TEXT PRIMARY KEY,
account_name TEXT,
group_nickname TEXT
group_nickname TEXT,
avatar TEXT
);
CREATE TABLE IF NOT EXISTS message (
@@ -101,10 +104,10 @@ export class TempDbWriter {
// 准备语句
this.insertMeta = this.db.prepare(`
INSERT INTO meta (name, platform, type) VALUES (?, ?, ?)
INSERT INTO meta (name, platform, type, group_id, group_avatar) VALUES (?, ?, ?, ?, ?)
`)
this.insertMember = this.db.prepare(`
INSERT OR IGNORE INTO member (platform_id, account_name, group_nickname) VALUES (?, ?, ?)
INSERT OR IGNORE INTO member (platform_id, account_name, group_nickname, avatar) VALUES (?, ?, ?, ?)
`)
this.insertMessage = this.db.prepare(`
INSERT INTO message (sender_platform_id, sender_account_name, sender_group_nickname, timestamp, type, content)
@@ -119,7 +122,7 @@ export class TempDbWriter {
* 写入元信息
*/
writeMeta(meta: ParsedMeta): void {
this.insertMeta.run(meta.name, meta.platform, meta.type)
this.insertMeta.run(meta.name, meta.platform, meta.type, meta.groupId || null, meta.groupAvatar || null)
}
/**
@@ -129,7 +132,7 @@ export class TempDbWriter {
for (const m of members) {
if (!this.memberSet.has(m.platformId)) {
this.memberSet.add(m.platformId)
this.insertMember.run(m.platformId, m.accountName || null, m.groupNickname || null)
this.insertMember.run(m.platformId, m.accountName || null, m.groupNickname || null, m.avatar || null)
}
}
}
@@ -139,10 +142,10 @@ export class TempDbWriter {
*/
writeMessages(messages: ParsedMessage[]): void {
for (const msg of messages) {
// 确保成员存在
// 确保成员存在(消息中没有头像信息,设为 null
if (!this.memberSet.has(msg.senderPlatformId)) {
this.memberSet.add(msg.senderPlatformId)
this.insertMember.run(msg.senderPlatformId, msg.senderAccountName || null, msg.senderGroupNickname || null)
this.insertMember.run(msg.senderPlatformId, msg.senderAccountName || null, msg.senderGroupNickname || null, null)
}
this.insertMessage.run(
@@ -202,13 +205,15 @@ export class TempDbReader {
*/
getMeta(): ParsedMeta | null {
const row = this.db.prepare('SELECT * FROM meta LIMIT 1').get() as
| { name: string; platform: string; type: string }
| { name: string; platform: string; type: string; group_id: string | null; group_avatar: string | null }
| undefined
if (!row) return null
return {
name: row.name,
platform: row.platform,
type: row.type as 'group' | 'private',
groupId: row.group_id || undefined,
groupAvatar: row.group_avatar || undefined,
}
}
@@ -220,11 +225,13 @@ export class TempDbReader {
platform_id: string
account_name: string | null
group_nickname: string | null
avatar: string | null
}>
return rows.map((r) => ({
platformId: r.platform_id,
accountName: r.account_name || r.platform_id, // 如果没有账号名称,使用 platformId
groupNickname: r.group_nickname || undefined,
avatar: r.avatar || undefined,
}))
}
+4
View File
@@ -70,6 +70,7 @@ interface ChatLabMember {
accountName: string // 账号名称
groupNickname?: string // 群昵称
aliases?: string[]
avatar?: string // 头像(base64 Data URL
}
// ==================== 解析器实现 ====================
@@ -125,6 +126,8 @@ async function* parseChatLab(options: ParseOptions): AsyncGenerator<ParseEvent,
name: metaObj.name || '未知群聊',
platform: (metaObj.platform as ChatPlatform) || ChatPlatform.UNKNOWN,
type: (metaObj.type as ChatType) || ChatType.GROUP,
groupId: metaObj.groupId,
groupAvatar: metaObj.groupAvatar,
}
}
}
@@ -150,6 +153,7 @@ async function* parseChatLab(options: ParseOptions): AsyncGenerator<ParseEvent,
platformId: m.platformId,
accountName: m.accountName,
groupNickname: m.groupNickname,
avatar: m.avatar,
})
}
}
@@ -99,6 +99,7 @@ interface MemberInfo {
platformId: string
accountName: string // 账号名称(QQ原始昵称 sendNickName
groupNickname: string | undefined // 群昵称(sendMemberName,可为空)
avatar: string | undefined // 头像(base64 Data URL
}
// ==================== 消息类型转换 ====================
@@ -271,6 +272,163 @@ async function* parseV4(options: ParseOptions): AsyncGenerator<ParseEvent, void,
// 如果无法获取 senders 数量,默认为群聊(群聊是更常见的使用场景)
const chatType = sendersCount > 0 ? (sendersCount > 2 ? ChatType.GROUP : ChatType.PRIVATE) : ChatType.GROUP // 默认为群聊
// 解析 avatars 对象(头像)
// avatars 格式:{ "uin1": "data:image/jpeg;base64,...", "uin2": "..." }
// 注意:base64 字符串很长,需要特殊处理匹配花括号
const avatarsMap = new Map<string, string>()
/**
* 从字符串中提取 avatars 对象内容
* 正确处理 JSON 字符串中的花括号匹配(考虑字符串内的转义字符)
*/
function extractAvatarsObject(content: string): string | null {
const searchStr = '"avatars":'
const startIdx = content.indexOf(searchStr)
if (startIdx === -1) return null
let i = startIdx + searchStr.length
// 跳过空白字符
while (i < content.length && /\s/.test(content[i])) i++
if (content[i] !== '{') return null
// 从 { 开始匹配
let braceDepth = 0
let inString = false
let escape = false
const objStart = i
for (; i < content.length; i++) {
const char = content[i]
if (escape) {
escape = false
continue
}
if (char === '\\' && inString) {
escape = true
continue
}
if (char === '"') {
inString = !inString
continue
}
if (!inString) {
if (char === '{') braceDepth++
if (char === '}') {
braceDepth--
if (braceDepth === 0) {
return content.slice(objStart, i + 1)
}
}
}
}
return null
}
try {
// 先尝试从文件头解析(适用于成员较少的聊天)
const avatarsContent = extractAvatarsObject(headContent)
if (avatarsContent) {
const avatarsObj = JSON.parse(avatarsContent) as Record<string, string>
for (const [uin, avatar] of Object.entries(avatarsObj)) {
if (avatar && typeof avatar === 'string' && avatar.startsWith('data:image/')) {
avatarsMap.set(uin, avatar)
}
}
}
} catch {
// avatars 解析失败,继续不带头像
}
// 如果文件头没有完整的 avatars(可能超出 100KB),尝试流式读取
if (avatarsMap.size === 0) {
try {
await new Promise<void>((resolve) => {
const avatarStream = fs.createReadStream(filePath, { encoding: 'utf-8' })
let avatarsContent = ''
let inAvatars = false
let braceDepth = 0
let inString = false
let escape = false
avatarStream.on('data', (chunk: string | Buffer) => {
const str = typeof chunk === 'string' ? chunk : chunk.toString()
for (let i = 0; i < str.length; i++) {
const char = str[i]
if (!inAvatars) {
// 查找 "avatars": 的位置
const searchStr = '"avatars":'
if (str.slice(i, i + searchStr.length) === searchStr) {
inAvatars = true
// 跳过 "avatars": 和可能的空白
i += searchStr.length - 1
continue
}
} else {
// 开始收集 avatars 对象内容
avatarsContent += char
if (escape) {
escape = false
continue
}
if (char === '\\' && inString) {
escape = true
continue
}
if (char === '"') {
inString = !inString
continue
}
if (!inString) {
if (char === '{') braceDepth++
if (char === '}') {
braceDepth--
if (braceDepth === 0) {
// avatars 对象结束
avatarStream.destroy()
return
}
}
}
}
}
})
avatarStream.on('close', () => {
if (avatarsContent) {
try {
const avatarsObj = JSON.parse(avatarsContent) as Record<string, string>
for (const [uin, avatar] of Object.entries(avatarsObj)) {
if (avatar && typeof avatar === 'string' && avatar.startsWith('data:image/')) {
avatarsMap.set(uin, avatar)
}
}
} catch {
// 解析失败
}
}
resolve()
})
avatarStream.on('error', () => resolve())
})
} catch {
// 流式解析失败,继续不带头像
}
}
// 发送 meta
const meta: ParsedMeta = {
name: chatInfo.name === '未知群聊' ? extractNameFromFilePath(filePath) : chatInfo.name,
@@ -303,6 +461,9 @@ async function* parseV4(options: ParseOptions): AsyncGenerator<ParseEvent, void,
const accountName = raw?.sendNickName || msg.sender.name || platformId // QQ 原始昵称
const groupNickname = raw?.sendMemberName || undefined // 群昵称(可为空)
// 获取头像(通过 uin 查找)
const avatar = avatarsMap.get(platformId)
// 更新成员信息(保留最新的名字)
const existingMember = memberMap.get(platformId)
if (!existingMember) {
@@ -310,6 +471,7 @@ async function* parseV4(options: ParseOptions): AsyncGenerator<ParseEvent, void,
platformId,
accountName,
groupNickname,
avatar,
})
} else {
// 更新为最新的名字
@@ -317,6 +479,10 @@ async function* parseV4(options: ParseOptions): AsyncGenerator<ParseEvent, void,
if (groupNickname) {
existingMember.groupNickname = groupNickname
}
// 头像使用最新的(覆盖更新)
if (avatar) {
existingMember.avatar = avatar
}
}
// 解析时间戳
@@ -386,6 +552,7 @@ async function* parseV4(options: ParseOptions): AsyncGenerator<ParseEvent, void,
platformId: m.platformId,
accountName: m.accountName,
groupNickname: m.groupNickname,
avatar: m.avatar,
}))
yield { type: 'members', data: members }
@@ -80,9 +80,17 @@ interface EchotraceMessage {
isSend: number | null // 0=接收, 1=发送, null=系统
senderUsername: string // 发送者微信ID
senderDisplayName: string // 发送者显示名
senderAvatarKey: string // 头像查找 key(通常与 senderUsername 相同)
source: string
}
// ==================== 头像信息结构 ====================
interface EchotraceAvatarInfo {
displayName: string
base64: string // 原始 base64,不包含 Data URL 前缀
}
// ==================== 消息类型映射 ====================
/**
@@ -133,6 +141,7 @@ function convertMessageType(typeStr: string): MessageType {
interface MemberInfo {
platformId: string
accountName: string
avatar: string | undefined // 头像(base64 Data URL
}
// ==================== 解析器实现 ====================
@@ -180,11 +189,178 @@ async function* parseEchotrace(options: ParseOptions): AsyncGenerator<ParseEvent
// 确定聊天名称
const chatName = session?.displayName || session?.nickname || extractNameFromFilePath(filePath)
// 提取群ID(群聊类型时有值)
// 群ID 格式:以 @chatroom 结尾
const groupId = chatType === ChatType.GROUP && session?.wxid ? session.wxid : undefined
// 解析 avatars 对象(头像)
// avatars 格式:{ "wxid": { "displayName": "...", "base64": "..." } }
// 注意:base64 不包含 Data URL 前缀,需要添加
const avatarsMap = new Map<string, string>()
/**
* 从字符串中提取 avatars 对象内容
* 正确处理 JSON 字符串中的花括号匹配(考虑字符串内的转义字符)
*/
function extractAvatarsObject(content: string): string | null {
const searchStr = '"avatars":'
const startIdx = content.indexOf(searchStr)
if (startIdx === -1) return null
let i = startIdx + searchStr.length
// 跳过空白字符
while (i < content.length && /\s/.test(content[i])) i++
if (content[i] !== '{') return null
// 从 { 开始匹配
let braceDepth = 0
let inString = false
let escape = false
const objStart = i
for (; i < content.length; i++) {
const char = content[i]
if (escape) {
escape = false
continue
}
if (char === '\\' && inString) {
escape = true
continue
}
if (char === '"') {
inString = !inString
continue
}
if (!inString) {
if (char === '{') braceDepth++
if (char === '}') {
braceDepth--
if (braceDepth === 0) {
return content.slice(objStart, i + 1)
}
}
}
}
return null
}
try {
// 先尝试从文件头解析(适用于成员较少的聊天)
const avatarsContent = extractAvatarsObject(headContent)
if (avatarsContent) {
const avatarsObj = JSON.parse(avatarsContent) as Record<string, EchotraceAvatarInfo>
for (const [wxid, avatarInfo] of Object.entries(avatarsObj)) {
if (avatarInfo && typeof avatarInfo === 'object' && avatarInfo.base64) {
// 添加 Data URL 前缀
avatarsMap.set(wxid, `data:image/jpeg;base64,${avatarInfo.base64}`)
}
}
}
} catch {
// avatars 解析失败,继续不带头像
}
// 如果文件头没有完整的 avatars(可能超出 2000 字节),尝试流式读取
if (avatarsMap.size === 0) {
try {
await new Promise<void>((resolve) => {
const avatarStream = fs.createReadStream(filePath, { encoding: 'utf-8' })
let avatarsContent = ''
let inAvatars = false
let braceDepth = 0
let inString = false
let escape = false
avatarStream.on('data', (chunk: string | Buffer) => {
const str = typeof chunk === 'string' ? chunk : chunk.toString()
for (let i = 0; i < str.length; i++) {
const char = str[i]
if (!inAvatars) {
// 查找 "avatars": 的位置
const searchStr = '"avatars":'
if (str.slice(i, i + searchStr.length) === searchStr) {
inAvatars = true
// 跳过 "avatars": 和可能的空白
i += searchStr.length - 1
continue
}
} else {
// 开始收集 avatars 对象内容
avatarsContent += char
if (escape) {
escape = false
continue
}
if (char === '\\' && inString) {
escape = true
continue
}
if (char === '"') {
inString = !inString
continue
}
if (!inString) {
if (char === '{') braceDepth++
if (char === '}') {
braceDepth--
if (braceDepth === 0) {
// avatars 对象结束
avatarStream.destroy()
return
}
}
}
}
}
})
avatarStream.on('close', () => {
if (avatarsContent) {
try {
const avatarsObj = JSON.parse(avatarsContent) as Record<string, EchotraceAvatarInfo>
for (const [wxid, avatarInfo] of Object.entries(avatarsObj)) {
if (avatarInfo && typeof avatarInfo === 'object' && avatarInfo.base64) {
avatarsMap.set(wxid, `data:image/jpeg;base64,${avatarInfo.base64}`)
}
}
} catch {
// 解析失败
}
}
resolve()
})
avatarStream.on('error', () => resolve())
})
} catch {
// 流式解析失败,继续不带头像
}
}
// 提取群头像(从 avatars 中获取群ID对应的头像)
const groupAvatar = groupId ? avatarsMap.get(groupId) : undefined
// 发送 meta
const meta: ParsedMeta = {
name: chatName,
platform: ChatPlatform.WECHAT,
type: chatType,
groupId,
groupAvatar,
}
yield { type: 'meta', data: meta }
@@ -209,18 +385,34 @@ async function* parseEchotrace(options: ParseOptions): AsyncGenerator<ParseEvent
}
const platformId = msg.senderUsername
// 跳过群"成员"(群ID以 @chatroom 结尾的消息)
// 这些通常是系统消息,发送者是群本身,不是真正的成员
if (platformId.endsWith('@chatroom')) {
return null
}
const accountName = msg.senderDisplayName || platformId
// 获取头像(优先使用 senderAvatarKeyfallback 到 senderUsername
const avatarKey = msg.senderAvatarKey || msg.senderUsername
const avatar = avatarsMap.get(avatarKey)
// 更新成员信息
if (!memberMap.has(platformId)) {
memberMap.set(platformId, {
platformId,
accountName,
avatar,
})
} else {
// 更新为最新的显示名
const existing = memberMap.get(platformId)!
existing.accountName = accountName
// 头像使用最新的(覆盖更新)
if (avatar) {
existing.avatar = avatar
}
}
// 转换消息类型
@@ -278,6 +470,7 @@ async function* parseEchotrace(options: ParseOptions): AsyncGenerator<ParseEvent
const members: ParsedMember[] = Array.from(memberMap.values()).map((m) => ({
platformId: m.platformId,
accountName: m.accountName,
avatar: m.avatar,
}))
yield { type: 'members', data: members }
+2
View File
@@ -14,6 +14,8 @@ export interface ParsedMeta {
name: string
platform: ChatPlatform
type: ChatType
groupId?: string // 群ID(群聊类型有值)
groupAvatar?: string // 群头像(base64 Data URL
}
/**
+35 -14
View File
@@ -65,13 +65,16 @@ function createTempDatabase(dbPath: string): Database.Database {
CREATE TABLE IF NOT EXISTS meta (
name TEXT NOT NULL,
platform TEXT NOT NULL,
type TEXT NOT NULL
type TEXT NOT NULL,
group_id TEXT,
group_avatar TEXT
);
CREATE TABLE IF NOT EXISTS member (
platform_id TEXT PRIMARY KEY,
account_name TEXT,
group_nickname TEXT
group_nickname TEXT,
avatar TEXT
);
CREATE TABLE IF NOT EXISTS message (
@@ -141,7 +144,9 @@ function createDatabaseWithoutIndexes(sessionId: string): Database.Database {
name TEXT NOT NULL,
platform TEXT NOT NULL,
type TEXT NOT NULL,
imported_at INTEGER NOT NULL
imported_at INTEGER NOT NULL,
group_id TEXT,
group_avatar TEXT
);
CREATE TABLE IF NOT EXISTS member (
@@ -149,7 +154,8 @@ function createDatabaseWithoutIndexes(sessionId: string): Database.Database {
platform_id TEXT NOT NULL UNIQUE,
account_name TEXT,
group_nickname TEXT,
aliases TEXT DEFAULT '[]'
aliases TEXT DEFAULT '[]',
avatar TEXT
);
CREATE TABLE IF NOT EXISTS member_name_history (
@@ -241,10 +247,10 @@ export async function streamImport(filePath: string, requestId: string): Promise
// 准备语句
const insertMeta = db.prepare(`
INSERT INTO meta (name, platform, type, imported_at) VALUES (?, ?, ?, ?)
INSERT INTO meta (name, platform, type, imported_at, group_id, group_avatar) VALUES (?, ?, ?, ?, ?, ?)
`)
const insertMember = db.prepare(`
INSERT OR IGNORE INTO member (platform_id, account_name, group_nickname) VALUES (?, ?, ?)
INSERT OR IGNORE INTO member (platform_id, account_name, group_nickname, avatar) VALUES (?, ?, ?, ?)
`)
const getMemberId = db.prepare(`SELECT id FROM member WHERE platform_id = ?`)
const insertMessage = db.prepare(`
@@ -347,14 +353,21 @@ export async function streamImport(filePath: string, requestId: string): Promise
onMeta: (meta: ParsedMeta) => {
if (!metaInserted) {
insertMeta.run(meta.name, meta.platform, meta.type, Math.floor(Date.now() / 1000))
insertMeta.run(
meta.name,
meta.platform,
meta.type,
Math.floor(Date.now() / 1000),
meta.groupId || null,
meta.groupAvatar || null
)
metaInserted = true
}
},
onMembers: (members: ParsedMember[]) => {
for (const member of members) {
insertMember.run(member.platformId, member.accountName || null, member.groupNickname || null)
insertMember.run(member.platformId, member.accountName || null, member.groupNickname || null, member.avatar || null)
const row = getMemberId.get(member.platformId) as { id: number } | undefined
if (row) {
memberIdMap.set(member.platformId, row.id)
@@ -387,7 +400,8 @@ export async function streamImport(filePath: string, requestId: string): Promise
// 确保成员存在
let t0 = Date.now()
if (!memberIdMap.has(msg.senderPlatformId)) {
insertMember.run(msg.senderPlatformId, msg.senderAccountName || null, msg.senderGroupNickname || null)
// 消息中没有头像信息,设为 null
insertMember.run(msg.senderPlatformId, msg.senderAccountName || null, msg.senderGroupNickname || null, null)
const row = getMemberId.get(msg.senderPlatformId) as { id: number } | undefined
if (row) {
memberIdMap.set(msg.senderPlatformId, row.id)
@@ -683,9 +697,9 @@ export async function streamParseFileInfo(filePath: string, requestId: string):
const db = createTempDatabase(tempDbPath)
// 准备语句
const insertMeta = db.prepare('INSERT INTO meta (name, platform, type) VALUES (?, ?, ?)')
const insertMeta = db.prepare('INSERT INTO meta (name, platform, type, group_id, group_avatar) VALUES (?, ?, ?, ?, ?)')
const insertMember = db.prepare(
'INSERT OR IGNORE INTO member (platform_id, account_name, group_nickname) VALUES (?, ?, ?)'
'INSERT OR IGNORE INTO member (platform_id, account_name, group_nickname, avatar) VALUES (?, ?, ?, ?)'
)
const insertMessage = db.prepare(`
INSERT INTO message (sender_platform_id, sender_account_name, sender_group_nickname, timestamp, type, content)
@@ -712,7 +726,13 @@ export async function streamParseFileInfo(filePath: string, requestId: string):
onMeta: (parsedMeta) => {
meta = parsedMeta
if (!metaInserted) {
insertMeta.run(parsedMeta.name, parsedMeta.platform, parsedMeta.type)
insertMeta.run(
parsedMeta.name,
parsedMeta.platform,
parsedMeta.type,
parsedMeta.groupId || null,
parsedMeta.groupAvatar || null
)
metaInserted = true
}
},
@@ -721,7 +741,7 @@ export async function streamParseFileInfo(filePath: string, requestId: string):
for (const m of parsedMembers) {
if (!memberSet.has(m.platformId)) {
memberSet.add(m.platformId)
insertMember.run(m.platformId, m.accountName || null, m.groupNickname || null)
insertMember.run(m.platformId, m.accountName || null, m.groupNickname || null, m.avatar || null)
}
}
},
@@ -731,7 +751,8 @@ export async function streamParseFileInfo(filePath: string, requestId: string):
// 确保成员存在
if (!memberSet.has(msg.senderPlatformId)) {
memberSet.add(msg.senderPlatformId)
insertMember.run(msg.senderPlatformId, msg.senderAccountName || null, msg.senderGroupNickname || null)
// 消息中没有头像信息,设为 null
insertMember.run(msg.senderPlatformId, msg.senderAccountName || null, msg.senderGroupNickname || null, null)
}
insertMessage.run(
+54 -2
View File
@@ -69,6 +69,7 @@ export function getMemberActivity(sessionId: string, filter?: TimeFilter): any[]
m.id as memberId,
m.platform_id as platformId,
COALESCE(m.group_nickname, m.account_name, m.platform_id) as name,
m.avatar as avatar,
COUNT(msg.id) as messageCount
FROM member m
LEFT JOIN message msg ON m.id = msg.sender_id ${msgFilterWithSystem}
@@ -82,6 +83,7 @@ export function getMemberActivity(sessionId: string, filter?: TimeFilter): any[]
memberId: number
platformId: string
name: string
avatar: string | null
messageCount: number
}>
@@ -89,6 +91,7 @@ export function getMemberActivity(sessionId: string, filter?: TimeFilter): any[]
memberId: row.memberId,
platformId: row.platformId,
name: row.name,
avatar: row.avatar,
messageCount: row.messageCount,
percentage: totalMessages > 0 ? Math.round((row.messageCount / totalMessages) * 10000) / 100 : 0,
}))
@@ -313,6 +316,8 @@ interface DbMeta {
platform: string
type: string
imported_at: number
group_id: string | null
group_avatar: string | null
}
/**
@@ -367,6 +372,8 @@ export function getAllSessions(): any[] {
messageCount,
memberCount,
dbPath,
groupId: meta.group_id || null,
groupAvatar: meta.group_avatar || null,
})
}
@@ -419,6 +426,8 @@ export function getSession(sessionId: string): any | null {
messageCount,
memberCount,
dbPath: getDbPath(sessionId),
groupId: meta.group_id || null,
groupAvatar: meta.group_avatar || null,
}
}
@@ -434,10 +443,13 @@ interface MemberWithStats {
groupNickname: string | null
aliases: string[]
messageCount: number
avatar: string | null
}
// 用于标记已检查过 aliases 字段的会话
const aliasesCheckedSessions = new Set<string>()
// 用于标记已检查过 avatar 字段的会话
const avatarCheckedSessions = new Set<string>()
/**
* 确保 member 表有 aliases 字段(数据库迁移)
@@ -476,11 +488,48 @@ function ensureAliasesColumn(sessionId: string): void {
}
/**
* 获取所有成员列表(含消息数和别名
* 确保 member 表有 avatar 字段(数据库迁移
* 用于兼容旧数据库
*/
export function ensureAvatarColumn(sessionId: string): void {
// 每个会话只检查一次
if (avatarCheckedSessions.has(sessionId)) return
const dbPath = getDbPath(sessionId)
if (!fs.existsSync(dbPath)) return
// 先关闭可能缓存的只读连接
closeDatabase(sessionId)
// 使用写入模式打开数据库检查并添加字段
const db = new Database(dbPath)
db.pragma('journal_mode = WAL')
try {
// 检查 avatar 字段是否存在
const columns = db.prepare('PRAGMA table_info(member)').all() as Array<{ name: string }>
const hasAvatar = columns.some((col) => col.name === 'avatar')
if (!hasAvatar) {
// 添加 avatar 字段
db.exec('ALTER TABLE member ADD COLUMN avatar TEXT')
console.log(`[Worker] Added avatar column to member table in session ${sessionId}`)
}
// 标记为已检查
avatarCheckedSessions.add(sessionId)
} finally {
db.close()
}
}
/**
* 获取所有成员列表(含消息数、别名和头像)
*/
export function getMembers(sessionId: string): MemberWithStats[] {
// 先确保数据库有 aliases 字段(兼容旧数据库)
// 先确保数据库有 aliases 和 avatar 字段(兼容旧数据库)
ensureAliasesColumn(sessionId)
ensureAvatarColumn(sessionId)
const db = openDatabase(sessionId)
if (!db) return []
@@ -494,6 +543,7 @@ export function getMembers(sessionId: string): MemberWithStats[] {
m.account_name as accountName,
m.group_nickname as groupNickname,
m.aliases,
m.avatar,
COUNT(msg.id) as messageCount
FROM member m
LEFT JOIN message msg ON m.id = msg.sender_id
@@ -508,6 +558,7 @@ export function getMembers(sessionId: string): MemberWithStats[] {
accountName: string | null
groupNickname: string | null
aliases: string | null
avatar: string | null
messageCount: number
}>
@@ -518,6 +569,7 @@ export function getMembers(sessionId: string): MemberWithStats[] {
groupNickname: row.groupNickname,
aliases: row.aliases ? JSON.parse(row.aliases) : [],
messageCount: row.messageCount,
avatar: row.avatar,
}))
}
+29 -1
View File
@@ -5,6 +5,7 @@
*/
import { openDatabase, buildTimeFilter, type TimeFilter } from '../core'
import { ensureAvatarColumn } from './basic'
// ==================== 类型定义 ====================
@@ -16,6 +17,7 @@ export interface MessageResult {
senderName: string
senderPlatformId: string
senderAliases: string[]
senderAvatar: string | null
content: string
timestamp: number
type: number
@@ -40,13 +42,14 @@ export interface MessagesWithTotal {
// ==================== 工具函数 ====================
/**
* 数据库行类型(包含 aliases JSON 字符串)
* 数据库行类型(包含 aliases JSON 字符串和头像
*/
interface DbMessageRow {
id: number
senderName: string
senderPlatformId: string
aliases: string | null
avatar: string | null
content: string
timestamp: number
type: number
@@ -72,6 +75,7 @@ function sanitizeMessageRow(row: DbMessageRow): MessageResult {
senderName: String(row.senderName || ''),
senderPlatformId: String(row.senderPlatformId || ''),
senderAliases: aliases,
senderAvatar: row.avatar || null,
content: row.content != null ? String(row.content) : '',
timestamp: Number(row.timestamp),
type: Number(row.type),
@@ -119,6 +123,9 @@ export function getRecentMessages(
filter?: TimeFilter,
limit: number = 100
): MessagesWithTotal {
// 确保数据库有 avatar 字段(兼容旧数据库)
ensureAvatarColumn(sessionId)
const db = openDatabase(sessionId)
if (!db) return { messages: [], total: 0 }
@@ -146,6 +153,7 @@ export function getRecentMessages(
COALESCE(m.group_nickname, m.account_name, m.platform_id) as senderName,
m.platform_id as senderPlatformId,
m.aliases,
m.avatar,
msg.content,
msg.ts as timestamp,
msg.type
@@ -185,6 +193,9 @@ export function searchMessages(
offset: number = 0,
senderId?: number
): MessagesWithTotal {
// 确保数据库有 avatar 字段(兼容旧数据库)
ensureAvatarColumn(sessionId)
const db = openDatabase(sessionId)
if (!db) return { messages: [], total: 0 }
@@ -223,6 +234,7 @@ export function searchMessages(
COALESCE(m.group_nickname, m.account_name, m.platform_id) as senderName,
m.platform_id as senderPlatformId,
m.aliases,
m.avatar,
msg.content,
msg.ts as timestamp,
msg.type
@@ -257,6 +269,9 @@ export function getMessageContext(
messageIds: number | number[],
contextSize: number = 20
): MessageResult[] {
// 确保数据库有 avatar 字段(兼容旧数据库)
ensureAvatarColumn(sessionId)
const db = openDatabase(sessionId)
if (!db) return []
@@ -305,6 +320,7 @@ export function getMessageContext(
COALESCE(m.group_nickname, m.account_name, m.platform_id) as senderName,
m.platform_id as senderPlatformId,
m.aliases,
m.avatar,
msg.content,
msg.ts as timestamp,
msg.type
@@ -336,6 +352,9 @@ export function getMessagesBefore(
senderId?: number,
keywords?: string[]
): PaginatedMessages {
// 确保数据库有 avatar 字段(兼容旧数据库)
ensureAvatarColumn(sessionId)
const db = openDatabase(sessionId)
if (!db) return { messages: [], hasMore: false }
@@ -355,6 +374,7 @@ export function getMessagesBefore(
COALESCE(m.group_nickname, m.account_name, m.platform_id) as senderName,
m.platform_id as senderPlatformId,
m.aliases,
m.avatar,
msg.content,
msg.ts as timestamp,
msg.type
@@ -398,6 +418,9 @@ export function getMessagesAfter(
senderId?: number,
keywords?: string[]
): PaginatedMessages {
// 确保数据库有 avatar 字段(兼容旧数据库)
ensureAvatarColumn(sessionId)
const db = openDatabase(sessionId)
if (!db) return { messages: [], hasMore: false }
@@ -417,6 +440,7 @@ export function getMessagesAfter(
COALESCE(m.group_nickname, m.account_name, m.platform_id) as senderName,
m.platform_id as senderPlatformId,
m.aliases,
m.avatar,
msg.content,
msg.ts as timestamp,
msg.type
@@ -458,6 +482,9 @@ export function getConversationBetween(
filter?: TimeFilter,
limit: number = 100
): MessagesWithTotal & { member1Name: string; member2Name: string } {
// 确保数据库有 avatar 字段(兼容旧数据库)
ensureAvatarColumn(sessionId)
const db = openDatabase(sessionId)
if (!db) return { messages: [], total: 0, member1Name: '', member2Name: '' }
@@ -499,6 +526,7 @@ export function getConversationBetween(
COALESCE(m.group_nickname, m.account_name, m.platform_id) as senderName,
m.platform_id as senderPlatformId,
m.aliases,
m.avatar,
msg.content,
msg.ts as timestamp,
msg.type