mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-02-02 22:10:50 +08:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
950fb4c7b4 | ||
|
|
891d4b8a1b | ||
|
|
55dc455921 | ||
|
|
ba9eb5e267 | ||
|
|
d0d518aed9 | ||
|
|
ae2d7f128d | ||
|
|
93ad7b7a1c |
@@ -14,12 +14,23 @@
|
||||
// So we detect desktop onMounted and update reactively.
|
||||
const isDesktop = ref(false)
|
||||
|
||||
const updateDprVar = () => {
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
document.documentElement.style.setProperty('--dpr', String(dpr))
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
isDesktop.value = !!window?.wechatDesktop
|
||||
updateDprVar()
|
||||
window.addEventListener('resize', updateDprVar)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', updateDprVar)
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const isChatRoute = computed(() => route.path?.startsWith('/chat'))
|
||||
const isChatRoute = computed(() => route.path?.startsWith('/chat') || route.path?.startsWith('/sns'))
|
||||
|
||||
const rootClass = computed(() => {
|
||||
const base = 'bg-gradient-to-br from-green-50 via-emerald-50 to-green-100'
|
||||
@@ -34,6 +45,14 @@ const contentClass = computed(() =>
|
||||
</script>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--dpr: 1;
|
||||
/* Left sidebar rail (chat/sns): icon size + spacing */
|
||||
--sidebar-rail-step: 48px;
|
||||
--sidebar-rail-btn: 32px;
|
||||
--sidebar-rail-icon: 24px;
|
||||
}
|
||||
|
||||
/* Electron 桌面端使用自绘标题栏(frame: false)。
|
||||
* 页面里如果继续用 Tailwind 的 h-screen/min-h-screen(100vh),会把标题栏高度叠加进去,从而出现外层滚动条。
|
||||
* 这里把 “screen” 在桌面端视为内容区高度(100%),让标题栏高度自然内嵌在布局里。 */
|
||||
|
||||
@@ -179,6 +179,46 @@ export const useApi = () => {
|
||||
return await request(url)
|
||||
}
|
||||
|
||||
// 朋友圈时间线
|
||||
const listSnsTimeline = async (params = {}) => {
|
||||
const query = new URLSearchParams()
|
||||
if (params && params.account) query.set('account', params.account)
|
||||
if (params && params.limit != null) query.set('limit', String(params.limit))
|
||||
if (params && params.offset != null) query.set('offset', String(params.offset))
|
||||
if (params && params.usernames && Array.isArray(params.usernames) && params.usernames.length > 0) {
|
||||
query.set('usernames', params.usernames.join(','))
|
||||
} else if (params && params.usernames && typeof params.usernames === 'string') {
|
||||
query.set('usernames', params.usernames)
|
||||
}
|
||||
if (params && params.keyword) query.set('keyword', params.keyword)
|
||||
const url = '/sns/timeline' + (query.toString() ? `?${query.toString()}` : '')
|
||||
return await request(url)
|
||||
}
|
||||
|
||||
// 朋友圈图片本地缓存候选(用于错图时手动选择)
|
||||
const listSnsMediaCandidates = async (params = {}) => {
|
||||
const query = new URLSearchParams()
|
||||
if (params && params.account) query.set('account', params.account)
|
||||
if (params && params.create_time != null) query.set('create_time', String(params.create_time))
|
||||
if (params && params.width != null) query.set('width', String(params.width))
|
||||
if (params && params.height != null) query.set('height', String(params.height))
|
||||
if (params && params.limit != null) query.set('limit', String(params.limit))
|
||||
if (params && params.offset != null) query.set('offset', String(params.offset))
|
||||
const url = '/sns/media_candidates' + (query.toString() ? `?${query.toString()}` : '')
|
||||
return await request(url)
|
||||
}
|
||||
|
||||
// 保存朋友圈图片手动匹配结果(本机)
|
||||
const saveSnsMediaPicks = async (data = {}) => {
|
||||
return await request('/sns/media_picks', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
account: data.account || null,
|
||||
picks: (data && data.picks && typeof data.picks === 'object' && !Array.isArray(data.picks)) ? data.picks : {}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const openChatMediaFolder = async (params = {}) => {
|
||||
const query = new URLSearchParams()
|
||||
if (params && params.account) query.set('account', params.account)
|
||||
@@ -288,6 +328,9 @@ export const useApi = () => {
|
||||
buildChatSearchIndex,
|
||||
listChatSearchSenders,
|
||||
getChatMessagesAround,
|
||||
listSnsTimeline,
|
||||
listSnsMediaCandidates,
|
||||
saveSnsMediaPicks,
|
||||
openChatMediaFolder,
|
||||
downloadChatEmoji,
|
||||
saveMediaKeys,
|
||||
|
||||
@@ -1,39 +1,91 @@
|
||||
<template>
|
||||
<div class="h-screen flex overflow-hidden" style="background-color: #EDEDED">
|
||||
<!-- 左侧边栏 -->
|
||||
<div class="w-16 border-r border-gray-200 flex flex-col" style="background-color: #e8e7e7">
|
||||
<div class="flex-1 flex flex-col justify-start pt-0">
|
||||
<div class="border-r border-gray-200 flex flex-col" style="background-color: #e8e7e7; width: 60px; min-width: 60px; max-width: 60px">
|
||||
<div class="flex-1 flex flex-col justify-start pt-0 gap-0">
|
||||
<!-- 头像(类似微信侧边栏) -->
|
||||
<div class="w-full h-[60px] flex items-center justify-center">
|
||||
<div class="w-[40px] h-[40px] rounded-md overflow-hidden bg-gray-300 flex-shrink-0">
|
||||
<img v-if="selfAvatarUrl" :src="selfAvatarUrl" alt="avatar" class="w-full h-full object-cover" />
|
||||
<div
|
||||
v-else
|
||||
class="w-full h-full flex items-center justify-center text-white text-xs font-bold"
|
||||
style="background-color: #4B5563"
|
||||
>
|
||||
我
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 聊天图标 (与 oh-my-wechat 一致) -->
|
||||
<div class="w-16 h-16 flex items-center justify-center chat-tab selected text-[#07b75b]">
|
||||
<div class="w-7 h-7">
|
||||
<div class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center group">
|
||||
<div class="w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md bg-transparent group-hover:bg-[#E1E1E1] flex items-center justify-center transition-colors">
|
||||
<div class="w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)] text-[#07b75b]">
|
||||
<svg class="w-full h-full" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M12 19.8C17.52 19.8 22 15.99 22 11.3C22 6.6 17.52 2.8 12 2.8C6.48 2.8 2 6.6 2 11.3C2 13.29 2.8 15.12 4.15 16.57C4.6 17.05 4.82 17.29 4.92 17.44C5.14 17.79 5.21 17.99 5.23 18.4C5.24 18.59 5.22 18.81 5.16 19.26C5.1 19.75 5.07 19.99 5.13 20.16C5.23 20.49 5.53 20.71 5.87 20.72C6.04 20.72 6.27 20.63 6.72 20.43L8.07 19.86C8.43 19.71 8.61 19.63 8.77 19.59C8.95 19.55 9.04 19.54 9.22 19.54C9.39 19.53 9.64 19.57 10.14 19.65C10.74 19.75 11.37 19.8 12 19.8Z"/>
|
||||
<path d="M12 19.8C17.52 19.8 22 15.99 22 11.3C22 6.6 17.52 2.8 12 2.8C6.48 2.8 2 6.6 2 11.3C2 13.29 2.8 15.12 4.15 16.57C4.6 17.05 4.82 17.29 4.92 17.44C5.14 17.79 5.21 17.99 5.23 18.4C5.24 18.59 5.22 18.81 5.16 19.26C5.1 19.75 5.07 19.99 5.13 20.16C5.23 20.49 5.53 20.71 5.87 20.72C6.04 20.72 6.27 20.63 6.72 20.43L8.07 19.86C8.43 19.71 8.61 19.63 8.77 19.59C8.95 19.55 9.04 19.54 9.22 19.54C9.39 19.53 9.64 19.57 10.14 19.65C10.74 19.75 11.37 19.8 12 19.8Z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 朋友圈图标(Aperture 风格) -->
|
||||
<div
|
||||
class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
|
||||
title="朋友圈"
|
||||
@click="goSns"
|
||||
>
|
||||
<div
|
||||
class="w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent group-hover:bg-[#E1E1E1]"
|
||||
>
|
||||
<div class="w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]" :class="isSnsRoute ? 'text-[#07b75b]' : 'text-[#5d5d5d]'">
|
||||
<svg
|
||||
class="w-full h-full"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="14.31" y1="8" x2="20.05" y2="17.94" />
|
||||
<line x1="9.69" y1="8" x2="21.17" y2="8" />
|
||||
<line x1="7.38" y1="12" x2="13.12" y2="2.06" />
|
||||
<line x1="9.69" y1="16" x2="3.95" y2="6.06" />
|
||||
<line x1="14.31" y1="16" x2="2.83" y2="16" />
|
||||
<line x1="16.62" y1="12" x2="10.88" y2="21.94" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 隐私模式按钮 -->
|
||||
<div
|
||||
class="w-16 h-12 flex items-center justify-center cursor-pointer transition-colors"
|
||||
:class="privacyMode ? 'text-[#03C160]' : 'text-gray-500 hover:text-gray-700'"
|
||||
class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
|
||||
@click="privacyMode = !privacyMode"
|
||||
:title="privacyMode ? '关闭隐私模式' : '开启隐私模式'"
|
||||
>
|
||||
<svg class="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<div
|
||||
class="w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent group-hover:bg-[#E1E1E1]"
|
||||
>
|
||||
<svg class="w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]" :class="privacyMode ? 'text-[#07b75b]' : 'text-[#5d5d5d]'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path v-if="privacyMode" stroke-linecap="round" stroke-linejoin="round" d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88" />
|
||||
<path v-else stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" />
|
||||
<circle v-if="!privacyMode" cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 设置按钮(仅桌面端) -->
|
||||
<div
|
||||
v-if="isDesktopEnv"
|
||||
class="w-16 h-12 flex items-center justify-center cursor-pointer transition-colors text-gray-500 hover:text-gray-700"
|
||||
class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
|
||||
@click="openDesktopSettings"
|
||||
title="设置"
|
||||
>
|
||||
<svg class="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<div class="w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent group-hover:bg-[#E1E1E1]">
|
||||
<svg class="w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)] text-[#5d5d5d]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
@@ -44,9 +96,21 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 中间列表区域 -->
|
||||
<div class="w-80 border-r border-gray-200 flex flex-col min-h-0" style="background-color: #F7F7F7">
|
||||
<div
|
||||
class="session-list-panel border-r border-gray-200 flex flex-col min-h-0 shrink-0 relative"
|
||||
:style="{ backgroundColor: '#F7F7F7', '--session-list-width': sessionListWidth + 'px' }"
|
||||
>
|
||||
<!-- 拖动调整会话列表宽度 -->
|
||||
<div
|
||||
class="session-list-resizer"
|
||||
:class="{ 'session-list-resizer-active': sessionListResizing }"
|
||||
title="拖动调整会话列表宽度"
|
||||
@pointerdown="onSessionListResizerPointerDown"
|
||||
@dblclick="resetSessionListWidth"
|
||||
/>
|
||||
<!-- 聊天列表 -->
|
||||
<div class="h-full flex flex-col min-h-0">
|
||||
<!-- 搜索栏 -->
|
||||
@@ -90,8 +154,8 @@
|
||||
<!-- 联系人列表 -->
|
||||
<div class="flex-1 overflow-y-auto min-h-0">
|
||||
<div v-if="isLoadingContacts" class="px-3 py-4 h-full overflow-hidden">
|
||||
<div v-for="i in 15" :key="i" class="flex items-center space-x-3 py-2">
|
||||
<div class="w-10 h-10 rounded-md bg-gray-200 skeleton-pulse"></div>
|
||||
<div v-for="i in 15" :key="i" class="flex items-center space-x-3 h-[calc(85px/var(--dpr))]">
|
||||
<div class="w-[calc(45px/var(--dpr))] h-[calc(45px/var(--dpr))] rounded-md bg-gray-200 skeleton-pulse"></div>
|
||||
<div class="flex-1 space-y-2">
|
||||
<div class="h-3.5 bg-gray-200 rounded skeleton-pulse" :style="{ width: (60 + (i % 4) * 15) + 'px' }"></div>
|
||||
<div class="h-3 bg-gray-200 rounded skeleton-pulse" :style="{ width: (80 + (i % 3) * 20) + 'px' }"></div>
|
||||
@@ -106,12 +170,12 @@
|
||||
</div>
|
||||
<template v-else>
|
||||
<div v-for="contact in filteredContacts" :key="contact.id"
|
||||
class="px-3 py-2 cursor-pointer transition-colors duration-150 border-b border-gray-100"
|
||||
class="px-3 cursor-pointer transition-colors duration-150 border-b border-gray-100 h-[calc(85px/var(--dpr))] flex items-center"
|
||||
:class="selectedContact?.id === contact.id ? 'bg-[#DEDEDE] hover:bg-[#d3d3d3]' : 'hover:bg-[#eaeaea]'"
|
||||
@click="selectContact(contact)">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="flex items-center space-x-3 w-full">
|
||||
<!-- 联系人头像 -->
|
||||
<div class="w-10 h-10 rounded-md overflow-hidden bg-gray-300 flex-shrink-0" :class="{ 'privacy-blur': privacyMode }">
|
||||
<div class="w-[calc(45px/var(--dpr))] h-[calc(45px/var(--dpr))] rounded-md overflow-hidden bg-gray-300 flex-shrink-0" :class="{ 'privacy-blur': privacyMode }">
|
||||
<div v-if="contact.avatar" class="w-full h-full">
|
||||
<img :src="contact.avatar" :alt="contact.name" class="w-full h-full object-cover">
|
||||
</div>
|
||||
@@ -287,7 +351,7 @@
|
||||
<div v-else class="flex items-center" :class="message.isSent ? 'justify-end' : 'justify-start'">
|
||||
<div class="flex items-start max-w-md" :class="message.isSent ? 'flex-row-reverse' : ''">
|
||||
<!-- 消息发送者头像 -->
|
||||
<div class="w-[36px] h-[36px] rounded-md overflow-hidden bg-gray-300 flex-shrink-0" :class="[message.isSent ? 'ml-3' : 'mr-3', { 'privacy-blur': privacyMode }]">
|
||||
<div class="w-[calc(42px/var(--dpr))] h-[calc(42px/var(--dpr))] rounded-md overflow-hidden bg-gray-300 flex-shrink-0" :class="[message.isSent ? 'ml-3' : 'mr-3', { 'privacy-blur': privacyMode }]">
|
||||
<div v-if="message.avatar" class="w-full h-full">
|
||||
<img
|
||||
:src="message.avatar"
|
||||
@@ -1714,6 +1778,7 @@ useHead({
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const isSnsRoute = computed(() => route.path?.startsWith('/sns'))
|
||||
|
||||
const routeUsername = computed(() => {
|
||||
const raw = route.params.username
|
||||
@@ -1729,6 +1794,147 @@ const selectedContact = ref(null)
|
||||
|
||||
// 隐私模式
|
||||
const privacyMode = ref(false)
|
||||
const PRIVACY_MODE_KEY = 'ui.privacy_mode'
|
||||
|
||||
onMounted(() => {
|
||||
if (!process.client) return
|
||||
try {
|
||||
privacyMode.value = localStorage.getItem(PRIVACY_MODE_KEY) === '1'
|
||||
} catch {}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => privacyMode.value,
|
||||
(v) => {
|
||||
if (!process.client) return
|
||||
try {
|
||||
localStorage.setItem(PRIVACY_MODE_KEY, v ? '1' : '0')
|
||||
} catch {}
|
||||
}
|
||||
)
|
||||
|
||||
// 会话列表(中间栏)宽度(按物理像素 px 配置):默认 295px,支持拖动调整并持久化
|
||||
const SESSION_LIST_WIDTH_KEY = 'ui.chat.session_list_width_physical'
|
||||
const SESSION_LIST_WIDTH_KEY_LEGACY = 'ui.chat.session_list_width'
|
||||
const SESSION_LIST_WIDTH_DEFAULT = 295
|
||||
const SESSION_LIST_WIDTH_MIN = 220
|
||||
const SESSION_LIST_WIDTH_MAX = 520
|
||||
|
||||
const sessionListWidth = ref(SESSION_LIST_WIDTH_DEFAULT)
|
||||
const sessionListResizing = ref(false)
|
||||
|
||||
let sessionListResizeStartX = 0
|
||||
let sessionListResizeStartWidth = SESSION_LIST_WIDTH_DEFAULT
|
||||
let sessionListResizeStartDpr = 1
|
||||
let sessionListResizePrevCursor = ''
|
||||
let sessionListResizePrevUserSelect = ''
|
||||
|
||||
const clampSessionListWidth = (n) => {
|
||||
const v = Number.isFinite(n) ? n : SESSION_LIST_WIDTH_DEFAULT
|
||||
return Math.min(SESSION_LIST_WIDTH_MAX, Math.max(SESSION_LIST_WIDTH_MIN, Math.round(v)))
|
||||
}
|
||||
|
||||
const loadSessionListWidth = () => {
|
||||
if (!process.client) return
|
||||
try {
|
||||
const raw = localStorage.getItem(SESSION_LIST_WIDTH_KEY)
|
||||
const v = parseInt(String(raw || ''), 10)
|
||||
if (!Number.isNaN(v)) {
|
||||
sessionListWidth.value = clampSessionListWidth(v)
|
||||
return
|
||||
}
|
||||
|
||||
// Legacy: value was stored as CSS px. Convert to physical px using current dpr.
|
||||
const legacy = localStorage.getItem(SESSION_LIST_WIDTH_KEY_LEGACY)
|
||||
const legacyV = parseInt(String(legacy || ''), 10)
|
||||
if (!Number.isNaN(legacyV)) {
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
const converted = clampSessionListWidth(legacyV * dpr)
|
||||
sessionListWidth.value = converted
|
||||
try {
|
||||
localStorage.setItem(SESSION_LIST_WIDTH_KEY, String(converted))
|
||||
localStorage.removeItem(SESSION_LIST_WIDTH_KEY_LEGACY)
|
||||
} catch {}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const saveSessionListWidth = () => {
|
||||
if (!process.client) return
|
||||
try {
|
||||
localStorage.setItem(SESSION_LIST_WIDTH_KEY, String(clampSessionListWidth(sessionListWidth.value)))
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const setSessionListResizingActive = (active) => {
|
||||
if (!process.client) return
|
||||
try {
|
||||
const body = document.body
|
||||
if (!body) return
|
||||
if (active) {
|
||||
sessionListResizePrevCursor = body.style.cursor || ''
|
||||
sessionListResizePrevUserSelect = body.style.userSelect || ''
|
||||
body.style.cursor = 'col-resize'
|
||||
body.style.userSelect = 'none'
|
||||
} else {
|
||||
body.style.cursor = sessionListResizePrevCursor
|
||||
body.style.userSelect = sessionListResizePrevUserSelect
|
||||
sessionListResizePrevCursor = ''
|
||||
sessionListResizePrevUserSelect = ''
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const onSessionListResizerPointerMove = (ev) => {
|
||||
if (!sessionListResizing.value) return
|
||||
const clientX = Number(ev?.clientX || 0)
|
||||
// `clientX` delta is in CSS px. We store width as physical px, so multiply by dpr.
|
||||
sessionListWidth.value = clampSessionListWidth(
|
||||
sessionListResizeStartWidth + (clientX - sessionListResizeStartX) * (sessionListResizeStartDpr || 1)
|
||||
)
|
||||
}
|
||||
|
||||
const stopSessionListResize = () => {
|
||||
if (!process.client) return
|
||||
if (!sessionListResizing.value) return
|
||||
sessionListResizing.value = false
|
||||
setSessionListResizingActive(false)
|
||||
try {
|
||||
window.removeEventListener('pointermove', onSessionListResizerPointerMove)
|
||||
} catch {}
|
||||
saveSessionListWidth()
|
||||
}
|
||||
|
||||
const onSessionListResizerPointerUp = () => {
|
||||
stopSessionListResize()
|
||||
}
|
||||
|
||||
const onSessionListResizerPointerDown = (ev) => {
|
||||
if (!process.client) return
|
||||
try {
|
||||
ev?.preventDefault?.()
|
||||
} catch {}
|
||||
|
||||
sessionListResizing.value = true
|
||||
sessionListResizeStartX = Number(ev?.clientX || 0)
|
||||
sessionListResizeStartWidth = Number(sessionListWidth.value || SESSION_LIST_WIDTH_DEFAULT)
|
||||
sessionListResizeStartDpr = window.devicePixelRatio || 1
|
||||
setSessionListResizingActive(true)
|
||||
|
||||
try {
|
||||
window.addEventListener('pointermove', onSessionListResizerPointerMove)
|
||||
window.addEventListener('pointerup', onSessionListResizerPointerUp, { once: true })
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const resetSessionListWidth = () => {
|
||||
sessionListWidth.value = SESSION_LIST_WIDTH_DEFAULT
|
||||
saveSessionListWidth()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadSessionListWidth()
|
||||
})
|
||||
|
||||
// 桌面端设置(仅 Electron 环境可见)
|
||||
const isDesktopEnv = ref(false)
|
||||
@@ -1880,6 +2086,18 @@ const selectedAccount = ref(null)
|
||||
|
||||
const availableAccounts = ref([])
|
||||
|
||||
const sidebarMediaBase = process.client ? 'http://localhost:8000' : ''
|
||||
|
||||
const selfAvatarUrl = computed(() => {
|
||||
const acc = String(selectedAccount.value || '').trim()
|
||||
if (!acc) return ''
|
||||
return `${sidebarMediaBase}/api/chat/avatar?account=${encodeURIComponent(acc)}&username=${encodeURIComponent(acc)}`
|
||||
})
|
||||
|
||||
const goSns = async () => {
|
||||
await navigateTo('/sns')
|
||||
}
|
||||
|
||||
// 实时更新(WCDB DLL + db_storage watcher)
|
||||
const realtimeEnabled = ref(false)
|
||||
const realtimeAvailable = ref(false)
|
||||
@@ -1894,6 +2112,7 @@ let realtimeSessionsRefreshQueued = false
|
||||
let realtimeFullSyncFuture = null
|
||||
let realtimeFullSyncQueued = false
|
||||
let realtimeFullSyncPriority = ''
|
||||
let realtimeChangeDebounceTimer = null
|
||||
|
||||
const allMessages = ref({})
|
||||
|
||||
@@ -4592,6 +4811,7 @@ onUnmounted(() => {
|
||||
if (!process.client) return
|
||||
document.removeEventListener('click', onGlobalClick)
|
||||
document.removeEventListener('keydown', onGlobalKeyDown)
|
||||
stopSessionListResize()
|
||||
if (messageSearchDebounceTimer) clearTimeout(messageSearchDebounceTimer)
|
||||
messageSearchDebounceTimer = null
|
||||
if (highlightMessageTimer) clearTimeout(highlightMessageTimer)
|
||||
@@ -4644,9 +4864,9 @@ const loadMessages = async ({ username, reset }) => {
|
||||
if (messageTypeFilter.value && messageTypeFilter.value !== 'all') {
|
||||
params.render_types = messageTypeFilter.value
|
||||
}
|
||||
|
||||
if (reset) {
|
||||
await queueRealtimeFullSync(username)
|
||||
if (realtimeEnabled.value) {
|
||||
// In realtime mode, read directly from WCDB to avoid blocking on background sync.
|
||||
params.source = 'realtime'
|
||||
}
|
||||
const resp = await api.listChatMessages(params)
|
||||
|
||||
@@ -4747,6 +4967,12 @@ const stopRealtimeStream = () => {
|
||||
} catch {}
|
||||
realtimeEventSource = null
|
||||
}
|
||||
if (realtimeChangeDebounceTimer) {
|
||||
try {
|
||||
clearTimeout(realtimeChangeDebounceTimer)
|
||||
} catch {}
|
||||
realtimeChangeDebounceTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const refreshRealtimeIncremental = async () => {
|
||||
@@ -4774,8 +5000,8 @@ const refreshRealtimeIncremental = async () => {
|
||||
if (messageTypeFilter.value && messageTypeFilter.value !== 'all') {
|
||||
params.render_types = messageTypeFilter.value
|
||||
}
|
||||
params.source = 'realtime'
|
||||
|
||||
await queueRealtimeFullSync(username)
|
||||
const resp = await api.listChatMessages(params)
|
||||
if (selectedContact.value?.username !== username) return
|
||||
|
||||
@@ -4820,6 +5046,19 @@ const queueRealtimeRefresh = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const queueRealtimeChange = () => {
|
||||
if (!process.client || typeof window === 'undefined') return
|
||||
if (!realtimeEnabled.value) return
|
||||
if (realtimeChangeDebounceTimer) return
|
||||
|
||||
// Debounce noisy db_storage change events to avoid hammering the backend.
|
||||
realtimeChangeDebounceTimer = setTimeout(() => {
|
||||
realtimeChangeDebounceTimer = null
|
||||
queueRealtimeRefresh()
|
||||
queueRealtimeSessionsRefresh()
|
||||
}, 500)
|
||||
}
|
||||
|
||||
const startRealtimeStream = () => {
|
||||
stopRealtimeStream()
|
||||
if (!process.client || typeof window === 'undefined') return
|
||||
@@ -4840,9 +5079,7 @@ const startRealtimeStream = () => {
|
||||
try {
|
||||
const data = JSON.parse(String(ev.data || '{}'))
|
||||
if (String(data?.type || '') === 'change') {
|
||||
queueRealtimeFullSync(selectedContact.value?.username || '')
|
||||
queueRealtimeRefresh()
|
||||
queueRealtimeSessionsRefresh()
|
||||
queueRealtimeChange()
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
@@ -4874,8 +5111,22 @@ const toggleRealtime = async (opts = {}) => {
|
||||
return true
|
||||
}
|
||||
|
||||
// Turning off realtime: sync the latest WCDB rows into the decrypted sqlite DB first,
|
||||
// otherwise the UI will fall back to an outdated decrypted snapshot.
|
||||
realtimeEnabled.value = false
|
||||
stopRealtimeStream()
|
||||
try {
|
||||
const api = useApi()
|
||||
const u = String(selectedContact.value?.username || '').trim()
|
||||
if (u) {
|
||||
// Use a larger scan window on shutdown to reduce the chance of missing a backlog.
|
||||
await api.syncChatRealtimeMessages({
|
||||
account: selectedAccount.value,
|
||||
username: u,
|
||||
max_scan: 5000
|
||||
})
|
||||
}
|
||||
} catch {}
|
||||
await refreshSessionsForSelectedAccount({ sourceOverride: '' })
|
||||
if (selectedContact.value?.username) {
|
||||
await refreshSelectedMessages()
|
||||
@@ -5144,6 +5395,38 @@ const LinkCard = defineComponent({
|
||||
background: #a1a1a1;
|
||||
}
|
||||
|
||||
/* 会话列表宽度:按物理像素(px)配置,按 dpr 换算为 CSS px */
|
||||
.session-list-panel {
|
||||
width: calc(var(--session-list-width, 295px) / var(--dpr));
|
||||
}
|
||||
|
||||
/* 会话列表拖动条(中间栏右侧) */
|
||||
.session-list-resizer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: -3px; /* 覆盖在 border 上,便于拖动 */
|
||||
width: 6px;
|
||||
height: 100%;
|
||||
cursor: col-resize;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.session-list-resizer::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 2px;
|
||||
width: 2px;
|
||||
background: transparent;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.session-list-resizer:hover::after,
|
||||
.session-list-resizer-active::after {
|
||||
background: rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
/* 消息气泡样式 */
|
||||
.message-bubble {
|
||||
border-radius: var(--message-radius);
|
||||
|
||||
1092
frontend/pages/sns.vue
Normal file
1092
frontend/pages/sns.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,7 @@ from .routers.decrypt import router as _decrypt_router
|
||||
from .routers.health import router as _health_router
|
||||
from .routers.keys import router as _keys_router
|
||||
from .routers.media import router as _media_router
|
||||
from .routers.sns import router as _sns_router
|
||||
from .routers.wechat_detection import router as _wechat_detection_router
|
||||
from .wcdb_realtime import WCDB_REALTIME, shutdown as _wcdb_shutdown
|
||||
|
||||
@@ -51,6 +52,7 @@ app.include_router(_media_router)
|
||||
app.include_router(_chat_router)
|
||||
app.include_router(_chat_export_router)
|
||||
app.include_router(_chat_media_router)
|
||||
app.include_router(_sns_router)
|
||||
|
||||
|
||||
class _SPAStaticFiles(StaticFiles):
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
@@ -58,6 +59,11 @@ class WeChatLogger:
|
||||
|
||||
def setup_logging(self, log_level: str = "INFO"):
|
||||
"""设置日志配置"""
|
||||
# Allow overriding via env var for easier debugging (e.g. WECHAT_TOOL_LOG_LEVEL=DEBUG)
|
||||
env_level = str(os.environ.get("WECHAT_TOOL_LOG_LEVEL", "") or "").strip()
|
||||
if env_level:
|
||||
log_level = env_level
|
||||
|
||||
# 创建日志目录
|
||||
now = datetime.now()
|
||||
log_dir = Path("output/logs") / str(now.year) / f"{now.month:02d}" / f"{now.day:02d}"
|
||||
@@ -88,46 +94,47 @@ class WeChatLogger:
|
||||
# 文件处理器
|
||||
file_handler = logging.FileHandler(self.log_file, encoding='utf-8')
|
||||
file_handler.setFormatter(file_formatter)
|
||||
file_handler.setLevel(getattr(logging, log_level.upper()))
|
||||
level = getattr(logging, str(log_level or "INFO").upper(), logging.INFO)
|
||||
file_handler.setLevel(level)
|
||||
|
||||
# 控制台处理器
|
||||
console_handler = logging.StreamHandler(sys.stdout)
|
||||
console_handler.setFormatter(console_formatter)
|
||||
console_handler.setLevel(getattr(logging, log_level.upper()))
|
||||
console_handler.setLevel(level)
|
||||
|
||||
# 配置根日志器
|
||||
root_logger.setLevel(getattr(logging, log_level.upper()))
|
||||
root_logger.setLevel(level)
|
||||
root_logger.addHandler(file_handler)
|
||||
root_logger.addHandler(console_handler)
|
||||
|
||||
# 只为uvicorn日志器添加文件处理器,保持其原有的控制台处理器(带颜色)
|
||||
uvicorn_logger = logging.getLogger("uvicorn")
|
||||
uvicorn_logger.addHandler(file_handler)
|
||||
uvicorn_logger.setLevel(getattr(logging, log_level.upper()))
|
||||
uvicorn_logger.setLevel(level)
|
||||
|
||||
# 只为uvicorn.access日志器添加文件处理器
|
||||
uvicorn_access_logger = logging.getLogger("uvicorn.access")
|
||||
uvicorn_access_logger.addHandler(file_handler)
|
||||
uvicorn_access_logger.setLevel(getattr(logging, log_level.upper()))
|
||||
uvicorn_access_logger.setLevel(level)
|
||||
|
||||
# 只为uvicorn.error日志器添加文件处理器
|
||||
uvicorn_error_logger = logging.getLogger("uvicorn.error")
|
||||
uvicorn_error_logger.addHandler(file_handler)
|
||||
uvicorn_error_logger.setLevel(getattr(logging, log_level.upper()))
|
||||
uvicorn_error_logger.setLevel(level)
|
||||
|
||||
# 配置FastAPI日志器
|
||||
fastapi_logger = logging.getLogger("fastapi")
|
||||
fastapi_logger.handlers = []
|
||||
fastapi_logger.addHandler(file_handler)
|
||||
fastapi_logger.addHandler(console_handler)
|
||||
fastapi_logger.setLevel(getattr(logging, log_level.upper()))
|
||||
fastapi_logger.setLevel(level)
|
||||
|
||||
# 记录初始化信息
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info("=" * 60)
|
||||
logger.info("微信解密工具日志系统初始化完成")
|
||||
logger.info(f"日志文件: {self.log_file}")
|
||||
logger.info(f"日志级别: {log_level}")
|
||||
logger.info(f"日志级别: {logging.getLevelName(level)}")
|
||||
logger.info("=" * 60)
|
||||
|
||||
return self.log_file
|
||||
|
||||
BIN
src/wechat_decrypt_tool/native/msvcp140.dll
Normal file
BIN
src/wechat_decrypt_tool/native/msvcp140.dll
Normal file
Binary file not shown.
BIN
src/wechat_decrypt_tool/native/msvcp140_1.dll
Normal file
BIN
src/wechat_decrypt_tool/native/msvcp140_1.dll
Normal file
Binary file not shown.
BIN
src/wechat_decrypt_tool/native/vcruntime140.dll
Normal file
BIN
src/wechat_decrypt_tool/native/vcruntime140.dll
Normal file
Binary file not shown.
BIN
src/wechat_decrypt_tool/native/vcruntime140_1.dll
Normal file
BIN
src/wechat_decrypt_tool/native/vcruntime140_1.dll
Normal file
Binary file not shown.
Binary file not shown.
@@ -68,6 +68,8 @@ from ..session_last_message import (
|
||||
from ..wcdb_realtime import (
|
||||
WCDBRealtimeError,
|
||||
WCDB_REALTIME,
|
||||
get_avatar_urls as _wcdb_get_avatar_urls,
|
||||
get_display_names as _wcdb_get_display_names,
|
||||
get_messages as _wcdb_get_messages,
|
||||
get_sessions as _wcdb_get_sessions,
|
||||
)
|
||||
@@ -213,6 +215,13 @@ async def stream_chat_realtime_events(
|
||||
if not db_storage_dir.exists() or not db_storage_dir.is_dir():
|
||||
raise HTTPException(status_code=400, detail="db_storage directory not found for this account.")
|
||||
|
||||
logger.info(
|
||||
"[realtime] SSE stream open account=%s interval_ms=%s db_storage=%s",
|
||||
account_dir.name,
|
||||
int(interval_ms),
|
||||
str(db_storage_dir),
|
||||
)
|
||||
|
||||
async def gen():
|
||||
last_mtime_ns = 0
|
||||
last_heartbeat = 0.0
|
||||
@@ -226,11 +235,21 @@ async def stream_chat_realtime_events(
|
||||
}
|
||||
yield f"data: {json.dumps(initial, ensure_ascii=False)}\n\n"
|
||||
|
||||
try:
|
||||
while True:
|
||||
if await request.is_disconnected():
|
||||
break
|
||||
|
||||
mtime_ns = _scan_db_storage_mtime_ns(db_storage_dir)
|
||||
# Avoid blocking the event loop on a potentially large directory walk.
|
||||
scan_t0 = time.perf_counter()
|
||||
try:
|
||||
mtime_ns = await asyncio.to_thread(_scan_db_storage_mtime_ns, db_storage_dir)
|
||||
except Exception:
|
||||
mtime_ns = 0
|
||||
scan_ms = (time.perf_counter() - scan_t0) * 1000.0
|
||||
if scan_ms > 1000:
|
||||
logger.warning("[realtime] SSE scan slow account=%s ms=%.1f", account_dir.name, scan_ms)
|
||||
|
||||
if mtime_ns and mtime_ns != last_mtime_ns:
|
||||
last_mtime_ns = mtime_ns
|
||||
payload = {
|
||||
@@ -239,6 +258,7 @@ async def stream_chat_realtime_events(
|
||||
"mtimeNs": int(mtime_ns),
|
||||
"ts": int(time.time() * 1000),
|
||||
}
|
||||
logger.info("[realtime] SSE change account=%s mtime_ns=%s", account_dir.name, int(mtime_ns))
|
||||
yield f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
|
||||
|
||||
now = time.time()
|
||||
@@ -247,6 +267,8 @@ async def stream_chat_realtime_events(
|
||||
yield ": ping\n\n"
|
||||
|
||||
await asyncio.sleep(interval_ms / 1000.0)
|
||||
finally:
|
||||
logger.info("[realtime] SSE stream closed account=%s", account_dir.name)
|
||||
|
||||
headers = {"Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no"}
|
||||
return StreamingResponse(gen(), media_type="text/event-stream", headers=headers)
|
||||
@@ -337,7 +359,7 @@ def _ensure_session_last_message_table(conn: sqlite3.Connection) -> None:
|
||||
|
||||
|
||||
@router.post("/api/chat/realtime/sync", summary="实时消息同步到解密库(按会话增量)")
|
||||
async def sync_chat_realtime_messages(
|
||||
def sync_chat_realtime_messages(
|
||||
request: Request,
|
||||
username: str,
|
||||
account: Optional[str] = None,
|
||||
@@ -357,11 +379,23 @@ async def sync_chat_realtime_messages(
|
||||
max_scan = 5000
|
||||
|
||||
account_dir = _resolve_account_dir(account)
|
||||
trace_id = f"rt-sync-{int(time.time() * 1000)}-{threading.get_ident()}"
|
||||
logger.info(
|
||||
"[%s] realtime sync start account=%s username=%s max_scan=%s",
|
||||
trace_id,
|
||||
account_dir.name,
|
||||
username,
|
||||
int(max_scan),
|
||||
)
|
||||
|
||||
# Lock per (account, username) to avoid concurrent writes to the same sqlite tables.
|
||||
logger.info("[%s] acquiring per-session lock account=%s username=%s", trace_id, account_dir.name, username)
|
||||
with _realtime_sync_lock(account_dir.name, username):
|
||||
logger.info("[%s] per-session lock acquired account=%s username=%s", trace_id, account_dir.name, username)
|
||||
try:
|
||||
logger.info("[%s] ensure wcdb connected account=%s", trace_id, account_dir.name)
|
||||
rt_conn = WCDB_REALTIME.ensure_connected(account_dir)
|
||||
logger.info("[%s] wcdb connected account=%s handle=%s", trace_id, account_dir.name, int(rt_conn.handle))
|
||||
except WCDBRealtimeError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
@@ -369,6 +403,14 @@ async def sync_chat_realtime_messages(
|
||||
if not resolved:
|
||||
raise HTTPException(status_code=404, detail="Conversation table not found in decrypted databases.")
|
||||
msg_db_path, table_name = resolved
|
||||
logger.info(
|
||||
"[%s] resolved decrypted table account=%s username=%s db=%s table=%s",
|
||||
trace_id,
|
||||
account_dir.name,
|
||||
username,
|
||||
str(msg_db_path),
|
||||
table_name,
|
||||
)
|
||||
|
||||
msg_conn = sqlite3.connect(str(msg_db_path))
|
||||
msg_conn.row_factory = sqlite3.Row
|
||||
@@ -457,8 +499,34 @@ async def sync_chat_realtime_messages(
|
||||
|
||||
while scanned < int(max_scan):
|
||||
take = min(batch_size, int(max_scan) - scanned)
|
||||
logger.info(
|
||||
"[%s] wcdb_get_messages account=%s username=%s take=%s offset=%s",
|
||||
trace_id,
|
||||
account_dir.name,
|
||||
username,
|
||||
int(take),
|
||||
int(offset),
|
||||
)
|
||||
wcdb_t0 = time.perf_counter()
|
||||
with rt_conn.lock:
|
||||
raw_rows = _wcdb_get_messages(rt_conn.handle, username, limit=take, offset=offset)
|
||||
wcdb_ms = (time.perf_counter() - wcdb_t0) * 1000.0
|
||||
logger.info(
|
||||
"[%s] wcdb_get_messages done account=%s username=%s rows=%s ms=%.1f",
|
||||
trace_id,
|
||||
account_dir.name,
|
||||
username,
|
||||
len(raw_rows or []),
|
||||
wcdb_ms,
|
||||
)
|
||||
if wcdb_ms > 2000:
|
||||
logger.warning(
|
||||
"[%s] wcdb_get_messages slow account=%s username=%s ms=%.1f",
|
||||
trace_id,
|
||||
account_dir.name,
|
||||
username,
|
||||
wcdb_ms,
|
||||
)
|
||||
if not raw_rows:
|
||||
break
|
||||
|
||||
@@ -526,9 +594,27 @@ async def sync_chat_realtime_messages(
|
||||
|
||||
# Insert older -> newer to keep sqlite btree locality similar to existing data.
|
||||
values = [tuple(r.get(c) for c in insert_cols) for r in reversed(new_rows)]
|
||||
insert_t0 = time.perf_counter()
|
||||
msg_conn.executemany(insert_sql, values)
|
||||
msg_conn.commit()
|
||||
insert_ms = (time.perf_counter() - insert_t0) * 1000.0
|
||||
inserted = len(new_rows)
|
||||
logger.info(
|
||||
"[%s] sqlite insert done account=%s username=%s inserted=%s ms=%.1f",
|
||||
trace_id,
|
||||
account_dir.name,
|
||||
username,
|
||||
int(inserted),
|
||||
insert_ms,
|
||||
)
|
||||
if insert_ms > 1000:
|
||||
logger.warning(
|
||||
"[%s] sqlite insert slow account=%s username=%s ms=%.1f",
|
||||
trace_id,
|
||||
account_dir.name,
|
||||
username,
|
||||
insert_ms,
|
||||
)
|
||||
|
||||
if ("packed_info_data" in insert_cols) and backfill_rows:
|
||||
update_values = []
|
||||
@@ -539,12 +625,30 @@ async def sync_chat_realtime_messages(
|
||||
update_values.append((pdata, int(r.get("local_id") or 0)))
|
||||
if update_values:
|
||||
before_changes = msg_conn.total_changes
|
||||
update_t0 = time.perf_counter()
|
||||
msg_conn.executemany(
|
||||
f"UPDATE {quoted_table} SET packed_info_data = ? WHERE local_id = ? AND (packed_info_data IS NULL OR length(packed_info_data) = 0)",
|
||||
update_values,
|
||||
)
|
||||
msg_conn.commit()
|
||||
update_ms = (time.perf_counter() - update_t0) * 1000.0
|
||||
backfilled = int(msg_conn.total_changes - before_changes)
|
||||
logger.info(
|
||||
"[%s] sqlite backfill done account=%s username=%s rows=%s ms=%.1f",
|
||||
trace_id,
|
||||
account_dir.name,
|
||||
username,
|
||||
int(backfilled),
|
||||
update_ms,
|
||||
)
|
||||
if update_ms > 1000:
|
||||
logger.warning(
|
||||
"[%s] sqlite backfill slow account=%s username=%s ms=%.1f",
|
||||
trace_id,
|
||||
account_dir.name,
|
||||
username,
|
||||
update_ms,
|
||||
)
|
||||
|
||||
# Update session.db so left sidebar ordering/time can follow new messages.
|
||||
newest = new_rows[0] if new_rows else None
|
||||
@@ -636,6 +740,16 @@ async def sync_chat_realtime_messages(
|
||||
finally:
|
||||
sconn.close()
|
||||
|
||||
logger.info(
|
||||
"[%s] realtime sync done account=%s username=%s scanned=%s inserted=%s backfilled=%s maxLocalIdBefore=%s",
|
||||
trace_id,
|
||||
account_dir.name,
|
||||
username,
|
||||
int(scanned),
|
||||
int(inserted),
|
||||
int(backfilled),
|
||||
int(max_local_id),
|
||||
)
|
||||
return {
|
||||
"status": "success",
|
||||
"account": account_dir.name,
|
||||
@@ -750,8 +864,31 @@ def _sync_chat_realtime_messages_for_table(
|
||||
|
||||
while scanned < int(max_scan):
|
||||
take = min(batch_size, int(max_scan) - scanned)
|
||||
logger.info(
|
||||
"[realtime] wcdb_get_messages account=%s username=%s take=%s offset=%s",
|
||||
account_dir.name,
|
||||
username,
|
||||
int(take),
|
||||
int(offset),
|
||||
)
|
||||
wcdb_t0 = time.perf_counter()
|
||||
with rt_conn.lock:
|
||||
raw_rows = _wcdb_get_messages(rt_conn.handle, username, limit=take, offset=offset)
|
||||
wcdb_ms = (time.perf_counter() - wcdb_t0) * 1000.0
|
||||
logger.info(
|
||||
"[realtime] wcdb_get_messages done account=%s username=%s rows=%s ms=%.1f",
|
||||
account_dir.name,
|
||||
username,
|
||||
len(raw_rows or []),
|
||||
wcdb_ms,
|
||||
)
|
||||
if wcdb_ms > 2000:
|
||||
logger.warning(
|
||||
"[realtime] wcdb_get_messages slow account=%s username=%s ms=%.1f",
|
||||
account_dir.name,
|
||||
username,
|
||||
wcdb_ms,
|
||||
)
|
||||
if not raw_rows:
|
||||
break
|
||||
|
||||
@@ -816,9 +953,25 @@ def _sync_chat_realtime_messages_for_table(
|
||||
continue
|
||||
|
||||
values = [tuple(r.get(c) for c in insert_cols) for r in reversed(new_rows)]
|
||||
insert_t0 = time.perf_counter()
|
||||
msg_conn.executemany(insert_sql, values)
|
||||
msg_conn.commit()
|
||||
insert_ms = (time.perf_counter() - insert_t0) * 1000.0
|
||||
inserted = len(new_rows)
|
||||
logger.info(
|
||||
"[realtime] sqlite insert done account=%s username=%s inserted=%s ms=%.1f",
|
||||
account_dir.name,
|
||||
username,
|
||||
int(inserted),
|
||||
insert_ms,
|
||||
)
|
||||
if insert_ms > 1000:
|
||||
logger.warning(
|
||||
"[realtime] sqlite insert slow account=%s username=%s ms=%.1f",
|
||||
account_dir.name,
|
||||
username,
|
||||
insert_ms,
|
||||
)
|
||||
|
||||
if ("packed_info_data" in insert_cols) and backfill_rows:
|
||||
update_values = []
|
||||
@@ -829,12 +982,28 @@ def _sync_chat_realtime_messages_for_table(
|
||||
update_values.append((pdata, int(r.get("local_id") or 0)))
|
||||
if update_values:
|
||||
before_changes = msg_conn.total_changes
|
||||
update_t0 = time.perf_counter()
|
||||
msg_conn.executemany(
|
||||
f"UPDATE {quoted_table} SET packed_info_data = ? WHERE local_id = ? AND (packed_info_data IS NULL OR length(packed_info_data) = 0)",
|
||||
update_values,
|
||||
)
|
||||
msg_conn.commit()
|
||||
update_ms = (time.perf_counter() - update_t0) * 1000.0
|
||||
backfilled = int(msg_conn.total_changes - before_changes)
|
||||
logger.info(
|
||||
"[realtime] sqlite backfill done account=%s username=%s rows=%s ms=%.1f",
|
||||
account_dir.name,
|
||||
username,
|
||||
int(backfilled),
|
||||
update_ms,
|
||||
)
|
||||
if update_ms > 1000:
|
||||
logger.warning(
|
||||
"[realtime] sqlite backfill slow account=%s username=%s ms=%.1f",
|
||||
account_dir.name,
|
||||
username,
|
||||
update_ms,
|
||||
)
|
||||
|
||||
newest = new_rows[0] if new_rows else None
|
||||
preview = ""
|
||||
@@ -938,7 +1107,7 @@ def _sync_chat_realtime_messages_for_table(
|
||||
|
||||
|
||||
@router.post("/api/chat/realtime/sync_all", summary="实时消息同步到解密库(全会话增量)")
|
||||
async def sync_chat_realtime_messages_all(
|
||||
def sync_chat_realtime_messages_all(
|
||||
request: Request,
|
||||
account: Optional[str] = None,
|
||||
max_scan: int = 200,
|
||||
@@ -953,6 +1122,16 @@ async def sync_chat_realtime_messages_all(
|
||||
说明:这是增量同步,不会每次全表扫描;priority_username 会优先同步并可设置更大的 priority_max_scan。
|
||||
"""
|
||||
account_dir = _resolve_account_dir(account)
|
||||
trace_id = f"rt-syncall-{int(time.time() * 1000)}-{threading.get_ident()}"
|
||||
logger.info(
|
||||
"[%s] realtime sync_all start account=%s max_scan=%s priority=%s include_hidden=%s include_official=%s",
|
||||
trace_id,
|
||||
account_dir.name,
|
||||
int(max_scan),
|
||||
str(priority_username or "").strip(),
|
||||
bool(include_hidden),
|
||||
bool(include_official),
|
||||
)
|
||||
|
||||
if max_scan < 20:
|
||||
max_scan = 20
|
||||
@@ -966,15 +1145,29 @@ async def sync_chat_realtime_messages_all(
|
||||
priority = str(priority_username or "").strip()
|
||||
started = time.time()
|
||||
|
||||
logger.info("[%s] acquiring global sync lock account=%s", trace_id, account_dir.name)
|
||||
with _realtime_sync_all_lock(account_dir.name):
|
||||
logger.info("[%s] global sync lock acquired account=%s", trace_id, account_dir.name)
|
||||
try:
|
||||
logger.info("[%s] ensure wcdb connected account=%s", trace_id, account_dir.name)
|
||||
rt_conn = WCDB_REALTIME.ensure_connected(account_dir)
|
||||
logger.info("[%s] wcdb connected account=%s handle=%s", trace_id, account_dir.name, int(rt_conn.handle))
|
||||
except WCDBRealtimeError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
try:
|
||||
logger.info("[%s] wcdb_get_sessions account=%s", trace_id, account_dir.name)
|
||||
wcdb_t0 = time.perf_counter()
|
||||
with rt_conn.lock:
|
||||
raw_sessions = _wcdb_get_sessions(rt_conn.handle)
|
||||
wcdb_ms = (time.perf_counter() - wcdb_t0) * 1000.0
|
||||
logger.info(
|
||||
"[%s] wcdb_get_sessions done account=%s sessions=%s ms=%.1f",
|
||||
trace_id,
|
||||
account_dir.name,
|
||||
len(raw_sessions or []),
|
||||
wcdb_ms,
|
||||
)
|
||||
except Exception:
|
||||
raw_sessions = []
|
||||
|
||||
@@ -1018,6 +1211,13 @@ async def sync_chat_realtime_messages_all(
|
||||
sessions = _dedupe(sessions)
|
||||
sessions.sort(key=lambda x: int(x[0] or 0), reverse=True)
|
||||
all_usernames = [u for _, u in sessions if u]
|
||||
logger.info(
|
||||
"[%s] sessions prepared account=%s raw=%s filtered=%s",
|
||||
trace_id,
|
||||
account_dir.name,
|
||||
len(raw_sessions or []),
|
||||
len(all_usernames),
|
||||
)
|
||||
|
||||
# Skip sessions whose decrypted session.db already has a newer/equal sort_timestamp.
|
||||
decrypted_ts_by_user: dict[str, int] = {}
|
||||
@@ -1080,10 +1280,25 @@ async def sync_chat_realtime_messages_all(
|
||||
continue
|
||||
sync_usernames.append(u)
|
||||
|
||||
logger.info(
|
||||
"[%s] sessions need_sync account=%s need_sync=%s skipped_up_to_date=%s",
|
||||
trace_id,
|
||||
account_dir.name,
|
||||
len(sync_usernames),
|
||||
int(skipped_up_to_date),
|
||||
)
|
||||
|
||||
if priority and priority in sync_usernames:
|
||||
sync_usernames = [priority] + [u for u in sync_usernames if u != priority]
|
||||
|
||||
table_map = _resolve_decrypted_message_tables(account_dir, sync_usernames)
|
||||
logger.info(
|
||||
"[%s] resolved decrypted tables account=%s resolved=%s need_sync=%s",
|
||||
trace_id,
|
||||
account_dir.name,
|
||||
len(table_map),
|
||||
len(sync_usernames),
|
||||
)
|
||||
|
||||
scanned_total = 0
|
||||
inserted_total = 0
|
||||
@@ -1116,17 +1331,50 @@ async def sync_chat_realtime_messages_all(
|
||||
inserted_total += ins
|
||||
if ins:
|
||||
updated_sessions += 1
|
||||
logger.info(
|
||||
"[%s] synced session account=%s username=%s inserted=%s scanned=%s",
|
||||
trace_id,
|
||||
account_dir.name,
|
||||
uname,
|
||||
ins,
|
||||
int(result.get("scanned") or 0),
|
||||
)
|
||||
except HTTPException as e:
|
||||
errors.append(f"{uname}: {str(e.detail or '')}".strip())
|
||||
logger.warning(
|
||||
"[%s] sync session failed account=%s username=%s err=%s",
|
||||
trace_id,
|
||||
account_dir.name,
|
||||
uname,
|
||||
str(e.detail or "").strip(),
|
||||
)
|
||||
continue
|
||||
except Exception as e:
|
||||
errors.append(f"{uname}: {str(e)}".strip())
|
||||
logger.exception(
|
||||
"[%s] sync session crashed account=%s username=%s",
|
||||
trace_id,
|
||||
account_dir.name,
|
||||
uname,
|
||||
)
|
||||
continue
|
||||
|
||||
elapsed_ms = int((time.time() - started) * 1000)
|
||||
if len(errors) > 20:
|
||||
errors = errors[:20] + [f"... and {len(errors) - 20} more"]
|
||||
|
||||
logger.info(
|
||||
"[%s] realtime sync_all done account=%s sessions_total=%s need_sync=%s synced=%s updated=%s inserted_total=%s elapsed_ms=%s errors=%s",
|
||||
trace_id,
|
||||
account_dir.name,
|
||||
len(all_usernames),
|
||||
len(sync_usernames),
|
||||
int(synced),
|
||||
int(updated_sessions),
|
||||
int(inserted_total),
|
||||
int(elapsed_ms),
|
||||
len(errors),
|
||||
)
|
||||
return {
|
||||
"status": "success",
|
||||
"account": account_dir.name,
|
||||
@@ -1985,6 +2233,35 @@ def _postprocess_full_messages(
|
||||
sender_contact_rows = _load_contact_rows(contact_db_path, uniq_senders)
|
||||
local_sender_avatars = _query_head_image_usernames(head_image_db_path, uniq_senders)
|
||||
|
||||
# contact.db may not include enterprise/openim contacts (or group chatroom records). WCDB has a more complete
|
||||
# view of display names + avatar URLs, so we use it as a best-effort fallback.
|
||||
wcdb_display_names: dict[str, str] = {}
|
||||
wcdb_avatar_urls: dict[str, str] = {}
|
||||
try:
|
||||
need_display: list[str] = []
|
||||
need_avatar: list[str] = []
|
||||
for u in uniq_senders:
|
||||
if not u:
|
||||
continue
|
||||
row = sender_contact_rows.get(u)
|
||||
if _pick_display_name(row, u) == u:
|
||||
need_display.append(u)
|
||||
if (not _pick_avatar_url(row)) and (u not in local_sender_avatars):
|
||||
need_avatar.append(u)
|
||||
|
||||
need_display = list(dict.fromkeys(need_display))
|
||||
need_avatar = list(dict.fromkeys(need_avatar))
|
||||
if need_display or need_avatar:
|
||||
wcdb_conn = WCDB_REALTIME.ensure_connected(account_dir)
|
||||
with wcdb_conn.lock:
|
||||
if need_display:
|
||||
wcdb_display_names = _wcdb_get_display_names(wcdb_conn.handle, need_display)
|
||||
if need_avatar:
|
||||
wcdb_avatar_urls = _wcdb_get_avatar_urls(wcdb_conn.handle, need_avatar)
|
||||
except Exception:
|
||||
wcdb_display_names = {}
|
||||
wcdb_avatar_urls = {}
|
||||
|
||||
for m in merged:
|
||||
# If appmsg doesn't provide sourcedisplayname, try mapping sourceusername to display name.
|
||||
if (not str(m.get("from") or "").strip()) and str(m.get("fromUsername") or "").strip():
|
||||
@@ -1992,15 +2269,28 @@ def _postprocess_full_messages(
|
||||
frow = sender_contact_rows.get(fu)
|
||||
if frow is not None:
|
||||
m["from"] = _pick_display_name(frow, fu)
|
||||
else:
|
||||
wd = str(wcdb_display_names.get(fu) or "").strip()
|
||||
if wd:
|
||||
m["from"] = wd
|
||||
|
||||
su = str(m.get("senderUsername") or "")
|
||||
if not su:
|
||||
continue
|
||||
row = sender_contact_rows.get(su)
|
||||
m["senderDisplayName"] = _pick_display_name(row, su)
|
||||
display_name = _pick_display_name(row, su)
|
||||
if display_name == su:
|
||||
wd = str(wcdb_display_names.get(su) or "").strip()
|
||||
if wd and wd != su:
|
||||
display_name = wd
|
||||
m["senderDisplayName"] = display_name
|
||||
avatar_url = _pick_avatar_url(row)
|
||||
if not avatar_url and su in local_sender_avatars:
|
||||
avatar_url = base_url + _build_avatar_url(account_dir.name, su)
|
||||
if not avatar_url:
|
||||
wa = str(wcdb_avatar_urls.get(su) or "").strip()
|
||||
if wa.lower().startswith(("http://", "https://")):
|
||||
avatar_url = wa
|
||||
m["senderAvatar"] = avatar_url
|
||||
|
||||
qu = str(m.get("quoteUsername") or "").strip()
|
||||
@@ -2016,9 +2306,15 @@ def _postprocess_full_messages(
|
||||
if remark:
|
||||
m["quoteTitle"] = remark
|
||||
elif not qt:
|
||||
m["quoteTitle"] = _pick_display_name(qrow, qu)
|
||||
title = _pick_display_name(qrow, qu)
|
||||
if title == qu:
|
||||
wd = str(wcdb_display_names.get(qu) or "").strip()
|
||||
if wd and wd != qu:
|
||||
title = wd
|
||||
m["quoteTitle"] = title
|
||||
elif not qt:
|
||||
m["quoteTitle"] = qu
|
||||
wd = str(wcdb_display_names.get(qu) or "").strip()
|
||||
m["quoteTitle"] = wd or qu
|
||||
|
||||
# Media URL fallback: if CDN URLs missing, use local media endpoints.
|
||||
try:
|
||||
@@ -2134,7 +2430,7 @@ async def list_chat_accounts():
|
||||
|
||||
|
||||
@router.get("/api/chat/sessions", summary="获取会话列表(聊天左侧列表)")
|
||||
async def list_chat_sessions(
|
||||
def list_chat_sessions(
|
||||
request: Request,
|
||||
account: Optional[str] = None,
|
||||
limit: int = 400,
|
||||
@@ -2155,12 +2451,36 @@ async def list_chat_sessions(
|
||||
head_image_db_path = account_dir / "head_image.db"
|
||||
base_url = str(request.base_url).rstrip("/")
|
||||
|
||||
rt_conn = None
|
||||
rows: list[Any]
|
||||
if source_norm == "realtime":
|
||||
trace_id = f"rt-sessions-{int(time.time() * 1000)}-{threading.get_ident()}"
|
||||
logger.info(
|
||||
"[%s] list_sessions realtime start account=%s limit=%s include_hidden=%s include_official=%s preview=%s",
|
||||
trace_id,
|
||||
account_dir.name,
|
||||
int(limit),
|
||||
bool(include_hidden),
|
||||
bool(include_official),
|
||||
str(preview or ""),
|
||||
)
|
||||
try:
|
||||
logger.info("[%s] ensure wcdb connected account=%s", trace_id, account_dir.name)
|
||||
conn = WCDB_REALTIME.ensure_connected(account_dir)
|
||||
rt_conn = conn
|
||||
logger.info("[%s] wcdb connected account=%s handle=%s", trace_id, account_dir.name, int(conn.handle))
|
||||
logger.info("[%s] wcdb_get_sessions account=%s", trace_id, account_dir.name)
|
||||
wcdb_t0 = time.perf_counter()
|
||||
with conn.lock:
|
||||
raw = _wcdb_get_sessions(conn.handle)
|
||||
wcdb_ms = (time.perf_counter() - wcdb_t0) * 1000.0
|
||||
logger.info(
|
||||
"[%s] wcdb_get_sessions done account=%s sessions=%s ms=%.1f",
|
||||
trace_id,
|
||||
account_dir.name,
|
||||
len(raw or []),
|
||||
wcdb_ms,
|
||||
)
|
||||
except WCDBRealtimeError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
@@ -2193,6 +2513,7 @@ async def list_chat_sessions(
|
||||
|
||||
norm.sort(key=lambda r: _ts(r.get("sort_timestamp")), reverse=True)
|
||||
rows = norm
|
||||
logger.info("[%s] list_sessions realtime normalized account=%s rows=%s", trace_id, account_dir.name, len(rows))
|
||||
else:
|
||||
session_db_path = account_dir / "session.db"
|
||||
sconn = sqlite3.connect(str(session_db_path))
|
||||
@@ -2260,6 +2581,36 @@ async def list_chat_sessions(
|
||||
contact_rows = _load_contact_rows(contact_db_path, usernames)
|
||||
local_avatar_usernames = _query_head_image_usernames(head_image_db_path, usernames)
|
||||
|
||||
# Some sessions (notably enterprise groups / openim-related IDs) may be missing from decrypted contact.db
|
||||
# (or lack nickname/avatar columns). In that case, fall back to WCDB APIs (same as WeFlow) to resolve
|
||||
# display names + avatar URLs.
|
||||
wcdb_display_names: dict[str, str] = {}
|
||||
wcdb_avatar_urls: dict[str, str] = {}
|
||||
try:
|
||||
need_display: list[str] = []
|
||||
need_avatar: list[str] = []
|
||||
for u in usernames:
|
||||
if not u:
|
||||
continue
|
||||
row = contact_rows.get(u)
|
||||
if _pick_display_name(row, u) == u:
|
||||
need_display.append(u)
|
||||
if (not _pick_avatar_url(row)) and (u not in local_avatar_usernames):
|
||||
need_avatar.append(u)
|
||||
|
||||
need_display = list(dict.fromkeys(need_display))
|
||||
need_avatar = list(dict.fromkeys(need_avatar))
|
||||
if need_display or need_avatar:
|
||||
wcdb_conn = rt_conn or WCDB_REALTIME.ensure_connected(account_dir)
|
||||
with wcdb_conn.lock:
|
||||
if need_display:
|
||||
wcdb_display_names = _wcdb_get_display_names(wcdb_conn.handle, need_display)
|
||||
if need_avatar:
|
||||
wcdb_avatar_urls = _wcdb_get_avatar_urls(wcdb_conn.handle, need_avatar)
|
||||
except Exception:
|
||||
wcdb_display_names = {}
|
||||
wcdb_avatar_urls = {}
|
||||
|
||||
preview_mode = str(preview or "").strip().lower()
|
||||
if preview_mode not in {"latest", "index", "session", "db", "none"}:
|
||||
preview_mode = "latest"
|
||||
@@ -2299,9 +2650,18 @@ async def list_chat_sessions(
|
||||
c_row = contact_rows.get(username)
|
||||
|
||||
display_name = _pick_display_name(c_row, username)
|
||||
if display_name == username:
|
||||
wd = str(wcdb_display_names.get(username) or "").strip()
|
||||
if wd and wd != username:
|
||||
display_name = wd
|
||||
|
||||
avatar_url = _pick_avatar_url(c_row)
|
||||
if not avatar_url and username in local_avatar_usernames:
|
||||
avatar_url = base_url + _build_avatar_url(account_dir.name, username)
|
||||
if not avatar_url:
|
||||
wa = str(wcdb_avatar_urls.get(username) or "").strip()
|
||||
if wa.lower().startswith(("http://", "https://")):
|
||||
avatar_url = wa
|
||||
|
||||
last_message = ""
|
||||
if preview_mode == "session":
|
||||
@@ -2873,7 +3233,7 @@ def _collect_chat_messages(
|
||||
|
||||
|
||||
@router.get("/api/chat/messages", summary="获取会话消息列表")
|
||||
async def list_chat_messages(
|
||||
def list_chat_messages(
|
||||
request: Request,
|
||||
username: str,
|
||||
account: Optional[str] = None,
|
||||
@@ -3627,6 +3987,35 @@ async def list_chat_messages(
|
||||
sender_contact_rows = _load_contact_rows(contact_db_path, uniq_senders)
|
||||
local_sender_avatars = _query_head_image_usernames(head_image_db_path, uniq_senders)
|
||||
|
||||
# contact.db may not include enterprise/openim contacts (or group chatroom records). WCDB has a more complete
|
||||
# view of display names + avatar URLs, so we use it as a best-effort fallback.
|
||||
wcdb_display_names: dict[str, str] = {}
|
||||
wcdb_avatar_urls: dict[str, str] = {}
|
||||
try:
|
||||
need_display: list[str] = []
|
||||
need_avatar: list[str] = []
|
||||
for u in uniq_senders:
|
||||
if not u:
|
||||
continue
|
||||
row = sender_contact_rows.get(u)
|
||||
if _pick_display_name(row, u) == u:
|
||||
need_display.append(u)
|
||||
if (not _pick_avatar_url(row)) and (u not in local_sender_avatars):
|
||||
need_avatar.append(u)
|
||||
|
||||
need_display = list(dict.fromkeys(need_display))
|
||||
need_avatar = list(dict.fromkeys(need_avatar))
|
||||
if need_display or need_avatar:
|
||||
wcdb_conn = WCDB_REALTIME.ensure_connected(account_dir)
|
||||
with wcdb_conn.lock:
|
||||
if need_display:
|
||||
wcdb_display_names = _wcdb_get_display_names(wcdb_conn.handle, need_display)
|
||||
if need_avatar:
|
||||
wcdb_avatar_urls = _wcdb_get_avatar_urls(wcdb_conn.handle, need_avatar)
|
||||
except Exception:
|
||||
wcdb_display_names = {}
|
||||
wcdb_avatar_urls = {}
|
||||
|
||||
for m in merged:
|
||||
# If appmsg doesn't provide sourcedisplayname, try mapping sourceusername to display name.
|
||||
if (not str(m.get("from") or "").strip()) and str(m.get("fromUsername") or "").strip():
|
||||
@@ -3634,15 +4023,28 @@ async def list_chat_messages(
|
||||
frow = sender_contact_rows.get(fu)
|
||||
if frow is not None:
|
||||
m["from"] = _pick_display_name(frow, fu)
|
||||
else:
|
||||
wd = str(wcdb_display_names.get(fu) or "").strip()
|
||||
if wd:
|
||||
m["from"] = wd
|
||||
|
||||
su = str(m.get("senderUsername") or "")
|
||||
if not su:
|
||||
continue
|
||||
row = sender_contact_rows.get(su)
|
||||
m["senderDisplayName"] = _pick_display_name(row, su)
|
||||
display_name = _pick_display_name(row, su)
|
||||
if display_name == su:
|
||||
wd = str(wcdb_display_names.get(su) or "").strip()
|
||||
if wd and wd != su:
|
||||
display_name = wd
|
||||
m["senderDisplayName"] = display_name
|
||||
avatar_url = _pick_avatar_url(row)
|
||||
if not avatar_url and su in local_sender_avatars:
|
||||
avatar_url = base_url + _build_avatar_url(account_dir.name, su)
|
||||
if not avatar_url:
|
||||
wa = str(wcdb_avatar_urls.get(su) or "").strip()
|
||||
if wa.lower().startswith(("http://", "https://")):
|
||||
avatar_url = wa
|
||||
m["senderAvatar"] = avatar_url
|
||||
|
||||
qu = str(m.get("quoteUsername") or "").strip()
|
||||
@@ -3658,9 +4060,15 @@ async def list_chat_messages(
|
||||
if remark:
|
||||
m["quoteTitle"] = remark
|
||||
elif not qt:
|
||||
m["quoteTitle"] = _pick_display_name(qrow, qu)
|
||||
title = _pick_display_name(qrow, qu)
|
||||
if title == qu:
|
||||
wd = str(wcdb_display_names.get(qu) or "").strip()
|
||||
if wd and wd != qu:
|
||||
title = wd
|
||||
m["quoteTitle"] = title
|
||||
elif not qt:
|
||||
m["quoteTitle"] = qu
|
||||
wd = str(wcdb_display_names.get(qu) or "").strip()
|
||||
m["quoteTitle"] = wd or qu
|
||||
|
||||
# Media URL fallback: if CDN URLs missing, use local media endpoints.
|
||||
try:
|
||||
@@ -4088,11 +4496,48 @@ async def _search_chat_messages_via_fts(
|
||||
uniq_usernames = list(dict.fromkeys([username] + [str(x.get("senderUsername") or "") for x in hits]))
|
||||
contact_rows = _load_contact_rows(contact_db_path, uniq_usernames)
|
||||
local_avatar_usernames = _query_head_image_usernames(head_image_db_path, uniq_usernames)
|
||||
|
||||
wcdb_display_names: dict[str, str] = {}
|
||||
wcdb_avatar_urls: dict[str, str] = {}
|
||||
try:
|
||||
need_display: list[str] = []
|
||||
need_avatar: list[str] = []
|
||||
for u in uniq_usernames:
|
||||
uu = str(u or "").strip()
|
||||
if not uu:
|
||||
continue
|
||||
row = contact_rows.get(uu)
|
||||
if _pick_display_name(row, uu) == uu:
|
||||
need_display.append(uu)
|
||||
if (not _pick_avatar_url(row)) and (uu not in local_avatar_usernames):
|
||||
need_avatar.append(uu)
|
||||
|
||||
need_display = list(dict.fromkeys(need_display))
|
||||
need_avatar = list(dict.fromkeys(need_avatar))
|
||||
if need_display or need_avatar:
|
||||
wcdb_conn = WCDB_REALTIME.ensure_connected(account_dir)
|
||||
with wcdb_conn.lock:
|
||||
if need_display:
|
||||
wcdb_display_names = _wcdb_get_display_names(wcdb_conn.handle, need_display)
|
||||
if need_avatar:
|
||||
wcdb_avatar_urls = _wcdb_get_avatar_urls(wcdb_conn.handle, need_avatar)
|
||||
except Exception:
|
||||
wcdb_display_names = {}
|
||||
wcdb_avatar_urls = {}
|
||||
|
||||
conv_row = contact_rows.get(username)
|
||||
conv_name = _pick_display_name(conv_row, username)
|
||||
if conv_name == username:
|
||||
wd = str(wcdb_display_names.get(username) or "").strip()
|
||||
if wd and wd != username:
|
||||
conv_name = wd
|
||||
conv_avatar = _pick_avatar_url(conv_row)
|
||||
if (not conv_avatar) and (username in local_avatar_usernames):
|
||||
conv_avatar = base_url + _build_avatar_url(account_dir.name, username)
|
||||
if not conv_avatar:
|
||||
wa = str(wcdb_avatar_urls.get(username) or "").strip()
|
||||
if wa.lower().startswith(("http://", "https://")):
|
||||
conv_avatar = wa
|
||||
|
||||
for h in hits:
|
||||
su = str(h.get("senderUsername") or "").strip()
|
||||
@@ -4100,14 +4545,19 @@ async def _search_chat_messages_via_fts(
|
||||
h["conversationAvatar"] = conv_avatar
|
||||
if su:
|
||||
row = contact_rows.get(su)
|
||||
h["senderDisplayName"] = (
|
||||
_pick_display_name(row, su)
|
||||
if row is not None
|
||||
else (conv_name if su == username else su)
|
||||
)
|
||||
display_name = _pick_display_name(row, su) if row is not None else (conv_name if su == username else su)
|
||||
if display_name == su:
|
||||
wd = str(wcdb_display_names.get(su) or "").strip()
|
||||
if wd and wd != su:
|
||||
display_name = wd
|
||||
h["senderDisplayName"] = display_name
|
||||
avatar_url = _pick_avatar_url(row)
|
||||
if (not avatar_url) and (su in local_avatar_usernames):
|
||||
avatar_url = base_url + _build_avatar_url(account_dir.name, su)
|
||||
if not avatar_url:
|
||||
wa = str(wcdb_avatar_urls.get(su) or "").strip()
|
||||
if wa.lower().startswith(("http://", "https://")):
|
||||
avatar_url = wa
|
||||
h["senderAvatar"] = avatar_url
|
||||
else:
|
||||
uniq_contacts = list(
|
||||
@@ -4118,24 +4568,67 @@ async def _search_chat_messages_via_fts(
|
||||
contact_rows = _load_contact_rows(contact_db_path, uniq_contacts)
|
||||
local_avatar_usernames = _query_head_image_usernames(head_image_db_path, uniq_contacts)
|
||||
|
||||
wcdb_display_names: dict[str, str] = {}
|
||||
wcdb_avatar_urls: dict[str, str] = {}
|
||||
try:
|
||||
need_display: list[str] = []
|
||||
need_avatar: list[str] = []
|
||||
for u in uniq_contacts:
|
||||
uu = str(u or "").strip()
|
||||
if not uu:
|
||||
continue
|
||||
row = contact_rows.get(uu)
|
||||
if _pick_display_name(row, uu) == uu:
|
||||
need_display.append(uu)
|
||||
if (not _pick_avatar_url(row)) and (uu not in local_avatar_usernames):
|
||||
need_avatar.append(uu)
|
||||
|
||||
need_display = list(dict.fromkeys(need_display))
|
||||
need_avatar = list(dict.fromkeys(need_avatar))
|
||||
if need_display or need_avatar:
|
||||
wcdb_conn = WCDB_REALTIME.ensure_connected(account_dir)
|
||||
with wcdb_conn.lock:
|
||||
if need_display:
|
||||
wcdb_display_names = _wcdb_get_display_names(wcdb_conn.handle, need_display)
|
||||
if need_avatar:
|
||||
wcdb_avatar_urls = _wcdb_get_avatar_urls(wcdb_conn.handle, need_avatar)
|
||||
except Exception:
|
||||
wcdb_display_names = {}
|
||||
wcdb_avatar_urls = {}
|
||||
|
||||
for h in hits:
|
||||
cu = str(h.get("username") or "").strip()
|
||||
su = str(h.get("senderUsername") or "").strip()
|
||||
crow = contact_rows.get(cu)
|
||||
conv_name = _pick_display_name(crow, cu) if cu else ""
|
||||
if cu and (conv_name == cu):
|
||||
wd = str(wcdb_display_names.get(cu) or "").strip()
|
||||
if wd and wd != cu:
|
||||
conv_name = wd
|
||||
h["conversationName"] = conv_name or cu
|
||||
conv_avatar = _pick_avatar_url(crow)
|
||||
if (not conv_avatar) and cu and (cu in local_avatar_usernames):
|
||||
conv_avatar = base_url + _build_avatar_url(account_dir.name, cu)
|
||||
if not conv_avatar and cu:
|
||||
wa = str(wcdb_avatar_urls.get(cu) or "").strip()
|
||||
if wa.lower().startswith(("http://", "https://")):
|
||||
conv_avatar = wa
|
||||
h["conversationAvatar"] = conv_avatar
|
||||
if su:
|
||||
row = contact_rows.get(su)
|
||||
h["senderDisplayName"] = (
|
||||
_pick_display_name(row, su) if row is not None else (conv_name if su == cu else su)
|
||||
)
|
||||
display_name = _pick_display_name(row, su) if row is not None else (conv_name if su == cu else su)
|
||||
if display_name == su:
|
||||
wd = str(wcdb_display_names.get(su) or "").strip()
|
||||
if wd and wd != su:
|
||||
display_name = wd
|
||||
h["senderDisplayName"] = display_name
|
||||
avatar_url = _pick_avatar_url(row)
|
||||
if (not avatar_url) and (su in local_avatar_usernames):
|
||||
avatar_url = base_url + _build_avatar_url(account_dir.name, su)
|
||||
if not avatar_url:
|
||||
wa = str(wcdb_avatar_urls.get(su) or "").strip()
|
||||
if wa.lower().startswith(("http://", "https://")):
|
||||
avatar_url = wa
|
||||
h["senderAvatar"] = avatar_url
|
||||
|
||||
return {
|
||||
|
||||
@@ -414,7 +414,7 @@ def _is_allowed_proxy_image_host(host: str) -> bool:
|
||||
if not h:
|
||||
return False
|
||||
# WeChat public account/article thumbnails and avatars commonly live on these CDNs.
|
||||
return h.endswith(".qpic.cn") or h.endswith(".qlogo.cn")
|
||||
return h.endswith(".qpic.cn") or h.endswith(".qlogo.cn") or h.endswith(".tc.qq.com")
|
||||
|
||||
|
||||
@router.get("/api/chat/media/proxy_image", summary="代理获取远程图片(解决微信公众号图片防盗链)")
|
||||
@@ -435,13 +435,24 @@ async def proxy_image(url: str):
|
||||
raise HTTPException(status_code=400, detail="Unsupported url host for proxy_image.")
|
||||
|
||||
def _download_bytes() -> tuple[bytes, str]:
|
||||
headers = {
|
||||
base_headers = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36",
|
||||
"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
|
||||
# qpic/qlogo often require a mp.weixin.qq.com referer (anti-hotlink)
|
||||
"Referer": "https://mp.weixin.qq.com/",
|
||||
"Origin": "https://mp.weixin.qq.com",
|
||||
}
|
||||
|
||||
# Different Tencent CDNs enforce different anti-hotlink rules.
|
||||
# Try a couple of safe referers so Moments(qpic) and MP(qpic) both work.
|
||||
header_variants = [
|
||||
{"Referer": "https://wx.qq.com/", "Origin": "https://wx.qq.com"},
|
||||
{"Referer": "https://mp.weixin.qq.com/", "Origin": "https://mp.weixin.qq.com"},
|
||||
{"Referer": "https://www.baidu.com/", "Origin": "https://www.baidu.com"},
|
||||
{},
|
||||
]
|
||||
|
||||
last_err: Exception | None = None
|
||||
for extra in header_variants:
|
||||
headers = dict(base_headers)
|
||||
headers.update(extra)
|
||||
r = requests.get(u, headers=headers, timeout=20, stream=True)
|
||||
try:
|
||||
r.raise_for_status()
|
||||
@@ -457,12 +468,20 @@ async def proxy_image(url: str):
|
||||
if total > max_bytes:
|
||||
raise HTTPException(status_code=400, detail="Proxy image too large (>10MB).")
|
||||
return b"".join(chunks), content_type
|
||||
except HTTPException:
|
||||
# Hard failure, don't retry with another referer.
|
||||
raise
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
finally:
|
||||
try:
|
||||
r.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# All variants failed.
|
||||
raise last_err or RuntimeError("proxy_image download failed")
|
||||
|
||||
try:
|
||||
data, ct = await asyncio.to_thread(_download_bytes)
|
||||
except HTTPException:
|
||||
|
||||
1068
src/wechat_decrypt_tool/routers/sns.py
Normal file
1068
src/wechat_decrypt_tool/routers/sns.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -68,6 +68,13 @@ def _load_wcdb_lib() -> ctypes.CDLL:
|
||||
lib.wcdb_close_account.argtypes = [ctypes.c_int64]
|
||||
lib.wcdb_close_account.restype = ctypes.c_int
|
||||
|
||||
# Optional: wcdb_set_my_wxid(handle, wxid)
|
||||
try:
|
||||
lib.wcdb_set_my_wxid.argtypes = [ctypes.c_int64, ctypes.c_char_p]
|
||||
lib.wcdb_set_my_wxid.restype = ctypes.c_int
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
lib.wcdb_get_sessions.argtypes = [ctypes.c_int64, ctypes.POINTER(ctypes.c_char_p)]
|
||||
lib.wcdb_get_sessions.restype = ctypes.c_int
|
||||
|
||||
@@ -95,6 +102,37 @@ def _load_wcdb_lib() -> ctypes.CDLL:
|
||||
lib.wcdb_get_group_members.argtypes = [ctypes.c_int64, ctypes.c_char_p, ctypes.POINTER(ctypes.c_char_p)]
|
||||
lib.wcdb_get_group_members.restype = ctypes.c_int
|
||||
|
||||
# Optional: execute arbitrary SQL on a selected database kind/path.
|
||||
# Signature: wcdb_exec_query(handle, kind, path, sql, out_json)
|
||||
try:
|
||||
lib.wcdb_exec_query.argtypes = [
|
||||
ctypes.c_int64,
|
||||
ctypes.c_char_p,
|
||||
ctypes.c_char_p,
|
||||
ctypes.c_char_p,
|
||||
ctypes.POINTER(ctypes.c_char_p),
|
||||
]
|
||||
lib.wcdb_exec_query.restype = ctypes.c_int
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Optional (newer DLLs): wcdb_get_sns_timeline(handle, limit, offset, usernames_json, keyword, start_time, end_time, out_json)
|
||||
try:
|
||||
lib.wcdb_get_sns_timeline.argtypes = [
|
||||
ctypes.c_int64,
|
||||
ctypes.c_int32,
|
||||
ctypes.c_int32,
|
||||
ctypes.c_char_p,
|
||||
ctypes.c_char_p,
|
||||
ctypes.c_int32,
|
||||
ctypes.c_int32,
|
||||
ctypes.POINTER(ctypes.c_char_p),
|
||||
]
|
||||
lib.wcdb_get_sns_timeline.restype = ctypes.c_int
|
||||
except Exception:
|
||||
# Older wcdb_api.dll may not expose this export.
|
||||
pass
|
||||
|
||||
lib.wcdb_get_logs.argtypes = [ctypes.POINTER(ctypes.c_char_p)]
|
||||
lib.wcdb_get_logs.restype = ctypes.c_int
|
||||
|
||||
@@ -195,6 +233,30 @@ def open_account(session_db_path: Path, key_hex: str) -> int:
|
||||
return int(out_handle.value)
|
||||
|
||||
|
||||
def set_my_wxid(handle: int, wxid: str) -> bool:
|
||||
"""Best-effort set the "my wxid" context for some WCDB APIs."""
|
||||
try:
|
||||
_ensure_initialized()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
lib = _load_wcdb_lib()
|
||||
fn = getattr(lib, "wcdb_set_my_wxid", None)
|
||||
if not fn:
|
||||
return False
|
||||
|
||||
w = str(wxid or "").strip()
|
||||
if not w:
|
||||
return False
|
||||
|
||||
try:
|
||||
rc = int(fn(ctypes.c_int64(int(handle)), w.encode("utf-8")))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
return rc == 0
|
||||
|
||||
|
||||
def close_account(handle: int) -> None:
|
||||
try:
|
||||
h = int(handle)
|
||||
@@ -293,6 +355,93 @@ def get_avatar_urls(handle: int, usernames: list[str]) -> dict[str, str]:
|
||||
return {}
|
||||
|
||||
|
||||
def exec_query(handle: int, *, kind: str, path: Optional[str], sql: str) -> list[dict[str, Any]]:
|
||||
"""Execute raw SQL on a specific db kind/path via WCDB.
|
||||
|
||||
This is primarily used for SNS/other dbs that are not directly exposed by dedicated APIs.
|
||||
"""
|
||||
_ensure_initialized()
|
||||
lib = _load_wcdb_lib()
|
||||
fn = getattr(lib, "wcdb_exec_query", None)
|
||||
if not fn:
|
||||
raise WCDBRealtimeError("Current wcdb_api.dll does not support exec_query.")
|
||||
|
||||
k = str(kind or "").strip()
|
||||
if not k:
|
||||
raise WCDBRealtimeError("Missing kind for exec_query.")
|
||||
|
||||
s = str(sql or "").strip()
|
||||
if not s:
|
||||
return []
|
||||
|
||||
p = None if path is None else str(path or "").strip()
|
||||
|
||||
out_json = _call_out_json(
|
||||
fn,
|
||||
ctypes.c_int64(int(handle)),
|
||||
k.encode("utf-8"),
|
||||
None if p is None else p.encode("utf-8"),
|
||||
s.encode("utf-8"),
|
||||
)
|
||||
decoded = _safe_load_json(out_json)
|
||||
if isinstance(decoded, list):
|
||||
out: list[dict[str, Any]] = []
|
||||
for x in decoded:
|
||||
if isinstance(x, dict):
|
||||
out.append(x)
|
||||
return out
|
||||
return []
|
||||
|
||||
|
||||
def get_sns_timeline(
|
||||
handle: int,
|
||||
*,
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
usernames: Optional[list[str]] = None,
|
||||
keyword: str | None = None,
|
||||
start_time: int = 0,
|
||||
end_time: int = 0,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Read Moments (SnsTimeLine) from the live encrypted db_storage via WCDB.
|
||||
|
||||
Requires a newer wcdb_api.dll export: wcdb_get_sns_timeline.
|
||||
"""
|
||||
_ensure_initialized()
|
||||
lib = _load_wcdb_lib()
|
||||
fn = getattr(lib, "wcdb_get_sns_timeline", None)
|
||||
if not fn:
|
||||
raise WCDBRealtimeError("Current wcdb_api.dll does not support sns timeline.")
|
||||
|
||||
lim = max(0, int(limit or 0))
|
||||
off = max(0, int(offset or 0))
|
||||
|
||||
users = [str(u or "").strip() for u in (usernames or []) if str(u or "").strip()]
|
||||
users = list(dict.fromkeys(users))
|
||||
users_json = json.dumps(users, ensure_ascii=False) if users else ""
|
||||
|
||||
kw = str(keyword or "").strip()
|
||||
|
||||
payload = _call_out_json(
|
||||
fn,
|
||||
ctypes.c_int64(int(handle)),
|
||||
ctypes.c_int32(lim),
|
||||
ctypes.c_int32(off),
|
||||
users_json.encode("utf-8"),
|
||||
kw.encode("utf-8"),
|
||||
ctypes.c_int32(int(start_time or 0)),
|
||||
ctypes.c_int32(int(end_time or 0)),
|
||||
)
|
||||
decoded = _safe_load_json(payload)
|
||||
if isinstance(decoded, list):
|
||||
out: list[dict[str, Any]] = []
|
||||
for x in decoded:
|
||||
if isinstance(x, dict):
|
||||
out.append(x)
|
||||
return out
|
||||
return []
|
||||
|
||||
|
||||
def shutdown() -> None:
|
||||
global _initialized
|
||||
lib = _load_wcdb_lib()
|
||||
@@ -427,6 +576,11 @@ class WCDBRealtimeManager:
|
||||
|
||||
session_db_path = _resolve_session_db_path(db_storage_dir)
|
||||
handle = open_account(session_db_path, key)
|
||||
# Some WCDB APIs (e.g. exec_query on non-session DBs) may require this context.
|
||||
try:
|
||||
set_my_wxid(handle, account)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
conn = WCDBRealtimeConnection(
|
||||
account=account,
|
||||
|
||||
Reference in New Issue
Block a user