diff --git a/frontend/composables/useApi.js b/frontend/composables/useApi.js index 8263de95..f7ae1ece 100644 --- a/frontend/composables/useApi.js +++ b/frontend/composables/useApi.js @@ -425,6 +425,28 @@ export const useApi = () => { return await request(url) } + const startSnsFullSync = async (params = {}) => { + const query = new URLSearchParams() + if (params && params.account) query.set('account', params.account) + const url = '/sns/realtime/full_sync' + (query.toString() ? `?${query.toString()}` : '') + return await request(url, { method: 'POST' }) + } + + const getSnsFullSyncStatus = async (params = {}) => { + const query = new URLSearchParams() + if (params && params.account) query.set('account', params.account) + const url = '/sns/realtime/full_sync/status' + (query.toString() ? `?${query.toString()}` : '') + return await request(url) + } + + const cancelSnsFullSync = async (params = {}) => { + const query = new URLSearchParams() + if (params && params.account) query.set('account', params.account) + if (params && params.sync_id) query.set('sync_id', String(params.sync_id)) + const url = '/sns/realtime/full_sync' + (query.toString() ? `?${query.toString()}` : '') + return await request(url, { method: 'DELETE' }) + } + const openChatMediaFolder = async (params = {}) => { const query = new URLSearchParams() if (params && params.account) query.set('account', params.account) @@ -1117,6 +1139,9 @@ export const useApi = () => { listSnsUsers, syncSnsRealtimeLatest, getSnsSnapshotStatus, + startSnsFullSync, + getSnsFullSyncStatus, + cancelSnsFullSync, openChatMediaFolder, downloadChatEmoji, saveMediaKeys, diff --git a/frontend/pages/sns.vue b/frontend/pages/sns.vue index 039c1649..90aaa930 100644 --- a/frontend/pages/sns.vue +++ b/frontend/pages/sns.vue @@ -13,10 +13,25 @@ :disabled="!selectedAccount || isRefreshing || isLoading" @click="refreshSnsData" > - {{ isRefreshing ? '刷新中…' : '刷新' }} + {{ snsFullSyncButtonLabel }} +
+ {{ snsFullSyncStatusText }} + +
{ + const status = String(snsFullSyncJob.value?.status || '') + return status === 'queued' || status === 'running' +}) +const snsFullSyncButtonLabel = computed(() => { + if (isRefreshing.value) return '启动中…' + return isSnsFullSyncActive.value ? '同步中' : '刷新' +}) +const snsFullSyncStatusText = computed(() => { + const job = snsFullSyncJob.value + const status = String(job?.status || '') + const progress = job?.progress || {} + const changed = Math.max(0, Number(progress?.changed || 0)) + const percent = Math.max(0, Math.min(100, Number(progress?.percent || 0))) + if (status === 'queued') return `等待同步 · 已变化 ${changed}` + if (status === 'running') return `${percent}% · 已变化 ${changed}` + if (status === 'done') return `同步完成 · 已变化 ${changed}` + if (status === 'cancelled') return `已取消 · 已保留变化 ${changed}` + if (status === 'error') return `同步失败 · 已保留变化 ${changed}` + return '' +}) // 首次水合时保持按钮禁用,挂载后再按账号状态启用,避免服务端 disabled 残留。 const isSnsPageMounted = ref(false) const error = ref('') @@ -3360,11 +3398,12 @@ const loadAccounts = async () => { } } -let refreshQueued = false const SNS_REALTIME_SYNC_TIMEOUT_MS = 10000 const SNS_VISIBLE_RECONCILE_BUFFER_MIN = 20 const SNS_VISIBLE_RECONCILE_WINDOW_MAX = 200 -const SNS_MANUAL_REFRESH_SCAN_LIMIT = 200 +const SNS_INCREMENTAL_DEFAULT_SCAN_LIMIT = 200 +const SNS_FULL_SYNC_MERGE_THROTTLE_MS = 400 +const SNS_FULL_SYNC_USER_REFRESH_BATCHES = 5 const SNS_EVENT_RECONNECT_DELAYS_MS = [1000, 2000, 5000, 10000, 30000] let snsSnapshotVersion = '' let snsRealtimeSyncInFlight = null @@ -3375,6 +3414,10 @@ let snsEventReconnectTimer = null let snsEventReconnectAttempt = 0 let snsLastEventSequence = 0 let snsQueuedRealtimeEvent = null +let snsQueuedFullSyncMerge = null +let snsFullSyncMergePromise = null +let snsFullSyncMergeTimer = null +let snsFullSyncLastUserRefreshBatch = 0 let snsPageUnmounted = false let snsVisiblePostStart = 0 let snsVisiblePostEnd = -1 @@ -3429,7 +3472,7 @@ const beginSnsRealtimeSync = ( const syncLatestSnsWithTimeout = async ( account, { - maxScan = SNS_MANUAL_REFRESH_SCAN_LIMIT, + maxScan = SNS_INCREMENTAL_DEFAULT_SCAN_LIMIT, scanOffset = null, usernames = [], waitForCurrent = false @@ -3506,80 +3549,58 @@ const describeSnsSyncFailure = (failure) => { } const refreshSnsData = async () => { - if (!String(selectedAccount.value || '').trim()) return - if (isRefreshing.value) { - refreshQueued = true - return - } - + const account = String(selectedAccount.value || '').trim() + if (!account || isRefreshing.value) return isRefreshing.value = true + syncWarning.value = '' try { - do { - refreshQueued = false - const account = String(selectedAccount.value || '').trim() - if (!account) break - const reconcileWindow = getSnsVisibleReconcileWindow() - const selectedUsername = String(selectedSnsUser.value || '').trim() - let shouldMergeTimeline = false - - // 按钮本身显示刷新状态,避免插入提示行导致联系人列表上下跳动。 - syncWarning.value = '' - const activeReconcile = snsVisibleReconcilePromise - if (activeReconcile) { - try { - await activeReconcile - } catch {} + const response = await api.startSnsFullSync({ account }) + if (account !== String(selectedAccount.value || '').trim()) return + const job = response?.job || null + applySnsFullSyncJob(job) + isSnsFullSyncCancelling.value = false + if (job) { + const status = String(job?.status || '') + const final = status === 'done' || status === 'error' || status === 'cancelled' + const version = String(job?.snapshotVersion || '').trim() + if (final || (version && version !== snsSnapshotVersion)) { + queueSnsFullSyncMerge(job, { final }) } - try { - const syncResult = await syncLatestSnsWithTimeout(account, { - maxScan: SNS_MANUAL_REFRESH_SCAN_LIMIT, - scanOffset: reconcileWindow.scanOffset, - usernames: selectedUsername ? [selectedUsername] : [], - waitForCurrent: true - }) - const syncStatus = String(syncResult?.status || '').trim().toLowerCase() - if (syncStatus === 'ok' || syncStatus === 'noop') { - syncWarning.value = '' - const responseVersion = String(syncResult?.snapshotVersion || '').trim() - shouldMergeTimeline = !!( - Number(syncResult?.changed ?? syncResult?.upserted ?? 0) > 0 - || syncResult?.snapshotChanged === true - || (responseVersion && snsSnapshotVersion && responseVersion !== snsSnapshotVersion) - ) - } else { - syncWarning.value = describeSnsSyncFailure(syncResult) - console.warn('同步最新朋友圈未成功,继续读取已解密快照', syncResult) - } - } catch (e) { - syncWarning.value = describeSnsSyncFailure(e) - console.warn('同步最新朋友圈失败,继续读取已解密快照', e) - } - if (!shouldMergeTimeline) { - try { - const localVersion = await readSnsSnapshotVersion(account) - shouldMergeTimeline = !!( - localVersion - && snsSnapshotVersion - && localVersion !== snsSnapshotVersion - ) - } catch {} - } - if (account !== String(selectedAccount.value || '').trim()) break - const refreshTasks = [loadSelfInfo()] - if (shouldMergeTimeline) { - refreshTasks.push( - loadSnsUsers({ preserveExisting: true }), - mergeVisiblePostsWindow(reconcileWindow) - ) - } - await Promise.all(refreshTasks) - await updateSnsSnapshotBaseline(account) - } while (refreshQueued) + } + } catch (e) { + if (account === String(selectedAccount.value || '').trim()) { + syncWarning.value = describeSnsSyncFailure(e) + } } finally { isRefreshing.value = false } } +const cancelSnsFullSync = async () => { + const account = String(selectedAccount.value || '').trim() + const syncId = String(snsFullSyncJob.value?.syncId || '').trim() + if (!account || !syncId || !isSnsFullSyncActive.value || isSnsFullSyncCancelling.value) return + isSnsFullSyncCancelling.value = true + try { + const response = await api.cancelSnsFullSync({ account, sync_id: syncId }) + if ( + account === String(selectedAccount.value || '').trim() + && syncId === String(snsFullSyncJob.value?.syncId || '') + && response?.job + ) { + snsFullSyncJob.value = response.job + } + } catch (e) { + if (account === String(selectedAccount.value || '').trim()) { + syncWarning.value = describeSnsSyncFailure(e) + } + } finally { + if (account === String(selectedAccount.value || '').trim()) { + isSnsFullSyncCancelling.value = false + } + } +} + let postsRequestGeneration = 0 const isCurrentPostsRequest = (generation, account) => { @@ -3885,6 +3906,145 @@ const mergeVisiblePostsWindow = async (windowRange = getSnsVisibleReconcileWindo const mergeLatestPosts = async () => mergeVisiblePostsWindow(getSnsVisibleReconcileWindow()) +const clearSnsFullSyncMergeTimer = () => { + if (!process.client || snsFullSyncMergeTimer === null) return + window.clearTimeout(snsFullSyncMergeTimer) + snsFullSyncMergeTimer = null +} + +const drainSnsFullSyncMerge = () => { + clearSnsFullSyncMergeTimer() + if (snsFullSyncMergePromise) return snsFullSyncMergePromise + + let trackedPromise = null + const task = (async () => { + let merged = false + while (snsQueuedFullSyncMerge) { + const pending = snsQueuedFullSyncMerge + snsQueuedFullSyncMerge = null + const account = String(pending?.account || '') + if ( + !process.client + || snsPageUnmounted + || document.visibilityState !== 'visible' + || !account + || account !== String(selectedAccount.value || '').trim() + ) continue + + const job = pending?.job || {} + const progress = job?.progress || {} + const snapshotVersion = String(job?.snapshotVersion || pending?.snapshotVersion || '').trim() + const changed = Math.max(0, Number(progress?.changed || 0)) + const batch = Math.max(0, Number(progress?.batchesCompleted || 0)) + const finalMerge = !!pending?.final + const snapshotChanged = !!( + snapshotVersion + && snapshotVersion !== snsSnapshotVersion + && (changed > 0 || finalMerge) + ) + if (!snapshotChanged && !finalMerge) continue + + const activeReconcile = snsVisibleReconcilePromise + if (activeReconcile) { + try { + await activeReconcile + } catch {} + } + + const shouldRefreshUsers = finalMerge + || batch - snsFullSyncLastUserRefreshBatch >= SNS_FULL_SYNC_USER_REFRESH_BATCHES + const tasks = [mergeVisiblePostsWindow(getSnsVisibleReconcileWindow())] + if (shouldRefreshUsers) tasks.push(loadSnsUsers({ preserveExisting: true })) + const results = await Promise.all(tasks) + const timelineMerged = results[0] === true + if (!timelineMerged) continue + + merged = true + if (shouldRefreshUsers) snsFullSyncLastUserRefreshBatch = batch + if (snapshotVersion) { + snsSnapshotVersion = snapshotVersion + } else { + await updateSnsSnapshotBaseline(account) + } + } + return merged + })() + + trackedPromise = task.finally(() => { + if (snsFullSyncMergePromise === trackedPromise) snsFullSyncMergePromise = null + if (snsQueuedFullSyncMerge) void drainSnsFullSyncMerge() + }) + snsFullSyncMergePromise = trackedPromise + return trackedPromise +} + +// 全量同步事件使用累计进度;中间事件即使被合并,下一次事件仍能恢复正确状态。 +const queueSnsFullSyncMerge = (job, { final = false } = {}) => { + const account = String(selectedAccount.value || '').trim() + if (!account || !job) return null + const previous = snsQueuedFullSyncMerge + snsQueuedFullSyncMerge = { + account, + job, + snapshotVersion: String(job?.snapshotVersion || ''), + final: !!(final || previous?.final) + } + + if (final) { + clearSnsFullSyncMergeTimer() + return drainSnsFullSyncMerge() + } + if (!process.client || snsFullSyncMergePromise || snsFullSyncMergeTimer !== null) { + return snsFullSyncMergePromise + } + snsFullSyncMergeTimer = window.setTimeout(() => { + snsFullSyncMergeTimer = null + void drainSnsFullSyncMerge() + }, SNS_FULL_SYNC_MERGE_THROTTLE_MS) + return null +} + +const applySnsFullSyncJob = (job) => { + const previousSyncId = String(snsFullSyncJob.value?.syncId || '') + const nextSyncId = String(job?.syncId || '') + if (nextSyncId && nextSyncId !== previousSyncId) { + snsFullSyncLastUserRefreshBatch = 0 + } + snsFullSyncJob.value = job || null + const status = String(job?.status || '') + if (status !== 'queued' && status !== 'running') { + isSnsFullSyncCancelling.value = false + } + if (status === 'error') { + syncWarning.value = String(job?.error?.message || '朋友圈全量同步失败,请稍后重试') + } else if (status === 'done' || status === 'cancelled') { + syncWarning.value = '' + } +} + +const restoreSnsFullSyncStatus = async (account) => { + const requestedAccount = String(account || '').trim() + if (!requestedAccount) return null + try { + const response = await api.getSnsFullSyncStatus({ account: requestedAccount }) + if (requestedAccount !== String(selectedAccount.value || '').trim()) return null + const job = response?.job || null + applySnsFullSyncJob(job) + if (job) { + const status = String(job?.status || '') + const final = status === 'done' || status === 'error' || status === 'cancelled' + const version = String(job?.snapshotVersion || '').trim() + if (final || (version && version !== snsSnapshotVersion)) { + queueSnsFullSyncMerge(job, { final }) + } + } + return job + } catch { + // 状态恢复失败不影响本地快照浏览,SSE 重连后还会再次核对。 + return null + } +} + // 首屏三路并行读取本地快照,不等待实时同步。 const loadLocalSnsData = async () => { const account = String(selectedAccount.value || '').trim() @@ -4065,6 +4225,7 @@ const onSnsRealtimeReady = async (event) => { snsEventReconnectAttempt = 0 snsLastEventSequence = Math.max(snsLastEventSequence, Number(payload?.sequence || 0)) + await restoreSnsFullSyncStatus(account) if (payload?.watcherAvailable === false) { syncWarning.value = String(payload?.message || '系统文件通知不可用,请使用手动刷新') return @@ -4112,6 +4273,24 @@ const onSnsRealtimeSyncError = (event) => { syncWarning.value = String(payload?.message || '朋友圈实时同步失败,请使用手动刷新') } +const onSnsFullSyncEvent = (event) => { + const payload = parseSnsRealtimeEvent(event) + const account = String(selectedAccount.value || '').trim() + if (!payload?.job || String(payload?.account || '') !== account) return + const sequence = Number(payload?.sequence || 0) + if (sequence > 0 && sequence <= snsLastEventSequence) return + snsLastEventSequence = Math.max(snsLastEventSequence, sequence) + + const job = payload.job + applySnsFullSyncJob(job) + const status = String(job?.status || '') + const final = status === 'done' || status === 'error' || status === 'cancelled' + const snapshotVersion = String(job?.snapshotVersion || payload?.snapshotVersion || '').trim() + if (final || (snapshotVersion && snapshotVersion !== snsSnapshotVersion)) { + queueSnsFullSyncMerge(job, { final }) + } +} + function connectSnsEventStream() { if (!process.client || snsPageUnmounted || document.visibilityState !== 'visible') return const account = String(selectedAccount.value || '').trim() @@ -4130,6 +4309,10 @@ function connectSnsEventStream() { source.addEventListener('ready', onSnsRealtimeReady) source.addEventListener('change', onSnsRealtimeChange) source.addEventListener('sync_error', onSnsRealtimeSyncError) + source.addEventListener('full_sync_progress', onSnsFullSyncEvent) + source.addEventListener('full_sync_done', onSnsFullSyncEvent) + source.addEventListener('full_sync_error', onSnsFullSyncEvent) + source.addEventListener('full_sync_cancelled', onSnsFullSyncEvent) source.onerror = () => { if (source !== snsEventSource) return closeSnsEventStream() @@ -4146,8 +4329,13 @@ watch( async (v, oldV) => { if (v !== oldV) { closeSnsEventStream({ resetAttempt: true }) + clearSnsFullSyncMergeTimer() snsLastEventSequence = 0 snsQueuedRealtimeEvent = null + snsQueuedFullSyncMerge = null + snsFullSyncJob.value = null + isSnsFullSyncCancelling.value = false + snsFullSyncLastUserRefreshBatch = 0 snsSnapshotVersion = '' } if (v && v !== oldV) { @@ -4172,6 +4360,7 @@ watch( resetSnsMediaErrors() if (previewCtx.value) closeImagePreview() await loadLocalSnsData() + await restoreSnsFullSyncStatus(String(v || '')) // 首屏就绪后建立事件连接;后端启动同步或重连差异由 ready 事件补齐。 connectSnsEventStream() } @@ -4248,6 +4437,7 @@ const runPassiveSnsRefresh = async () => { if (!String(selectedAccount.value || '').trim()) return // 窗口重新可见时只核对一次本地版本,然后恢复 SSE。 await reconcileSnsSnapshotOnce() + await restoreSnsFullSyncStatus(String(selectedAccount.value || '')) connectSnsEventStream() } @@ -4290,7 +4480,9 @@ onUnmounted(() => { passiveRefreshTimer = null } closeSnsEventStream({ resetAttempt: true }) + clearSnsFullSyncMergeTimer() snsQueuedRealtimeEvent = null + snsQueuedFullSyncMerge = null if (snsVisibleWindowRaf !== null) { window.cancelAnimationFrame(snsVisibleWindowRaf) snsVisibleWindowRaf = null diff --git a/frontend/tests/sns-page-initialization.test.mjs b/frontend/tests/sns-page-initialization.test.mjs index df0c314e..b7807ad5 100644 --- a/frontend/tests/sns-page-initialization.test.mjs +++ b/frontend/tests/sns-page-initialization.test.mjs @@ -54,10 +54,11 @@ test('朋友圈使用 SSE 事件单飞核对随视口浮动的上下窗口', asy assert.match(source, /const SNS_VISIBLE_RECONCILE_BUFFER_MIN = 20/) assert.match(source, /const SNS_VISIBLE_RECONCILE_WINDOW_MAX = 200/) - assert.match(source, /const SNS_MANUAL_REFRESH_SCAN_LIMIT = 200/) + assert.match(source, /const SNS_INCREMENTAL_DEFAULT_SCAN_LIMIT = 200/) assert.match(source, /const SNS_EVENT_RECONNECT_DELAYS_MS = \[1000, 2000, 5000, 10000, 30000\]/) assert.match(source, /new EventSource\([\s\S]*?\/sns\/realtime\/events\?account=/) assert.match(source, /source\.addEventListener\('change', onSnsRealtimeChange\)/) + assert.match(source, /source\.addEventListener\('full_sync_progress', onSnsFullSyncEvent\)/) assert.match(source, /const versionChanged = !!\(version && version !== snsSnapshotVersion\)/) assert.match(source, /api\.syncSnsRealtimeLatest\(\{[\s\S]*?force: 1,[\s\S]*?max_scan: maxScan/) assert.match(source, /if \(snsVisibleReconcilePromise\) return snsVisibleReconcilePromise/) @@ -74,6 +75,25 @@ test('朋友圈使用 SSE 事件单飞核对随视口浮动的上下窗口', asy }) +test('朋友圈手动刷新启动全账号任务并可恢复、取消和无感合并', async () => { + const source = await readFile(new URL('../pages/sns.vue', import.meta.url), 'utf8') + const apiSource = await readFile(new URL('../composables/useApi.js', import.meta.url), 'utf8') + const refresh = source.split('const refreshSnsData = async () => {', 2)[1] + .split(/\r?\n\r?\nconst cancelSnsFullSync/, 1)[0] + + assert.match(apiSource, /const startSnsFullSync = async \(params = \{\}\) => \{[\s\S]*?\/sns\/realtime\/full_sync/) + assert.match(apiSource, /const getSnsFullSyncStatus = async/) + assert.match(apiSource, /const cancelSnsFullSync = async/) + assert.match(refresh, /api\.startSnsFullSync\(\{ account \}\)/) + assert.doesNotMatch(refresh, /selectedSnsUser|scanOffset|usernames|syncLatestSnsWithTimeout/) + assert.match(source, /const restoreSnsFullSyncStatus = async \(account\) =>/) + assert.match(source, /await restoreSnsFullSyncStatus\(String\(v \|\| ''\)\)/) + assert.match(source, /const SNS_FULL_SYNC_MERGE_THROTTLE_MS = 400/) + assert.match(source, /mergeVisiblePostsWindow\(getSnsVisibleReconcileWindow\(\)\)/) + assert.match(source, /restoreSnsScrollAnchor\(anchor\)/) +}) + + test('朋友圈导出按钮在客户端挂载后再解除禁用,避免水合残留', async () => { const source = await readFile(new URL('../pages/sns.vue', import.meta.url), 'utf8') diff --git a/src/wechat_decrypt_tool/api.py b/src/wechat_decrypt_tool/api.py index 3c95f155..e4372a08 100644 --- a/src/wechat_decrypt_tool/api.py +++ b/src/wechat_decrypt_tool/api.py @@ -281,8 +281,12 @@ async def _startup_background_jobs() -> None: logger.exception("Failed to start realtime autosync service") try: SNS_REALTIME_AUTOSYNC.start() - except Exception: + except Exception as exc: logger.exception("Failed to start SNS realtime autosync service") + logger.error( + "[sns.incremental-sync] status=error phase=service-start error_type=%s", + type(exc).__name__, + ) @app.on_event("shutdown") diff --git a/src/wechat_decrypt_tool/routers/sns.py b/src/wechat_decrypt_tool/routers/sns.py index f676a640..48064363 100644 --- a/src/wechat_decrypt_tool/routers/sns.py +++ b/src/wechat_decrypt_tool/routers/sns.py @@ -13,6 +13,7 @@ import subprocess import threading import time +import uuid import xml.etree.ElementTree as ET from typing import Any, Optional from urllib.parse import urlparse @@ -31,6 +32,7 @@ from ..path_fix import PathFixRoute from ..perf_trace import create_perf_trace from ..sns_realtime_autosync import SNS_REALTIME_AUTOSYNC +from ..sns_full_sync import SNS_FULL_SYNC from .. import sns_media as _sns_media from ..wcdb_realtime import ( WCDBRealtimeError, @@ -535,6 +537,11 @@ def _pack_blob(value: Any) -> Optional[bytes]: len(rows), error_text, ) + logger.warning( + "[sns.incremental-sync] status=error phase=writing prepared=%s error_type=%s", + len(rows), + type(e).__name__, + ) try: conn.rollback() except Exception: @@ -1796,6 +1803,28 @@ async def event_stream(): ) +@router.post("/api/sns/realtime/full_sync", summary="启动朋友圈全量缓存同步") +def start_sns_realtime_full_sync(account: Optional[str] = None): + account_dir = _resolve_account_dir(account) + job, reused = SNS_FULL_SYNC.start(account_dir) + return {"status": "ok", "reused": reused, "job": job} + + +@router.get("/api/sns/realtime/full_sync/status", summary="获取朋友圈全量同步状态") +def get_sns_realtime_full_sync_status(account: Optional[str] = None): + account_dir = _resolve_account_dir(account) + return {"status": "ok", "job": SNS_FULL_SYNC.get(account_dir)} + + +@router.delete("/api/sns/realtime/full_sync", summary="取消朋友圈全量缓存同步") +def cancel_sns_realtime_full_sync(account: Optional[str] = None, sync_id: str = ""): + account_dir = _resolve_account_dir(account) + job, accepted = SNS_FULL_SYNC.cancel(account_dir, sync_id) + if not accepted: + raise HTTPException(status_code=409, detail="同步任务已结束或任务标识不匹配") + return {"status": "ok", "cancelled": True, "job": job} + + @router.post("/api/sns/realtime/sync_latest", summary="实时朋友圈同步到解密库(增量)") def sync_sns_realtime_timeline_latest( account: Optional[str] = None, @@ -1809,6 +1838,12 @@ def sync_sns_realtime_timeline_latest( This is best-effort and intentionally **append-only**: we never delete rows from the decrypted snapshot even if the post is deleted/hidden later, so users can still browse/export historical cached content. """ + sync_request_id = uuid.uuid4().hex + sync_started = time.perf_counter() + logger.info( + "[sns.incremental-sync] status=running request_id=%s phase=connecting", + sync_request_id, + ) try: lim = int(max_scan or 200) except Exception: @@ -1855,6 +1890,21 @@ def _sync_response( result["highwaterAdvanced"] = bool(highwater_advanced) result["scanOffset"] = int(requested_scan_offset) result["scanLimit"] = int(lim) + status = str(result.get("status") or "error").strip().lower() + raw_code = str(result.get("error") or result.get("reason") or "").strip().lower() + code = raw_code if re.fullmatch(r"[a-z0-9_.-]{1,80}", raw_code) else "" + log_method = logger.error if status == "error" else logger.info + log_method( + "[sns.incremental-sync] status=%s request_id=%s phase=finalizing code=%s scanned=%s prepared=%s changed=%s unchanged=%s elapsed_ms=%s", + status, + sync_request_id, + code, + int(result.get("scanned") or 0), + int(prepared), + int(changed), + int(unchanged), + int((time.perf_counter() - sync_started) * 1000), + ) return result # If there is no local decrypted sns.db yet, force a first-time materialization. @@ -1867,6 +1917,11 @@ def _sync_response( info = WCDB_REALTIME.get_status(account_dir) available = bool(info.get("dll_present") and info.get("key_present") and info.get("db_storage_dir")) if not available: + logger.error( + "[sns.incremental-sync] status=error request_id=%s phase=connecting code=realtime_not_available error_type=AvailabilityError elapsed_ms=%s", + sync_request_id, + int((time.perf_counter() - sync_started) * 1000), + ) raise HTTPException(status_code=404, detail="WCDB realtime not available.") st = _read_sns_realtime_sync_state(account_dir) @@ -1879,9 +1934,18 @@ def _sync_response( if last_max_id_u <= 0: last_max_id_u = _max_sns_timeline_tid_unsigned_in_decrypted_sqlite(account_dir / "sns.db") - conn = WCDB_REALTIME.ensure_connected(account_dir) + try: + conn = WCDB_REALTIME.ensure_connected(account_dir) + except Exception as exc: + logger.error( + "[sns.incremental-sync] status=error request_id=%s phase=connecting code=connection_failed error_type=%s elapsed_ms=%s", + sync_request_id, + type(exc).__name__, + int((time.perf_counter() - sync_started) * 1000), + ) + raise - t0 = time.perf_counter() + t0 = sync_started rows: list[dict[str, Any]] = [] max_id_u = 0 upsert_rows: list[tuple[int, str, str, Optional[Any]]] = [] @@ -2107,6 +2171,16 @@ def _sync_response( write_success = changed_count == prepared_count write_error = "" + logger.info( + "[sns.incremental-sync] status=running request_id=%s phase=scanning batches=1 scanned=%s prepared=%s changed=%s unchanged=%s elapsed_ms=%s", + sync_request_id, + len(rows), + prepared_count, + changed_count, + unchanged_count, + int((time.perf_counter() - sync_started) * 1000), + ) + prepared_tids = {int(row[0]) for row in upsert_rows} missing_required_tids = required_tids - prepared_tids snapshot_complete = bool(upsert_rows) and all(( @@ -2125,6 +2199,15 @@ def _sync_response( unchanged_count, len(missing_required_tids), ) + logger.warning( + "[sns.incremental-sync] status=error request_id=%s phase=writing code=snapshot_write_incomplete scanned=%s prepared=%s changed=%s unchanged=%s skipped=%s", + sync_request_id, + len(rows), + prepared_count, + changed_count, + unchanged_count, + len(missing_required_tids), + ) return _sync_response({ "status": "error", "error": "decrypted_snapshot_write_incomplete", @@ -2143,6 +2226,11 @@ def _sync_response( len(rows), last_max_id_u, ) + logger.warning( + "[sns.incremental-sync] status=skipped request_id=%s phase=scanning code=scan_cap_reached scanned=%s", + sync_request_id, + len(rows), + ) return _sync_response({ "status": "skipped", "reason": "backlog exceeds scan cap", @@ -2161,6 +2249,10 @@ def _sync_response( st2["updatedAt"] = int(time.time()) if _write_sns_realtime_sync_state(account_dir, st2) is False: logger.warning("[sns-sync] state write failed account=%s", account_dir.name) + logger.warning( + "[sns.incremental-sync] status=error request_id=%s phase=finalizing code=sync_state_write_failed", + sync_request_id, + ) return _sync_response({ "status": "error", "error": "sync_state_write_failed", diff --git a/src/wechat_decrypt_tool/sns_full_sync.py b/src/wechat_decrypt_tool/sns_full_sync.py new file mode 100644 index 00000000..622f12ca --- /dev/null +++ b/src/wechat_decrypt_tool/sns_full_sync.py @@ -0,0 +1,557 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +import re +import threading +import time +import uuid +from typing import Any, Optional + +from .logging_config import get_logger +from .sns_realtime_autosync import SNS_REALTIME_AUTOSYNC +from .wcdb_realtime import WCDB_REALTIME, exec_query as _wcdb_exec_query + + +logger = get_logger(__name__) + +_BATCH_SIZE = 200 +_ACTIVE_STATUSES = {"queued", "running"} +_SAFE_ERROR_TYPE_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{0,79}$") + + +@dataclass +class _FullSyncProgress: + phase: str = "connecting" + source_rows_total: int = 0 + source_rows_scanned: int = 0 + batches_completed: int = 0 + prepared: int = 0 + changed: int = 0 + unchanged: int = 0 + skipped: int = 0 + + +@dataclass +class _FullSyncJob: + account_dir: Path + sync_id: str = field(default_factory=lambda: uuid.uuid4().hex) + status: str = "queued" + created_at: int = field(default_factory=lambda: int(time.time() * 1000)) + started_at: Optional[int] = None + finished_at: Optional[int] = None + cancel_requested: bool = False + snapshot_version: str = "" + progress: _FullSyncProgress = field(default_factory=_FullSyncProgress) + error: Optional[dict[str, str]] = None + cancel_event: threading.Event = field(default_factory=threading.Event, repr=False) + + +class SnsFullSyncManager: + """朋友圈全量缓存同步任务管理器。 + + 每个账号只保留一个活动任务,同时通过全局信号量保证任意时刻只扫描一个账号。 + """ + + def __init__(self) -> None: + self._mu = threading.RLock() + self._global_slot = threading.BoundedSemaphore(1) + self._latest_by_account: dict[str, _FullSyncJob] = {} + + @staticmethod + def _account_key(account_dir: Path) -> str: + # 账号仅作为内存索引,不进入日志或公开任务结构。 + return str(Path(account_dir).resolve()) + + @staticmethod + def _safe_error_type(exc: BaseException) -> str: + name = type(exc).__name__ + return name if _SAFE_ERROR_TYPE_RE.fullmatch(name) else "Exception" + + def _public_job_locked(self, job: _FullSyncJob) -> dict[str, Any]: + progress = job.progress + total = max(0, int(progress.source_rows_total)) + scanned = max(0, int(progress.source_rows_scanned)) + if job.status == "done": + percent = 100 + elif total <= 0: + percent = 0 + else: + percent = min(99, int(scanned * 100 / total)) + + payload: dict[str, Any] = { + "syncId": job.sync_id, + "status": job.status, + "createdAt": job.created_at, + "startedAt": job.started_at, + "finishedAt": job.finished_at, + "cancelRequested": bool(job.cancel_requested), + "snapshotVersion": job.snapshot_version, + "progress": { + "phase": progress.phase, + "sourceRowsTotal": total, + "sourceRowsScanned": scanned, + "batchesCompleted": int(progress.batches_completed), + "prepared": int(progress.prepared), + "changed": int(progress.changed), + "unchanged": int(progress.unchanged), + "skipped": int(progress.skipped), + "percent": percent, + }, + } + if job.error is not None: + payload["error"] = dict(job.error) + return payload + + def get(self, account_dir: Path) -> Optional[dict[str, Any]]: + key = self._account_key(account_dir) + with self._mu: + job = self._latest_by_account.get(key) + return self._public_job_locked(job) if job is not None else None + + def start(self, account_dir: Path) -> tuple[dict[str, Any], bool]: + resolved = Path(account_dir).resolve() + key = self._account_key(resolved) + with self._mu: + current = self._latest_by_account.get(key) + if current is not None and current.status in _ACTIVE_STATUSES: + return self._public_job_locked(current), True + + job = _FullSyncJob(account_dir=resolved) + self._latest_by_account[key] = job + public = self._public_job_locked(job) + + worker = threading.Thread( + target=self._run_job, + args=(key, job), + name=f"sns-full-sync-{job.sync_id[:8]}", + daemon=True, + ) + logger.info( + "[sns.full-sync] status=queued sync_id=%s phase=connecting", + job.sync_id, + ) + self._publish(job, "full_sync_progress") + try: + worker.start() + except Exception as exc: + self._finish_error( + job, + code="sync_worker_unavailable", + message="朋友圈同步线程不可用,请稍后重试", + exc=exc, + started_monotonic=time.monotonic(), + ) + # 极小数据集可能在线程启动后立即完成,返回最新状态避免旧 queued 覆盖 SSE 终态。 + return self.get(resolved) or public, False + + def cancel(self, account_dir: Path, sync_id: str) -> tuple[Optional[dict[str, Any]], bool]: + key = self._account_key(account_dir) + requested_id = str(sync_id or "").strip() + with self._mu: + job = self._latest_by_account.get(key) + if ( + job is None + or job.sync_id != requested_id + or job.status not in _ACTIVE_STATUSES + ): + return (self._public_job_locked(job) if job is not None else None), False + job.cancel_requested = True + job.cancel_event.set() + return self._public_job_locked(job), True + + def _publish(self, job: _FullSyncJob, event_type: str) -> None: + with self._mu: + public = self._public_job_locked(job) + SNS_REALTIME_AUTOSYNC.publish_external_event( + Path(job.account_dir).name, + { + "type": event_type, + "account": Path(job.account_dir).name, + "job": public, + "snapshotVersion": public.get("snapshotVersion") or "", + "timestamp": int(time.time() * 1000), + }, + ) + + def _finish_cancelled(self, job: _FullSyncJob, started_monotonic: float) -> None: + with self._mu: + job.status = "cancelled" + job.finished_at = int(time.time() * 1000) + logger.info( + "[sns.full-sync] status=cancelled sync_id=%s phase=%s batches=%s scanned=%s prepared=%s changed=%s unchanged=%s skipped=%s elapsed_ms=%s", + job.sync_id, + job.progress.phase, + job.progress.batches_completed, + job.progress.source_rows_scanned, + job.progress.prepared, + job.progress.changed, + job.progress.unchanged, + job.progress.skipped, + int((time.monotonic() - started_monotonic) * 1000), + ) + self._publish(job, "full_sync_cancelled") + + def _finish_error( + self, + job: _FullSyncJob, + *, + code: str, + message: str, + exc: Optional[BaseException], + started_monotonic: float, + ) -> None: + error_type = self._safe_error_type(exc) if exc is not None else "SyncError" + with self._mu: + job.status = "error" + job.finished_at = int(time.time() * 1000) + job.error = {"code": code, "message": message} + logger.error( + "[sns.full-sync] status=error sync_id=%s phase=%s code=%s error_type=%s batches=%s scanned=%s prepared=%s changed=%s unchanged=%s skipped=%s elapsed_ms=%s", + job.sync_id, + job.progress.phase, + code, + error_type, + job.progress.batches_completed, + job.progress.source_rows_scanned, + job.progress.prepared, + job.progress.changed, + job.progress.unchanged, + job.progress.skipped, + int((time.monotonic() - started_monotonic) * 1000), + ) + self._publish(job, "full_sync_error") + + @staticmethod + def _row_value(row: dict[str, Any], name: str, default: Any = None) -> Any: + if name in row: + return row.get(name) + lowered = name.lower() + for key, value in row.items(): + if str(key).lower() == lowered: + return value + return default + + @staticmethod + def _source_db_path(connection: Any) -> Optional[Path]: + try: + root = Path(connection.db_storage_dir) + candidates = (root / "sns" / "sns.db", root / "sns.db") + for candidate in candidates: + if candidate.is_file(): + return candidate + except Exception: + return None + return None + + def _query(self, connection: Any, source_path: Path, sql: str) -> list[dict[str, Any]]: + with connection.lock: + rows = _wcdb_exec_query( + connection.handle, + kind="media", + path=str(source_path), + sql=sql, + ) + return [row for row in (rows or []) if isinstance(row, dict)] + + def _count_and_bounds( + self, + connection: Any, + source_path: Path, + ) -> tuple[str, int, Optional[int], Optional[int]]: + valid_where = ( + "tid IS NOT NULL AND user_name IS NOT NULL AND user_name != '' " + "AND content IS NOT NULL AND content != ''" + ) + last_exc: Optional[BaseException] = None + for cursor_column in ("rowid", "tid"): + sql = ( + "SELECT COUNT(*) AS source_rows_total, " + f"MIN({cursor_column}) AS min_cursor, MAX({cursor_column}) AS max_cursor " + f"FROM SnsTimeLine WHERE {valid_where}" + ) + try: + rows = self._query(connection, source_path, sql) + row = rows[0] if rows else {} + total = int(self._row_value(row, "source_rows_total", 0) or 0) + min_raw = self._row_value(row, "min_cursor") + max_raw = self._row_value(row, "max_cursor") + min_cursor = int(min_raw) if min_raw is not None else None + max_cursor = int(max_raw) if max_raw is not None else None + return cursor_column, total, min_cursor, max_cursor + except Exception as exc: + last_exc = exc + if last_exc is not None: + raise last_exc + raise RuntimeError("SnsTimeLine cursor is unavailable") + + def _read_batch( + self, + connection: Any, + source_path: Path, + *, + cursor_column: str, + min_cursor: int, + max_cursor: int, + after_cursor: Optional[int], + include_pack: bool, + ) -> tuple[list[dict[str, Any]], bool]: + lower = ( + f"{cursor_column} >= {int(min_cursor)}" + if after_cursor is None + else f"{cursor_column} > {int(after_cursor)}" + ) + where_sql = ( + f"{lower} AND {cursor_column} <= {int(max_cursor)} " + "AND tid IS NOT NULL AND user_name IS NOT NULL AND user_name != '' " + "AND content IS NOT NULL AND content != ''" + ) + select_pack = ", pack_info_buf" if include_pack else "" + sql = ( + f"SELECT {cursor_column} AS source_cursor, tid, user_name, content{select_pack} " + f"FROM SnsTimeLine WHERE {where_sql} " + f"ORDER BY {cursor_column} ASC LIMIT {_BATCH_SIZE}" + ) + try: + return self._query(connection, source_path, sql), include_pack + except Exception: + if not include_pack: + raise + # 老版本源表没有 pack_info_buf,保持主记录同步能力。 + return self._read_batch( + connection, + source_path, + cursor_column=cursor_column, + min_cursor=min_cursor, + max_cursor=max_cursor, + after_cursor=after_cursor, + include_pack=False, + ) + + def _run_job(self, _key: str, job: _FullSyncJob) -> None: + started_monotonic = time.monotonic() + slot_acquired = False + try: + while not job.cancel_event.is_set(): + if self._global_slot.acquire(timeout=0.1): + slot_acquired = True + break + if not slot_acquired: + self._finish_cancelled(job, started_monotonic) + return + + with self._mu: + job.status = "running" + job.started_at = int(time.time() * 1000) + job.progress.phase = "connecting" + logger.info( + "[sns.full-sync] status=running sync_id=%s phase=connecting", + job.sync_id, + ) + self._publish(job, "full_sync_progress") + + if job.cancel_event.is_set(): + self._finish_cancelled(job, started_monotonic) + return + + try: + connection = WCDB_REALTIME.ensure_connected(job.account_dir, timeout=15.0) + except Exception as exc: + self._finish_error( + job, + code="realtime_not_available", + message="朋友圈实时组件未连接,请确认微信已登录且数据库密钥有效", + exc=exc, + started_monotonic=started_monotonic, + ) + return + source_path = self._source_db_path(connection) + if source_path is None: + self._finish_error( + job, + code="sns_source_not_found", + message="未找到微信本地朋友圈数据库", + exc=None, + started_monotonic=started_monotonic, + ) + return + + with self._mu: + job.progress.phase = "counting" + self._publish(job, "full_sync_progress") + + try: + cursor_column, total, min_cursor, max_cursor = self._count_and_bounds( + connection, + source_path, + ) + except Exception as exc: + self._finish_error( + job, + code="sns_source_schema_unsupported", + message="当前朋友圈数据库结构暂不支持全量同步", + exc=exc, + started_monotonic=started_monotonic, + ) + return + + with self._mu: + job.progress.source_rows_total = total + job.progress.phase = "scanning" + self._publish(job, "full_sync_progress") + + # 延迟导入路由辅助函数,避免模块加载时形成循环依赖。 + from .routers.sns import ( + _build_sns_snapshot_status, + _decode_sns_text_blob, + _looks_like_xml_text, + _read_sns_realtime_sync_state, + _upsert_sns_timeline_rows_to_decrypted_db, + _write_sns_realtime_sync_state, + ) + + after_cursor: Optional[int] = None + include_pack = True + max_tid_unsigned = 0 + + while min_cursor is not None and max_cursor is not None: + if job.cancel_event.is_set(): + self._finish_cancelled(job, started_monotonic) + return + + rows, include_pack = self._read_batch( + connection, + source_path, + cursor_column=cursor_column, + min_cursor=min_cursor, + max_cursor=max_cursor, + after_cursor=after_cursor, + include_pack=include_pack, + ) + if not rows: + break + + prepared_rows: list[tuple[int, str, str, Optional[Any]]] = [] + skipped = 0 + for row in rows: + try: + source_cursor = int(self._row_value(row, "source_cursor")) + tid = int(self._row_value(row, "tid")) + username = str(self._row_value(row, "user_name", "") or "").strip() + content = _decode_sns_text_blob(self._row_value(row, "content")) + if ( + not username + or not _looks_like_xml_text(content) + or "7" in content + ): + skipped += 1 + continue + pack = self._row_value(row, "pack_info_buf") if include_pack else None + prepared_rows.append((tid, username, content, pack)) + max_tid_unsigned = max(max_tid_unsigned, tid & 0xFFFFFFFFFFFFFFFF) + except Exception: + skipped += 1 + continue + + result = _upsert_sns_timeline_rows_to_decrypted_db( + job.account_dir, + prepared_rows, + source="sns.full-sync", + ) + if not bool(result.get("success")): + self._finish_error( + job, + code="snapshot_write_failed", + message="朋友圈本地快照写入失败,可稍后重试", + exc=None, + started_monotonic=started_monotonic, + ) + return + + after_cursor = max( + int(self._row_value(row, "source_cursor")) for row in rows + ) + snapshot = _build_sns_snapshot_status(job.account_dir) + with self._mu: + job.progress.source_rows_scanned += len(rows) + job.progress.batches_completed += 1 + job.progress.prepared += int(result.get("prepared") or 0) + job.progress.changed += int(result.get("changed") or 0) + job.progress.unchanged += int(result.get("unchanged") or 0) + job.progress.skipped += skipped + job.snapshot_version = str(snapshot.get("version") or "") + + logger.info( + "[sns.full-sync] status=running sync_id=%s phase=scanning batches=%s scanned=%s total=%s prepared=%s changed=%s unchanged=%s skipped=%s elapsed_ms=%s", + job.sync_id, + job.progress.batches_completed, + job.progress.source_rows_scanned, + job.progress.source_rows_total, + job.progress.prepared, + job.progress.changed, + job.progress.unchanged, + job.progress.skipped, + int((time.monotonic() - started_monotonic) * 1000), + ) + self._publish(job, "full_sync_progress") + + if len(rows) < _BATCH_SIZE: + break + + if job.cancel_event.is_set(): + self._finish_cancelled(job, started_monotonic) + return + + with self._mu: + job.progress.phase = "finalizing" + self._publish(job, "full_sync_progress") + + if max_tid_unsigned > 0: + state = _read_sns_realtime_sync_state(job.account_dir) + state["maxId"] = str(max_tid_unsigned) + state["updatedAt"] = int(time.time() * 1000) + if not _write_sns_realtime_sync_state(job.account_dir, state): + self._finish_error( + job, + code="sync_state_write_failed", + message="朋友圈同步状态写入失败,可安全重试", + exc=None, + started_monotonic=started_monotonic, + ) + return + + snapshot = _build_sns_snapshot_status(job.account_dir) + with self._mu: + job.status = "done" + job.finished_at = int(time.time() * 1000) + job.snapshot_version = str(snapshot.get("version") or "") + logger.info( + "[sns.full-sync] status=done sync_id=%s phase=finalizing batches=%s scanned=%s total=%s prepared=%s changed=%s unchanged=%s skipped=%s elapsed_ms=%s", + job.sync_id, + job.progress.batches_completed, + job.progress.source_rows_scanned, + job.progress.source_rows_total, + job.progress.prepared, + job.progress.changed, + job.progress.unchanged, + job.progress.skipped, + int((time.monotonic() - started_monotonic) * 1000), + ) + self._publish(job, "full_sync_done") + except Exception as exc: + self._finish_error( + job, + code="full_sync_failed", + message="朋友圈全量同步失败,请稍后重试", + exc=exc, + started_monotonic=started_monotonic, + ) + finally: + if slot_acquired: + try: + self._global_slot.release() + except Exception: + pass + + +SNS_FULL_SYNC = SnsFullSyncManager() diff --git a/src/wechat_decrypt_tool/sns_realtime_autosync.py b/src/wechat_decrypt_tool/sns_realtime_autosync.py index 9648f953..3cf56e5a 100644 --- a/src/wechat_decrypt_tool/sns_realtime_autosync.py +++ b/src/wechat_decrypt_tool/sns_realtime_autosync.py @@ -176,8 +176,9 @@ def _bootstrap_accounts(self) -> None: """启动时只枚举一次账号;后续账号由 SSE 连接动态注册。""" try: accounts = list(_list_decrypted_accounts() or []) - except Exception: + except Exception as exc: logger.exception("[sns-autosync] 初始账号枚举失败") + logger.error("[sns.incremental-sync] status=error phase=account-scan error_type=%s", type(exc).__name__) return for account in accounts: if self._stop.is_set(): @@ -314,9 +315,10 @@ def _watch_directory(self, watch_key: str) -> None: if not self._stop.is_set(): logger.error("[sns-autosync] 系统文件监听意外结束") self._mark_watcher_failed(watch_key, "sns_file_watch_unavailable") - except Exception: + except Exception as exc: if not self._stop.is_set(): logger.exception("[sns-autosync] 系统文件监听失败") + logger.error("[sns.incremental-sync] status=error phase=file-watch error_type=%s", type(exc).__name__) self._mark_watcher_failed(watch_key, "sns_file_watch_unavailable") def _mark_watcher_failed(self, watch_key: str, code: str) -> None: @@ -365,13 +367,17 @@ def _schedule_sync(self, account: str, *, reason: str) -> None: try: worker.start() - except Exception: + except Exception as exc: with self._mu: state = self._states.get(account) if state is not None and state.worker is worker: state.sync_running = False state.worker = None logger.exception("[sns-autosync] 启动同步线程失败 account=%s", account) + logger.error( + "[sns.incremental-sync] status=error phase=worker-start error_type=%s", + type(exc).__name__, + ) self._publish_error( account, source_revision=revision, @@ -386,6 +392,12 @@ def _sync_account_runner(self, account: str, reason: str, revision: int) -> None if reason == "startup": self._refresh_native_moments_once(account) while not self._stop.is_set(): + sync_id = uuid.uuid4().hex + started = time.monotonic() + logger.info( + "[sns.incremental-sync] status=running request_id=%s phase=scanning", + sync_id, + ) result, superseded = self._sync_with_bounded_retries(account, revision) # WCDB 读取可能刷新共享内存文件;短暂忽略纯 -shm 事件,防止读取自身形成事件环。 with self._mu: @@ -397,6 +409,15 @@ def _sync_account_runner(self, account: str, reason: str, revision: int) -> None ) if not superseded and not self._stop.is_set(): self._publish_sync_result(account, reason, revision, result) + status = str((result or {}).get("status") or "error").strip().lower() + logger.info( + "[sns.incremental-sync] status=%s request_id=%s phase=finalizing scanned=%s changed=%s elapsed_ms=%s", + status, + sync_id, + int((result or {}).get("scanned") or 0), + int((result or {}).get("changed") or (result or {}).get("upserted") or 0), + int((time.monotonic() - started) * 1000), + ) with self._mu: state = self._states.get(account) @@ -463,12 +484,17 @@ def _refresh_native_moments_once(self, account: str) -> None: ) client.refresh_wechat_moments(context.name, context.account_dir) logger.info("[sns-autosync] native refresh 调用完成 account=%s", account) + logger.info("[sns.incremental-sync] status=done phase=native-refresh") except Exception as exc: logger.warning( "[sns-autosync] native refresh 调用失败 account=%s error=%s;保留手动刷新", account, exc, ) + logger.warning( + "[sns.incremental-sync] status=error phase=native-refresh error_type=%s", + type(exc).__name__, + ) def _sync_with_bounded_retries(self, account: str, revision: int) -> tuple[dict[str, Any], bool]: last_result: dict[str, Any] = {"status": "error", "error": "sns_sync_failed"} @@ -480,8 +506,13 @@ def _sync_with_bounded_retries(self, account: str, revision: int) -> tuple[dict[ if self._stop.is_set(): return {"status": "skipped", "reason": "service_stopping"}, False last_result = dict(self._sync_account(account) or {}) - except Exception: + except Exception as exc: logger.exception("[sns-autosync] 同步失败 account=%s", account) + logger.error( + "[sns.incremental-sync] status=error phase=scanning error_type=%s attempt=%s", + type(exc).__name__, + attempt + 1, + ) last_result = {"status": "error", "error": "sns_sync_failed"} if not self._should_retry(last_result) or attempt >= len(self._retry_delays): @@ -534,8 +565,12 @@ def _sync_account(self, account: str) -> dict[str, Any]: ) except HTTPException as exc: return {"status": "error", "error": str(exc.detail or "sns_sync_failed")} - except Exception: + except Exception as exc: logger.exception("[sns-autosync] 增量同步调用失败 account=%s", account) + logger.error( + "[sns.incremental-sync] status=error phase=scanning error_type=%s", + type(exc).__name__, + ) return {"status": "error", "error": "sns_sync_failed"} def subscribe( @@ -661,5 +696,9 @@ def _publish_event(self, account: str, event: dict[str, Any]) -> None: except Exception: pass + def publish_external_event(self, account: str, event: dict[str, Any]) -> None: + """向当前账号的 SSE 订阅者投递外部同步事件。""" + self._publish_event(account, event) + SNS_REALTIME_AUTOSYNC = SnsRealtimeAutoSyncService() diff --git a/tests/test_sns_full_sync.py b/tests/test_sns_full_sync.py new file mode 100644 index 00000000..11bd3caa --- /dev/null +++ b/tests/test_sns_full_sync.py @@ -0,0 +1,364 @@ +import json +import sqlite3 +import sys +import threading +import time +import unittest +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest import mock + + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "src")) + + +from wechat_decrypt_tool import sns_full_sync +from wechat_decrypt_tool.routers import sns as sns_router + + +class _FakeConnection: + def __init__(self, db_storage_dir: Path): + self.handle = 1 + self.db_storage_dir = Path(db_storage_dir) + self.lock = threading.RLock() + + +def _sqlite_query(_connection, source_path: Path, sql: str): + conn = sqlite3.connect(str(source_path)) + conn.row_factory = sqlite3.Row + try: + return [dict(row) for row in conn.execute(sql).fetchall()] + finally: + conn.close() + + +def _wait_job(manager, account_dir: Path, *, timeout: float = 10.0): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + job = manager.get(account_dir) + if job and job.get("status") not in {"queued", "running"}: + return job + time.sleep(0.01) + raise AssertionError(f"朋友圈全量同步任务未在 {timeout} 秒内结束: {manager.get(account_dir)}") + + +def _create_source_db(root: Path, rows, *, with_pack: bool = True, without_rowid: bool = False): + source_dir = root / "sns" + source_dir.mkdir(parents=True, exist_ok=True) + source_path = source_dir / "sns.db" + conn = sqlite3.connect(str(source_path)) + try: + pack_sql = ", pack_info_buf BLOB" if with_pack else "" + suffix = " WITHOUT ROWID" if without_rowid else "" + conn.execute( + f"CREATE TABLE SnsTimeLine(tid INTEGER PRIMARY KEY, user_name TEXT, content TEXT{pack_sql}){suffix}" + ) + if with_pack: + conn.executemany( + "INSERT INTO SnsTimeLine(tid, user_name, content, pack_info_buf) VALUES (?, ?, ?, ?)", + rows, + ) + else: + conn.executemany( + "INSERT INTO SnsTimeLine(tid, user_name, content) VALUES (?, ?, ?)", + rows, + ) + conn.commit() + finally: + conn.close() + return source_path + + +class TestSnsFullSync(unittest.TestCase): + def _run_with_source(self, manager, account_dir: Path, source_root: Path, *, events=None): + connection = _FakeConnection(source_root) + event_list = events if events is not None else [] + with ( + mock.patch.object(sns_full_sync.WCDB_REALTIME, "ensure_connected", return_value=connection), + mock.patch.object(manager, "_query", side_effect=_sqlite_query), + mock.patch.object( + sns_full_sync.SNS_REALTIME_AUTOSYNC, + "publish_external_event", + side_effect=lambda _account, event: event_list.append(event), + ), + ): + started, reused = manager.start(account_dir) + self.assertFalse(reused) + self.assertTrue(started.get("syncId")) + return _wait_job(manager, account_dir), event_list + + def test_full_sync_reads_more_than_2000_rows_and_second_run_is_unchanged(self): + with TemporaryDirectory() as td: + root = Path(td) + account_dir = root / "decrypted" / "account-a" + account_dir.mkdir(parents=True) + source_root = root / "source-a" + rows = [] + for tid in range(1, 2206): + username = "friend-main" if tid <= 1600 else f"friend-{tid % 7}" + rows.append((tid, username, f"1{tid}", None)) + for tid in range(-5, 0): + rows.append((tid, "friend-negative", f"1{tid}", None)) + _create_source_db(source_root, rows, with_pack=True) + + manager = sns_full_sync.SnsFullSyncManager() + events = [] + first, events = self._run_with_source(manager, account_dir, source_root, events=events) + + self.assertEqual(first["status"], "done") + self.assertEqual(first["progress"]["sourceRowsTotal"], len(rows)) + self.assertEqual(first["progress"]["sourceRowsScanned"], len(rows)) + self.assertEqual(first["progress"]["prepared"], len(rows)) + self.assertEqual(first["progress"]["changed"], len(rows)) + self.assertEqual(first["progress"]["percent"], 100) + self.assertGreater(first["progress"]["batchesCompleted"], 10) + + conn = sqlite3.connect(str(account_dir / "sns.db")) + try: + count = conn.execute("SELECT COUNT(*) FROM SnsTimeLine").fetchone()[0] + main_count = conn.execute( + "SELECT COUNT(*) FROM SnsTimeLine WHERE user_name = ?", + ("friend-main",), + ).fetchone()[0] + finally: + conn.close() + self.assertEqual(count, len(rows)) + self.assertGreater(main_count, 200) + + progress_events = [event["job"] for event in events if event.get("type") == "full_sync_progress"] + changed_counts = [job["progress"]["changed"] for job in progress_events] + percents = [job["progress"]["percent"] for job in progress_events] + self.assertEqual(changed_counts, sorted(changed_counts)) + self.assertEqual(percents, sorted(percents)) + self.assertLessEqual(max(percents), 99) + self.assertEqual(events[-1].get("type"), "full_sync_done") + self.assertEqual(events[-1]["job"]["progress"]["percent"], 100) + self.assertTrue(events[-1]["job"].get("snapshotVersion")) + + second, _ = self._run_with_source(manager, account_dir, source_root) + self.assertEqual(second["status"], "done") + self.assertEqual(second["progress"]["changed"], 0) + self.assertEqual(second["progress"]["unchanged"], len(rows)) + + def test_full_sync_ignores_highwater_and_supports_signed_tid_old_schema(self): + with TemporaryDirectory() as td: + root = Path(td) + account_dir = root / "decrypted" / "account-b" + account_dir.mkdir(parents=True) + (account_dir / "_sns_realtime_sync_state.json").write_text( + json.dumps({"maxId": "999999"}), + encoding="utf-8", + ) + source_root = root / "source-b" + rows = [ + (-2, "friend-a", "1"), + (1, "friend-a", "7"), + (2, "friend-b", "damaged-but-nonempty"), + (3, "friend-b", "1"), + ] + _create_source_db(source_root, rows, with_pack=False, without_rowid=True) + + manager = sns_full_sync.SnsFullSyncManager() + result, _ = self._run_with_source(manager, account_dir, source_root) + + self.assertEqual(result["status"], "done") + self.assertEqual(result["progress"]["sourceRowsScanned"], 4) + self.assertEqual(result["progress"]["prepared"], 2) + self.assertEqual(result["progress"]["skipped"], 2) + conn = sqlite3.connect(str(account_dir / "sns.db")) + try: + tids = {row[0] for row in conn.execute("SELECT tid FROM SnsTimeLine")} + columns = {row[1] for row in conn.execute("PRAGMA table_info(SnsTimeLine)")} + finally: + conn.close() + self.assertEqual(tids, {-2, 3}) + self.assertNotIn("pack_info_buf", columns) + state = json.loads((account_dir / "_sns_realtime_sync_state.json").read_text(encoding="utf-8")) + self.assertEqual(state["maxId"], str((-2) & 0xFFFFFFFFFFFFFFFF)) + + def test_full_sync_backfills_rows_below_existing_highwater_without_regressing_it(self): + with TemporaryDirectory() as td: + root = Path(td) + account_dir = root / "decrypted" / "account-low-history" + account_dir.mkdir(parents=True) + state_path = account_dir / "_sns_realtime_sync_state.json" + state_path.write_text(json.dumps({"maxId": "999999"}), encoding="utf-8") + source_root = root / "source-low-history" + rows = [ + (tid, "friend-history", "1", None) + for tid in range(1, 351) + ] + _create_source_db(source_root, rows) + + manager = sns_full_sync.SnsFullSyncManager() + result, _ = self._run_with_source(manager, account_dir, source_root) + + self.assertEqual(result["status"], "done") + self.assertEqual(result["progress"]["changed"], 350) + conn = sqlite3.connect(str(account_dir / "sns.db")) + try: + self.assertEqual(conn.execute("SELECT COUNT(*) FROM SnsTimeLine").fetchone()[0], 350) + finally: + conn.close() + state = json.loads(state_path.read_text(encoding="utf-8")) + self.assertEqual(state["maxId"], "999999") + + def test_duplicate_reuses_job_other_account_queues_and_cancel_keeps_batches(self): + with TemporaryDirectory() as td: + root = Path(td) + account_a = root / "decrypted" / "account-a" + account_b = root / "decrypted" / "account-b" + account_a.mkdir(parents=True) + account_b.mkdir(parents=True) + source_a = root / "source-a" + source_b = root / "source-b" + _create_source_db( + source_a, + [(tid, "friend-a", "1", None) for tid in range(1, 451)], + ) + _create_source_db( + source_b, + [(tid, "friend-b", "1", None) for tid in range(1, 11)], + ) + connections = { + str(account_a.resolve()): _FakeConnection(source_a), + str(account_b.resolve()): _FakeConnection(source_b), + } + first_batch_entered = threading.Event() + release_first_batch = threading.Event() + real_upsert = sns_router._upsert_sns_timeline_rows_to_decrypted_db + events = [] + + def slow_upsert(account_dir, rows, *, source): + result = real_upsert(account_dir, rows, source=source) + if Path(account_dir).resolve() == account_a.resolve() and not first_batch_entered.is_set(): + first_batch_entered.set() + release_first_batch.wait(timeout=3) + return result + + manager = sns_full_sync.SnsFullSyncManager() + with ( + mock.patch.object( + sns_full_sync.WCDB_REALTIME, + "ensure_connected", + side_effect=lambda account_dir, timeout=15.0: connections[str(Path(account_dir).resolve())], + ), + mock.patch.object(manager, "_query", side_effect=_sqlite_query), + mock.patch.object(sns_router, "_upsert_sns_timeline_rows_to_decrypted_db", side_effect=slow_upsert), + mock.patch.object( + sns_full_sync.SNS_REALTIME_AUTOSYNC, + "publish_external_event", + side_effect=lambda account, event: events.append((account, event)), + ), + ): + first, reused = manager.start(account_a) + self.assertFalse(reused) + self.assertTrue(first_batch_entered.wait(timeout=3)) + + duplicate, reused = manager.start(account_a) + self.assertTrue(reused) + self.assertEqual(duplicate["syncId"], first["syncId"]) + + queued, reused = manager.start(account_b) + self.assertFalse(reused) + self.assertEqual(queued["status"], "queued") + + current, accepted = manager.cancel(account_a, "stale-sync-id") + self.assertFalse(accepted) + self.assertEqual(current["syncId"], first["syncId"]) + self.assertFalse(current["cancelRequested"]) + + cancelling, accepted = manager.cancel(account_a, first["syncId"]) + self.assertTrue(accepted) + self.assertTrue(cancelling["cancelRequested"]) + release_first_batch.set() + + cancelled = _wait_job(manager, account_a) + completed = _wait_job(manager, account_b) + + self.assertEqual(cancelled["status"], "cancelled") + self.assertEqual(cancelled["progress"]["batchesCompleted"], 1) + self.assertFalse((account_a / "_sns_realtime_sync_state.json").exists()) + conn = sqlite3.connect(str(account_a / "sns.db")) + try: + self.assertEqual(conn.execute("SELECT COUNT(*) FROM SnsTimeLine").fetchone()[0], 200) + finally: + conn.close() + self.assertEqual(completed["status"], "done") + self.assertEqual(completed["progress"]["changed"], 10) + event_types = [event.get("type") for _account, event in events] + self.assertIn("full_sync_cancelled", event_types) + self.assertIn("full_sync_done", event_types) + + def test_failures_use_stable_public_errors(self): + with TemporaryDirectory() as td: + root = Path(td) + account_dir = root / "account" + account_dir.mkdir() + manager = sns_full_sync.SnsFullSyncManager() + sentinel = "C:/private/path/account-secret" + events = [] + with ( + mock.patch.object( + sns_full_sync.WCDB_REALTIME, + "ensure_connected", + side_effect=RuntimeError(sentinel), + ), + mock.patch.object( + sns_full_sync.SNS_REALTIME_AUTOSYNC, + "publish_external_event", + side_effect=lambda _account, event: events.append(event), + ), + mock.patch.object(sns_full_sync.logger, "error") as log_error, + ): + manager.start(account_dir) + result = _wait_job(manager, account_dir) + + self.assertEqual(result["status"], "error") + self.assertEqual(result["error"]["code"], "realtime_not_available") + self.assertEqual(events[-1].get("type"), "full_sync_error") + rendered = "\n".join(" ".join(map(str, call.args)) for call in log_error.call_args_list) + self.assertNotIn(sentinel, rendered) + + def test_batch_write_failure_does_not_advance_highwater(self): + with TemporaryDirectory() as td: + root = Path(td) + account_dir = root / "account" + account_dir.mkdir() + source_root = root / "source" + _create_source_db( + source_root, + [(1, "friend", "1", None)], + ) + manager = sns_full_sync.SnsFullSyncManager() + connection = _FakeConnection(source_root) + with ( + mock.patch.object(sns_full_sync.WCDB_REALTIME, "ensure_connected", return_value=connection), + mock.patch.object(manager, "_query", side_effect=_sqlite_query), + mock.patch.object( + sns_router, + "_upsert_sns_timeline_rows_to_decrypted_db", + return_value={"success": False, "prepared": 1, "changed": 0, "unchanged": 0}, + ), + mock.patch.object(sns_full_sync.SNS_REALTIME_AUTOSYNC, "publish_external_event"), + ): + manager.start(account_dir) + result = _wait_job(manager, account_dir) + + self.assertEqual(result["status"], "error") + self.assertEqual(result["error"]["code"], "snapshot_write_failed") + self.assertFalse((account_dir / "_sns_realtime_sync_state.json").exists()) + + def test_routes_expose_start_status_and_exact_cancel(self): + methods_by_path = { + (getattr(route, "path", ""), tuple(sorted(getattr(route, "methods", ()) or ()))) + for route in sns_router.router.routes + } + self.assertIn(("/api/sns/realtime/full_sync", ("POST",)), methods_by_path) + self.assertIn(("/api/sns/realtime/full_sync/status", ("GET",)), methods_by_path) + self.assertIn(("/api/sns/realtime/full_sync", ("DELETE",)), methods_by_path) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_sns_page_decrypted_source.py b/tests/test_sns_page_decrypted_source.py index 0e4e5c51..3e37dd74 100644 --- a/tests/test_sns_page_decrypted_source.py +++ b/tests/test_sns_page_decrypted_source.py @@ -177,6 +177,9 @@ def test_sns_page_loads_local_snapshot_before_event_connection(self): self.assertIn("query.set('usernames', params.usernames.join(','))", api) self.assertIn("syncSnsRealtimeLatest,", api) self.assertIn("getSnsSnapshotStatus,", api) + self.assertIn("startSnsFullSync,", api) + self.assertIn("getSnsFullSyncStatus,", api) + self.assertIn("cancelSnsFullSync,", api) self.assertRegex( page, re.compile( @@ -201,19 +204,19 @@ def test_sns_page_loads_local_snapshot_before_event_connection(self): refresh = page.split("const refreshSnsData = async () => {", 1)[1].split( "\n\nlet postsRequestGeneration", 1 )[0] - self.assertIn("await syncLatestSnsWithTimeout(account, {", refresh) - self.assertIn("maxScan: SNS_MANUAL_REFRESH_SCAN_LIMIT", refresh) - self.assertIn("scanOffset: reconcileWindow.scanOffset", refresh) - self.assertIn("waitForCurrent: true", refresh) - self.assertIn("await activeReconcile", refresh) - self.assertIn("mergeVisiblePostsWindow(reconcileWindow)", refresh) - self.assertIn("loadSnsUsers({ preserveExisting: true })", refresh) - self.assertIn("if (shouldMergeTimeline)", refresh) + self.assertIn("await api.startSnsFullSync({ account })", refresh) + self.assertNotIn("syncLatestSnsWithTimeout", refresh) + self.assertNotIn("selectedSnsUser", refresh) + self.assertNotIn("scanOffset", refresh) + self.assertNotIn("usernames", refresh) self.assertNotIn("loadPosts({ reset: true })", refresh) self.assertNotIn("posts.value = []", refresh) self.assertIn('@click="refreshSnsData"', page) - self.assertIn("refreshQueued = true", page) - self.assertIn("syncStatus === 'ok' || syncStatus === 'noop'", page) + self.assertIn("const cancelSnsFullSync = async () =>", page) + self.assertIn("await api.cancelSnsFullSync({ account, sync_id: syncId })", page) + self.assertIn("await restoreSnsFullSyncStatus(String(v || ''))", page) + self.assertIn("source.addEventListener('full_sync_progress', onSnsFullSyncEvent)", page) + self.assertIn("queueSnsFullSyncMerge(job, { final })", page) self.assertIn("实时同步失败,当前显示本地快照", page) self.assertIn("const describeSnsSyncFailure = (failure) =>", page) self.assertIn("实时同步响应超时,后台任务仍可能完成", page)