mirror of
https://mirror.skon.top/github.com/ILoveBingLu/CipherTalk
synced 2026-05-01 06:15:23 +08:00
feat: 新增单库解密功能并支持数据库批量解密
在dataManagementService中实现decryptSingleDatabase方法,支持单个数据库文件解密 增强httpApiService服务,新增消息查询接口端点,支持多条件筛选与分页查询 在ChatPage中新增微信号复制 UI 组件,并优化消息加载逻辑 在DataManagementPage中新增数据库选择与批量解密能力,支持用户勾选多个数据库解密 更新ChatPage.scss和DataManagementPage.scss样式文件,适配新增 UI 元素 扩展 Electron API 类型定义,新增数据库解密与消息检索相关方法
This commit is contained in:
@@ -1638,6 +1638,10 @@ function registerIpcHandlers() {
|
||||
return dataManagementService.decryptAll()
|
||||
})
|
||||
|
||||
ipcMain.handle('dataManagement:decryptSingleDatabase', async (_, filePath: string) => {
|
||||
return dataManagementService.decryptSingleDatabase(filePath)
|
||||
})
|
||||
|
||||
ipcMain.handle('dataManagement:incrementalUpdate', async () => {
|
||||
return dataManagementService.incrementalUpdate()
|
||||
})
|
||||
@@ -1955,6 +1959,27 @@ function registerIpcHandlers() {
|
||||
return result
|
||||
})
|
||||
|
||||
ipcMain.handle('chat:getMessagesBefore', async (
|
||||
_,
|
||||
sessionId: string,
|
||||
cursorSortSeq: number,
|
||||
limit?: number,
|
||||
cursorCreateTime?: number,
|
||||
cursorLocalId?: number
|
||||
) => {
|
||||
const result = await chatService.getMessagesBefore(sessionId, cursorSortSeq, limit, cursorCreateTime, cursorLocalId)
|
||||
if (!result.success) {
|
||||
logService?.warn('Chat', '按游标获取更早消息失败', {
|
||||
sessionId,
|
||||
cursorSortSeq,
|
||||
cursorCreateTime,
|
||||
cursorLocalId,
|
||||
error: result.error
|
||||
})
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
ipcMain.handle('chat:getAllVoiceMessages', async (_, sessionId: string) => {
|
||||
const result = await chatService.getAllVoiceMessages(sessionId)
|
||||
|
||||
@@ -2022,6 +2047,19 @@ function registerIpcHandlers() {
|
||||
return result
|
||||
})
|
||||
|
||||
ipcMain.handle('chat:resolveEmojiPath', async (_, md5?: string, cdnUrl?: string, productId?: string, createTime?: number, encryptUrl?: string, aesKey?: string) => {
|
||||
const result = await chatService.downloadEmoji(cdnUrl || '', md5, productId, createTime, encryptUrl, aesKey)
|
||||
if (!result.success) {
|
||||
logService?.warn('Chat', '解析表情缓存路径失败', { md5, cdnUrl, error: result.error })
|
||||
return result
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
cachePath: result.cachePath,
|
||||
localPath: result.localPath
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('chat:close', async () => {
|
||||
logService?.info('Chat', '关闭聊天服务')
|
||||
chatService.close()
|
||||
|
||||
@@ -161,6 +161,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
dataManagement: {
|
||||
scanDatabases: () => ipcRenderer.invoke('dataManagement:scanDatabases'),
|
||||
decryptAll: () => ipcRenderer.invoke('dataManagement:decryptAll'),
|
||||
decryptSingleDatabase: (filePath: string) => ipcRenderer.invoke('dataManagement:decryptSingleDatabase', filePath),
|
||||
incrementalUpdate: () => ipcRenderer.invoke('dataManagement:incrementalUpdate'),
|
||||
getCurrentCachePath: () => ipcRenderer.invoke('dataManagement:getCurrentCachePath'),
|
||||
getDefaultCachePath: () => ipcRenderer.invoke('dataManagement:getDefaultCachePath'),
|
||||
@@ -239,6 +240,14 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
getContacts: () => ipcRenderer.invoke('chat:getContacts'),
|
||||
getMessages: (sessionId: string, offset?: number, limit?: number) =>
|
||||
ipcRenderer.invoke('chat:getMessages', sessionId, offset, limit),
|
||||
getMessagesBefore: (
|
||||
sessionId: string,
|
||||
cursorSortSeq: number,
|
||||
limit?: number,
|
||||
cursorCreateTime?: number,
|
||||
cursorLocalId?: number
|
||||
) =>
|
||||
ipcRenderer.invoke('chat:getMessagesBefore', sessionId, cursorSortSeq, limit, cursorCreateTime, cursorLocalId),
|
||||
getAllVoiceMessages: (sessionId: string) =>
|
||||
ipcRenderer.invoke('chat:getAllVoiceMessages', sessionId),
|
||||
getAllImageMessages: (sessionId: string) =>
|
||||
|
||||
@@ -1285,6 +1285,278 @@ class ChatService extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于 sortSeq 游标,获取更早的消息(严格小于 cursorSortSeq)
|
||||
*/
|
||||
async getMessagesBefore(
|
||||
sessionId: string,
|
||||
cursorSortSeq: number,
|
||||
limit: number = 50,
|
||||
cursorCreateTime?: number,
|
||||
cursorLocalId?: number
|
||||
): Promise<{ success: boolean; messages?: Message[]; hasMore?: boolean; error?: string }> {
|
||||
try {
|
||||
if (!this.dbDir) {
|
||||
const connectResult = await this.connect()
|
||||
if (!connectResult.success) {
|
||||
return { success: false, error: connectResult.error || '数据库未连接' }
|
||||
}
|
||||
}
|
||||
|
||||
const myWxid = this.configService.get('myWxid')
|
||||
const cleanedMyWxid = myWxid ? this.cleanAccountDirName(myWxid) : ''
|
||||
|
||||
const dbTablePairs = this.findSessionTables(sessionId)
|
||||
if (dbTablePairs.length === 0) {
|
||||
return { success: false, error: '未找到该会话的消息表' }
|
||||
}
|
||||
|
||||
let allMessages: Message[] = []
|
||||
const fetchLimitPerDb = Math.max(limit + 1, 50)
|
||||
const effectiveCursorCreateTime = cursorCreateTime ?? Number.MAX_SAFE_INTEGER
|
||||
const effectiveCursorLocalId = cursorLocalId ?? Number.MAX_SAFE_INTEGER
|
||||
|
||||
for (const { db, tableName, dbPath } of dbTablePairs) {
|
||||
try {
|
||||
const hasName2IdTable = this.checkTableExists(db, 'Name2Id')
|
||||
|
||||
let myRowId: number | null = null
|
||||
if (myWxid && hasName2IdTable) {
|
||||
const cacheKeyOriginal = `${dbPath}:${myWxid}`
|
||||
const cachedRowIdOriginal = this.myRowIdCache.get(cacheKeyOriginal)
|
||||
|
||||
if (cachedRowIdOriginal !== undefined) {
|
||||
myRowId = cachedRowIdOriginal
|
||||
} else {
|
||||
const row = db.prepare('SELECT rowid FROM Name2Id WHERE user_name = ?').get(myWxid) as any
|
||||
if (row?.rowid) {
|
||||
myRowId = row.rowid
|
||||
this.myRowIdCache.set(cacheKeyOriginal, myRowId)
|
||||
} else if (cleanedMyWxid && cleanedMyWxid !== myWxid) {
|
||||
const cacheKeyCleaned = `${dbPath}:${cleanedMyWxid}`
|
||||
const cachedRowIdCleaned = this.myRowIdCache.get(cacheKeyCleaned)
|
||||
|
||||
if (cachedRowIdCleaned !== undefined) {
|
||||
myRowId = cachedRowIdCleaned
|
||||
} else {
|
||||
const row2 = db.prepare('SELECT rowid FROM Name2Id WHERE user_name = ?').get(cleanedMyWxid) as any
|
||||
myRowId = row2?.rowid ?? null
|
||||
this.myRowIdCache.set(cacheKeyCleaned, myRowId)
|
||||
}
|
||||
} else {
|
||||
this.myRowIdCache.set(cacheKeyOriginal, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let sql: string
|
||||
let rows: any[]
|
||||
|
||||
if (hasName2IdTable && myRowId !== null) {
|
||||
sql = `SELECT m.*,
|
||||
CASE WHEN m.real_sender_id = ? THEN 1 ELSE 0 END AS computed_is_send,
|
||||
n.user_name AS sender_username
|
||||
FROM ${tableName} m
|
||||
LEFT JOIN Name2Id n ON m.real_sender_id = n.rowid
|
||||
WHERE (
|
||||
m.sort_seq < ?
|
||||
OR (m.sort_seq = ? AND m.create_time < ?)
|
||||
OR (m.sort_seq = ? AND m.create_time = ? AND m.local_id < ?)
|
||||
)
|
||||
ORDER BY m.sort_seq DESC
|
||||
LIMIT ?`
|
||||
rows = db.prepare(sql).all(
|
||||
myRowId,
|
||||
cursorSortSeq,
|
||||
cursorSortSeq,
|
||||
effectiveCursorCreateTime,
|
||||
cursorSortSeq,
|
||||
effectiveCursorCreateTime,
|
||||
effectiveCursorLocalId,
|
||||
fetchLimitPerDb
|
||||
) as any[]
|
||||
} else if (hasName2IdTable) {
|
||||
sql = `SELECT m.*, n.user_name AS sender_username
|
||||
FROM ${tableName} m
|
||||
LEFT JOIN Name2Id n ON m.real_sender_id = n.rowid
|
||||
WHERE (
|
||||
m.sort_seq < ?
|
||||
OR (m.sort_seq = ? AND m.create_time < ?)
|
||||
OR (m.sort_seq = ? AND m.create_time = ? AND m.local_id < ?)
|
||||
)
|
||||
ORDER BY m.sort_seq DESC
|
||||
LIMIT ?`
|
||||
rows = db.prepare(sql).all(
|
||||
cursorSortSeq,
|
||||
cursorSortSeq,
|
||||
effectiveCursorCreateTime,
|
||||
cursorSortSeq,
|
||||
effectiveCursorCreateTime,
|
||||
effectiveCursorLocalId,
|
||||
fetchLimitPerDb
|
||||
) as any[]
|
||||
} else {
|
||||
sql = `SELECT * FROM ${tableName}
|
||||
WHERE (
|
||||
sort_seq < ?
|
||||
OR (sort_seq = ? AND create_time < ?)
|
||||
OR (sort_seq = ? AND create_time = ? AND local_id < ?)
|
||||
)
|
||||
ORDER BY sort_seq DESC
|
||||
LIMIT ?`
|
||||
rows = db.prepare(sql).all(
|
||||
cursorSortSeq,
|
||||
cursorSortSeq,
|
||||
effectiveCursorCreateTime,
|
||||
cursorSortSeq,
|
||||
effectiveCursorCreateTime,
|
||||
effectiveCursorLocalId,
|
||||
fetchLimitPerDb
|
||||
) as any[]
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
const content = this.decodeMessageContent(row.message_content, row.compress_content)
|
||||
const localType = row.local_type || row.type || 1
|
||||
const isSend = row.computed_is_send ?? row.is_send ?? null
|
||||
|
||||
let emojiCdnUrl: string | undefined
|
||||
let emojiMd5: string | undefined
|
||||
let emojiProductId: string | undefined
|
||||
let quotedContent: string | undefined
|
||||
let quotedSender: string | undefined
|
||||
let quotedImageMd5: string | undefined
|
||||
let quotedEmojiMd5: string | undefined
|
||||
let quotedEmojiCdnUrl: string | undefined
|
||||
let imageMd5: string | undefined
|
||||
let imageDatName: string | undefined
|
||||
let isLivePhoto: boolean | undefined
|
||||
let videoMd5: string | undefined
|
||||
let videoDuration: number | undefined
|
||||
let voiceDuration: number | undefined
|
||||
|
||||
if (localType === 47 && content) {
|
||||
const emojiInfo = this.parseEmojiInfo(content)
|
||||
emojiCdnUrl = emojiInfo.cdnUrl
|
||||
emojiMd5 = emojiInfo.md5
|
||||
emojiProductId = emojiInfo.productId
|
||||
} else if (localType === 3 && content) {
|
||||
const imageInfo = this.parseImageInfo(content)
|
||||
imageMd5 = imageInfo.md5
|
||||
imageDatName = this.parseImageDatNameFromRow(row)
|
||||
isLivePhoto = imageInfo.isLivePhoto
|
||||
} else if (localType === 43 && content) {
|
||||
videoMd5 = this.parseVideoMd5(content)
|
||||
videoDuration = this.parseVideoDuration(content)
|
||||
} else if (localType === 34 && content) {
|
||||
voiceDuration = this.parseVoiceDuration(content)
|
||||
} else if (localType === 244813135921 || (content && content.includes('<type>57</type>'))) {
|
||||
const quoteInfo = this.parseQuoteMessage(content)
|
||||
quotedContent = quoteInfo.content
|
||||
quotedSender = quoteInfo.sender
|
||||
quotedImageMd5 = quoteInfo.imageMd5
|
||||
quotedEmojiMd5 = quoteInfo.emojiMd5
|
||||
quotedEmojiCdnUrl = quoteInfo.emojiCdnUrl
|
||||
}
|
||||
|
||||
let fileName: string | undefined
|
||||
let fileSize: number | undefined
|
||||
let fileExt: string | undefined
|
||||
let fileMd5: string | undefined
|
||||
if (localType === 49 && content) {
|
||||
const fileInfo = this.parseFileInfo(content)
|
||||
fileName = fileInfo.fileName
|
||||
fileSize = fileInfo.fileSize
|
||||
fileExt = fileInfo.fileExt
|
||||
fileMd5 = fileInfo.fileMd5
|
||||
}
|
||||
|
||||
let chatRecordList: ChatRecordItem[] | undefined
|
||||
if (content) {
|
||||
const xmlType = this.extractXmlValue(content, 'type')
|
||||
if (xmlType === '19' || localType === 49) {
|
||||
chatRecordList = this.parseChatHistory(content)
|
||||
}
|
||||
}
|
||||
|
||||
let transferPayerUsername: string | undefined
|
||||
let transferReceiverUsername: string | undefined
|
||||
if ((localType === 49 || localType === 8589934592049) && content) {
|
||||
const xmlType = this.extractXmlValue(content, 'type')
|
||||
if (xmlType === '2000') {
|
||||
transferPayerUsername = this.extractXmlValue(content, 'payer_username') || undefined
|
||||
transferReceiverUsername = this.extractXmlValue(content, 'receiver_username') || undefined
|
||||
}
|
||||
}
|
||||
|
||||
const parsedContent = this.parseMessageContent(content, localType)
|
||||
|
||||
allMessages.push({
|
||||
localId: row.local_id || 0,
|
||||
serverId: row.server_id || 0,
|
||||
localType,
|
||||
createTime: row.create_time || 0,
|
||||
sortSeq: row.sort_seq || 0,
|
||||
isSend,
|
||||
senderUsername: row.sender_username || null,
|
||||
parsedContent,
|
||||
rawContent: content,
|
||||
emojiCdnUrl,
|
||||
emojiMd5,
|
||||
productId: emojiProductId,
|
||||
quotedContent,
|
||||
quotedSender,
|
||||
quotedImageMd5,
|
||||
quotedEmojiMd5,
|
||||
quotedEmojiCdnUrl,
|
||||
imageMd5,
|
||||
imageDatName,
|
||||
isLivePhoto,
|
||||
videoMd5,
|
||||
videoDuration,
|
||||
voiceDuration,
|
||||
fileName,
|
||||
fileSize,
|
||||
fileExt,
|
||||
fileMd5,
|
||||
chatRecordList,
|
||||
transferPayerUsername,
|
||||
transferReceiverUsername
|
||||
})
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e?.code === 'SQLITE_CORRUPT' || e?.message?.includes('malformed')) {
|
||||
console.error(`[ChatService] 数据库损坏: ${dbPath}`, e)
|
||||
this.messageDbCache.delete(dbPath)
|
||||
try { db.close() } catch { }
|
||||
this.refreshMessageDbCache()
|
||||
} else {
|
||||
console.error('ChatService: 查询更早消息失败:', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
allMessages.sort((a, b) => b.sortSeq - a.sortSeq)
|
||||
|
||||
const seen = new Set<string>()
|
||||
allMessages = allMessages.filter(msg => {
|
||||
const key = `${msg.serverId}-${msg.localId}-${msg.createTime}-${msg.sortSeq}`
|
||||
if (seen.has(key)) return false
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
|
||||
const hasMore = allMessages.length > limit
|
||||
const messages = allMessages.slice(0, limit)
|
||||
messages.reverse()
|
||||
|
||||
return { success: true, messages, hasMore }
|
||||
} catch (e) {
|
||||
console.error('ChatService: 获取更早消息失败:', e)
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话的所有语音消息(用于批量转写)
|
||||
* 复用 getMessages 的查询逻辑,只查询语音消息类型
|
||||
@@ -3487,7 +3759,7 @@ class ChatService extends EventEmitter {
|
||||
* 下载或获取表情包本地缓存
|
||||
* 如果 cdnUrl 为空但 md5 存在,则尝试通过本地存储或多种拼接规则下载
|
||||
*/
|
||||
async downloadEmoji(cdnUrl: string, md5?: string, productId?: string, createTime?: number, encryptUrl?: string, aesKey?: string): Promise<{ success: boolean; localPath?: string; error?: string }> {
|
||||
async downloadEmoji(cdnUrl: string, md5?: string, productId?: string, createTime?: number, encryptUrl?: string, aesKey?: string): Promise<{ success: boolean; localPath?: string; cachePath?: string; error?: string }> {
|
||||
// 如果没有 cdnUrl 也没有 md5,无法处理
|
||||
if (!cdnUrl && !md5) {
|
||||
return { success: false, error: '无效的 CDN URL 和 MD5' }
|
||||
@@ -3501,7 +3773,7 @@ class ChatService extends EventEmitter {
|
||||
if (cached && fs.existsSync(cached)) {
|
||||
const dataUrl = this.fileToDataUrl(cached)
|
||||
if (dataUrl) {
|
||||
return { success: true, localPath: dataUrl }
|
||||
return { success: true, localPath: dataUrl, cachePath: cached }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3512,7 +3784,7 @@ class ChatService extends EventEmitter {
|
||||
if (result) {
|
||||
const dataUrl = this.fileToDataUrl(result)
|
||||
if (dataUrl) {
|
||||
return { success: true, localPath: dataUrl }
|
||||
return { success: true, localPath: dataUrl, cachePath: result }
|
||||
}
|
||||
}
|
||||
return { success: false, error: '下载失败' }
|
||||
@@ -3532,7 +3804,7 @@ class ChatService extends EventEmitter {
|
||||
emojiCache.set(cacheKey, filePath)
|
||||
const dataUrl = this.fileToDataUrl(filePath)
|
||||
if (dataUrl) {
|
||||
return { success: true, localPath: dataUrl }
|
||||
return { success: true, localPath: dataUrl, cachePath: filePath }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3656,7 +3928,7 @@ class ChatService extends EventEmitter {
|
||||
const dataUrl = this.fileToDataUrl(localFile)
|
||||
if (dataUrl) {
|
||||
emojiCache.set(cacheKey, localFile)
|
||||
return { success: true, localPath: dataUrl }
|
||||
return { success: true, localPath: dataUrl, cachePath: localFile }
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -3678,7 +3950,7 @@ class ChatService extends EventEmitter {
|
||||
const dataUrl = this.fileToDataUrl(localPath)
|
||||
if (dataUrl) {
|
||||
emojiCache.set(cacheKey, localPath)
|
||||
return { success: true, localPath: dataUrl }
|
||||
return { success: true, localPath: dataUrl, cachePath: localPath }
|
||||
}
|
||||
}
|
||||
} catch (e) { }
|
||||
@@ -3697,7 +3969,7 @@ class ChatService extends EventEmitter {
|
||||
if (localPath) {
|
||||
emojiCache.set(cacheKey, localPath)
|
||||
const dataUrl = this.fileToDataUrl(localPath)
|
||||
if (dataUrl) return { success: true, localPath: dataUrl }
|
||||
if (dataUrl) return { success: true, localPath: dataUrl, cachePath: localPath }
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略下载失败
|
||||
@@ -3714,7 +3986,7 @@ class ChatService extends EventEmitter {
|
||||
emojiCache.set(cacheKey, localPath)
|
||||
const dataUrl = this.fileToDataUrl(localPath)
|
||||
if (dataUrl) {
|
||||
return { success: true, localPath: dataUrl }
|
||||
return { success: true, localPath: dataUrl, cachePath: localPath }
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -3740,7 +4012,7 @@ class ChatService extends EventEmitter {
|
||||
try { fs.unlinkSync(encLocalPath) } catch { }
|
||||
emojiCache.set(cacheKey, outputPath)
|
||||
const dataUrl = this.fileToDataUrl(outputPath)
|
||||
if (dataUrl) return { success: true, localPath: dataUrl }
|
||||
if (dataUrl) return { success: true, localPath: dataUrl, cachePath: outputPath }
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[ChatService] encryptUrl fallback 失败:', e)
|
||||
|
||||
@@ -366,6 +366,47 @@ class DataManagementService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解密单个数据库
|
||||
*/
|
||||
async decryptSingleDatabase(filePath: string): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const key = this.configService.get('decryptKey')
|
||||
if (!key) {
|
||||
return { success: false, error: '请先在设置页面配置解密密钥' }
|
||||
}
|
||||
|
||||
const scanResult = await this.scanDatabases()
|
||||
if (!scanResult.success || !scanResult.databases) {
|
||||
return { success: false, error: '扫描数据库失败' }
|
||||
}
|
||||
|
||||
const dbFile = scanResult.databases.find(db => db.filePath === filePath)
|
||||
if (!dbFile) {
|
||||
return { success: false, error: '未找到指定的数据库文件' }
|
||||
}
|
||||
|
||||
const outputDir = path.dirname(dbFile.decryptedPath!)
|
||||
if (!fs.existsSync(outputDir)) {
|
||||
fs.mkdirSync(outputDir, { recursive: true })
|
||||
}
|
||||
|
||||
const result = await wechatDecryptService.decryptDatabase(
|
||||
dbFile.filePath,
|
||||
dbFile.decryptedPath!,
|
||||
key
|
||||
)
|
||||
|
||||
if (result.success) {
|
||||
chatService.refreshMessageDbCache()
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 增量更新(只更新有变化的文件)
|
||||
*/
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import * as http from 'http'
|
||||
import { URL } from 'url'
|
||||
import { URL, fileURLToPath } from 'url'
|
||||
import { app } from 'electron'
|
||||
import { existsSync, mkdirSync } from 'fs'
|
||||
import { writeFile } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { ConfigService } from './config'
|
||||
import { chatService } from './chatService'
|
||||
import { imageDecryptService } from './imageDecryptService'
|
||||
import { videoService } from './videoService'
|
||||
|
||||
interface ApiEnvelopeSuccess<T> {
|
||||
success: true
|
||||
@@ -139,6 +144,7 @@ class HttpApiService {
|
||||
{ method: 'GET', path: '/v1/health', desc: '健康检查' },
|
||||
{ method: 'GET', path: '/v1/status', desc: '服务状态' },
|
||||
{ method: 'GET', path: '/v1/sessions', desc: '会话列表' },
|
||||
{ method: 'GET', path: '/v1/messages', desc: '会话消息' },
|
||||
{ method: 'GET', path: '/v1/contacts', desc: '联系人列表' }
|
||||
],
|
||||
lastError: this.startError
|
||||
@@ -239,6 +245,164 @@ class HttpApiService {
|
||||
return Math.max(min, Math.min(max, n))
|
||||
}
|
||||
|
||||
private parseNumberList(value: string | null): number[] | null {
|
||||
if (!value) return null
|
||||
const nums = value
|
||||
.split(',')
|
||||
.map((x) => Number.parseInt(x.trim(), 10))
|
||||
.filter((x) => Number.isFinite(x))
|
||||
return nums.length > 0 ? nums : null
|
||||
}
|
||||
|
||||
private parseStringSet(value: string | null): Set<string> | null {
|
||||
if (!value) return null
|
||||
const values = value
|
||||
.split(',')
|
||||
.map((x) => x.trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
return values.length > 0 ? new Set(values) : null
|
||||
}
|
||||
|
||||
private parseFields(value: string | null): Set<string> {
|
||||
const defaults = ['base', 'type', 'time', 'sender', 'metadata', 'media']
|
||||
if (!value || !value.trim()) {
|
||||
return new Set(defaults)
|
||||
}
|
||||
|
||||
const parts = value
|
||||
.split(',')
|
||||
.map((x) => x.trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
|
||||
if (parts.includes('all')) {
|
||||
return new Set([
|
||||
'all',
|
||||
'base',
|
||||
'type',
|
||||
'time',
|
||||
'sender',
|
||||
'metadata',
|
||||
'media',
|
||||
'quote',
|
||||
'file',
|
||||
'transfer',
|
||||
'chatrecord',
|
||||
'raw',
|
||||
'schema'
|
||||
])
|
||||
}
|
||||
|
||||
return new Set(parts)
|
||||
}
|
||||
|
||||
private parseTimestampMs(value: string | null): number | null {
|
||||
if (!value) return null
|
||||
const raw = Number.parseInt(value, 10)
|
||||
if (!Number.isFinite(raw) || raw <= 0) return null
|
||||
return raw < 1_000_000_000_000 ? raw * 1000 : raw
|
||||
}
|
||||
|
||||
private normalizeTimestampMs(value: number): number {
|
||||
if (!Number.isFinite(value) || value <= 0) return 0
|
||||
return value < 1_000_000_000_000 ? value * 1000 : value
|
||||
}
|
||||
|
||||
private extractXmlType(content?: string): string | undefined {
|
||||
if (!content) return undefined
|
||||
const match = content.match(/<type>\s*([^<]+)\s*<\/type>/i)
|
||||
return match?.[1]?.trim()
|
||||
}
|
||||
|
||||
private fileUrlToPathMaybe(input?: string | null): string | null {
|
||||
if (!input) return null
|
||||
if (input.startsWith('file:///')) {
|
||||
try {
|
||||
return fileURLToPath(input)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private sanitizePathPart(value: string): string {
|
||||
return value.replace(/[\\/:*?"<>|]/g, '_')
|
||||
}
|
||||
|
||||
private pruneEmpty(value: any): any {
|
||||
if (value === null || value === undefined) return undefined
|
||||
if (typeof value === 'string') return value === '' ? undefined : value
|
||||
if (Array.isArray(value)) {
|
||||
const next = value
|
||||
.map((v) => this.pruneEmpty(v))
|
||||
.filter((v) => v !== undefined)
|
||||
return next.length > 0 ? next : undefined
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
const out: Record<string, any> = {}
|
||||
for (const [k, v] of Object.entries(value)) {
|
||||
const pruned = this.pruneEmpty(v)
|
||||
if (pruned !== undefined) out[k] = pruned
|
||||
}
|
||||
return Object.keys(out).length > 0 ? out : undefined
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
private detectMessageKind(message: Record<string, any>): {
|
||||
messageKind: string
|
||||
typeLabel: string
|
||||
appMsgType?: string
|
||||
} {
|
||||
const localType = Number(message.localType || 0)
|
||||
const raw = String(message.rawContent || message.parsedContent || '')
|
||||
const appMsgType = this.extractXmlType(raw)
|
||||
|
||||
if (localType === 1) return { messageKind: 'text', typeLabel: '文本' }
|
||||
if (localType === 3) return { messageKind: 'image', typeLabel: '图片' }
|
||||
if (localType === 34) return { messageKind: 'voice', typeLabel: '语音' }
|
||||
if (localType === 42) return { messageKind: 'contact_card', typeLabel: '名片' }
|
||||
if (localType === 43) return { messageKind: 'video', typeLabel: '视频' }
|
||||
if (localType === 47) return { messageKind: 'emoji', typeLabel: '表情' }
|
||||
if (localType === 48) return { messageKind: 'location', typeLabel: '位置' }
|
||||
if (localType === 50) return { messageKind: 'voip', typeLabel: '音视频通话' }
|
||||
if (localType === 10000) return { messageKind: 'system', typeLabel: '系统消息' }
|
||||
if (localType === 244813135921) return { messageKind: 'quote', typeLabel: '引用消息' }
|
||||
|
||||
if (localType === 49 || appMsgType) {
|
||||
switch (appMsgType) {
|
||||
case '3':
|
||||
return { messageKind: 'app_music', typeLabel: '音乐分享', appMsgType }
|
||||
case '5':
|
||||
case '49':
|
||||
return { messageKind: 'app_link', typeLabel: '链接', appMsgType }
|
||||
case '6':
|
||||
return { messageKind: 'app_file', typeLabel: '文件', appMsgType }
|
||||
case '19':
|
||||
return { messageKind: 'app_chat_record', typeLabel: '聊天记录', appMsgType }
|
||||
case '33':
|
||||
case '36':
|
||||
return { messageKind: 'app_mini_program', typeLabel: '小程序', appMsgType }
|
||||
case '57':
|
||||
return { messageKind: 'app_quote', typeLabel: '引用消息', appMsgType }
|
||||
case '62':
|
||||
return { messageKind: 'app_pat', typeLabel: '拍一拍', appMsgType }
|
||||
case '87':
|
||||
return { messageKind: 'app_announcement', typeLabel: '群公告', appMsgType }
|
||||
case '115':
|
||||
return { messageKind: 'app_gift', typeLabel: '微信礼物', appMsgType }
|
||||
case '2000':
|
||||
return { messageKind: 'app_transfer', typeLabel: '转账', appMsgType }
|
||||
case '2001':
|
||||
return { messageKind: 'app_red_packet', typeLabel: '红包', appMsgType }
|
||||
default:
|
||||
return { messageKind: 'app', typeLabel: '应用消息', appMsgType }
|
||||
}
|
||||
}
|
||||
|
||||
return { messageKind: 'unknown', typeLabel: `未知类型(${localType})` }
|
||||
}
|
||||
|
||||
private parseTypeFilter(value: string | null): Set<ContactType> | null {
|
||||
if (!value) return null
|
||||
const allowed: ContactType[] = ['friend', 'group', 'official', 'former_friend', 'other']
|
||||
@@ -317,6 +481,10 @@ class HttpApiService {
|
||||
this.sendRedirect(res, '/v1/status')
|
||||
return
|
||||
}
|
||||
if (pathname === '/api/v1/messages') {
|
||||
this.sendRedirect(res, '/v1/messages')
|
||||
return
|
||||
}
|
||||
if (pathname === '/api/v1/sessions') {
|
||||
this.sendRedirect(res, '/v1/sessions')
|
||||
return
|
||||
@@ -537,6 +705,440 @@ class HttpApiService {
|
||||
return
|
||||
}
|
||||
|
||||
if (pathname === '/v1/messages') {
|
||||
const sessionId = (url.searchParams.get('sessionId') || '').trim()
|
||||
if (!sessionId) {
|
||||
this.sendJson(
|
||||
res,
|
||||
400,
|
||||
this.failure(
|
||||
requestId,
|
||||
'BAD_REQUEST',
|
||||
'Missing required parameter: sessionId',
|
||||
'Use query parameter: sessionId=<chat_username>'
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const offset = this.parseIntInRange(url.searchParams.get('offset'), 0, 0, 100000)
|
||||
const limit = this.parseIntInRange(url.searchParams.get('limit'), 50, 1, 200)
|
||||
const sort = (url.searchParams.get('sort') || 'createTime_desc').trim()
|
||||
const keyword = (url.searchParams.get('keyword') || '').trim().toLowerCase()
|
||||
const msgTypeFilter = this.parseNumberList(url.searchParams.get('msgType'))
|
||||
const messageKindFilter = this.parseStringSet(url.searchParams.get('messageKind'))
|
||||
const appMsgTypeFilter = this.parseStringSet(url.searchParams.get('appMsgType'))
|
||||
const startTimeMs = this.parseTimestampMs(url.searchParams.get('startTime'))
|
||||
const endTimeMs = this.parseTimestampMs(url.searchParams.get('endTime'))
|
||||
const includeRaw = this.parseBoolean(url.searchParams.get('includeRaw'), false)
|
||||
const resolveMediaPath = this.parseBoolean(url.searchParams.get('resolveMediaPath'), true)
|
||||
const resolveVoicePath = this.parseBoolean(url.searchParams.get('resolveVoicePath'), false)
|
||||
const adaptive = this.parseBoolean(url.searchParams.get('adaptive'), true)
|
||||
const maxScan = this.parseIntInRange(url.searchParams.get('maxScan'), 5000, 100, 20000)
|
||||
const fields = this.parseFields(url.searchParams.get('fields'))
|
||||
if (includeRaw) fields.add('raw')
|
||||
|
||||
const includeField = (name: string): boolean => fields.has('all') || fields.has(name)
|
||||
|
||||
const needKindForFilter = Boolean(messageKindFilter || appMsgTypeFilter)
|
||||
const needKindForOutput = [
|
||||
includeField('type'),
|
||||
includeField('metadata'),
|
||||
includeField('media')
|
||||
].some(Boolean)
|
||||
|
||||
const shouldResolveMediaPath = includeField('media') && resolveMediaPath
|
||||
const shouldResolveVoicePath = includeField('media') && resolveVoicePath
|
||||
const includeChatRecordItems = includeField('chatrecord')
|
||||
|
||||
let myWxid = ''
|
||||
let dbPath = ''
|
||||
let cachePath = ''
|
||||
if (shouldResolveVoicePath || shouldResolveMediaPath || includeField('file')) {
|
||||
const runtimeConfig = new ConfigService()
|
||||
myWxid = String(runtimeConfig.get('myWxid') || '')
|
||||
dbPath = String(runtimeConfig.get('dbPath') || '')
|
||||
cachePath = String(runtimeConfig.get('cachePath') || '')
|
||||
runtimeConfig.close()
|
||||
}
|
||||
|
||||
const fetchBatchSize = 200
|
||||
const targetCount = offset + limit
|
||||
let scanOffset = 0
|
||||
let scanned = 0
|
||||
let reachedEnd = false
|
||||
const matched: any[] = []
|
||||
|
||||
while (scanned < maxScan && matched.length < targetCount) {
|
||||
const part = await chatService.getMessages(sessionId, scanOffset, fetchBatchSize)
|
||||
if (!part.success) {
|
||||
this.sendJson(
|
||||
res,
|
||||
503,
|
||||
this.failure(
|
||||
requestId,
|
||||
'DB_NOT_CONNECTED',
|
||||
part.error || 'Failed to read messages',
|
||||
'Please complete DB decrypt/setup in Settings and ensure sessionId is correct.'
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const chunk = part.messages || []
|
||||
if (chunk.length === 0) {
|
||||
reachedEnd = true
|
||||
break
|
||||
}
|
||||
|
||||
scanned += chunk.length
|
||||
scanOffset += chunk.length
|
||||
|
||||
for (const msg of chunk) {
|
||||
if (msgTypeFilter && !msgTypeFilter.includes(Number(msg.localType || 0))) continue
|
||||
|
||||
if (needKindForFilter) {
|
||||
const kindInfo = this.detectMessageKind(msg as Record<string, any>)
|
||||
if (messageKindFilter && !messageKindFilter.has(kindInfo.messageKind)) continue
|
||||
if (appMsgTypeFilter) {
|
||||
const appMsgType = (kindInfo.appMsgType || '').toLowerCase()
|
||||
if (!appMsgType || !appMsgTypeFilter.has(appMsgType)) continue
|
||||
}
|
||||
}
|
||||
|
||||
const tMs = this.normalizeTimestampMs(Number(msg.createTime || 0))
|
||||
if (startTimeMs && tMs < startTimeMs) continue
|
||||
if (endTimeMs && tMs > endTimeMs) continue
|
||||
|
||||
if (keyword) {
|
||||
const parsed = String(msg.parsedContent || '').toLowerCase()
|
||||
const raw = String(msg.rawContent || '').toLowerCase()
|
||||
if (!parsed.includes(keyword) && !raw.includes(keyword)) continue
|
||||
}
|
||||
|
||||
matched.push(msg)
|
||||
}
|
||||
|
||||
if (!part.hasMore) {
|
||||
reachedEnd = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (sort === 'createTime_asc') {
|
||||
matched.sort((a, b) => Number(a.createTime || 0) - Number(b.createTime || 0))
|
||||
} else {
|
||||
matched.sort((a, b) => Number(b.createTime || 0) - Number(a.createTime || 0))
|
||||
}
|
||||
|
||||
const page = matched.slice(offset, offset + limit)
|
||||
const hasMore = reachedEnd ? matched.length > offset + page.length : true
|
||||
|
||||
const enrichOne = async (m: any): Promise<Record<string, any>> => {
|
||||
const base = m as Record<string, any>
|
||||
const kind = needKindForOutput ? this.detectMessageKind(base) : { messageKind: 'unknown', typeLabel: '未知类型', appMsgType: undefined }
|
||||
const createTimeMs = this.normalizeTimestampMs(Number(base.createTime || 0))
|
||||
const senderUsername = base.senderUsername || null
|
||||
|
||||
const metadata = {
|
||||
localType: Number(base.localType || 0),
|
||||
messageKind: kind.messageKind,
|
||||
typeLabel: kind.typeLabel,
|
||||
appMsgType: kind.appMsgType || null,
|
||||
direction: Number(base.isSend) === 1 ? 'out' : 'in',
|
||||
isSystem: Number(base.localType || 0) === 10000 || kind.messageKind === 'app_pat',
|
||||
isMedia: ['image', 'voice', 'video', 'emoji'].includes(kind.messageKind),
|
||||
hasRawContent: Boolean(base.rawContent),
|
||||
hasParsedContent: Boolean(base.parsedContent),
|
||||
hasQuote: Boolean(base.quotedContent || base.quotedImageMd5 || base.quotedEmojiMd5),
|
||||
hasFile: Boolean(base.fileName || base.fileMd5),
|
||||
hasTransfer: Boolean(base.transferPayerUsername || base.transferReceiverUsername),
|
||||
hasChatRecord: Array.isArray(base.chatRecordList) && base.chatRecordList.length > 0,
|
||||
isLivePhoto: Boolean(base.isLivePhoto)
|
||||
}
|
||||
|
||||
const media = {
|
||||
imageMd5: base.imageMd5 || null,
|
||||
imageDatName: base.imageDatName || null,
|
||||
imageCachePath: null as string | null,
|
||||
emojiMd5: base.emojiMd5 || null,
|
||||
emojiCdnUrl: base.emojiCdnUrl || null,
|
||||
emojiCachePath: null as string | null,
|
||||
videoMd5: base.videoMd5 || null,
|
||||
videoDuration: base.videoDuration || null,
|
||||
videoCachePath: null as string | null,
|
||||
voiceDuration: base.voiceDuration || null,
|
||||
voiceCachePath: null as string | null
|
||||
}
|
||||
|
||||
if (shouldResolveMediaPath && (kind.messageKind === 'emoji' || kind.messageKind.startsWith('app_')) && (base.emojiMd5 || base.emojiCdnUrl)) {
|
||||
try {
|
||||
const emojiResult = await chatService.downloadEmoji(
|
||||
String(base.emojiCdnUrl || ''),
|
||||
base.emojiMd5,
|
||||
base.productId,
|
||||
Number(base.createTime || 0),
|
||||
base.emojiEncryptUrl,
|
||||
base.emojiAesKey
|
||||
)
|
||||
if (emojiResult.success && emojiResult.cachePath) {
|
||||
media.emojiCachePath = emojiResult.cachePath
|
||||
}
|
||||
} catch {
|
||||
// ignore media path resolve errors for API stability
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldResolveMediaPath && kind.messageKind === 'image' && (base.imageMd5 || base.imageDatName)) {
|
||||
try {
|
||||
const resolved = await imageDecryptService.resolveCachedImage({
|
||||
sessionId,
|
||||
imageMd5: base.imageMd5,
|
||||
imageDatName: base.imageDatName
|
||||
})
|
||||
|
||||
if (resolved.success && resolved.localPath) {
|
||||
media.imageCachePath = this.fileUrlToPathMaybe(resolved.localPath)
|
||||
} else {
|
||||
const decrypted = await imageDecryptService.decryptImage({
|
||||
sessionId,
|
||||
imageMd5: base.imageMd5,
|
||||
imageDatName: base.imageDatName,
|
||||
force: false
|
||||
})
|
||||
if (decrypted.success && decrypted.localPath) {
|
||||
media.imageCachePath = this.fileUrlToPathMaybe(decrypted.localPath)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore image path resolve errors
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldResolveMediaPath && kind.messageKind === 'video' && base.videoMd5) {
|
||||
try {
|
||||
const videoInfo = videoService.getVideoInfo(String(base.videoMd5))
|
||||
if (videoInfo.exists && videoInfo.videoUrl) {
|
||||
media.videoCachePath = this.fileUrlToPathMaybe(videoInfo.videoUrl)
|
||||
}
|
||||
} catch {
|
||||
// ignore video path resolve errors
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldResolveVoicePath && kind.messageKind === 'voice') {
|
||||
try {
|
||||
const voiceResult = await chatService.getVoiceData(
|
||||
sessionId,
|
||||
String(base.localId || ''),
|
||||
Number(base.createTime || 0)
|
||||
)
|
||||
if (voiceResult.success && voiceResult.data) {
|
||||
const baseCacheDir = cachePath || join(process.cwd(), 'cache')
|
||||
const voiceDir = join(baseCacheDir, 'HttpApiVoices', this.sanitizePathPart(sessionId))
|
||||
if (!existsSync(voiceDir)) {
|
||||
mkdirSync(voiceDir, { recursive: true })
|
||||
}
|
||||
const fileName = `${Number(base.createTime || 0)}_${Number(base.localId || 0)}.wav`
|
||||
const absPath = join(voiceDir, fileName)
|
||||
await writeFile(absPath, Buffer.from(voiceResult.data, 'base64'))
|
||||
media.voiceCachePath = absPath
|
||||
}
|
||||
} catch {
|
||||
// ignore voice path resolve errors
|
||||
}
|
||||
}
|
||||
|
||||
const quote = metadata.hasQuote
|
||||
? {
|
||||
content: base.quotedContent || null,
|
||||
sender: base.quotedSender || null,
|
||||
imageMd5: base.quotedImageMd5 || null,
|
||||
emojiMd5: base.quotedEmojiMd5 || null,
|
||||
emojiCdnUrl: base.quotedEmojiCdnUrl || null
|
||||
}
|
||||
: null
|
||||
|
||||
const file = metadata.hasFile
|
||||
? {
|
||||
name: base.fileName || null,
|
||||
size: base.fileSize || null,
|
||||
ext: base.fileExt || null,
|
||||
md5: base.fileMd5 || null,
|
||||
absolutePath: null as string | null,
|
||||
exists: false
|
||||
}
|
||||
: null
|
||||
|
||||
if (shouldResolveMediaPath && file?.name && dbPath && myWxid) {
|
||||
try {
|
||||
const msgDate = createTimeMs ? new Date(createTimeMs) : new Date()
|
||||
const year = msgDate.getFullYear()
|
||||
const month = String(msgDate.getMonth() + 1).padStart(2, '0')
|
||||
const dateFolder = `${year}-${month}`
|
||||
const abs = join(dbPath, myWxid, 'msg', 'file', dateFolder, String(file.name))
|
||||
file.absolutePath = abs
|
||||
file.exists = existsSync(abs)
|
||||
} catch {
|
||||
// ignore file path resolve errors
|
||||
}
|
||||
}
|
||||
|
||||
const transfer = metadata.hasTransfer
|
||||
? {
|
||||
payerUsername: base.transferPayerUsername || null,
|
||||
receiverUsername: base.transferReceiverUsername || null
|
||||
}
|
||||
: null
|
||||
|
||||
const chatRecord = metadata.hasChatRecord
|
||||
? {
|
||||
count: Array.isArray(base.chatRecordList) ? base.chatRecordList.length : 0,
|
||||
items: includeChatRecordItems ? (base.chatRecordList || []) : undefined
|
||||
}
|
||||
: null
|
||||
|
||||
const out: Record<string, any> = {}
|
||||
|
||||
if (includeField('base')) {
|
||||
out.localId = base.localId || 0
|
||||
out.serverId = base.serverId || 0
|
||||
out.localType = Number(base.localType || 0)
|
||||
out.createTime = Number(base.createTime || 0)
|
||||
out.sortSeq = Number(base.sortSeq || 0)
|
||||
out.isSend = base.isSend ?? null
|
||||
out.senderUsername = senderUsername
|
||||
out.parsedContent = base.parsedContent || ''
|
||||
}
|
||||
|
||||
if (includeField('raw')) {
|
||||
out.rawContent = base.rawContent || null
|
||||
}
|
||||
|
||||
if (includeField('type')) {
|
||||
out.messageKind = kind.messageKind
|
||||
out.typeLabel = kind.typeLabel
|
||||
out.appMsgType = kind.appMsgType || null
|
||||
out.direction = metadata.direction
|
||||
}
|
||||
|
||||
if (includeField('time')) {
|
||||
out.createTimeMs = createTimeMs
|
||||
out.createTimeIso = createTimeMs ? new Date(createTimeMs).toISOString() : null
|
||||
}
|
||||
|
||||
if (includeField('sender')) {
|
||||
out.sender = {
|
||||
username: senderUsername,
|
||||
isSelf: Number(base.isSend) === 1
|
||||
}
|
||||
}
|
||||
|
||||
if (includeField('metadata')) {
|
||||
out.metadata = metadata
|
||||
}
|
||||
|
||||
if (includeField('media')) {
|
||||
out.media = media
|
||||
}
|
||||
|
||||
if (includeField('quote')) {
|
||||
out.quote = quote
|
||||
}
|
||||
|
||||
if (includeField('file')) {
|
||||
out.file = file
|
||||
}
|
||||
|
||||
if (includeField('transfer')) {
|
||||
out.transfer = transfer
|
||||
}
|
||||
|
||||
if (includeField('chatrecord')) {
|
||||
out.chatRecord = chatRecord
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
const enrichResults = await Promise.allSettled(page.map((m) => enrichOne(m)))
|
||||
const enrichedMessages = enrichResults
|
||||
.filter((r): r is PromiseFulfilledResult<Record<string, any>> => r.status === 'fulfilled')
|
||||
.map((r) => r.value)
|
||||
|
||||
const normalizedMessages = adaptive
|
||||
? enrichedMessages.map((m) => this.pruneEmpty(m)).filter(Boolean)
|
||||
: enrichedMessages
|
||||
|
||||
const finalMessages = includeRaw
|
||||
? normalizedMessages
|
||||
: normalizedMessages.map((m) => {
|
||||
const { rawContent, ...rest } = m as Record<string, any>
|
||||
return rest
|
||||
})
|
||||
|
||||
const responsePayload: Record<string, any> = {
|
||||
sessionId,
|
||||
total: reachedEnd ? matched.length : null,
|
||||
offset,
|
||||
limit,
|
||||
hasMore,
|
||||
scanned,
|
||||
maxScan,
|
||||
sort,
|
||||
filters: {
|
||||
keyword,
|
||||
msgType: msgTypeFilter,
|
||||
messageKind: messageKindFilter ? Array.from(messageKindFilter) : null,
|
||||
appMsgType: appMsgTypeFilter ? Array.from(appMsgTypeFilter) : null,
|
||||
startTime: startTimeMs,
|
||||
endTime: endTimeMs,
|
||||
includeRaw,
|
||||
resolveMediaPath,
|
||||
resolveVoicePath,
|
||||
adaptive,
|
||||
fields: Array.from(fields)
|
||||
},
|
||||
messages: finalMessages
|
||||
}
|
||||
|
||||
if (includeField('schema')) {
|
||||
responsePayload.messageTypeSchema = {
|
||||
messageKind: {
|
||||
text: '文本',
|
||||
image: '图片',
|
||||
voice: '语音',
|
||||
video: '视频',
|
||||
emoji: '表情',
|
||||
location: '位置',
|
||||
contact_card: '名片',
|
||||
system: '系统消息',
|
||||
quote: '引用消息',
|
||||
voip: '音视频通话',
|
||||
app_link: '链接',
|
||||
app_file: '文件',
|
||||
app_chat_record: '聊天记录',
|
||||
app_mini_program: '小程序',
|
||||
app_transfer: '转账',
|
||||
app_red_packet: '红包',
|
||||
app_announcement: '群公告',
|
||||
app_pat: '拍一拍',
|
||||
app_gift: '微信礼物',
|
||||
app_music: '音乐分享',
|
||||
app: '应用消息',
|
||||
unknown: '未知类型'
|
||||
},
|
||||
direction: {
|
||||
out: '我发送',
|
||||
in: '我接收'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.sendJson(res, 200, this.success(requestId, responsePayload))
|
||||
return
|
||||
}
|
||||
|
||||
if (pathname === '/v1/contacts') {
|
||||
const q = (url.searchParams.get('q') || '').trim().toLowerCase()
|
||||
const typeFilter = this.parseTypeFilter(url.searchParams.get('type'))
|
||||
|
||||
@@ -2869,6 +2869,38 @@
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&.value-with-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.inline-copy-btn {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 4px;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -253,6 +253,10 @@ function ChatPage(_props: ChatPageProps) {
|
||||
const updateStatusTimerRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const isUserOperatingRef = useRef<boolean>(false) // 标记用户是否正在操作
|
||||
const [currentOffset, setCurrentOffset] = useState(0)
|
||||
const [isDateJumpMode, setIsDateJumpMode] = useState(false)
|
||||
const [dateJumpCursorSortSeq, setDateJumpCursorSortSeq] = useState<number | null>(null)
|
||||
const [dateJumpCursorCreateTime, setDateJumpCursorCreateTime] = useState<number | null>(null)
|
||||
const [dateJumpCursorLocalId, setDateJumpCursorLocalId] = useState<number | null>(null)
|
||||
|
||||
// 更新状态管理
|
||||
const setIsUpdating = useUpdateStatusStore(state => state.setIsUpdating)
|
||||
@@ -316,6 +320,16 @@ function ChatPage(_props: ChatPageProps) {
|
||||
const [batchImageDates, setBatchImageDates] = useState<string[]>([])
|
||||
const [batchImageSelectedDates, setBatchImageSelectedDates] = useState<Set<string>>(new Set())
|
||||
|
||||
const copyText = useCallback(async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text || '')
|
||||
setCopyToast(true)
|
||||
setTimeout(() => setCopyToast(false), 2000)
|
||||
} catch (e) {
|
||||
console.error('复制失败:', e)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 检查图片密钥配置(XOR 和 AES 都需要配置)
|
||||
useEffect(() => {
|
||||
Promise.all([getImageXorKey(), getImageAesKey()]).then(([xorKey, aesKey]) => {
|
||||
@@ -466,6 +480,10 @@ function ChatPage(_props: ChatPageProps) {
|
||||
if (offset === 0) {
|
||||
setLoadingMessages(true)
|
||||
setMessages([])
|
||||
setIsDateJumpMode(false)
|
||||
setDateJumpCursorSortSeq(null)
|
||||
setDateJumpCursorCreateTime(null)
|
||||
setDateJumpCursorLocalId(null)
|
||||
// 标记用户正在操作(首次加载)
|
||||
isUserOperatingRef.current = true
|
||||
} else {
|
||||
@@ -528,6 +546,14 @@ function ChatPage(_props: ChatPageProps) {
|
||||
|
||||
const cleanup = window.electronAPI.chat.onNewMessages((data: { sessionId: string; messages: Message[] }) => {
|
||||
if (data.sessionId === currentSessionId && data.messages && data.messages.length > 0) {
|
||||
const listEl = messageListRef.current
|
||||
let shouldAutoScroll = false
|
||||
if (listEl) {
|
||||
const { scrollTop, scrollHeight, clientHeight } = listEl
|
||||
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
|
||||
shouldAutoScroll = distanceFromBottom < 120
|
||||
}
|
||||
|
||||
setMessages((prev: Message[]) => {
|
||||
// 使用与后端一致的多维 Key (serverId + localId + createTime + sortSeq) 进行去重
|
||||
const existingKeys = new Set(
|
||||
@@ -546,8 +572,10 @@ function ChatPage(_props: ChatPageProps) {
|
||||
return [...prev, ...newMsgs]
|
||||
})
|
||||
|
||||
// 平滑滚动到底部
|
||||
requestAnimationFrame(() => scrollToBottom(true))
|
||||
// 仅当用户已在底部附近时才自动滚动,避免浏览历史时被打断
|
||||
if (shouldAutoScroll) {
|
||||
requestAnimationFrame(() => scrollToBottom(true))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -604,6 +632,74 @@ function ChatPage(_props: ChatPageProps) {
|
||||
}
|
||||
|
||||
// 滚动加载更多 + 显示/隐藏回到底部按钮
|
||||
const loadMoreMessagesInDateJumpMode = useCallback(async () => {
|
||||
if (!currentSessionId || dateJumpCursorSortSeq === null || isLoadingMore || !hasMoreMessages) return
|
||||
|
||||
const listEl = messageListRef.current
|
||||
const firstMsgEl = listEl?.querySelector('.message-wrapper') as HTMLElement | null
|
||||
|
||||
setLoadingMore(true)
|
||||
try {
|
||||
const result = await window.electronAPI.chat.getMessagesBefore(
|
||||
currentSessionId,
|
||||
dateJumpCursorSortSeq,
|
||||
50,
|
||||
dateJumpCursorCreateTime ?? undefined,
|
||||
dateJumpCursorLocalId ?? undefined
|
||||
)
|
||||
|
||||
if (result.success && result.messages) {
|
||||
const existingKeys = new Set(
|
||||
messagesRef.current.map(m => `${m.serverId}-${m.localId}-${m.createTime}-${m.sortSeq}`)
|
||||
)
|
||||
const uniqueOlderMessages = result.messages.filter(msg =>
|
||||
!existingKeys.has(`${msg.serverId}-${msg.localId}-${msg.createTime}-${msg.sortSeq}`)
|
||||
)
|
||||
|
||||
if (uniqueOlderMessages.length === 0) {
|
||||
setHasMoreMessages(false)
|
||||
return
|
||||
}
|
||||
|
||||
appendMessages(uniqueOlderMessages, true)
|
||||
|
||||
const oldestSortSeq = uniqueOlderMessages[0]?.sortSeq
|
||||
const oldestCreateTime = uniqueOlderMessages[0]?.createTime
|
||||
const oldestLocalId = uniqueOlderMessages[0]?.localId
|
||||
if (typeof oldestSortSeq !== 'number' || oldestSortSeq >= dateJumpCursorSortSeq) {
|
||||
setHasMoreMessages(false)
|
||||
} else {
|
||||
setDateJumpCursorSortSeq(oldestSortSeq)
|
||||
setDateJumpCursorCreateTime(typeof oldestCreateTime === 'number' ? oldestCreateTime : null)
|
||||
setDateJumpCursorLocalId(typeof oldestLocalId === 'number' ? oldestLocalId : null)
|
||||
setHasMoreMessages(result.hasMore ?? false)
|
||||
}
|
||||
|
||||
if (firstMsgEl && listEl) {
|
||||
requestAnimationFrame(() => {
|
||||
listEl.scrollTop = firstMsgEl.offsetTop - 80
|
||||
})
|
||||
}
|
||||
} else {
|
||||
setHasMoreMessages(false)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('日期跳转模式加载更多失败:', e)
|
||||
} finally {
|
||||
setLoadingMore(false)
|
||||
}
|
||||
}, [
|
||||
currentSessionId,
|
||||
dateJumpCursorSortSeq,
|
||||
dateJumpCursorCreateTime,
|
||||
dateJumpCursorLocalId,
|
||||
isLoadingMore,
|
||||
hasMoreMessages,
|
||||
appendMessages,
|
||||
setHasMoreMessages,
|
||||
setLoadingMore
|
||||
])
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
if (!messageListRef.current) return
|
||||
|
||||
@@ -617,10 +713,14 @@ function ChatPage(_props: ChatPageProps) {
|
||||
if (!isLoadingMore && hasMoreMessages && currentSessionId) {
|
||||
const threshold = clientHeight * 0.3
|
||||
if (scrollTop < threshold) {
|
||||
loadMessages(currentSessionId, currentOffset)
|
||||
if (isDateJumpMode) {
|
||||
loadMoreMessagesInDateJumpMode()
|
||||
} else {
|
||||
loadMessages(currentSessionId, currentOffset)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [isLoadingMore, hasMoreMessages, currentSessionId, currentOffset])
|
||||
}, [isLoadingMore, hasMoreMessages, currentSessionId, currentOffset, isDateJumpMode, loadMoreMessagesInDateJumpMode])
|
||||
|
||||
// 滚动到底部
|
||||
const scrollToBottom = useCallback((smooth: boolean | React.MouseEvent = true) => {
|
||||
@@ -657,8 +757,12 @@ function ChatPage(_props: ChatPageProps) {
|
||||
if (result.success && result.messages && result.messages.length > 0) {
|
||||
// 清空当前消息并加载新消息
|
||||
setMessages(result.messages)
|
||||
setHasMoreMessages(true) // 假设还有更多历史消息
|
||||
setHasMoreMessages(true)
|
||||
setCurrentOffset(result.messages.length)
|
||||
setIsDateJumpMode(true)
|
||||
setDateJumpCursorSortSeq(result.messages[0]?.sortSeq ?? null)
|
||||
setDateJumpCursorCreateTime(result.messages[0]?.createTime ?? null)
|
||||
setDateJumpCursorLocalId(result.messages[0]?.localId ?? null)
|
||||
|
||||
// 滚动到顶部显示目标日期的消息
|
||||
requestAnimationFrame(() => {
|
||||
@@ -1752,7 +1856,17 @@ function ChatPage(_props: ChatPageProps) {
|
||||
<div className="detail-item">
|
||||
<Hash size={14} />
|
||||
<span className="label">微信ID</span>
|
||||
<span className="value">{sessionDetail.wxid}</span>
|
||||
<span className="value value-with-action">
|
||||
<span>{sessionDetail.wxid}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-copy-btn"
|
||||
title="复制微信ID"
|
||||
onClick={() => copyText(sessionDetail.wxid)}
|
||||
>
|
||||
<Copy size={12} />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
{sessionDetail.remark && (
|
||||
<div className="detail-item">
|
||||
|
||||
@@ -202,6 +202,29 @@
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.db-checkbox {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
border-radius: 6px;
|
||||
border: 2px solid var(--border-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
|
||||
&.checked {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { Database, Check, Circle, Unlock, RefreshCw, RefreshCcw, Image as ImageIcon, Smile, Download, Trash2 } from 'lucide-react'
|
||||
import { Database, Check, Circle, Unlock, RefreshCw, RefreshCcw, Image as ImageIcon, Smile, Download, Trash2, Minus, X } from 'lucide-react'
|
||||
import './DataManagementPage.scss'
|
||||
|
||||
interface DatabaseFile {
|
||||
@@ -38,6 +38,8 @@ function DataManagementPage() {
|
||||
const [emojiCount, setEmojiCount] = useState({ total: 0, decrypted: 0 })
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isDecrypting, setIsDecrypting] = useState(false)
|
||||
const [decryptingDbPath, setDecryptingDbPath] = useState<string | null>(null)
|
||||
const [selectedDbs, setSelectedDbs] = useState<Set<string>>(new Set())
|
||||
const [message, setMessage] = useState<{ text: string; success: boolean } | null>(null)
|
||||
const [progress, setProgress] = useState<any>(null)
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<DeleteConfirmData>({ image: null as any, show: false })
|
||||
@@ -324,6 +326,90 @@ function DataManagementPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleDecryptSingle = async (db: DatabaseFile) => {
|
||||
const isChatOpen = await window.electronAPI.window.isChatWindowOpen()
|
||||
if (isChatOpen) {
|
||||
showMessage('请先关闭聊天窗口再进行解密操作', false)
|
||||
return
|
||||
}
|
||||
|
||||
setDecryptingDbPath(db.filePath)
|
||||
try {
|
||||
const result = await window.electronAPI.dataManagement.decryptSingleDatabase(db.filePath)
|
||||
if (result.success) {
|
||||
showMessage(`${db.fileName} 解密成功`, true)
|
||||
await loadDatabases()
|
||||
} else {
|
||||
showMessage(result.error || '解密失败', false)
|
||||
}
|
||||
} catch (e) {
|
||||
showMessage(`解密失败: ${e}`, false)
|
||||
} finally {
|
||||
setDecryptingDbPath(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleSelect = (filePath: string) => {
|
||||
setSelectedDbs(prev => {
|
||||
const newSet = new Set(prev)
|
||||
if (newSet.has(filePath)) {
|
||||
newSet.delete(filePath)
|
||||
} else {
|
||||
newSet.add(filePath)
|
||||
}
|
||||
return newSet
|
||||
})
|
||||
}
|
||||
|
||||
const handleSelectAll = () => {
|
||||
setSelectedDbs(new Set(databases.map(db => db.filePath)))
|
||||
}
|
||||
|
||||
const handleDeselectAll = () => {
|
||||
setSelectedDbs(new Set())
|
||||
}
|
||||
|
||||
const handleBatchDecrypt = async () => {
|
||||
if (selectedDbs.size === 0) {
|
||||
showMessage('请先选择要解密的数据库', false)
|
||||
return
|
||||
}
|
||||
|
||||
const isChatOpen = await window.electronAPI.window.isChatWindowOpen()
|
||||
if (isChatOpen) {
|
||||
showMessage('请先关闭聊天窗口再进行解密操作', false)
|
||||
return
|
||||
}
|
||||
|
||||
setIsDecrypting(true)
|
||||
let successCount = 0
|
||||
let failCount = 0
|
||||
|
||||
try {
|
||||
for (const filePath of selectedDbs) {
|
||||
const db = databases.find(d => d.filePath === filePath)
|
||||
if (!db) continue
|
||||
|
||||
setDecryptingDbPath(filePath)
|
||||
const result = await window.electronAPI.dataManagement.decryptSingleDatabase(filePath)
|
||||
if (result.success) {
|
||||
successCount++
|
||||
} else {
|
||||
failCount++
|
||||
}
|
||||
}
|
||||
|
||||
showMessage(`批量解密完成!成功: ${successCount}, 失败: ${failCount}`, failCount === 0)
|
||||
setSelectedDbs(new Set())
|
||||
await loadDatabases()
|
||||
} catch (e) {
|
||||
showMessage(`批量解密失败: ${e}`, false)
|
||||
} finally {
|
||||
setIsDecrypting(false)
|
||||
setDecryptingDbPath(null)
|
||||
}
|
||||
}
|
||||
|
||||
const [isDeletingThumbs, setIsDeletingThumbs] = useState(false)
|
||||
const [thumbDeleteConfirm, setThumbDeleteConfirm] = useState<{ show: boolean; count: number }>({ show: false, count: 0 })
|
||||
|
||||
@@ -632,6 +718,27 @@ function DataManagementPage() {
|
||||
<RefreshCw size={16} className={isLoading ? 'spin' : ''} />
|
||||
刷新
|
||||
</button>
|
||||
{selectedDbs.size > 0 ? (
|
||||
<>
|
||||
<button className="btn btn-secondary" onClick={handleDeselectAll}>
|
||||
<Minus size={16} />
|
||||
取消全选
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-warning"
|
||||
onClick={handleBatchDecrypt}
|
||||
disabled={isDecrypting}
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
批量重新解密 ({selectedDbs.size})
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button className="btn btn-secondary" onClick={handleSelectAll} disabled={databases.length === 0}>
|
||||
<Check size={16} />
|
||||
全选
|
||||
</button>
|
||||
)}
|
||||
{needsUpdateCount > 0 && (
|
||||
<button
|
||||
className="btn btn-warning"
|
||||
@@ -656,6 +763,12 @@ function DataManagementPage() {
|
||||
<div className="database-list">
|
||||
{databases.map((db, index) => (
|
||||
<div key={index} className={`database-item ${db.isDecrypted ? (db.needsUpdate ? 'needs-update' : 'decrypted') : 'pending'}`}>
|
||||
<div
|
||||
className={`db-checkbox ${selectedDbs.has(db.filePath) ? 'checked' : ''}`}
|
||||
onClick={() => handleToggleSelect(db.filePath)}
|
||||
>
|
||||
{selectedDbs.has(db.filePath) && <Check size={14} />}
|
||||
</div>
|
||||
<div className={`status-icon ${db.isDecrypted ? (db.needsUpdate ? 'needs-update' : 'decrypted') : 'pending'}`}>
|
||||
{db.isDecrypted ? <Check size={16} /> : <Circle size={16} />}
|
||||
</div>
|
||||
@@ -670,6 +783,15 @@ function DataManagementPage() {
|
||||
<div className={`db-status ${db.isDecrypted ? (db.needsUpdate ? 'needs-update' : 'decrypted') : 'pending'}`}>
|
||||
{db.isDecrypted ? (db.needsUpdate ? '需更新' : '已解密') : '待解密'}
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-sm btn-secondary"
|
||||
onClick={() => handleDecryptSingle(db)}
|
||||
disabled={decryptingDbPath === db.filePath}
|
||||
title="重新解密此数据库"
|
||||
>
|
||||
<RefreshCw size={14} className={decryptingDbPath === db.filePath ? 'spin' : ''} />
|
||||
{decryptingDbPath === db.filePath ? '解密中' : '重新解密'}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
|
||||
16
src/types/electron.d.ts
vendored
16
src/types/electron.d.ts
vendored
@@ -171,6 +171,10 @@ export interface ElectronAPI {
|
||||
failCount?: number
|
||||
error?: string
|
||||
}>
|
||||
decryptSingleDatabase: (filePath: string) => Promise<{
|
||||
success: boolean
|
||||
error?: string
|
||||
}>
|
||||
incrementalUpdate: () => Promise<{
|
||||
success: boolean
|
||||
successCount?: number
|
||||
@@ -295,6 +299,18 @@ export interface ElectronAPI {
|
||||
hasMore?: boolean;
|
||||
error?: string
|
||||
}>
|
||||
getMessagesBefore: (
|
||||
sessionId: string,
|
||||
cursorSortSeq: number,
|
||||
limit?: number,
|
||||
cursorCreateTime?: number,
|
||||
cursorLocalId?: number
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
messages?: Message[];
|
||||
hasMore?: boolean;
|
||||
error?: string
|
||||
}>
|
||||
getAllVoiceMessages: (sessionId: string) => Promise<{
|
||||
success: boolean;
|
||||
messages?: Message[];
|
||||
|
||||
Reference in New Issue
Block a user