fix(export): stabilize contact cache fallback and batched avatar enrich

This commit is contained in:
tisonhuang
2026-03-02 11:57:04 +08:00
parent cc5c323ccb
commit d1ef159e87

View File

@@ -228,6 +228,7 @@ const createTaskId = (): string => `task-${Date.now()}-${Math.random().toString(
const CONTACT_ENRICH_TIMEOUT_MS = 7000 const CONTACT_ENRICH_TIMEOUT_MS = 7000
const EXPORT_SNS_STATS_CACHE_STALE_MS = 12 * 60 * 60 * 1000 const EXPORT_SNS_STATS_CACHE_STALE_MS = 12 * 60 * 60 * 1000
const EXPORT_AVATAR_RECHECK_INTERVAL_MS = 24 * 60 * 60 * 1000 const EXPORT_AVATAR_RECHECK_INTERVAL_MS = 24 * 60 * 60 * 1000
const EXPORT_AVATAR_ENRICH_BATCH_SIZE = 80
type SessionDataSource = 'cache' | 'network' | null type SessionDataSource = 'cache' | 'network' | null
const withTimeout = async <T,>(promise: Promise<T>, timeoutMs: number): Promise<T | null> => { const withTimeout = async <T,>(promise: Promise<T>, timeoutMs: number): Promise<T | null> => {
@@ -482,25 +483,74 @@ function ExportPage() {
'default' 'default'
].filter(Boolean))) ].filter(Boolean)))
type CacheCandidate = {
scopeKey: string
contactsItem: configService.ContactsListCacheItem | null
avatarItem: configService.ContactsAvatarCacheItem | null
contactsCount: number
avatarCount: number
contactsUpdatedAt: number
avatarUpdatedAt: number
}
const candidatesWithData: CacheCandidate[] = []
for (const candidate of candidates) { for (const candidate of candidates) {
const [contactsItem, avatarItem] = await Promise.all([ const [contactsItem, avatarItem] = await Promise.all([
configService.getContactsListCache(candidate), configService.getContactsListCache(candidate),
configService.getContactsAvatarCache(candidate) configService.getContactsAvatarCache(candidate)
]) ])
const hasContacts = Boolean(contactsItem?.contacts?.length) const contactsCount = contactsItem?.contacts?.length || 0
const hasAvatars = Boolean(avatarItem && Object.keys(avatarItem.avatars || {}).length > 0) const avatarCount = avatarItem ? Object.keys(avatarItem.avatars || {}).length : 0
if (!hasContacts && !hasAvatars) continue if (contactsCount === 0 && avatarCount === 0) continue
return { candidatesWithData.push({
resolvedScopeKey: candidate, scopeKey: candidate,
contactsItem, contactsItem,
avatarItem avatarItem,
contactsCount,
avatarCount,
contactsUpdatedAt: contactsItem?.updatedAt || 0,
avatarUpdatedAt: avatarItem?.updatedAt || 0
})
}
if (candidatesWithData.length === 0) {
return {
resolvedContactsScopeKey: primaryScopeKey,
resolvedAvatarScopeKeys: [] as string[],
contactsItem: null as configService.ContactsListCacheItem | null,
avatarItem: null as configService.ContactsAvatarCacheItem | null
} }
} }
const bestContactsCandidate = candidatesWithData
.filter(item => item.contactsCount > 0)
.sort((a, b) => {
if (b.contactsCount !== a.contactsCount) return b.contactsCount - a.contactsCount
if (b.contactsUpdatedAt !== a.contactsUpdatedAt) return b.contactsUpdatedAt - a.contactsUpdatedAt
return b.avatarCount - a.avatarCount
})[0]
const avatarCandidates = candidatesWithData
.filter(item => item.avatarCount > 0)
.sort((a, b) => a.avatarUpdatedAt - b.avatarUpdatedAt)
const mergedAvatarEntries: Record<string, configService.ContactsAvatarCacheEntry> = {}
for (const candidate of avatarCandidates) {
Object.assign(mergedAvatarEntries, candidate.avatarItem?.avatars || {})
}
const mergedAvatarUpdatedAt = avatarCandidates.reduce((max, candidate) => (
candidate.avatarUpdatedAt > max ? candidate.avatarUpdatedAt : max
), 0)
return { return {
resolvedScopeKey: primaryScopeKey, resolvedContactsScopeKey: bestContactsCandidate?.scopeKey || primaryScopeKey,
contactsItem: null as configService.ContactsListCacheItem | null, resolvedAvatarScopeKeys: avatarCandidates.map(candidate => candidate.scopeKey),
avatarItem: null as configService.ContactsAvatarCacheItem | null contactsItem: bestContactsCandidate?.contactsItem || null,
avatarItem: Object.keys(mergedAvatarEntries).length > 0
? {
updatedAt: mergedAvatarUpdatedAt,
avatars: mergedAvatarEntries
}
: null
} }
}, []) }, [])
@@ -650,7 +700,8 @@ function ExportPage() {
if (isStale()) return if (isStale()) return
const { const {
resolvedScopeKey, resolvedContactsScopeKey,
resolvedAvatarScopeKeys,
contactsItem: cachedContactsItem, contactsItem: cachedContactsItem,
avatarItem: cachedAvatarItem avatarItem: cachedAvatarItem
} = await loadContactsCachesWithScopeFallback(scopeKey) } = await loadContactsCachesWithScopeFallback(scopeKey)
@@ -668,12 +719,12 @@ function ExportPage() {
setSessionContactsUpdatedAt(cachedContactsItem?.updatedAt || null) setSessionContactsUpdatedAt(cachedContactsItem?.updatedAt || null)
setSessionAvatarUpdatedAt(cachedAvatarItem?.updatedAt || null) setSessionAvatarUpdatedAt(cachedAvatarItem?.updatedAt || null)
if (resolvedScopeKey !== scopeKey && cachedContacts.length > 0) { if (resolvedContactsScopeKey !== scopeKey && cachedContacts.length > 0) {
void configService.setContactsListCache(scopeKey, cachedContacts).catch((error) => { void configService.setContactsListCache(scopeKey, cachedContacts).catch((error) => {
console.error('回填主 scope 通讯录缓存失败:', error) console.error('回填主 scope 通讯录缓存失败:', error)
}) })
} }
if (resolvedScopeKey !== scopeKey && Object.keys(cachedAvatarEntries).length > 0) { if (!resolvedAvatarScopeKeys.includes(scopeKey) && Object.keys(cachedAvatarEntries).length > 0) {
void configService.setContactsAvatarCache(scopeKey, cachedAvatarEntries).catch((error) => { void configService.setContactsAvatarCache(scopeKey, cachedAvatarEntries).catch((error) => {
console.error('回填主 scope 头像缓存失败:', error) console.error('回填主 scope 头像缓存失败:', error)
}) })
@@ -710,7 +761,6 @@ function ExportPage() {
let hasFreshNetworkData = false let hasFreshNetworkData = false
if (isStale()) return if (isStale()) return
if (cachedContacts.length === 0) {
const contactsResult = await withTimeout(window.electronAPI.chat.getContacts(), CONTACT_ENRICH_TIMEOUT_MS) const contactsResult = await withTimeout(window.electronAPI.chat.getContacts(), CONTACT_ENRICH_TIMEOUT_MS)
if (isStale()) return if (isStale()) return
@@ -725,7 +775,6 @@ function ExportPage() {
contactMap = nextContactMap contactMap = nextContactMap
setSessionContactsUpdatedAt(Date.now()) setSessionContactsUpdatedAt(Date.now())
} }
}
const now = Date.now() const now = Date.now()
const needsEnrichment = baseSessions const needsEnrichment = baseSessions
@@ -741,15 +790,28 @@ function ExportPage() {
let extraContactMap: Record<string, { displayName?: string; avatarUrl?: string }> = {} let extraContactMap: Record<string, { displayName?: string; avatarUrl?: string }> = {}
if (needsEnrichment.length > 0) { if (needsEnrichment.length > 0) {
for (let i = 0; i < needsEnrichment.length; i += EXPORT_AVATAR_ENRICH_BATCH_SIZE) {
if (isStale()) return if (isStale()) return
const batch = needsEnrichment.slice(i, i + EXPORT_AVATAR_ENRICH_BATCH_SIZE)
if (batch.length === 0) continue
try {
const enrichResult = await withTimeout( const enrichResult = await withTimeout(
window.electronAPI.chat.enrichSessionsContactInfo(needsEnrichment), window.electronAPI.chat.enrichSessionsContactInfo(batch),
CONTACT_ENRICH_TIMEOUT_MS CONTACT_ENRICH_TIMEOUT_MS
) )
if (isStale()) return
if (enrichResult?.success && enrichResult.contacts) { if (enrichResult?.success && enrichResult.contacts) {
extraContactMap = enrichResult.contacts extraContactMap = {
...extraContactMap,
...enrichResult.contacts
}
hasFreshNetworkData = true hasFreshNetworkData = true
} }
} catch (batchError) {
console.error('导出页分批补充会话联系人信息失败:', batchError)
}
await new Promise(resolve => setTimeout(resolve, 0))
}
} }
const persistAt = Date.now() const persistAt = Date.now()
@@ -824,8 +886,10 @@ function ExportPage() {
await configService.setContactsListCache(scopeKey, contactsCachePayload) await configService.setContactsListCache(scopeKey, contactsCachePayload)
setSessionContactsUpdatedAt(persistAt) setSessionContactsUpdatedAt(persistAt)
} }
if (Object.keys(avatarEntries).length > 0) {
await configService.setContactsAvatarCache(scopeKey, avatarEntries) await configService.setContactsAvatarCache(scopeKey, avatarEntries)
setSessionAvatarUpdatedAt(persistAt) setSessionAvatarUpdatedAt(persistAt)
}
if (hasFreshNetworkData) { if (hasFreshNetworkData) {
setSessionDataSource('network') setSessionDataSource('network')
} }