From 4908a705816366379037b45689c72700a017dd3c Mon Sep 17 00:00:00 2001 From: xiaosheng <73678111+xiaoshengbao@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:10:38 +0800 Subject: [PATCH 1/2] fix(sns): add full-history sync and privacy-safe logs --- frontend/composables/useApi.js | 25 + frontend/pages/sns.vue | 359 ++++++++--- .../tests/sns-page-initialization.test.mjs | 22 +- src/wechat_decrypt_tool/api.py | 7 +- src/wechat_decrypt_tool/logging_config.py | 4 + src/wechat_decrypt_tool/request_logging.py | 29 +- src/wechat_decrypt_tool/routers/sns.py | 167 ++++-- src/wechat_decrypt_tool/sns_export_service.py | 18 +- src/wechat_decrypt_tool/sns_full_sync.py | 557 ++++++++++++++++++ src/wechat_decrypt_tool/sns_media.py | 32 +- .../sns_realtime_autosync.py | 66 ++- tests/test_request_log_redaction.py | 34 ++ tests/test_sns_full_sync.py | 364 ++++++++++++ tests/test_sns_log_privacy.py | 174 ++++++ tests/test_sns_media.py | 6 +- tests/test_sns_media_route_weflow_default.py | 24 +- tests/test_sns_page_decrypted_source.py | 23 +- 17 files changed, 1685 insertions(+), 226 deletions(-) create mode 100644 src/wechat_decrypt_tool/sns_full_sync.py create mode 100644 tests/test_sns_full_sync.py create mode 100644 tests/test_sns_log_privacy.py 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 18bddbc9..3cb4c43f 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('') @@ -2013,12 +2050,6 @@ const saveSnsExportToSelectedFolder = async (options = {}) => { } const response = await fetch(getSnsExportDownloadUrl(exportId)) if (!response.ok) { - await reportServerErrorFromResponse(response, { - method: 'GET', - requestUrl: getSnsExportDownloadUrl(exportId), - message: `\u4e0b\u8f7d\u5bfc\u51fa\u6587\u4ef6\u5931\u8d25\uff08${response.status}\uff09`, - source: 'sns.exportDownload' - }) throw new Error(`\u4e0b\u8f7d\u5bfc\u51fa\u6587\u4ef6\u5931\u8d25\uff08${response.status}\uff09`) } exportSaveBytesTotal.value = asNumber(response.headers.get('Content-Length')) @@ -2261,14 +2292,8 @@ const loadSelfInfo = async () => { const unchanged = Object.keys(resp).every((key) => resp[key] === selfInfo.value?.[key]) if (!unchanged) selfInfo.value = resp } - } catch (e) { - await reportServerErrorFromError(e, { - method: 'GET', - requestUrl, - source: 'sns.loadSelfInfo', - apiBase, - }) - console.error('获取个人信息失败', e) + } catch { + console.error('[sns.self-info] status=error phase=load') } } @@ -2302,7 +2327,7 @@ const loadSnsUsers = async ({ preserveExisting = false } = {}) => { for (const item of nextByUsername.values()) merged.push(item) snsUsers.value = merged } catch (e) { - console.error('加载朋友圈联系人失败', e) + console.error('[sns.users] status=error phase=load') // 后台刷新失败时保留已显示的联系人,避免侧边栏闪空。 } } @@ -2578,7 +2603,7 @@ const onCopyPostTextClick = async () => { const ok = await copyTextToClipboard(text) if (!ok) showErrorAlert('复制失败:无法写入剪贴板') } catch (e) { - console.error('复制失败:', e) + console.error('[sns.copy] status=error phase=clipboard-write') showErrorAlert('复制失败') } finally { closeContextMenu() @@ -2596,7 +2621,7 @@ const onCopyPostJsonClick = async () => { const ok = await copyTextToClipboard(json) if (!ok) showErrorAlert('复制失败:无法写入剪贴板') } catch (e) { - console.error('复制失败:', e) + console.error('[sns.copy] status=error phase=clipboard-write') showErrorAlert('复制失败') } finally { closeContextMenu() @@ -3369,11 +3394,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 @@ -3384,6 +3410,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 @@ -3438,7 +3468,7 @@ const beginSnsRealtimeSync = ( const syncLatestSnsWithTimeout = async ( account, { - maxScan = SNS_MANUAL_REFRESH_SCAN_LIMIT, + maxScan = SNS_INCREMENTAL_DEFAULT_SCAN_LIMIT, scanOffset = null, usernames = [], waitForCurrent = false @@ -3515,80 +3545,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 {} - } - 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) - ) + 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 }) } - 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) => { @@ -3887,13 +3895,152 @@ const mergeVisiblePostsWindow = async (windowRange = getSnsVisibleReconcileWindo scheduleSnsVisibleWindowUpdate() return true } catch (e) { - console.warn('合并朋友圈浮动窗口失败', e) + console.warn('[sns.timeline] status=error phase=viewport-merge') return false } } 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() @@ -4051,7 +4198,7 @@ const queueSnsRealtimeReconcile = (eventPayload) => { if (account === String(selectedAccount.value || '').trim()) { syncWarning.value = describeSnsSyncFailure(e) } - console.warn('朋友圈事件对账失败,继续使用本地快照', e) + console.warn('[sns.incremental-sync] status=error phase=viewport-reconcile') } } return changed @@ -4074,6 +4221,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 @@ -4121,6 +4269,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() @@ -4139,6 +4305,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() @@ -4155,8 +4325,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) { @@ -4181,6 +4356,7 @@ watch( resetSnsMediaErrors() if (previewCtx.value) closeImagePreview() await loadLocalSnsData() + await restoreSnsFullSyncStatus(String(v || '')) // 首屏就绪后建立事件连接;后端启动同步或重连差异由 ready 事件补齐。 connectSnsEventStream() } @@ -4257,6 +4433,7 @@ const runPassiveSnsRefresh = async () => { if (!String(selectedAccount.value || '').trim()) return // 窗口重新可见时只核对一次本地版本,然后恢复 SSE。 await reconcileSnsSnapshotOnce() + await restoreSnsFullSyncStatus(String(selectedAccount.value || '')) connectSnsEventStream() } @@ -4299,7 +4476,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..a475f279 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('\n\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..dceb4a48 100644 --- a/src/wechat_decrypt_tool/api.py +++ b/src/wechat_decrypt_tool/api.py @@ -281,8 +281,11 @@ async def _startup_background_jobs() -> None: logger.exception("Failed to start realtime autosync service") try: SNS_REALTIME_AUTOSYNC.start() - except Exception: - logger.exception("Failed to start SNS realtime autosync service") + except Exception as exc: + 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/logging_config.py b/src/wechat_decrypt_tool/logging_config.py index 9142d7d0..bd90a73d 100644 --- a/src/wechat_decrypt_tool/logging_config.py +++ b/src/wechat_decrypt_tool/logging_config.py @@ -274,6 +274,10 @@ def setup_logging(self, log_level: str = "INFO"): if console_handler is not None: fastapi_logger.addHandler(console_handler) fastapi_logger.setLevel(level) + + # httpx 的 INFO 请求日志会展开完整远程 URL;朋友圈媒体 URL 可能含 token 和用户标识。 + logging.getLogger("httpx").setLevel(logging.WARNING) + logging.getLogger("httpcore").setLevel(logging.WARNING) # 记录初始化信息 logger = logging.getLogger(__name__) diff --git a/src/wechat_decrypt_tool/request_logging.py b/src/wechat_decrypt_tool/request_logging.py index 70fe16c6..348e5fef 100644 --- a/src/wechat_decrypt_tool/request_logging.py +++ b/src/wechat_decrypt_tool/request_logging.py @@ -94,10 +94,14 @@ def redact_sensitive_log_data(value: Any, *, _depth: int = 0) -> Any: def redact_sensitive_query_text(value: Any) -> str: """Redact sensitive query values without decoding or rewriting unrelated values.""" text = str(value or "") - if "?" not in text: + prefix, separator, query = text.partition("?") + normalized_path = prefix.lower().rstrip("/") + if normalized_path == "/sns" or "/sns/" in f"{normalized_path}/": + # 朋友圈查询参数可能包含账号、联系人、动态/媒体 ID、URL、路径、时间和尺寸; + # 路径段也可能包含导出或文件 ID,因此统一压缩为固定命名空间。 + return "/api/sns" if "/api/sns" in normalized_path else "/sns" + if not separator: return text - - prefix, query = text.split("?", 1) redacted_parts: list[str] = [] for part in query.split("&"): raw_key, separator, raw_value = part.partition("=") @@ -232,20 +236,31 @@ def _extract_response_detail_from_body(response: Response, body: bytes) -> str: async def log_server_errors_middleware(logger, request: Request, call_next): method = str(request.method or "").upper() or "GET" path = str(request.url.path or "").strip() or "/" + normalized_path = path.lower().rstrip("/") + is_sns_request = normalized_path == "/sns" or "/sns/" in f"{normalized_path}/" + safe_path = "/api/sns" if is_sns_request else path try: response = await call_next(request) except Exception as exc: - logger.exception("[server-exception] method=%s path=%s error=%s", method, path, exc) + if is_sns_request: + logger.error( + "[server-exception] method=%s path=%s code=sns_request_failed error_type=%s", + method, + safe_path, + type(exc).__name__, + ) + else: + logger.exception("[server-exception] method=%s path=%s error=%s", method, path, exc) raise status = int(getattr(response, "status_code", 0) or 0) if status >= 500: response, body = await _buffer_response_body(response) detail = _extract_response_detail_from_body(response, body) or _extract_response_detail(response) - if detail: - logger.error("[server-5xx] status=%s method=%s path=%s detail=%s", status, method, path, detail) + if detail and not is_sns_request: + logger.error("[server-5xx] status=%s method=%s path=%s detail=%s", status, method, safe_path, detail) else: - logger.error("[server-5xx] status=%s method=%s path=%s", status, method, path) + logger.error("[server-5xx] status=%s method=%s path=%s", status, method, safe_path) return response diff --git a/src/wechat_decrypt_tool/routers/sns.py b/src/wechat_decrypt_tool/routers/sns.py index b7af8e09..4aaa0600 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, @@ -527,11 +529,9 @@ def _pack_blob(value: Any) -> Optional[bytes]: "unchanged": unchanged, } except Exception as e: - raw_error_text = f"{type(e).__name__}: {e}" - error_text = raw_error_text.encode("ascii", errors="backslashreplace").decode("ascii") + error_text = type(e).__name__ logger.warning( - "[sns] decrypted sns.db upsert failed source=%s prepared=%s err=%s", - source, + "[sns.incremental-sync] status=error phase=writing prepared=%s error_type=%s", len(rows), error_text, ) @@ -1571,7 +1571,7 @@ def _get_sns_covers( # 利用 exec_query 强行查 rows = _wcdb_exec_query(conn.handle, kind="media", path=str(sns_db_path), sql=cover_sql) or [] except Exception as e: - logger.warning("[sns] WCDB cover fetch failed: %s", e) + logger.warning("[sns.cover] status=error phase=source-read error_type=%s", type(e).__name__) # 2) Fallback to local decrypted snapshot sns.db. if not rows: @@ -1585,7 +1585,7 @@ def _get_sns_covers( conn_sq.close() rows = [{"tid": r["tid"], "content": r["content"]} for r in (rows_sq or [])] except Exception as e: - logger.warning("[sns] SQLite cover fetch failed: %s", e) + logger.warning("[sns.cover] status=error phase=snapshot-read error_type=%s", type(e).__name__) out: list[dict[str, Any]] = [] seen: set[str] = set() @@ -1643,7 +1643,7 @@ def api_sns_self_info(account: Optional[str] = None, source: str = "auto"): if requested_source == "auto" and account_prefers_decrypted_snapshot(account_dir): requested_source = "decrypted" - logger.info(f"[self_info] 开始获取账号信息, 预设 wxid: {wxid}") + logger.info("[sns.self-info] status=running phase=lookup") nickname = wxid result_source = "wxid_dir" @@ -1658,10 +1658,10 @@ def api_sns_self_info(account: Optional[str] = None, source: str = "auto"): if names_map and names_map.get(wxid): nickname = names_map[wxid] result_source = "wcdb_realtime" - logger.info(f"[self_info] 从 WCDB 实时连接获取成功: {nickname}") + logger.info("[sns.self-info] status=done phase=wcdb") return {"wxid": wxid, "nickname": nickname, "source": result_source} except Exception as e: - logger.debug(f"[self_info] WCDB 路径跳过或失败: {e}") + logger.debug("[sns.self-info] status=skipped phase=wcdb error_type=%s", type(e).__name__) contact_db_path = account_dir / "contact.db" if contact_db_path.exists(): @@ -1673,7 +1673,7 @@ def api_sns_self_info(account: Optional[str] = None, source: str = "auto"): cursor = conn.execute("PRAGMA table_info(contact)") cols = {row["name"].lower() for row in cursor.fetchall()} - logger.debug(f"[self_info] contact 表现有字段: {cols}") + logger.debug("[sns.self-info] status=running phase=snapshot-schema") target_nick_col = "nick_name" if "nick_name" in cols else ("nickname" if "nickname" in cols else None) @@ -1697,18 +1697,18 @@ def api_sns_self_info(account: Optional[str] = None, source: str = "auto"): nickname = raw_alias result_source = "contact_db_alias" - logger.info(f"[self_info] 从数据库提取成功: {nickname} (src: {result_source})") + logger.info("[sns.self-info] status=done phase=snapshot") else: - logger.warning("[self_info] contact 表中找不到任何昵称相关字段") + logger.warning("[sns.self-info] status=skipped phase=snapshot-schema code=nickname_column_missing") except sqlite3.OperationalError as e: - logger.error(f"[self_info] 数据库繁忙或锁定: {e}") + logger.error("[sns.self-info] status=error phase=snapshot-read error_type=%s", type(e).__name__) except Exception as e: - logger.exception(f"[self_info] 查询异常: {e}") + logger.error("[sns.self-info] status=error phase=snapshot-read error_type=%s", type(e).__name__) finally: if conn: conn.close() else: - logger.warning(f"[self_info] 找不到 contact.db: {contact_db_path}") + logger.warning("[sns.self-info] status=skipped phase=snapshot-read code=contact_db_missing") return { "wxid": wxid, @@ -1796,6 +1796,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 +1831,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 +1883,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 +1910,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 +1927,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 +2164,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(( @@ -2117,8 +2184,8 @@ def _sync_response( )) if not snapshot_complete: logger.warning( - "[sns-sync] snapshot write incomplete account=%s scanned=%s prepared=%s changed=%s unchanged=%s missing_required=%s", - account_dir.name, + "[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, @@ -2138,10 +2205,9 @@ def _sync_response( if backlog_truncated: logger.warning( - "[sns-sync] backlog exceeds scan cap account=%s scanned=%s last_max_id=%s", - account_dir.name, + "[sns.incremental-sync] status=skipped request_id=%s phase=scanning code=scan_cap_reached scanned=%s", + sync_request_id, len(rows), - last_max_id_u, ) return _sync_response({ "status": "skipped", @@ -2160,7 +2226,10 @@ def _sync_response( st2["maxId"] = str(committed_max_id_u) 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", @@ -2258,8 +2327,8 @@ def _list_from_decrypted_sqlite() -> dict[str, Any]: try: rows2 = conn2.execute(sql, params_with_page).fetchall() except sqlite3.OperationalError as e: - logger.warning("[sns] query failed: %s", e) - raise HTTPException(status_code=500, detail=f"sns.db query failed: {e}") + logger.warning("[sns.timeline] status=error phase=snapshot-read error_type=%s", type(e).__name__) + raise HTTPException(status_code=500, detail="sns.db query failed") finally: conn2.close() @@ -2925,10 +2994,10 @@ def _clean_name(v: Any) -> str: return wcdb_resp except WCDBRealtimeError as e: - logger.info("[sns] wcdb realtime unavailable: %s", e) + logger.info("[sns.timeline] status=skipped phase=source-read error_type=%s", type(e).__name__) fallback_reason = str(e) except Exception as e: - logger.warning("[sns] wcdb realtime failed: %s", e) + logger.warning("[sns.timeline] status=error phase=source-read error_type=%s", type(e).__name__) fallback_reason = str(e) fallback = _list_from_decrypted_sqlite() @@ -2981,7 +3050,7 @@ def worker() -> None: finally: conn.close() except Exception as exc: - logger.info("[sns] background index creation deferred: %s", exc) + logger.info("[sns.timeline] status=skipped phase=index error_type=%s", type(exc).__name__) with _SNS_INDEX_SCHEDULE_LOCK: _SNS_INDEX_SCHEDULED.discard(key) @@ -3522,35 +3591,25 @@ async def get_sns_media( media_type_i = int(media_type or 2) md5_norm = _normalize_hex32(md5) request_id = f"sns-media-{time.time_ns()}-{threading.get_ident()}" - _trace_id, trace = create_perf_trace( + _trace_id, raw_trace = create_perf_trace( logger, "sns.media", requestId=request_id, - account=str(account_dir.name), - accountDir=str(account_dir), - wxidDir=str(wxid_dir or ""), - postId=str(post_id or ""), - mediaId=str(media_id or ""), - postType=post_type_i, - mediaType=media_type_i, - createTime=int(create_time or 0), - width=int(width or 0), - height=int(height or 0), - totalSize=int(total_size or 0), - idx=max(0, int(idx or 0)), - md5=md5_norm, - variant=variant_norm, - preferRemoteOriginal=prefer_remote_original, - useCacheRequested=str(use_cache), - useCacheEffective=use_cache_flag, - tokenPresent=bool(str(token or "")), - tokenLength=len(str(token or "")), - tokenHash=_sns_media_value_hash(token), - keyPresent=bool(str(key or "")), - keyLength=len(str(key or "")), - keyHash=_sns_media_value_hash(key), - **_sns_media_url_trace_fields(url), ) + + def trace(phase: str, **fields: Any) -> None: + """严格筛选媒体诊断字段,避免后续调用误把用户数据写入日志。""" + safe_fields: dict[str, Any] = {} + for field_name in ("result", "statusCode", "elapsedMs"): + if field_name in fields: + safe_fields[field_name] = fields[field_name] + error_type = str(fields.get("errorType") or "") + if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]{0,79}", error_type): + safe_fields["errorType"] = error_type + safe_phase = str(phase or "unknown") + if not re.fullmatch(r"[a-z0-9:_-]{1,80}", safe_phase, flags=re.I): + safe_phase = "unknown" + raw_trace(safe_phase, **safe_fields) trace("request:start") # 点击预览需要高清原图:本地 sns 缓存有时只命中缩略图,所以 full/original 请求先按 @@ -3799,7 +3858,7 @@ async def proxy_article_thumb(url: str): ) except Exception as e: - logger.warning(f"[sns] 提取公众号封面失败 url={u[:50]}... : {e}") + logger.warning("[sns.article-thumb] status=error phase=fetch error_type=%s", type(e).__name__) raise HTTPException(status_code=404, detail="无法获取文章封面") diff --git a/src/wechat_decrypt_tool/sns_export_service.py b/src/wechat_decrypt_tool/sns_export_service.py index 16a21fc0..996f0cc3 100644 --- a/src/wechat_decrypt_tool/sns_export_service.py +++ b/src/wechat_decrypt_tool/sns_export_service.py @@ -247,7 +247,10 @@ async def worker(http_client: httpx.AsyncClient) -> None: except Exception as exc: result.failed += 1 result.missing.append(task_id) - logger.info("sns media prefetch failed: kind=%s url=%s error=%s", task.kind, task.url, exc) + logger.info( + "[sns.export] status=error phase=media-prefetch error_type=%s", + type(exc).__name__, + ) finally: completed += 1 if on_progress is not None: @@ -1631,7 +1634,12 @@ def _run_job_safe(self, job: ExportJob, account_dir: Path) -> None: pass self.commit_staged_files(job.export_id) except Exception as e: - logger.exception("sns export job failed: %s: %s", job.export_id, e) + logger.error( + "[sns.export] status=error export_id=%s phase=%s error_type=%s", + job.export_id, + str(job.progress.phase or "unknown"), + type(e).__name__, + ) with self._lock: job.status = "error" job.error = str(e) @@ -1696,7 +1704,11 @@ def set_phase(name: str) -> None: if sync_status not in {"ok", "noop"}: job.warning = "实时同步未完成,已继续导出本地历史快照。" except Exception as exc: # 导出必须可在无 native broker 的环境中降级运行 - logger.warning("sns realtime sync before export failed: export=%s error=%s", job.export_id, exc) + logger.warning( + "[sns.export] status=error export_id=%s phase=syncing error_type=%s", + job.export_id, + type(exc).__name__, + ) job.warning = "实时同步失败,已继续导出本地历史快照。" job.freshness = { "status": "warning", 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_media.py b/src/wechat_decrypt_tool/sns_media.py index 0de619f3..7605c8c6 100644 --- a/src/wechat_decrypt_tool/sns_media.py +++ b/src/wechat_decrypt_tool/sns_media.py @@ -91,33 +91,20 @@ def _sns_remote_diagnostic_log( error: Optional[BaseException] = None, **fields: object, ) -> None: - raw_url = str(url or "").strip() - try: - host = str(urlparse(raw_url).hostname or "").strip().lower() - except Exception: - host = "" - - stable_url = normalize_sns_cache_url(raw_url) + # 朋友圈日志只保留诊断阶段和受控结果,不记录 URL、Host、密钥摘要或响应尺寸。 payload: dict[str, object] = { "diagnosticId": str(diagnostic_id or ""), "event": str(event or ""), - "urlHost": host, - "urlIdentity": ( - hashlib.sha256(stable_url.encode("utf-8", errors="ignore")).hexdigest()[:16] - if stable_url - else "" - ), - **fields, } + for field_name in ("stage", "result", "statusCode", "elapsedMs", "attempt"): + if field_name in fields: + payload[field_name] = fields[field_name] if error is not None: - error_text = str(error).strip() or repr(error) - for sensitive in (raw_url, str(key or ""), str(token or "")): - if sensitive: - error_text = error_text.replace(sensitive, "") - error_text = re.sub(r"https?://[^\s\"']+", "", error_text, flags=re.I) - payload["errorType"] = type(error).__name__ - payload["errorText"] = error_text[:500] + error_type = type(error).__name__ + payload["errorType"] = ( + error_type if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]{0,79}", error_type) else "Exception" + ) response = getattr(error, "response", None) status_code = getattr(response, "status_code", None) @@ -125,7 +112,7 @@ def _sns_remote_diagnostic_log( try: payload["statusCode"] = int(status_code) except Exception: - payload["statusCode"] = str(status_code) + payload["statusCode"] = 0 logger.info( "[sns_media] %s", @@ -1082,4 +1069,3 @@ async def try_fetch_and_decrypt_sns_image_remote( x_enc=str(x_enc or "").strip(), cache_path=cache_path, ) - diff --git a/src/wechat_decrypt_tool/sns_realtime_autosync.py b/src/wechat_decrypt_tool/sns_realtime_autosync.py index 9648f953..f232f7cd 100644 --- a/src/wechat_decrypt_tool/sns_realtime_autosync.py +++ b/src/wechat_decrypt_tool/sns_realtime_autosync.py @@ -176,8 +176,8 @@ def _bootstrap_accounts(self) -> None: """启动时只枚举一次账号;后续账号由 SSE 连接动态注册。""" try: accounts = list(_list_decrypted_accounts() or []) - except Exception: - logger.exception("[sns-autosync] 初始账号枚举失败") + except Exception as exc: + 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 +314,9 @@ 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: @@ -358,20 +358,23 @@ def _schedule_sync(self, account: str, *, reason: str) -> None: worker = threading.Thread( target=self._sync_account_runner, args=(account, reason, revision), - name=f"sns-event-sync-{account}", + name="sns-incremental-sync", daemon=True, ) state.worker = worker 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 +389,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 +406,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) @@ -462,12 +480,11 @@ def _refresh_native_moments_once(self, account: str) -> None: client, NativeCoreFeature.WECHAT_MOMENTS_REFRESH ) 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, + "[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]: @@ -480,8 +497,12 @@ 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: - logger.exception("[sns-autosync] 同步失败 account=%s", account) + except Exception as exc: + 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 +555,11 @@ 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: - logger.exception("[sns-autosync] 增量同步调用失败 account=%s", account) + except Exception as exc: + logger.error( + "[sns.incremental-sync] status=error phase=scanning error_type=%s", + type(exc).__name__, + ) return {"status": "error", "error": "sns_sync_failed"} def subscribe( @@ -599,14 +623,6 @@ def _publish_sync_result(self, account: str, reason: str, revision: int, result: "timestamp": int(time.time() * 1000), } self._publish_event(account, event) - logger.info( - "[sns-autosync] 事件同步完成 account=%s reason=%s revision=%s status=%s changed=%s", - account, - reason, - revision, - status, - event["changed"], - ) return raw_code = str((result or {}).get("error") or (result or {}).get("reason") or "sns_sync_failed").strip() @@ -661,5 +677,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_request_log_redaction.py b/tests/test_request_log_redaction.py index ae19880b..c486b7eb 100644 --- a/tests/test_request_log_redaction.py +++ b/tests/test_request_log_redaction.py @@ -118,6 +118,40 @@ def test_uvicorn_access_filter_redacts_query_secrets(self): self.assertNotIn("AES_SECRET", rendered) self.assertIn("db_storage_path=C%3A%5Cdb", rendered) + def test_uvicorn_access_filter_removes_all_sns_query_values(self): + from wechat_decrypt_tool.request_logging import ( + SensitiveQueryLogFilter, + redact_sensitive_query_text, + ) + + record = logging.LogRecord( + "uvicorn.access", + logging.INFO, + __file__, + 1, + '%s - "%s %s HTTP/%s" %d', + ( + "127.0.0.1:1234", + "GET", + "/api/sns/media?account=ACCOUNT_SENTINEL&post_id=POST_SENTINEL&url=https%3A%2F%2Fprivate.example", + "1.1", + 200, + ), + None, + ) + + self.assertTrue(SensitiveQueryLogFilter().filter(record)) + rendered = record.getMessage() + self.assertIn("/api/sns", rendered) + self.assertNotIn("/media", rendered) + self.assertNotIn("ACCOUNT_SENTINEL", rendered) + self.assertNotIn("POST_SENTINEL", rendered) + self.assertNotIn("private.example", rendered) + self.assertEqual( + redact_sensitive_query_text("/api/sns/exports/EXPORT_SENTINEL/files/private-file.jpg"), + "/api/sns", + ) + if __name__ == "__main__": unittest.main() 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_log_privacy.py b/tests/test_sns_log_privacy.py new file mode 100644 index 00000000..89914819 --- /dev/null +++ b/tests/test_sns_log_privacy.py @@ -0,0 +1,174 @@ +import asyncio +import hashlib +import sys +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 fastapi import HTTPException + +from wechat_decrypt_tool import sns_export_service, sns_media +from wechat_decrypt_tool import request_logging +from wechat_decrypt_tool.routers import sns as sns_router + + +def _render_calls(calls) -> str: + rendered = [] + for item in calls: + args = item.args + if not args: + continue + try: + rendered.append(str(args[0]) % tuple(args[1:])) + except Exception: + rendered.append(" ".join(str(value) for value in args)) + return "\n".join(rendered) + + +class TestSnsLogPrivacy(unittest.TestCase): + def test_sns_server_exception_log_omits_exception_text_and_traceback(self): + sentinel = "C:/private/account-sentinel/source.db" + + class _Url: + path = "/api/sns/media" + + class _Request: + method = "GET" + url = _Url() + + async def fail(_request): + raise RuntimeError(sentinel) + + fake_logger = mock.Mock() + with self.assertRaises(RuntimeError): + asyncio.run(request_logging.log_server_errors_middleware(fake_logger, _Request(), fail)) + + logs = _render_calls(fake_logger.error.call_args_list) + self.assertIn("code=sns_request_failed", logs) + self.assertIn("error_type=RuntimeError", logs) + self.assertNotIn(sentinel, logs) + fake_logger.exception.assert_not_called() + + def test_remote_media_log_omits_all_user_and_derived_sentinels(self): + sentinels = { + "account": "privacy-account-sentinel", + "nickname": "隐私昵称哨兵", + "content": "朋友圈正文哨兵", + "url": "https://private.example.test/secret/path?token=url-token-sentinel", + "path": "C:/Users/private-user/朋友圈/private-file.jpg", + "post": "post-id-sentinel", + "media": "media-id-sentinel", + "key": "key-sentinel-value", + "token": "token-sentinel-value", + } + derived = { + hashlib.sha256(value.encode("utf-8")).hexdigest() + for value in sentinels.values() + } + + with mock.patch.object(sns_media.logger, "info") as log_info: + sns_media._sns_remote_diagnostic_log( + "remote:download-error", + url=sentinels["url"], + diagnostic_id="diag-safe-1", + key=sentinels["key"], + token=sentinels["token"], + error=RuntimeError(f"failed at {sentinels['path']} {sentinels['content']}"), + account=sentinels["account"], + nickname=sentinels["nickname"], + postId=sentinels["post"], + mediaId=sentinels["media"], + candidatePath=sentinels["path"], + responseSha256=next(iter(derived)), + width=123, + height=456, + createTime=1234567890, + statusCode=503, + ) + + logs = _render_calls(log_info.call_args_list) + self.assertIn("diag-safe-1", logs) + self.assertIn('"statusCode": 503', logs) + self.assertIn('"errorType": "RuntimeError"', logs) + for value in (*sentinels.values(), *derived): + self.assertNotIn(value, logs) + for forbidden_field in ( + "urlHost", + "urlIdentity", + "errorText", + "candidatePath", + "responseSha256", + "postId", + "mediaId", + "width", + "height", + "createTime", + ): + self.assertNotIn(forbidden_field, logs) + + def test_incremental_unavailable_log_does_not_include_account_or_path(self): + with TemporaryDirectory() as td: + account_dir = Path(td) / "private-account-sentinel" + account_dir.mkdir() + with ( + mock.patch.object(sns_router, "_resolve_account_dir", return_value=account_dir), + mock.patch.object(sns_router.WCDB_REALTIME, "get_status", return_value={}), + mock.patch.object(sns_router.logger, "info") as log_info, + mock.patch.object(sns_router.logger, "error") as log_error, + ): + with self.assertRaises(HTTPException): + sns_router.sync_sns_realtime_timeline_latest(account=account_dir.name) + + logs = _render_calls(log_info.call_args_list + log_error.call_args_list) + self.assertIn("[sns.incremental-sync]", logs) + self.assertIn("realtime_not_available", logs) + self.assertNotIn(account_dir.name, logs) + self.assertNotIn(str(account_dir), logs) + + def test_export_prefetch_failure_log_omits_url_and_exception_text(self): + task = sns_export_service.SnsRemoteMediaTask( + kind="image", + url="https://private.example.test/media/url-sentinel", + key="private-key-sentinel", + token="private-token-sentinel", + ) + + async def fail_fetch(**_kwargs): + raise RuntimeError("C:/private/path-sentinel") + + async def run(account_dir: Path): + return await sns_export_service._prefetch_sns_remote_media( + account_dir=account_dir, + tasks=[task], + use_cache=False, + concurrency=1, + ) + + with TemporaryDirectory() as td: + account_dir = Path(td) / "account-sentinel" + account_dir.mkdir() + with ( + mock.patch.object( + sns_export_service, + "_try_fetch_and_decrypt_sns_image_remote", + side_effect=fail_fetch, + ), + mock.patch.object(sns_export_service.logger, "info") as log_info, + ): + result = asyncio.run(run(account_dir)) + + self.assertEqual(result.failed, 1) + logs = _render_calls(log_info.call_args_list) + self.assertIn("error_type=RuntimeError", logs) + for sentinel in (task.url, task.key, task.token, "C:/private/path-sentinel", account_dir.name): + self.assertNotIn(sentinel, logs) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_sns_media.py b/tests/test_sns_media.py index 305b3a7c..716968e3 100644 --- a/tests/test_sns_media.py +++ b/tests/test_sns_media.py @@ -265,8 +265,10 @@ async def run(account_dir: Path): self.assertIn('"diagnosticId": "diag-http-400"', logs) self.assertIn('"errorType": "HTTPStatusError"', logs) self.assertIn('"statusCode": 400', logs) - self.assertIn('"urlHost": "mmsns.qpic.cn"', logs) - self.assertIn('"urlIdentity":', logs) + self.assertNotIn("mmsns.qpic.cn", logs) + self.assertNotIn('"urlHost":', logs) + self.assertNotIn('"urlIdentity":', logs) + self.assertNotIn('"errorText":', logs) self.assertNotIn(raw_url_token, logs) self.assertNotIn(request_token, logs) self.assertNotIn(request_key, logs) diff --git a/tests/test_sns_media_route_weflow_default.py b/tests/test_sns_media_route_weflow_default.py index d05f262e..1a5084af 100644 --- a/tests/test_sns_media_route_weflow_default.py +++ b/tests/test_sns_media_route_weflow_default.py @@ -201,16 +201,17 @@ def test_route_logs_redacted_identity_and_local_match_details(self): self.assertIn("sns.media request:start", logs) self.assertIn("sns.media local-key-post:probe", logs) self.assertIn("sns.media local-heuristic:probe", logs) - self.assertIn('"matchedBy": "local-heuristic"', logs) - self.assertIn('"candidatePath":', logs) - self.assertIn("matched-local.jpg", logs) - self.assertIn('"postId": "post-42"', logs) - self.assertIn('"mediaId": "media-7"', logs) - self.assertIn('"urlHost": "mmsns.qpic.cn"', logs) - self.assertIn('"urlIdentity":', logs) - self.assertIn('"tokenHash":', logs) - self.assertIn('"keyHash":', logs) - self.assertIn('"responseSha256":', logs) + self.assertIn('"result": "local-cache"', logs) + self.assertNotIn('"matchedBy":', logs) + self.assertNotIn('"candidatePath":', logs) + self.assertNotIn("matched-local.jpg", logs) + self.assertNotIn("post-42", logs) + self.assertNotIn("media-7", logs) + self.assertNotIn("mmsns.qpic.cn", logs) + self.assertNotIn('"urlIdentity":', logs) + self.assertNotIn('"tokenHash":', logs) + self.assertNotIn('"keyHash":', logs) + self.assertNotIn('"responseSha256":', logs) self.assertNotIn("super-secret-token", logs) self.assertNotIn("super-secret-key", logs) self.assertNotIn("url-secret-token", logs) @@ -338,7 +339,8 @@ def test_route_logs_exact_key_and_md5_match_strategies(self): logs = self._render_log_calls(log_info) self.assertEqual(resp.status_code, 200) - self.assertIn(f'"matchedBy": "{expected_match}"', logs) + self.assertIn(f"sns.media {expected_match}:probe", logs) + self.assertNotIn('"matchedBy":', logs) heuristic.assert_not_called() remote.assert_not_called() 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) From 55eaa1be2717fd23649e63978bd640047ed414be Mon Sep 17 00:00:00 2001 From: xiaosheng <73678111+xiaoshengbao@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:25:34 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E4=BF=9D?= =?UTF-8?q?=E7=95=99=E5=8E=9F=E6=9C=89=E6=9C=8B=E5=8F=8B=E5=9C=88=E6=97=A5?= =?UTF-8?q?=E5=BF=97=E5=B9=B6=E6=96=B0=E5=A2=9E=E5=90=8C=E6=AD=A5=E8=AF=8A?= =?UTF-8?q?=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/wechat_decrypt_tool/api.py | 1 + src/wechat_decrypt_tool/logging_config.py | 4 - src/wechat_decrypt_tool/request_logging.py | 29 +-- src/wechat_decrypt_tool/routers/sns.py | 101 ++++++---- src/wechat_decrypt_tool/sns_export_service.py | 18 +- src/wechat_decrypt_tool/sns_media.py | 31 +++- .../sns_realtime_autosync.py | 21 ++- tests/test_request_log_redaction.py | 34 ---- tests/test_sns_log_privacy.py | 174 ------------------ tests/test_sns_media.py | 6 +- tests/test_sns_media_route_weflow_default.py | 24 ++- 11 files changed, 133 insertions(+), 310 deletions(-) delete mode 100644 tests/test_sns_log_privacy.py diff --git a/src/wechat_decrypt_tool/api.py b/src/wechat_decrypt_tool/api.py index dceb4a48..e4372a08 100644 --- a/src/wechat_decrypt_tool/api.py +++ b/src/wechat_decrypt_tool/api.py @@ -282,6 +282,7 @@ async def _startup_background_jobs() -> None: try: SNS_REALTIME_AUTOSYNC.start() 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__, diff --git a/src/wechat_decrypt_tool/logging_config.py b/src/wechat_decrypt_tool/logging_config.py index bd90a73d..9142d7d0 100644 --- a/src/wechat_decrypt_tool/logging_config.py +++ b/src/wechat_decrypt_tool/logging_config.py @@ -274,10 +274,6 @@ def setup_logging(self, log_level: str = "INFO"): if console_handler is not None: fastapi_logger.addHandler(console_handler) fastapi_logger.setLevel(level) - - # httpx 的 INFO 请求日志会展开完整远程 URL;朋友圈媒体 URL 可能含 token 和用户标识。 - logging.getLogger("httpx").setLevel(logging.WARNING) - logging.getLogger("httpcore").setLevel(logging.WARNING) # 记录初始化信息 logger = logging.getLogger(__name__) diff --git a/src/wechat_decrypt_tool/request_logging.py b/src/wechat_decrypt_tool/request_logging.py index 348e5fef..70fe16c6 100644 --- a/src/wechat_decrypt_tool/request_logging.py +++ b/src/wechat_decrypt_tool/request_logging.py @@ -94,14 +94,10 @@ def redact_sensitive_log_data(value: Any, *, _depth: int = 0) -> Any: def redact_sensitive_query_text(value: Any) -> str: """Redact sensitive query values without decoding or rewriting unrelated values.""" text = str(value or "") - prefix, separator, query = text.partition("?") - normalized_path = prefix.lower().rstrip("/") - if normalized_path == "/sns" or "/sns/" in f"{normalized_path}/": - # 朋友圈查询参数可能包含账号、联系人、动态/媒体 ID、URL、路径、时间和尺寸; - # 路径段也可能包含导出或文件 ID,因此统一压缩为固定命名空间。 - return "/api/sns" if "/api/sns" in normalized_path else "/sns" - if not separator: + if "?" not in text: return text + + prefix, query = text.split("?", 1) redacted_parts: list[str] = [] for part in query.split("&"): raw_key, separator, raw_value = part.partition("=") @@ -236,31 +232,20 @@ def _extract_response_detail_from_body(response: Response, body: bytes) -> str: async def log_server_errors_middleware(logger, request: Request, call_next): method = str(request.method or "").upper() or "GET" path = str(request.url.path or "").strip() or "/" - normalized_path = path.lower().rstrip("/") - is_sns_request = normalized_path == "/sns" or "/sns/" in f"{normalized_path}/" - safe_path = "/api/sns" if is_sns_request else path try: response = await call_next(request) except Exception as exc: - if is_sns_request: - logger.error( - "[server-exception] method=%s path=%s code=sns_request_failed error_type=%s", - method, - safe_path, - type(exc).__name__, - ) - else: - logger.exception("[server-exception] method=%s path=%s error=%s", method, path, exc) + logger.exception("[server-exception] method=%s path=%s error=%s", method, path, exc) raise status = int(getattr(response, "status_code", 0) or 0) if status >= 500: response, body = await _buffer_response_body(response) detail = _extract_response_detail_from_body(response, body) or _extract_response_detail(response) - if detail and not is_sns_request: - logger.error("[server-5xx] status=%s method=%s path=%s detail=%s", status, method, safe_path, detail) + if detail: + logger.error("[server-5xx] status=%s method=%s path=%s detail=%s", status, method, path, detail) else: - logger.error("[server-5xx] status=%s method=%s path=%s", status, method, safe_path) + logger.error("[server-5xx] status=%s method=%s path=%s", status, method, path) return response diff --git a/src/wechat_decrypt_tool/routers/sns.py b/src/wechat_decrypt_tool/routers/sns.py index 4aaa0600..9330fcf1 100644 --- a/src/wechat_decrypt_tool/routers/sns.py +++ b/src/wechat_decrypt_tool/routers/sns.py @@ -529,12 +529,19 @@ def _pack_blob(value: Any) -> Optional[bytes]: "unchanged": unchanged, } except Exception as e: - error_text = type(e).__name__ + raw_error_text = f"{type(e).__name__}: {e}" + error_text = raw_error_text.encode("ascii", errors="backslashreplace").decode("ascii") logger.warning( - "[sns.incremental-sync] status=error phase=writing prepared=%s error_type=%s", + "[sns] decrypted sns.db upsert failed source=%s prepared=%s err=%s", + source, 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: @@ -1571,7 +1578,7 @@ def _get_sns_covers( # 利用 exec_query 强行查 rows = _wcdb_exec_query(conn.handle, kind="media", path=str(sns_db_path), sql=cover_sql) or [] except Exception as e: - logger.warning("[sns.cover] status=error phase=source-read error_type=%s", type(e).__name__) + logger.warning("[sns] WCDB cover fetch failed: %s", e) # 2) Fallback to local decrypted snapshot sns.db. if not rows: @@ -1585,7 +1592,7 @@ def _get_sns_covers( conn_sq.close() rows = [{"tid": r["tid"], "content": r["content"]} for r in (rows_sq or [])] except Exception as e: - logger.warning("[sns.cover] status=error phase=snapshot-read error_type=%s", type(e).__name__) + logger.warning("[sns] SQLite cover fetch failed: %s", e) out: list[dict[str, Any]] = [] seen: set[str] = set() @@ -1643,7 +1650,7 @@ def api_sns_self_info(account: Optional[str] = None, source: str = "auto"): if requested_source == "auto" and account_prefers_decrypted_snapshot(account_dir): requested_source = "decrypted" - logger.info("[sns.self-info] status=running phase=lookup") + logger.info(f"[self_info] 开始获取账号信息, 预设 wxid: {wxid}") nickname = wxid result_source = "wxid_dir" @@ -1658,10 +1665,10 @@ def api_sns_self_info(account: Optional[str] = None, source: str = "auto"): if names_map and names_map.get(wxid): nickname = names_map[wxid] result_source = "wcdb_realtime" - logger.info("[sns.self-info] status=done phase=wcdb") + logger.info(f"[self_info] 从 WCDB 实时连接获取成功: {nickname}") return {"wxid": wxid, "nickname": nickname, "source": result_source} except Exception as e: - logger.debug("[sns.self-info] status=skipped phase=wcdb error_type=%s", type(e).__name__) + logger.debug(f"[self_info] WCDB 路径跳过或失败: {e}") contact_db_path = account_dir / "contact.db" if contact_db_path.exists(): @@ -1673,7 +1680,7 @@ def api_sns_self_info(account: Optional[str] = None, source: str = "auto"): cursor = conn.execute("PRAGMA table_info(contact)") cols = {row["name"].lower() for row in cursor.fetchall()} - logger.debug("[sns.self-info] status=running phase=snapshot-schema") + logger.debug(f"[self_info] contact 表现有字段: {cols}") target_nick_col = "nick_name" if "nick_name" in cols else ("nickname" if "nickname" in cols else None) @@ -1697,18 +1704,18 @@ def api_sns_self_info(account: Optional[str] = None, source: str = "auto"): nickname = raw_alias result_source = "contact_db_alias" - logger.info("[sns.self-info] status=done phase=snapshot") + logger.info(f"[self_info] 从数据库提取成功: {nickname} (src: {result_source})") else: - logger.warning("[sns.self-info] status=skipped phase=snapshot-schema code=nickname_column_missing") + logger.warning("[self_info] contact 表中找不到任何昵称相关字段") except sqlite3.OperationalError as e: - logger.error("[sns.self-info] status=error phase=snapshot-read error_type=%s", type(e).__name__) + logger.error(f"[self_info] 数据库繁忙或锁定: {e}") except Exception as e: - logger.error("[sns.self-info] status=error phase=snapshot-read error_type=%s", type(e).__name__) + logger.exception(f"[self_info] 查询异常: {e}") finally: if conn: conn.close() else: - logger.warning("[sns.self-info] status=skipped phase=snapshot-read code=contact_db_missing") + logger.warning(f"[self_info] 找不到 contact.db: {contact_db_path}") return { "wxid": wxid, @@ -2183,6 +2190,15 @@ def _sync_response( not missing_required_tids, )) if not snapshot_complete: + logger.warning( + "[sns-sync] snapshot write incomplete account=%s scanned=%s prepared=%s changed=%s unchanged=%s missing_required=%s", + account_dir.name, + len(rows), + prepared_count, + changed_count, + 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, @@ -2204,6 +2220,12 @@ def _sync_response( }, prepared=prepared_count, changed=changed_count, unchanged=unchanged_count) if backlog_truncated: + logger.warning( + "[sns-sync] backlog exceeds scan cap account=%s scanned=%s last_max_id=%s", + account_dir.name, + 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, @@ -2226,6 +2248,7 @@ def _sync_response( st2["maxId"] = str(committed_max_id_u) 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, @@ -2327,8 +2350,8 @@ def _list_from_decrypted_sqlite() -> dict[str, Any]: try: rows2 = conn2.execute(sql, params_with_page).fetchall() except sqlite3.OperationalError as e: - logger.warning("[sns.timeline] status=error phase=snapshot-read error_type=%s", type(e).__name__) - raise HTTPException(status_code=500, detail="sns.db query failed") + logger.warning("[sns] query failed: %s", e) + raise HTTPException(status_code=500, detail=f"sns.db query failed: {e}") finally: conn2.close() @@ -2994,10 +3017,10 @@ def _clean_name(v: Any) -> str: return wcdb_resp except WCDBRealtimeError as e: - logger.info("[sns.timeline] status=skipped phase=source-read error_type=%s", type(e).__name__) + logger.info("[sns] wcdb realtime unavailable: %s", e) fallback_reason = str(e) except Exception as e: - logger.warning("[sns.timeline] status=error phase=source-read error_type=%s", type(e).__name__) + logger.warning("[sns] wcdb realtime failed: %s", e) fallback_reason = str(e) fallback = _list_from_decrypted_sqlite() @@ -3050,7 +3073,7 @@ def worker() -> None: finally: conn.close() except Exception as exc: - logger.info("[sns.timeline] status=skipped phase=index error_type=%s", type(exc).__name__) + logger.info("[sns] background index creation deferred: %s", exc) with _SNS_INDEX_SCHEDULE_LOCK: _SNS_INDEX_SCHEDULED.discard(key) @@ -3591,25 +3614,35 @@ async def get_sns_media( media_type_i = int(media_type or 2) md5_norm = _normalize_hex32(md5) request_id = f"sns-media-{time.time_ns()}-{threading.get_ident()}" - _trace_id, raw_trace = create_perf_trace( + _trace_id, trace = create_perf_trace( logger, "sns.media", requestId=request_id, + account=str(account_dir.name), + accountDir=str(account_dir), + wxidDir=str(wxid_dir or ""), + postId=str(post_id or ""), + mediaId=str(media_id or ""), + postType=post_type_i, + mediaType=media_type_i, + createTime=int(create_time or 0), + width=int(width or 0), + height=int(height or 0), + totalSize=int(total_size or 0), + idx=max(0, int(idx or 0)), + md5=md5_norm, + variant=variant_norm, + preferRemoteOriginal=prefer_remote_original, + useCacheRequested=str(use_cache), + useCacheEffective=use_cache_flag, + tokenPresent=bool(str(token or "")), + tokenLength=len(str(token or "")), + tokenHash=_sns_media_value_hash(token), + keyPresent=bool(str(key or "")), + keyLength=len(str(key or "")), + keyHash=_sns_media_value_hash(key), + **_sns_media_url_trace_fields(url), ) - - def trace(phase: str, **fields: Any) -> None: - """严格筛选媒体诊断字段,避免后续调用误把用户数据写入日志。""" - safe_fields: dict[str, Any] = {} - for field_name in ("result", "statusCode", "elapsedMs"): - if field_name in fields: - safe_fields[field_name] = fields[field_name] - error_type = str(fields.get("errorType") or "") - if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]{0,79}", error_type): - safe_fields["errorType"] = error_type - safe_phase = str(phase or "unknown") - if not re.fullmatch(r"[a-z0-9:_-]{1,80}", safe_phase, flags=re.I): - safe_phase = "unknown" - raw_trace(safe_phase, **safe_fields) trace("request:start") # 点击预览需要高清原图:本地 sns 缓存有时只命中缩略图,所以 full/original 请求先按 @@ -3858,7 +3891,7 @@ async def proxy_article_thumb(url: str): ) except Exception as e: - logger.warning("[sns.article-thumb] status=error phase=fetch error_type=%s", type(e).__name__) + logger.warning(f"[sns] 提取公众号封面失败 url={u[:50]}... : {e}") raise HTTPException(status_code=404, detail="无法获取文章封面") diff --git a/src/wechat_decrypt_tool/sns_export_service.py b/src/wechat_decrypt_tool/sns_export_service.py index 996f0cc3..16a21fc0 100644 --- a/src/wechat_decrypt_tool/sns_export_service.py +++ b/src/wechat_decrypt_tool/sns_export_service.py @@ -247,10 +247,7 @@ async def worker(http_client: httpx.AsyncClient) -> None: except Exception as exc: result.failed += 1 result.missing.append(task_id) - logger.info( - "[sns.export] status=error phase=media-prefetch error_type=%s", - type(exc).__name__, - ) + logger.info("sns media prefetch failed: kind=%s url=%s error=%s", task.kind, task.url, exc) finally: completed += 1 if on_progress is not None: @@ -1634,12 +1631,7 @@ def _run_job_safe(self, job: ExportJob, account_dir: Path) -> None: pass self.commit_staged_files(job.export_id) except Exception as e: - logger.error( - "[sns.export] status=error export_id=%s phase=%s error_type=%s", - job.export_id, - str(job.progress.phase or "unknown"), - type(e).__name__, - ) + logger.exception("sns export job failed: %s: %s", job.export_id, e) with self._lock: job.status = "error" job.error = str(e) @@ -1704,11 +1696,7 @@ def set_phase(name: str) -> None: if sync_status not in {"ok", "noop"}: job.warning = "实时同步未完成,已继续导出本地历史快照。" except Exception as exc: # 导出必须可在无 native broker 的环境中降级运行 - logger.warning( - "[sns.export] status=error export_id=%s phase=syncing error_type=%s", - job.export_id, - type(exc).__name__, - ) + logger.warning("sns realtime sync before export failed: export=%s error=%s", job.export_id, exc) job.warning = "实时同步失败,已继续导出本地历史快照。" job.freshness = { "status": "warning", diff --git a/src/wechat_decrypt_tool/sns_media.py b/src/wechat_decrypt_tool/sns_media.py index 7605c8c6..5faba142 100644 --- a/src/wechat_decrypt_tool/sns_media.py +++ b/src/wechat_decrypt_tool/sns_media.py @@ -91,20 +91,33 @@ def _sns_remote_diagnostic_log( error: Optional[BaseException] = None, **fields: object, ) -> None: - # 朋友圈日志只保留诊断阶段和受控结果,不记录 URL、Host、密钥摘要或响应尺寸。 + raw_url = str(url or "").strip() + try: + host = str(urlparse(raw_url).hostname or "").strip().lower() + except Exception: + host = "" + + stable_url = normalize_sns_cache_url(raw_url) payload: dict[str, object] = { "diagnosticId": str(diagnostic_id or ""), "event": str(event or ""), + "urlHost": host, + "urlIdentity": ( + hashlib.sha256(stable_url.encode("utf-8", errors="ignore")).hexdigest()[:16] + if stable_url + else "" + ), + **fields, } - for field_name in ("stage", "result", "statusCode", "elapsedMs", "attempt"): - if field_name in fields: - payload[field_name] = fields[field_name] if error is not None: - error_type = type(error).__name__ - payload["errorType"] = ( - error_type if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]{0,79}", error_type) else "Exception" - ) + error_text = str(error).strip() or repr(error) + for sensitive in (raw_url, str(key or ""), str(token or "")): + if sensitive: + error_text = error_text.replace(sensitive, "") + error_text = re.sub(r"https?://[^\s\"']+", "", error_text, flags=re.I) + payload["errorType"] = type(error).__name__ + payload["errorText"] = error_text[:500] response = getattr(error, "response", None) status_code = getattr(response, "status_code", None) @@ -112,7 +125,7 @@ def _sns_remote_diagnostic_log( try: payload["statusCode"] = int(status_code) except Exception: - payload["statusCode"] = 0 + payload["statusCode"] = str(status_code) logger.info( "[sns_media] %s", diff --git a/src/wechat_decrypt_tool/sns_realtime_autosync.py b/src/wechat_decrypt_tool/sns_realtime_autosync.py index f232f7cd..3cf56e5a 100644 --- a/src/wechat_decrypt_tool/sns_realtime_autosync.py +++ b/src/wechat_decrypt_tool/sns_realtime_autosync.py @@ -177,6 +177,7 @@ def _bootstrap_accounts(self) -> None: try: accounts = list(_list_decrypted_accounts() or []) 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: @@ -316,6 +317,7 @@ def _watch_directory(self, watch_key: str) -> None: self._mark_watcher_failed(watch_key, "sns_file_watch_unavailable") 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") @@ -358,7 +360,7 @@ def _schedule_sync(self, account: str, *, reason: str) -> None: worker = threading.Thread( target=self._sync_account_runner, args=(account, reason, revision), - name="sns-incremental-sync", + name=f"sns-event-sync-{account}", daemon=True, ) state.worker = worker @@ -371,6 +373,7 @@ def _schedule_sync(self, account: str, *, reason: str) -> None: 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__, @@ -480,8 +483,14 @@ def _refresh_native_moments_once(self, account: str) -> None: client, NativeCoreFeature.WECHAT_MOMENTS_REFRESH ) 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__, @@ -498,6 +507,7 @@ def _sync_with_bounded_retries(self, account: str, revision: int) -> tuple[dict[ return {"status": "skipped", "reason": "service_stopping"}, False last_result = dict(self._sync_account(account) or {}) 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__, @@ -556,6 +566,7 @@ 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 as exc: + logger.exception("[sns-autosync] 增量同步调用失败 account=%s", account) logger.error( "[sns.incremental-sync] status=error phase=scanning error_type=%s", type(exc).__name__, @@ -623,6 +634,14 @@ def _publish_sync_result(self, account: str, reason: str, revision: int, result: "timestamp": int(time.time() * 1000), } self._publish_event(account, event) + logger.info( + "[sns-autosync] 事件同步完成 account=%s reason=%s revision=%s status=%s changed=%s", + account, + reason, + revision, + status, + event["changed"], + ) return raw_code = str((result or {}).get("error") or (result or {}).get("reason") or "sns_sync_failed").strip() diff --git a/tests/test_request_log_redaction.py b/tests/test_request_log_redaction.py index c486b7eb..ae19880b 100644 --- a/tests/test_request_log_redaction.py +++ b/tests/test_request_log_redaction.py @@ -118,40 +118,6 @@ def test_uvicorn_access_filter_redacts_query_secrets(self): self.assertNotIn("AES_SECRET", rendered) self.assertIn("db_storage_path=C%3A%5Cdb", rendered) - def test_uvicorn_access_filter_removes_all_sns_query_values(self): - from wechat_decrypt_tool.request_logging import ( - SensitiveQueryLogFilter, - redact_sensitive_query_text, - ) - - record = logging.LogRecord( - "uvicorn.access", - logging.INFO, - __file__, - 1, - '%s - "%s %s HTTP/%s" %d', - ( - "127.0.0.1:1234", - "GET", - "/api/sns/media?account=ACCOUNT_SENTINEL&post_id=POST_SENTINEL&url=https%3A%2F%2Fprivate.example", - "1.1", - 200, - ), - None, - ) - - self.assertTrue(SensitiveQueryLogFilter().filter(record)) - rendered = record.getMessage() - self.assertIn("/api/sns", rendered) - self.assertNotIn("/media", rendered) - self.assertNotIn("ACCOUNT_SENTINEL", rendered) - self.assertNotIn("POST_SENTINEL", rendered) - self.assertNotIn("private.example", rendered) - self.assertEqual( - redact_sensitive_query_text("/api/sns/exports/EXPORT_SENTINEL/files/private-file.jpg"), - "/api/sns", - ) - if __name__ == "__main__": unittest.main() diff --git a/tests/test_sns_log_privacy.py b/tests/test_sns_log_privacy.py deleted file mode 100644 index 89914819..00000000 --- a/tests/test_sns_log_privacy.py +++ /dev/null @@ -1,174 +0,0 @@ -import asyncio -import hashlib -import sys -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 fastapi import HTTPException - -from wechat_decrypt_tool import sns_export_service, sns_media -from wechat_decrypt_tool import request_logging -from wechat_decrypt_tool.routers import sns as sns_router - - -def _render_calls(calls) -> str: - rendered = [] - for item in calls: - args = item.args - if not args: - continue - try: - rendered.append(str(args[0]) % tuple(args[1:])) - except Exception: - rendered.append(" ".join(str(value) for value in args)) - return "\n".join(rendered) - - -class TestSnsLogPrivacy(unittest.TestCase): - def test_sns_server_exception_log_omits_exception_text_and_traceback(self): - sentinel = "C:/private/account-sentinel/source.db" - - class _Url: - path = "/api/sns/media" - - class _Request: - method = "GET" - url = _Url() - - async def fail(_request): - raise RuntimeError(sentinel) - - fake_logger = mock.Mock() - with self.assertRaises(RuntimeError): - asyncio.run(request_logging.log_server_errors_middleware(fake_logger, _Request(), fail)) - - logs = _render_calls(fake_logger.error.call_args_list) - self.assertIn("code=sns_request_failed", logs) - self.assertIn("error_type=RuntimeError", logs) - self.assertNotIn(sentinel, logs) - fake_logger.exception.assert_not_called() - - def test_remote_media_log_omits_all_user_and_derived_sentinels(self): - sentinels = { - "account": "privacy-account-sentinel", - "nickname": "隐私昵称哨兵", - "content": "朋友圈正文哨兵", - "url": "https://private.example.test/secret/path?token=url-token-sentinel", - "path": "C:/Users/private-user/朋友圈/private-file.jpg", - "post": "post-id-sentinel", - "media": "media-id-sentinel", - "key": "key-sentinel-value", - "token": "token-sentinel-value", - } - derived = { - hashlib.sha256(value.encode("utf-8")).hexdigest() - for value in sentinels.values() - } - - with mock.patch.object(sns_media.logger, "info") as log_info: - sns_media._sns_remote_diagnostic_log( - "remote:download-error", - url=sentinels["url"], - diagnostic_id="diag-safe-1", - key=sentinels["key"], - token=sentinels["token"], - error=RuntimeError(f"failed at {sentinels['path']} {sentinels['content']}"), - account=sentinels["account"], - nickname=sentinels["nickname"], - postId=sentinels["post"], - mediaId=sentinels["media"], - candidatePath=sentinels["path"], - responseSha256=next(iter(derived)), - width=123, - height=456, - createTime=1234567890, - statusCode=503, - ) - - logs = _render_calls(log_info.call_args_list) - self.assertIn("diag-safe-1", logs) - self.assertIn('"statusCode": 503', logs) - self.assertIn('"errorType": "RuntimeError"', logs) - for value in (*sentinels.values(), *derived): - self.assertNotIn(value, logs) - for forbidden_field in ( - "urlHost", - "urlIdentity", - "errorText", - "candidatePath", - "responseSha256", - "postId", - "mediaId", - "width", - "height", - "createTime", - ): - self.assertNotIn(forbidden_field, logs) - - def test_incremental_unavailable_log_does_not_include_account_or_path(self): - with TemporaryDirectory() as td: - account_dir = Path(td) / "private-account-sentinel" - account_dir.mkdir() - with ( - mock.patch.object(sns_router, "_resolve_account_dir", return_value=account_dir), - mock.patch.object(sns_router.WCDB_REALTIME, "get_status", return_value={}), - mock.patch.object(sns_router.logger, "info") as log_info, - mock.patch.object(sns_router.logger, "error") as log_error, - ): - with self.assertRaises(HTTPException): - sns_router.sync_sns_realtime_timeline_latest(account=account_dir.name) - - logs = _render_calls(log_info.call_args_list + log_error.call_args_list) - self.assertIn("[sns.incremental-sync]", logs) - self.assertIn("realtime_not_available", logs) - self.assertNotIn(account_dir.name, logs) - self.assertNotIn(str(account_dir), logs) - - def test_export_prefetch_failure_log_omits_url_and_exception_text(self): - task = sns_export_service.SnsRemoteMediaTask( - kind="image", - url="https://private.example.test/media/url-sentinel", - key="private-key-sentinel", - token="private-token-sentinel", - ) - - async def fail_fetch(**_kwargs): - raise RuntimeError("C:/private/path-sentinel") - - async def run(account_dir: Path): - return await sns_export_service._prefetch_sns_remote_media( - account_dir=account_dir, - tasks=[task], - use_cache=False, - concurrency=1, - ) - - with TemporaryDirectory() as td: - account_dir = Path(td) / "account-sentinel" - account_dir.mkdir() - with ( - mock.patch.object( - sns_export_service, - "_try_fetch_and_decrypt_sns_image_remote", - side_effect=fail_fetch, - ), - mock.patch.object(sns_export_service.logger, "info") as log_info, - ): - result = asyncio.run(run(account_dir)) - - self.assertEqual(result.failed, 1) - logs = _render_calls(log_info.call_args_list) - self.assertIn("error_type=RuntimeError", logs) - for sentinel in (task.url, task.key, task.token, "C:/private/path-sentinel", account_dir.name): - self.assertNotIn(sentinel, logs) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_sns_media.py b/tests/test_sns_media.py index 716968e3..305b3a7c 100644 --- a/tests/test_sns_media.py +++ b/tests/test_sns_media.py @@ -265,10 +265,8 @@ async def run(account_dir: Path): self.assertIn('"diagnosticId": "diag-http-400"', logs) self.assertIn('"errorType": "HTTPStatusError"', logs) self.assertIn('"statusCode": 400', logs) - self.assertNotIn("mmsns.qpic.cn", logs) - self.assertNotIn('"urlHost":', logs) - self.assertNotIn('"urlIdentity":', logs) - self.assertNotIn('"errorText":', logs) + self.assertIn('"urlHost": "mmsns.qpic.cn"', logs) + self.assertIn('"urlIdentity":', logs) self.assertNotIn(raw_url_token, logs) self.assertNotIn(request_token, logs) self.assertNotIn(request_key, logs) diff --git a/tests/test_sns_media_route_weflow_default.py b/tests/test_sns_media_route_weflow_default.py index 1a5084af..d05f262e 100644 --- a/tests/test_sns_media_route_weflow_default.py +++ b/tests/test_sns_media_route_weflow_default.py @@ -201,17 +201,16 @@ def test_route_logs_redacted_identity_and_local_match_details(self): self.assertIn("sns.media request:start", logs) self.assertIn("sns.media local-key-post:probe", logs) self.assertIn("sns.media local-heuristic:probe", logs) - self.assertIn('"result": "local-cache"', logs) - self.assertNotIn('"matchedBy":', logs) - self.assertNotIn('"candidatePath":', logs) - self.assertNotIn("matched-local.jpg", logs) - self.assertNotIn("post-42", logs) - self.assertNotIn("media-7", logs) - self.assertNotIn("mmsns.qpic.cn", logs) - self.assertNotIn('"urlIdentity":', logs) - self.assertNotIn('"tokenHash":', logs) - self.assertNotIn('"keyHash":', logs) - self.assertNotIn('"responseSha256":', logs) + self.assertIn('"matchedBy": "local-heuristic"', logs) + self.assertIn('"candidatePath":', logs) + self.assertIn("matched-local.jpg", logs) + self.assertIn('"postId": "post-42"', logs) + self.assertIn('"mediaId": "media-7"', logs) + self.assertIn('"urlHost": "mmsns.qpic.cn"', logs) + self.assertIn('"urlIdentity":', logs) + self.assertIn('"tokenHash":', logs) + self.assertIn('"keyHash":', logs) + self.assertIn('"responseSha256":', logs) self.assertNotIn("super-secret-token", logs) self.assertNotIn("super-secret-key", logs) self.assertNotIn("url-secret-token", logs) @@ -339,8 +338,7 @@ def test_route_logs_exact_key_and_md5_match_strategies(self): logs = self._render_log_calls(log_info) self.assertEqual(resp.status_code, 200) - self.assertIn(f"sns.media {expected_match}:probe", logs) - self.assertNotIn('"matchedBy":', logs) + self.assertIn(f'"matchedBy": "{expected_match}"', logs) heuristic.assert_not_called() remote.assert_not_called()