Compare commits

..

5 Commits

21 changed files with 2952 additions and 288 deletions
+90 -11
View File
@@ -1,6 +1,22 @@
; This file is included for both installer and uninstaller builds.
; Guard installer-only pages/functions to avoid "function not referenced" warnings
; when electron-builder compiles the standalone uninstaller.
!define /ifndef WDA_DEFAULT_SETTINGS_PATH "$APPDATA\${APP_FILENAME}\desktop-settings.json"
!define /ifndef WDA_DEFAULT_OUTPUT_DIR "$APPDATA\${APP_FILENAME}\output"
!ifdef APP_PRODUCT_FILENAME
!define /ifndef WDA_PRODUCT_SETTINGS_PATH "$APPDATA\${APP_PRODUCT_FILENAME}\desktop-settings.json"
!define /ifndef WDA_PRODUCT_OUTPUT_DIR "$APPDATA\${APP_PRODUCT_FILENAME}\output"
!else
!define /ifndef WDA_PRODUCT_SETTINGS_PATH ""
!define /ifndef WDA_PRODUCT_OUTPUT_DIR ""
!endif
!ifdef APP_PACKAGE_NAME
!define /ifndef WDA_PACKAGE_SETTINGS_PATH "$APPDATA\${APP_PACKAGE_NAME}\desktop-settings.json"
!define /ifndef WDA_PACKAGE_OUTPUT_DIR "$APPDATA\${APP_PACKAGE_NAME}\output"
!else
!define /ifndef WDA_PACKAGE_SETTINGS_PATH ""
!define /ifndef WDA_PACKAGE_OUTPUT_DIR ""
!endif
!ifndef BUILD_UNINSTALLER
!include nsDialogs.nsh
!include LogicLib.nsh
@@ -13,6 +29,10 @@
!define /ifndef MUI_DIRECTORYPAGE_TEXT_DESTINATION "安装位置:"
Var WDA_InstallDirPage
Var WDA_OutputDirPage
Var WDA_OutputDirInput
Var WDA_OutputDirBrowseButton
Var WDA_SelectedOutputDir
!macro customInit
; Safety: older versions created an `output` junction inside the install directory that points to the
@@ -22,17 +42,10 @@ Var WDA_InstallDirPage
!macroend
!macro customInstall
; Provide a safe, non-junction way for users to locate the real per-user output directory.
; The actual data is NOT stored inside $INSTDIR (it is wiped on update/reinstall).
; `open-output.cmd` uses %APPDATA% so it works for the current user.
FileOpen $0 "$INSTDIR\output-location.txt" w
FileWrite $0 "WeChatDataAnalysis output folder (per user):$\r$\n%APPDATA%\\${APP_PACKAGE_NAME}\\output$\r$\n"
FileClose $0
FileOpen $1 "$INSTDIR\open-output.cmd" w
; NSIS escaping: use $\" to output a literal quote character into the .cmd file.
FileWrite $1 "@echo off$\r$\nexplorer $\"%APPDATA%\\${APP_PACKAGE_NAME}\\output$\"$\r$\n"
FileClose $1
${If} $WDA_SelectedOutputDir == ""
Call WDA_InitOutputDirSelection
${EndIf}
Call WDA_WritePendingOutputDirSetting
!macroend
Function WDA_RemoveLegacyOutputLink
@@ -47,9 +60,27 @@ FunctionEnd
; the final install location (includes the app sub-folder).
!ifdef allowToChangeInstallationDirectory
Page custom WDA_InstallDirPageCreate WDA_InstallDirPageLeave
Page custom WDA_OutputDirPageCreate WDA_OutputDirPageLeave
!endif
!macroend
Function WDA_InitOutputDirSelection
StrCpy $WDA_SelectedOutputDir "${WDA_DEFAULT_OUTPUT_DIR}"
nsExec::ExecToStack '"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -Command "& { param([string] $$defaultSettingsPath, [string] $$defaultOutputPath, [string] $$legacySettingsPath1, [string] $$legacySettingsPath2) $$candidates = @($$defaultSettingsPath, $$legacySettingsPath1, $$legacySettingsPath2) | Where-Object { -not [string]::IsNullOrWhiteSpace($$_) } | Select-Object -Unique; $$settingsPath = $$defaultSettingsPath; foreach ($$candidate in $$candidates) { if (Test-Path -LiteralPath $$candidate) { $$settingsPath = $$candidate; break } }; $$result = $$defaultOutputPath; if (Test-Path -LiteralPath $$settingsPath) { try { $$json = Get-Content -LiteralPath $$settingsPath -Raw | ConvertFrom-Json; $$value = [string] $$json.pendingOutputDir; if ([string]::IsNullOrWhiteSpace($$value)) { $$value = [string] $$json.outputDir }; if ($$value -eq '''') { $$result = $$defaultOutputPath } elseif (-not [string]::IsNullOrWhiteSpace($$value)) { $$result = $$value } } catch {} }; [Console]::OutputEncoding = [System.Text.Encoding]::UTF8; [Console]::Write($$result) }" "${WDA_DEFAULT_SETTINGS_PATH}" "${WDA_DEFAULT_OUTPUT_DIR}" "${WDA_PRODUCT_SETTINGS_PATH}" "${WDA_PACKAGE_SETTINGS_PATH}"'
Pop $0
Pop $1
${If} $0 == "0"
${AndIf} $1 != ""
StrCpy $WDA_SelectedOutputDir "$1"
${EndIf}
FunctionEnd
Function WDA_WritePendingOutputDirSetting
nsExec::ExecToStack '"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -Command "& { param([string] $$defaultSettingsPath, [string] $$defaultOutputPath, [string] $$selectedOutputPath, [string] $$legacySettingsPath1, [string] $$legacySettingsPath2) $$candidates = @($$defaultSettingsPath, $$legacySettingsPath1, $$legacySettingsPath2) | Where-Object { -not [string]::IsNullOrWhiteSpace($$_) } | Select-Object -Unique; $$sourceSettingsPath = $$defaultSettingsPath; foreach ($$candidate in $$candidates) { if (Test-Path -LiteralPath $$candidate) { $$sourceSettingsPath = $$candidate; break } }; if ([string]::IsNullOrWhiteSpace($$selectedOutputPath)) { $$selectedOutputPath = $$defaultOutputPath }; $$pending = if ([string]::Equals($$selectedOutputPath, $$defaultOutputPath, [System.StringComparison]::OrdinalIgnoreCase)) { '''' } else { $$selectedOutputPath }; $$obj = @{}; if (Test-Path -LiteralPath $$sourceSettingsPath) { try { $$existing = Get-Content -LiteralPath $$sourceSettingsPath -Raw | ConvertFrom-Json; if ($$null -ne $$existing) { $$existing.PSObject.Properties | ForEach-Object { $$obj[$$_.Name] = $$_.Value } } } catch {} }; $$obj[''pendingOutputDir''] = $$pending; $$dir = Split-Path -Parent $$defaultSettingsPath; New-Item -ItemType Directory -Force -Path $$dir | Out-Null; $$json = [PSCustomObject] $$obj | ConvertTo-Json -Depth 10; Set-Content -LiteralPath $$defaultSettingsPath -Value $$json -Encoding UTF8 }" "${WDA_DEFAULT_SETTINGS_PATH}" "${WDA_DEFAULT_OUTPUT_DIR}" "$WDA_SelectedOutputDir" "${WDA_PRODUCT_SETTINGS_PATH}" "${WDA_PACKAGE_SETTINGS_PATH}"'
Pop $0
Pop $1
FunctionEnd
Function WDA_EnsureAppSubDir
; Normalize $INSTDIR to always end with "\${APP_FILENAME}" (avoid cluttering a parent folder).
StrCpy $0 "$INSTDIR"
@@ -105,6 +136,48 @@ FunctionEnd
Function WDA_InstallDirPageLeave
FunctionEnd
Function WDA_OutputDirBrowse
nsDialogs::SelectFolderDialog "选择 output 目录" "$WDA_SelectedOutputDir"
Pop $0
${If} $0 != error
StrCpy $WDA_SelectedOutputDir "$0"
${NSD_SetText} $WDA_OutputDirInput "$0"
${EndIf}
FunctionEnd
Function WDA_OutputDirPageCreate
Call WDA_InitOutputDirSelection
nsDialogs::Create 1018
Pop $WDA_OutputDirPage
${If} $WDA_OutputDirPage == error
Abort
${EndIf}
${NSD_CreateLabel} 0u 0u 100% 24u "请选择 output 目录(保存解密数据库、导出内容、缓存、日志等)。"
Pop $0
${NSD_CreateText} 0u 28u 78% 12u "$WDA_SelectedOutputDir"
Pop $WDA_OutputDirInput
${NSD_CreateButton} 82% 27u 18% 14u "浏览..."
Pop $WDA_OutputDirBrowseButton
${NSD_OnClick} $WDA_OutputDirBrowseButton WDA_OutputDirBrowse
${NSD_CreateLabel} 0u 52u 100% 28u "安装器只记录你的选择;真正的数据迁移会在首次启动应用时执行。若目标目录已有内容,应用会阻止切换并提示处理。"
Pop $0
nsDialogs::Show
FunctionEnd
Function WDA_OutputDirPageLeave
${NSD_GetText} $WDA_OutputDirInput $WDA_SelectedOutputDir
${If} $WDA_SelectedOutputDir == ""
StrCpy $WDA_SelectedOutputDir "${WDA_DEFAULT_OUTPUT_DIR}"
${EndIf}
FunctionEnd
!endif
!ifdef BUILD_UNINSTALLER
@@ -177,6 +250,12 @@ FunctionEnd
RMDir /r "$APPDATA\${APP_PACKAGE_NAME}"
!endif
IfFileExists "$INSTDIR\output-location.path" 0 WDA_SkipCustomOutputDelete
nsExec::ExecToStack '"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -Command "& { param([string] $$pathFile, [string] $$defaultPath1, [string] $$defaultPath2, [string] $$defaultPath3) if (Test-Path -LiteralPath $$pathFile) { $$target = (Get-Content -LiteralPath $$pathFile -Raw).Trim(); $$defaults = @($$defaultPath1, $$defaultPath2, $$defaultPath3) | Where-Object { -not [string]::IsNullOrWhiteSpace($$_) }; $$isDefault = $$false; foreach ($$defaultPath in $$defaults) { if ([string]::Equals($$target, $$defaultPath, [System.StringComparison]::OrdinalIgnoreCase)) { $$isDefault = $$true; break } }; if (-not $$isDefault -and -not [string]::IsNullOrWhiteSpace($$target) -and (Test-Path -LiteralPath $$target)) { Remove-Item -LiteralPath $$target -Recurse -Force -ErrorAction SilentlyContinue } } }" "$INSTDIR\output-location.path" "${WDA_DEFAULT_OUTPUT_DIR}" "${WDA_PRODUCT_OUTPUT_DIR}" "${WDA_PACKAGE_OUTPUT_DIR}"'
Pop $0
Pop $1
WDA_SkipCustomOutputDelete:
${if} $installMode == "all"
SetShellVarContext all
${endif}
+369 -22
View File
@@ -21,6 +21,13 @@ const fs = require("fs");
const http = require("http");
const net = require("net");
const path = require("path");
const {
getDefaultOutputDirPath,
getEffectiveOutputDirPath,
migrateOutputDirectory,
normalizeDirectoryPath,
rollbackOutputDirectoryChange,
} = require("./output-dir.cjs");
const DEFAULT_BACKEND_HOST = String(process.env.WECHAT_TOOL_HOST || "127.0.0.1").trim() || "127.0.0.1";
const DEFAULT_BACKEND_PORT = parsePort(process.env.WECHAT_TOOL_PORT) ?? 10392;
@@ -32,6 +39,7 @@ let tray = null;
let isQuitting = false;
let desktopSettings = null;
let backendPortChangeInProgress = false;
let outputDirChangeInProgress = false;
const gotSingleInstanceLock = app.requestSingleInstanceLock();
if (!gotSingleInstanceLock) {
@@ -216,9 +224,76 @@ function resolveDataDir() {
}
function getUserDataDir() {
// Backwards-compat: we historically used Electron's userData directory for runtime storage.
// Keep this name but resolve to the effective data dir (can be overridden via env).
return resolveDataDir();
try {
const dir = app.getPath("userData");
if (!dir) return null;
fs.mkdirSync(dir, { recursive: true });
return dir;
} catch {
return null;
}
}
function safeNormalizeDirectory(value) {
try {
return normalizeDirectoryPath(value || "");
} catch {
return "";
}
}
function getDefaultOutputDir() {
const dataDir = resolveDataDir();
if (!dataDir) return null;
try {
return getDefaultOutputDirPath(dataDir);
} catch {
return null;
}
}
function syncOutputDirEnv(nextDir) {
const normalized = safeNormalizeDirectory(nextDir);
if (normalized) process.env.WECHAT_TOOL_OUTPUT_DIR = normalized;
else delete process.env.WECHAT_TOOL_OUTPUT_DIR;
}
function normalizePendingOutputDirValue(value) {
if (value == null) return null;
const text = String(value).trim();
if (!text) return "";
try {
return normalizeDirectoryPath(text);
} catch {
return null;
}
}
function resolveOutputDir() {
const dataDir = resolveDataDir();
if (!dataDir) return null;
const envOutputDir = safeNormalizeDirectory(process.env.WECHAT_TOOL_OUTPUT_DIR || "");
const settingsOutputDir = app.isPackaged ? safeNormalizeDirectory(loadDesktopSettings()?.outputDir || "") : "";
let chosen = null;
try {
chosen = getEffectiveOutputDirPath({
dataDir,
envOutputDir,
settingsOutputDir,
});
} catch {
chosen = getDefaultOutputDir();
}
if (!chosen) return null;
try {
fs.mkdirSync(chosen, { recursive: true });
} catch {}
syncOutputDirEnv(chosen);
return chosen;
}
function sanitizeAccountName(account) {
@@ -261,7 +336,8 @@ function resolveAccountDirInOutput(account) {
const dataDir = resolveDataDir();
if (!dataDir) throw new Error("无法定位数据目录");
const outputDir = path.join(dataDir, "output");
const outputDir = resolveOutputDir();
if (!outputDir) throw new Error("无法定位 output 目录");
const databasesDir = path.join(outputDir, "databases");
const accountName = sanitizeAccountName(account);
@@ -311,8 +387,8 @@ function getAccountInfoFromDisk(account) {
};
}
function removeAccountFromKeyStore(dataDir, accountName) {
const keyStorePath = path.join(dataDir, "output", "account_keys.json");
function removeAccountFromKeyStore(outputDir, accountName) {
const keyStorePath = path.join(outputDir, "account_keys.json");
try {
if (!fs.existsSync(keyStorePath)) return false;
const raw = fs.readFileSync(keyStorePath, { encoding: "utf8" });
@@ -328,7 +404,7 @@ function removeAccountFromKeyStore(dataDir, accountName) {
}
async function deleteAccountDataFromDisk(account) {
const { dataDir, outputDir, databasesDir, accountName, accountDir } = resolveAccountDirInOutput(account);
const { outputDir, databasesDir, accountName, accountDir } = resolveAccountDirInOutput(account);
if (!fs.existsSync(accountDir) || !fs.statSync(accountDir).isDirectory()) {
throw new Error("账号数据不存在");
}
@@ -348,7 +424,7 @@ async function deleteAccountDataFromDisk(account) {
} catch {}
fs.rmSync(accountDir, { recursive: true, force: true });
const removedKeyCache = removeAccountFromKeyStore(dataDir, accountName);
const removedKeyCache = removeAccountFromKeyStore(outputDir, accountName);
const accounts = listDecryptedAccountsOnDisk(databasesDir);
result = {
status: "success",
@@ -394,10 +470,8 @@ function ensureOutputLink() {
if (!app.isPackaged) return;
const exeDir = getExeDir();
const dataDir = resolveDataDir();
if (!exeDir || !dataDir) return;
const target = path.join(dataDir, "output");
const target = resolveOutputDir();
if (!exeDir || !target) return;
const legacyLinkPath = path.join(exeDir, "output");
// Ensure the real output dir exists.
@@ -443,6 +517,11 @@ function ensureOutputLink() {
fs.writeFileSync(p, text, { encoding: "utf8" });
} catch {}
try {
const p = path.join(exeDir, "output-location.path");
fs.writeFileSync(p, `${target}\n`, { encoding: "utf8" });
} catch {}
try {
const p = path.join(exeDir, "open-output.cmd");
const text = `@echo off\r\nexplorer \"${target}\"\r\n`;
@@ -510,6 +589,12 @@ function loadDesktopSettings() {
ignoredUpdateVersion: "",
// Backend (FastAPI) listens on this port. Used in packaged builds.
backendPort: DEFAULT_BACKEND_PORT,
// Custom output dir; empty string means use the default dataDir/output.
outputDir: "",
// Pending output dir written by the installer before the next app startup.
pendingOutputDir: null,
// Last startup/apply failure when changing output dir.
lastOutputDirError: "",
// Tracks the packaged UI build so we can invalidate Chromium's HTTP cache
// after upgrades without wiping user data/localStorage.
lastSeenUiBuildId: "",
@@ -530,6 +615,12 @@ function loadDesktopSettings() {
const parsed = JSON.parse(raw || "{}");
desktopSettings = { ...defaults, ...(parsed && typeof parsed === "object" ? parsed : {}) };
desktopSettings.backendPort = parsePort(desktopSettings.backendPort) ?? defaults.backendPort;
desktopSettings.outputDir = safeNormalizeDirectory(desktopSettings.outputDir || "");
desktopSettings.pendingOutputDir =
parsed && typeof parsed === "object" && Object.prototype.hasOwnProperty.call(parsed, "pendingOutputDir")
? normalizePendingOutputDirValue(parsed.pendingOutputDir)
: defaults.pendingOutputDir;
desktopSettings.lastOutputDirError = String(desktopSettings.lastOutputDirError || "").trim();
} catch (err) {
desktopSettings = { ...defaults };
logMain(`[main] failed to load settings: ${err?.message || err}`);
@@ -551,6 +642,82 @@ function persistDesktopSettings() {
}
}
function snapshotOutputDirSettings() {
loadDesktopSettings();
return {
outputDir: desktopSettings.outputDir,
pendingOutputDir: desktopSettings.pendingOutputDir,
lastOutputDirError: desktopSettings.lastOutputDirError,
};
}
function restoreOutputDirSettings(snapshot) {
loadDesktopSettings();
desktopSettings.outputDir = safeNormalizeDirectory(snapshot?.outputDir || "");
desktopSettings.pendingOutputDir = normalizePendingOutputDirValue(snapshot?.pendingOutputDir);
desktopSettings.lastOutputDirError = String(snapshot?.lastOutputDirError || "").trim();
const effectiveOutputDir = desktopSettings.outputDir || getDefaultOutputDir() || "";
syncOutputDirEnv(effectiveOutputDir);
persistDesktopSettings();
}
function setOutputDirSetting(nextDir) {
loadDesktopSettings();
const defaultDir = getDefaultOutputDir();
const normalized = safeNormalizeDirectory(nextDir || "");
if (!normalized || (defaultDir && normalized === defaultDir)) {
desktopSettings.outputDir = "";
} else {
desktopSettings.outputDir = normalized;
}
syncOutputDirEnv(desktopSettings.outputDir || defaultDir || "");
persistDesktopSettings();
return desktopSettings.outputDir;
}
function setPendingOutputDirSetting(nextDir) {
loadDesktopSettings();
desktopSettings.pendingOutputDir = normalizePendingOutputDirValue(nextDir);
persistDesktopSettings();
return desktopSettings.pendingOutputDir;
}
function clearPendingOutputDirSetting() {
loadDesktopSettings();
desktopSettings.pendingOutputDir = null;
persistDesktopSettings();
}
function setOutputDirLastError(message) {
loadDesktopSettings();
desktopSettings.lastOutputDirError = String(message || "").trim();
persistDesktopSettings();
return desktopSettings.lastOutputDirError;
}
function getOutputDirInfo() {
loadDesktopSettings();
const defaultPath = getDefaultOutputDir() || "";
const currentPath = resolveOutputDir() || defaultPath;
const hasPending = desktopSettings.pendingOutputDir !== null;
const pendingPath =
desktopSettings.pendingOutputDir === null
? ""
: desktopSettings.pendingOutputDir === ""
? defaultPath
: safeNormalizeDirectory(desktopSettings.pendingOutputDir);
return {
path: currentPath || "",
defaultPath,
isDefault: !!currentPath && !!defaultPath && currentPath === defaultPath,
pendingPath,
hasPending,
lastError: String(desktopSettings.lastOutputDirError || "").trim(),
canChange: !!app.isPackaged,
changeUnavailableReason: app.isPackaged ? "" : "开发模式不支持界面修改 output 目录",
};
}
function getCloseBehavior() {
const v = String(loadDesktopSettings()?.closeBehavior || "").trim().toLowerCase();
return v === "exit" ? "exit" : "tray";
@@ -576,6 +743,137 @@ function setIgnoredUpdateVersion(version) {
return desktopSettings.ignoredUpdateVersion;
}
async function applyOutputDirChange(nextValue) {
if (!app.isPackaged) {
throw new Error("开发模式不支持界面修改 output 目录");
}
const defaultPath = getDefaultOutputDir();
const currentPath = resolveOutputDir();
if (!defaultPath || !currentPath) {
throw new Error("无法定位 output 目录");
}
const rawText = String(nextValue ?? "").trim();
const nextPath = rawText ? normalizeDirectoryPath(rawText) : defaultPath;
const previousSettings = snapshotOutputDirSettings();
if (nextPath === currentPath) {
setOutputDirSetting(nextPath);
clearPendingOutputDirSetting();
setOutputDirLastError("");
ensureOutputLink();
const info = getOutputDirInfo();
return {
success: true,
changed: false,
path: info.path,
defaultPath: info.defaultPath,
isDefault: info.isDefault,
pendingPath: info.pendingPath,
backupPath: "",
sourceWasEmpty: false,
message: "output 目录未变化",
};
}
const wasBackendRunning = !!backendProc;
let migration = null;
let settingsSwitched = false;
try {
if (wasBackendRunning) {
await stopBackendAndWait({ timeoutMs: 10_000 });
}
migration = migrateOutputDirectory({
currentDir: currentPath,
nextDir: nextPath,
});
setOutputDirSetting(nextPath);
clearPendingOutputDirSetting();
setOutputDirLastError("");
settingsSwitched = true;
ensureOutputLink();
if (wasBackendRunning) {
startBackend();
await waitForBackend({ timeoutMs: 30_000 });
}
const info = getOutputDirInfo();
return {
success: true,
changed: true,
path: info.path,
defaultPath: info.defaultPath,
isDefault: info.isDefault,
pendingPath: info.pendingPath,
backupPath: migration?.backupDir || "",
sourceWasEmpty: !!migration?.sourceWasEmpty,
message: migration?.sourceWasEmpty ? "output 目录已切换" : "output 目录已迁移并切换",
};
} catch (err) {
const message = err?.message || String(err);
let rollbackMessage = "";
if (migration?.changed) {
try {
rollbackOutputDirectoryChange({
previousDir: currentPath,
currentDir: nextPath,
backupDir: migration.backupDir,
sourceWasEmpty: migration.sourceWasEmpty,
});
} catch (rollbackErr) {
logMain(`[main] output dir rollback failed: ${rollbackErr?.message || rollbackErr}`);
rollbackMessage = `;回滚失败:${rollbackErr?.message || rollbackErr}`;
if (migration?.backupDir) {
rollbackMessage += `;备份目录:${migration.backupDir}`;
}
}
}
if (settingsSwitched) {
restoreOutputDirSettings(previousSettings);
} else {
syncOutputDirEnv(currentPath);
}
ensureOutputLink();
if (wasBackendRunning) {
try {
startBackend();
await waitForBackend({ timeoutMs: 30_000 });
} catch (restartErr) {
throw new Error(
`切换 output 目录失败:${message}${rollbackMessage};且旧后端恢复失败:${restartErr?.message || restartErr}`
);
}
}
if (rollbackMessage) {
throw new Error(`切换 output 目录失败:${message}${rollbackMessage}`);
}
throw err;
}
}
async function applyPendingOutputDirOnStartup() {
if (!app.isPackaged) return;
loadDesktopSettings();
if (desktopSettings.pendingOutputDir === null) return;
try {
await applyOutputDirChange(desktopSettings.pendingOutputDir);
} catch (err) {
clearPendingOutputDirSetting();
setOutputDirLastError(`安装时设置的 output 目录未能应用:${err?.message || err}`);
ensureOutputLink();
logMain(`[main] failed to apply pending output dir: ${err?.message || err}`);
}
}
async function refreshRendererCacheForPackagedUi() {
if (!app.isPackaged) return;
@@ -1171,11 +1469,11 @@ function startBackend() {
}
if (app.isPackaged) {
if (!env.WECHAT_TOOL_DATA_DIR) {
env.WECHAT_TOOL_DATA_DIR = app.getPath("userData");
}
env.WECHAT_TOOL_DATA_DIR = resolveDataDir() || app.getPath("userData");
env.WECHAT_TOOL_OUTPUT_DIR = resolveOutputDir() || getDefaultOutputDir() || path.join(env.WECHAT_TOOL_DATA_DIR, "output");
try {
fs.mkdirSync(env.WECHAT_TOOL_DATA_DIR, { recursive: true });
fs.mkdirSync(env.WECHAT_TOOL_OUTPUT_DIR, { recursive: true });
} catch {}
const backendExe = getPackagedBackendPath();
@@ -1689,16 +1987,31 @@ function registerWindowIpc() {
}
});
ipcMain.handle("app:getOutputDirInfo", () => {
try {
return getOutputDirInfo();
} catch (err) {
logMain(`[main] app:getOutputDirInfo failed: ${err?.message || err}`);
return {
path: "",
defaultPath: "",
isDefault: true,
pendingPath: "",
hasPending: false,
lastError: err?.message || String(err),
canChange: !!app.isPackaged,
changeUnavailableReason: app.isPackaged ? "" : "开发模式不支持界面修改 output 目录",
};
}
});
ipcMain.handle("app:getOutputDir", () => {
const dir = resolveDataDir();
if (!dir) return "";
return path.join(dir, "output");
return resolveOutputDir() || "";
});
ipcMain.handle("app:openOutputDir", async () => {
const dir = resolveDataDir();
if (!dir) throw new Error("无法定位数据目录");
const outDir = path.join(dir, "output");
const outDir = resolveOutputDir();
if (!outDir) throw new Error("无法定位 output 目录");
try {
fs.mkdirSync(outDir, { recursive: true });
} catch {}
@@ -1713,6 +2026,28 @@ function registerWindowIpc() {
}
});
ipcMain.handle("app:setOutputDir", async (_event, nextDir) => {
if (outputDirChangeInProgress) {
return {
success: false,
error: "output 目录切换中,请稍后重试",
};
}
outputDirChangeInProgress = true;
try {
return await applyOutputDirChange(nextDir);
} catch (err) {
const message = err?.message || String(err);
logMain(`[main] app:setOutputDir failed: ${message}`);
return {
success: false,
error: message,
};
} finally {
outputDirChangeInProgress = false;
}
});
ipcMain.handle("app:getAccountInfo", async (_event, account) => {
try {
return getAccountInfoFromDisk(account);
@@ -1796,6 +2131,8 @@ async function main() {
// Resolve/create the data dir early so we can log reliably and place helper files
// next to the installed exe for easier access.
resolveDataDir();
loadDesktopSettings();
await applyPendingOutputDirOnStartup();
ensureOutputLink();
await ensureBackendPortAvailableOnStartup();
@@ -1876,10 +2213,20 @@ if (gotSingleInstanceLock) {
stopBackend();
try {
const dir = getUserDataDir();
const outputDir = resolveOutputDir();
if (dir) {
const detailLines = [
`启动失败:${err?.message || err}`,
"",
`桌面日志目录:${dir}`,
"文件:desktop-main.log / backend-stdio.log",
];
if (outputDir) {
detailLines.push("", `当前 output 目录:${outputDir}`, "其中 output\\logs\\... 也在这里");
}
dialog.showErrorBox(
"WeChatDataAnalysis 启动失败",
`启动失败:${err?.message || err}\n\n请查看日志目录:\n${dir}\n\n文件:desktop-main.log / backend-stdio.log / output\\\\logs\\\\...`
detailLines.join("\n")
);
shell.openPath(dir);
}
+252
View File
@@ -0,0 +1,252 @@
const fs = require("fs");
const path = require("path");
const SENTINEL_NAMES = [
"account_keys.json",
"runtime_settings.json",
"message_edits.db",
"databases",
"exports",
"logs",
];
function normalizeDirectoryPath(value) {
const text = String(value || "").trim();
if (!text) return "";
const expanded = text.replace(/^~(?=$|[\\/])/, process.env.USERPROFILE || process.env.HOME || "~");
if (!path.isAbsolute(expanded)) {
throw new Error("output 目录必须使用绝对路径");
}
return path.resolve(expanded);
}
function getDefaultOutputDirPath(dataDir) {
const base = normalizeDirectoryPath(dataDir);
if (!base) throw new Error("无法定位数据目录");
return path.join(base, "output");
}
function getEffectiveOutputDirPath({ dataDir, envOutputDir, settingsOutputDir }) {
const envPath = normalizeDirectoryPath(envOutputDir || "");
if (envPath) return envPath;
const settingsPath = normalizeDirectoryPath(settingsOutputDir || "");
if (settingsPath) return settingsPath;
return getDefaultOutputDirPath(dataDir);
}
function hasDirectoryContents(dirPath) {
try {
return fs.readdirSync(dirPath).length > 0;
} catch (err) {
if (err && err.code === "ENOENT") return false;
throw err;
}
}
function pathExists(dirPath) {
try {
fs.accessSync(dirPath);
return true;
} catch {
return false;
}
}
function isDirectory(dirPath) {
try {
return fs.statSync(dirPath).isDirectory();
} catch {
return false;
}
}
function isPathInside(parentPath, candidatePath) {
const parent = path.resolve(parentPath);
const candidate = path.resolve(candidatePath);
if (parent === candidate) return false;
const relative = path.relative(parent, candidate);
return !!relative && !relative.startsWith("..") && !path.isAbsolute(relative);
}
function collectSentinels(sourceDir) {
const sentinels = [];
for (const name of SENTINEL_NAMES) {
const sourcePath = path.join(sourceDir, name);
if (!pathExists(sourcePath)) continue;
sentinels.push({
name,
isDir: isDirectory(sourcePath),
size: !isDirectory(sourcePath) ? fs.statSync(sourcePath).size : null,
});
}
return sentinels;
}
function verifyCopiedOutputTree(sourceDir, copiedDir) {
const sentinels = collectSentinels(sourceDir);
for (const item of sentinels) {
const copiedPath = path.join(copiedDir, item.name);
if (!pathExists(copiedPath)) {
throw new Error(`迁移校验失败:缺少 ${item.name}`);
}
if (item.isDir) {
if (!isDirectory(copiedPath)) {
throw new Error(`迁移校验失败:${item.name} 不是目录`);
}
continue;
}
const copiedStat = fs.statSync(copiedPath);
if (copiedStat.size !== item.size) {
throw new Error(`迁移校验失败:${item.name} 大小不一致`);
}
}
}
function makeTimestamp(now = new Date()) {
const parts = [
now.getFullYear(),
String(now.getMonth() + 1).padStart(2, "0"),
String(now.getDate()).padStart(2, "0"),
String(now.getHours()).padStart(2, "0"),
String(now.getMinutes()).padStart(2, "0"),
String(now.getSeconds()).padStart(2, "0"),
];
return parts.join("");
}
function makeUniqueSiblingPath(basePath, suffix, now = new Date()) {
const stamp = makeTimestamp(now);
let attempt = 0;
while (true) {
const candidate = `${basePath}.${suffix}-${stamp}${attempt ? `-${attempt}` : ""}`;
if (!pathExists(candidate)) return candidate;
attempt += 1;
}
}
function ensureTargetIsUsable(targetDir) {
if (!pathExists(targetDir)) return;
if (!isDirectory(targetDir)) {
throw new Error("目标 output 路径已存在且不是目录");
}
if (hasDirectoryContents(targetDir)) {
throw new Error("目标 output 目录已有内容,请先清空后再重试");
}
}
function migrateOutputDirectory({ currentDir, nextDir, now = new Date() }) {
const currentPath = normalizeDirectoryPath(currentDir);
const targetPath = normalizeDirectoryPath(nextDir);
if (!currentPath || !targetPath) {
throw new Error("output 路径不能为空");
}
if (currentPath === targetPath) {
return {
changed: false,
currentDir: currentPath,
targetDir: targetPath,
sourceWasEmpty: !hasDirectoryContents(currentPath),
backupDir: "",
};
}
if (isPathInside(currentPath, targetPath) || isPathInside(targetPath, currentPath)) {
throw new Error("新旧 output 路径不能互相包含");
}
ensureTargetIsUsable(targetPath);
const sourceExists = pathExists(currentPath);
if (sourceExists && !isDirectory(currentPath)) {
throw new Error("当前 output 路径不是目录");
}
const sourceWasEmpty = !sourceExists || !hasDirectoryContents(currentPath);
if (sourceWasEmpty) {
fs.mkdirSync(targetPath, { recursive: true });
return {
changed: true,
currentDir: currentPath,
targetDir: targetPath,
sourceWasEmpty: true,
backupDir: "",
};
}
const tempTarget = makeUniqueSiblingPath(targetPath, "migrating", now);
const backupDir = makeUniqueSiblingPath(currentPath, "backup", now);
fs.cpSync(currentPath, tempTarget, {
recursive: true,
force: false,
errorOnExist: true,
preserveTimestamps: true,
});
try {
verifyCopiedOutputTree(currentPath, tempTarget);
if (pathExists(targetPath)) {
fs.rmSync(targetPath, { recursive: true, force: true });
}
fs.renameSync(currentPath, backupDir);
try {
fs.renameSync(tempTarget, targetPath);
} catch (err) {
try {
if (!pathExists(currentPath) && pathExists(backupDir)) {
fs.renameSync(backupDir, currentPath);
}
} catch {}
throw err;
}
} catch (err) {
try {
if (pathExists(tempTarget)) {
fs.rmSync(tempTarget, { recursive: true, force: true });
}
} catch {}
throw err;
}
return {
changed: true,
currentDir: currentPath,
targetDir: targetPath,
sourceWasEmpty: false,
backupDir,
};
}
function rollbackOutputDirectoryChange({ previousDir, currentDir, backupDir, sourceWasEmpty }) {
const previousPath = normalizeDirectoryPath(previousDir);
const currentPath = normalizeDirectoryPath(currentDir);
try {
if (currentPath && pathExists(currentPath)) {
fs.rmSync(currentPath, { recursive: true, force: true });
}
} catch {}
if (sourceWasEmpty) {
return;
}
const backupPath = normalizeDirectoryPath(backupDir);
if (!backupPath || !pathExists(backupPath)) return;
try {
if (!pathExists(previousPath)) {
fs.renameSync(backupPath, previousPath);
}
} catch {}
}
module.exports = {
getDefaultOutputDirPath,
getEffectiveOutputDirPath,
hasDirectoryContents,
migrateOutputDirectory,
normalizeDirectoryPath,
rollbackOutputDirectoryChange,
};
+2
View File
@@ -82,7 +82,9 @@ contextBridge.exposeInMainWorld("wechatDesktop", {
chooseDirectory: (options = {}) => ipcRenderer.invoke("dialog:chooseDirectory", options),
// Data/output folder helpers
getOutputDirInfo: () => ipcRenderer.invoke("app:getOutputDirInfo"),
getOutputDir: () => ipcRenderer.invoke("app:getOutputDir"),
setOutputDir: (dir) => ipcRenderer.invoke("app:setOutputDir", String(dir ?? "")),
openOutputDir: () => ipcRenderer.invoke("app:openOutputDir"),
getAccountInfo: (account) => ipcRenderer.invoke("app:getAccountInfo", String(account || "")),
deleteAccountData: (account) => ipcRenderer.invoke("app:deleteAccountData", String(account || "")),
+162
View File
@@ -0,0 +1,162 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("fs");
const os = require("os");
const path = require("path");
const {
getDefaultOutputDirPath,
getEffectiveOutputDirPath,
migrateOutputDirectory,
normalizeDirectoryPath,
rollbackOutputDirectoryChange,
} = require("../src/output-dir.cjs");
function makeTempDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), "wda-output-"));
}
function cleanupDir(dirPath) {
try {
fs.rmSync(dirPath, { recursive: true, force: true });
} catch {}
}
test("normalizeDirectoryPath requires absolute paths", () => {
assert.throws(() => normalizeDirectoryPath("relative/path"), /绝对路径/);
});
test("getEffectiveOutputDirPath prefers env, then settings, then default", () => {
const root = makeTempDir();
const envDir = path.join(root, "env-output");
const settingsDir = path.join(root, "settings-output");
const defaultDir = path.join(root, "data", "output");
try {
assert.equal(
getEffectiveOutputDirPath({
dataDir: path.join(root, "data"),
envOutputDir: envDir,
settingsOutputDir: settingsDir,
}),
path.resolve(envDir)
);
assert.equal(
getEffectiveOutputDirPath({
dataDir: path.join(root, "data"),
envOutputDir: "",
settingsOutputDir: settingsDir,
}),
path.resolve(settingsDir)
);
assert.equal(getDefaultOutputDirPath(path.join(root, "data")), path.resolve(defaultDir));
} finally {
cleanupDir(root);
}
});
test("migrateOutputDirectory switches empty source to a new directory", () => {
const root = makeTempDir();
const currentDir = path.join(root, "current-output");
const nextDir = path.join(root, "custom-output");
try {
fs.mkdirSync(currentDir, { recursive: true });
const result = migrateOutputDirectory({ currentDir, nextDir });
assert.equal(result.changed, true);
assert.equal(result.sourceWasEmpty, true);
assert.equal(result.backupDir, "");
assert.ok(fs.existsSync(nextDir));
assert.equal(fs.existsSync(currentDir), true);
} finally {
cleanupDir(root);
}
});
test("migrateOutputDirectory blocks non-empty targets", () => {
const root = makeTempDir();
const currentDir = path.join(root, "current-output");
const nextDir = path.join(root, "custom-output");
try {
fs.mkdirSync(path.join(currentDir, "logs"), { recursive: true });
fs.writeFileSync(path.join(currentDir, "runtime_settings.json"), "{}");
fs.mkdirSync(nextDir, { recursive: true });
fs.writeFileSync(path.join(nextDir, "existing.txt"), "occupied");
assert.throws(
() => migrateOutputDirectory({ currentDir, nextDir }),
/已有内容/
);
} finally {
cleanupDir(root);
}
});
test("migrateOutputDirectory blocks invalid current paths", () => {
const root = makeTempDir();
const currentDir = path.join(root, "current-output");
const nextDir = path.join(root, "custom-output");
try {
fs.writeFileSync(currentDir, "not-a-directory");
assert.throws(
() => migrateOutputDirectory({ currentDir, nextDir }),
/不是目录/
);
} finally {
cleanupDir(root);
}
});
test("migrateOutputDirectory copies data and leaves the old directory as a backup", () => {
const root = makeTempDir();
const currentDir = path.join(root, "current-output");
const nextDir = path.join(root, "custom-output");
try {
fs.mkdirSync(path.join(currentDir, "databases", "wxid_test"), { recursive: true });
fs.writeFileSync(path.join(currentDir, "runtime_settings.json"), "{\"backend_port\":10392}");
fs.writeFileSync(path.join(currentDir, "databases", "wxid_test", "session.db"), "session");
fs.writeFileSync(path.join(currentDir, "databases", "wxid_test", "contact.db"), "contact");
const result = migrateOutputDirectory({ currentDir, nextDir, now: new Date("2026-03-30T08:00:00Z") });
assert.equal(result.changed, true);
assert.equal(result.sourceWasEmpty, false);
assert.match(path.basename(result.backupDir), /^current-output\.backup-\d{14}$/);
assert.ok(fs.existsSync(nextDir));
assert.ok(fs.existsSync(path.join(nextDir, "runtime_settings.json")));
assert.ok(fs.existsSync(path.join(nextDir, "databases", "wxid_test", "session.db")));
assert.ok(fs.existsSync(result.backupDir));
assert.equal(fs.existsSync(currentDir), false);
} finally {
cleanupDir(root);
}
});
test("rollbackOutputDirectoryChange restores the previous directory", () => {
const root = makeTempDir();
const previousDir = path.join(root, "current-output");
const currentDir = path.join(root, "custom-output");
const backupDir = path.join(root, "current-output.backup-20260330080100");
try {
fs.mkdirSync(path.join(currentDir, "databases"), { recursive: true });
fs.writeFileSync(path.join(currentDir, "databases", "temp.db"), "temp");
fs.mkdirSync(path.join(backupDir, "databases"), { recursive: true });
fs.writeFileSync(path.join(backupDir, "databases", "session.db"), "restored");
rollbackOutputDirectoryChange({
previousDir,
currentDir,
backupDir,
sourceWasEmpty: false,
});
assert.equal(fs.existsSync(currentDir), false);
assert.ok(fs.existsSync(path.join(previousDir, "databases", "session.db")));
assert.equal(fs.existsSync(backupDir), false);
} finally {
cleanupDir(root);
}
});
+170 -15
View File
@@ -4,9 +4,9 @@
class="settings-dialog fixed inset-0 z-[120] flex items-center justify-center bg-black/40 px-4 py-4 backdrop-blur-md sm:py-8"
@click.self="handleClose"
>
<div class="settings-dialog-panel flex h-[80vh] min-h-[380px] w-full max-w-[760px] overflow-hidden rounded-[10px] border border-[#e2e2e2] bg-white shadow-2xl">
<div class="settings-dialog-panel flex h-[80vh] min-h-[380px] w-full max-w-[880px] overflow-hidden rounded-[10px] border border-[#e2e2e2] bg-white shadow-2xl">
<!-- Sidebar -->
<aside class="flex w-[180px] shrink-0 flex-col bg-[#fcfcfc] border-r border-[#eeeeee]">
<aside class="flex w-[160px] shrink-0 flex-col bg-[#fcfcfc] border-r border-[#eeeeee]">
<div class="mt-4 mb-2 flex items-center px-4 gap-2">
<div class="flex h-6 w-6 items-center justify-center rounded-[5px] bg-[#e7f5ee] text-[#07b75b]">
<svg class="h-[15px] w-[15px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
@@ -149,19 +149,74 @@
</div>
<div class="px-3.5 py-3">
<div class="flex flex-col gap-1.5 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0 flex-1">
<div class="text-[13px] font-medium text-[#222]">output 目录</div>
<div class="mt-0.5 text-[11px] text-[#909090] break-words">{{ desktopOutputDirText }}</div>
<div class="flex flex-col gap-2.5">
<div class="flex flex-col gap-1.5 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0 flex-1">
<div class="text-[13px] font-medium text-[#222]">output 目录</div>
<div class="mt-0.5 text-[11px] text-[#909090] break-words">
当前{{ desktopOutputDirText }}
<span class="ml-1 text-[#666]">{{ desktopOutputDirIsDefault ? '(默认位置)' : '(自定义位置)' }}</span>
</div>
<div class="mt-0.5 text-[11px] text-[#909090] break-words">默认{{ desktopOutputDirDefaultText }}</div>
<div v-if="desktopOutputDirPendingText" class="mt-0.5 text-[11px] text-amber-700 break-words">
待应用{{ desktopOutputDirPendingText }}
</div>
<div v-if="desktopOutputDirUnavailableReason" class="mt-1 text-[11px] text-amber-700 break-words">
{{ desktopOutputDirUnavailableReason }}
</div>
</div>
<button
type="button"
class="shrink-0 rounded-[6px] border border-[#e2e2e2] bg-white px-2 py-1 text-[12px] text-[#222] transition hover:bg-[#f9f9f9] disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!isDesktopEnv || desktopOutputDirLoading || desktopOutputDirApplying"
@click="onDesktopOpenOutputDir"
>
打开当前 output
</button>
</div>
<div class="flex flex-col gap-1.5 sm:flex-row sm:items-center">
<input
v-model="desktopOutputDirInput"
type="text"
spellcheck="false"
class="min-w-0 flex-1 rounded-[6px] border border-[#e2e2e2] bg-white px-2.5 py-1.5 text-[12px] text-[#333] outline-none transition focus:border-[#07b75b] focus:ring-1 focus:ring-[#07b75b]/30"
:disabled="desktopOutputDirControlsDisabled"
:placeholder="desktopOutputDirCanChange ? '选择新的 output 目录' : '当前环境不支持修改 output 目录'"
@keyup.enter="onDesktopOutputDirApply"
/>
<div class="flex shrink-0 items-center gap-1.5">
<button
type="button"
class="rounded-[6px] border border-[#e2e2e2] bg-white px-2 py-1 text-[12px] text-[#222] transition hover:bg-[#f9f9f9] disabled:cursor-not-allowed disabled:opacity-50"
:disabled="desktopOutputDirControlsDisabled"
@click="onDesktopChooseOutputDir"
>
选择文件夹
</button>
<button
type="button"
class="rounded-[6px] border border-[#e2e2e2] bg-white px-2 py-1 text-[12px] text-[#222] transition hover:bg-[#f9f9f9] disabled:cursor-not-allowed disabled:opacity-50"
:disabled="desktopOutputDirControlsDisabled"
@click="onDesktopOutputDirApply"
>
{{ desktopOutputDirApplying ? '迁移中...' : '应用' }}
</button>
<button
type="button"
class="rounded-[6px] border border-[#e2e2e2] bg-white px-2 py-1 text-[12px] text-[#222] transition hover:bg-[#f9f9f9] disabled:cursor-not-allowed disabled:opacity-50"
:disabled="desktopOutputDirControlsDisabled"
@click="onDesktopOutputDirReset"
>
恢复默认
</button>
</div>
</div>
<div v-if="desktopOutputDirCanChange" class="text-[11px] text-[#909090]">
修改后会迁移整个 output 目录如果目标目录已有内容会先阻止并提示
</div>
<div v-if="desktopOutputDirMessage" class="rounded-[6px] border border-[#d8efe2] bg-[#f4fbf7] px-2.5 py-1.5 text-[11px] text-[#1b6b43] whitespace-pre-wrap">
{{ desktopOutputDirMessage }}
</div>
<button
type="button"
class="shrink-0 rounded-[6px] border border-[#e2e2e2] bg-white px-2 py-1 text-[12px] text-[#222] transition hover:bg-[#f9f9f9] disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!isDesktopEnv || desktopOutputDirLoading"
@click="onDesktopOpenOutputDir"
>
打开 output
</button>
</div>
<div v-if="desktopOutputDirError" class="mt-1.5 text-[11px] text-red-600 whitespace-pre-wrap">
{{ desktopOutputDirError }}
@@ -345,13 +400,33 @@ const desktopBackendPortError = ref('')
const desktopBackendPortDefault = ref(10392)
const desktopOutputDir = ref('')
const desktopOutputDirDefault = ref('')
const desktopOutputDirInput = ref('')
const desktopOutputDirPending = ref('')
const desktopOutputDirLoading = ref(false)
const desktopOutputDirApplying = ref(false)
const desktopOutputDirError = ref('')
const desktopOutputDirMessage = ref('')
const desktopOutputDirIsDefault = ref(true)
const desktopOutputDirCanChange = ref(true)
const desktopOutputDirUnavailableReason = ref('')
const desktopOutputDirText = computed(() => {
if (!isDesktopEnv.value) return '仅桌面端可用'
const v = String(desktopOutputDir.value || '').trim()
return v || '—'
})
const desktopOutputDirDefaultText = computed(() => {
if (!isDesktopEnv.value) return '仅桌面端可用'
const v = String(desktopOutputDirDefault.value || '').trim()
return v || '—'
})
const desktopOutputDirPendingText = computed(() => {
const v = String(desktopOutputDirPending.value || '').trim()
return v || ''
})
const desktopOutputDirControlsDisabled = computed(() => (
!isDesktopEnv.value || !desktopOutputDirCanChange.value || desktopOutputDirLoading.value || desktopOutputDirApplying.value
))
const desktopLogFilePath = ref('')
const desktopLogFileLoading = ref(false)
@@ -530,12 +605,33 @@ const refreshDesktopBackendPort = async () => {
const refreshDesktopOutputDir = async () => {
if (!process.client || typeof window === 'undefined') return
if (!window.wechatDesktop?.getOutputDir) return
if (!window.wechatDesktop?.getOutputDir && !window.wechatDesktop?.getOutputDirInfo) return
desktopOutputDirLoading.value = true
desktopOutputDirError.value = ''
try {
if (window.wechatDesktop?.getOutputDirInfo) {
const info = await window.wechatDesktop.getOutputDirInfo()
desktopOutputDir.value = String(info?.path || '').trim()
desktopOutputDirDefault.value = String(info?.defaultPath || '').trim()
desktopOutputDirPending.value = String(info?.pendingPath || '').trim()
desktopOutputDirIsDefault.value = !!info?.isDefault
desktopOutputDirCanChange.value = info?.canChange !== false
desktopOutputDirUnavailableReason.value = String(info?.changeUnavailableReason || '').trim()
desktopOutputDirInput.value = desktopOutputDir.value || desktopOutputDirDefault.value
if (info?.lastError) {
desktopOutputDirError.value = String(info.lastError || '').trim()
}
return
}
const v = await window.wechatDesktop.getOutputDir()
desktopOutputDir.value = String(v || '').trim()
desktopOutputDirDefault.value = desktopOutputDir.value
desktopOutputDirPending.value = ''
desktopOutputDirIsDefault.value = true
desktopOutputDirCanChange.value = false
desktopOutputDirUnavailableReason.value = '当前桌面环境不支持修改 output 目录'
desktopOutputDirInput.value = desktopOutputDir.value
} catch (e) {
desktopOutputDirError.value = e?.message || '读取 output 目录失败'
} finally {
@@ -558,6 +654,62 @@ const onDesktopOpenOutputDir = async () => {
}
}
const onDesktopChooseOutputDir = async () => {
if (!process.client || typeof window === 'undefined') return
if (!window.wechatDesktop?.chooseDirectory) return
desktopOutputDirError.value = ''
desktopOutputDirMessage.value = ''
try {
const result = await window.wechatDesktop.chooseDirectory({ title: '选择新的 output 目录' })
if (result && !result.canceled && Array.isArray(result.filePaths) && result.filePaths.length > 0) {
desktopOutputDirInput.value = String(result.filePaths[0] || '').trim()
}
} catch (e) {
desktopOutputDirError.value = e?.message || '选择 output 目录失败'
}
}
const applyDesktopOutputDir = async (nextDir) => {
if (!process.client || typeof window === 'undefined') return
if (!window.wechatDesktop?.setOutputDir) {
desktopOutputDirError.value = '当前桌面环境不支持修改 output 目录'
return
}
if (!desktopOutputDirCanChange.value) {
desktopOutputDirError.value = desktopOutputDirUnavailableReason.value || '开发模式不支持界面修改 output 目录'
return
}
desktopOutputDirApplying.value = true
desktopOutputDirError.value = ''
desktopOutputDirMessage.value = ''
try {
const res = await window.wechatDesktop.setOutputDir(String(nextDir ?? '').trim())
if (res?.success === false) {
desktopOutputDirError.value = String(res?.error || '修改 output 目录失败').trim()
await refreshDesktopOutputDir()
return
}
await refreshDesktopOutputDir()
desktopOutputDirMessage.value = String(
res?.message || (res?.changed === false ? 'output 目录未变化' : 'output 目录已更新')
).trim()
} catch (e) {
desktopOutputDirError.value = e?.message || '修改 output 目录失败'
await refreshDesktopOutputDir()
} finally {
desktopOutputDirApplying.value = false
}
}
const onDesktopOutputDirApply = async () => {
await applyDesktopOutputDir(desktopOutputDirInput.value)
}
const onDesktopOutputDirReset = async () => {
desktopOutputDirInput.value = desktopOutputDirDefault.value
await applyDesktopOutputDir('')
}
const refreshBackendLogFileInfo = async () => {
if (!process.client || typeof window === 'undefined') return
desktopLogFileLoading.value = true
@@ -702,6 +854,9 @@ const onDesktopCheckUpdates = async () => {
watch(() => props.open, async (isOpen) => {
if (!isOpen) return
await refreshBackendLogFileInfo()
if (isDesktopEnv.value) {
await refreshDesktopOutputDir()
}
}, { immediate: true })
onMounted(async () => {
+7 -5
View File
@@ -128,7 +128,7 @@
<button
type="button"
class="search-sidebar-close"
@click="closeMessageSearch"
@click="closeMessageSearch('close-button')"
title="关闭搜索 (Esc)"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -175,9 +175,9 @@
:class="{ 'privacy-blur': privacyMode }"
@focus="searchInputFocused = true"
@blur="searchInputFocused = false"
@keydown.enter.exact.prevent="runMessageSearch({ reset: true })"
@keydown.enter.exact.prevent="runMessageSearch({ reset: true, source: 'input-enter' })"
@keydown.enter.shift.prevent="onSearchPrev"
@keydown.escape="closeMessageSearch"
@keydown.escape="closeMessageSearch('input-escape')"
/>
<!-- 清除按钮 -->
@@ -185,7 +185,7 @@
v-if="messageSearchQuery"
type="button"
class="search-clear-inline"
@click="messageSearchQuery = ''; runMessageSearch({ reset: true })"
@click="messageSearchQuery = ''; runMessageSearch({ reset: true, source: 'clear-button' })"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
@@ -197,7 +197,7 @@
type="button"
class="search-btn-inline"
:disabled="messageSearchLoading"
@click="runMessageSearch({ reset: true })"
@click="runMessageSearch({ reset: true, source: 'search-button' })"
>
<svg v-if="messageSearchLoading" class="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
@@ -428,6 +428,8 @@
:key="hit.id + ':' + idx"
class="sidebar-result-card"
:class="{ 'sidebar-result-card-selected': idx === messageSearchSelectedIndex }"
@pointerdown="onSearchHitPointerDown(hit, idx, $event)"
@click.capture="onSearchHitClickCapture(hit, idx, $event)"
@click="onSearchHitClick(hit, idx)"
>
<div class="sidebar-result-row">
+41 -1
View File
@@ -74,6 +74,7 @@ export const useChatMessages = ({
let highlightTimer = null
const messageTypeFilter = ref('all')
const localMediaVersion = ref(0)
const messageTypeFilterOptions = [
{ value: 'all', label: '全部' },
{ value: 'text', label: '文本' },
@@ -95,9 +96,39 @@ export const useChatMessages = ({
const normalizeMessage = createMessageNormalizer({
apiBase,
getSelectedAccount: () => selectedAccount.value,
getSelectedContact: () => selectedContact.value
getSelectedContact: () => selectedContact.value,
getLocalMediaVersion: () => localMediaVersion.value
})
const bumpLocalMediaVersion = () => {
localMediaVersion.value = (localMediaVersion.value + 1) % 1000000000
return localMediaVersion.value
}
const renormalizeLoadedMessages = (username) => {
const key = String(username || '').trim()
if (!key) return
const existing = allMessages.value[key]
if (!Array.isArray(existing) || !existing.length) return
const refreshed = dedupeMessagesById(existing.map((message) => {
const normalized = normalizeMessage(message)
return {
...message,
...normalized,
_emojiDownloading: !!message?._emojiDownloading,
_emojiDownloaded: typeof message?._emojiDownloaded === 'boolean' ? message._emojiDownloaded : normalized._emojiDownloaded,
_quoteImageError: false,
_quoteThumbError: false
}
}))
allMessages.value = {
...allMessages.value,
[key]: refreshed
}
}
const messages = computed(() => {
if (!selectedContact.value) return []
return allMessages.value[selectedContact.value.username] || []
@@ -534,9 +565,17 @@ export const useChatMessages = ({
const refreshSelectedMessages = async () => {
if (!selectedContact.value) return
bumpLocalMediaVersion()
await loadMessages({ username: selectedContact.value.username, reset: true })
}
const refreshCurrentMessageMedia = async () => {
if (!selectedContact.value?.username) return
bumpLocalMediaVersion()
renormalizeLoadedMessages(selectedContact.value.username)
await nextTick()
}
const refreshRealtimeIncremental = async () => {
if (!realtimeEnabled.value || !selectedAccount.value || !selectedContact.value?.username) return
if (searchContext.value?.active || isLoadingMessages.value) return
@@ -912,6 +951,7 @@ export const useChatMessages = ({
loadMessages,
loadMoreMessages,
refreshSelectedMessages,
refreshCurrentMessageMedia,
refreshRealtimeIncremental,
queueRealtimeRefresh,
tryEnableRealtimeAuto,
+445 -31
View File
@@ -43,6 +43,74 @@ export const useChatSearch = ({
selectContact,
loadMoreMessages
}) => {
const isDesktopRenderer = () => {
if (!process.client || typeof window === 'undefined') return false
return !!window.wechatDesktop?.__brand
}
const logSearchPhase = (phase, details = {}) => {
const payload = {
account: String(selectedAccount.value || '').trim(),
selectedUsername: String(selectedContact.value?.username || '').trim(),
contextUsername: String(searchContext.value?.username || '').trim(),
...details
}
if (isDesktopRenderer()) {
try {
window.wechatDesktop?.logDebug?.('chat-search', phase, payload)
} catch {}
}
console.info(`[chat-search] ${phase}`, payload)
}
const describeEventTarget = (target) => {
if (!target || typeof target !== 'object') return ''
const nodeName = String(target.nodeName || '').trim().toLowerCase()
let cls = ''
try {
cls = typeof target.className === 'string'
? target.className
: String(target.className?.baseVal || '')
} catch {
cls = ''
}
cls = String(cls || '').trim().replace(/\s+/g, '.')
if (!nodeName) return cls.slice(0, 120)
if (!cls) return nodeName
return `${nodeName}.${cls}`.slice(0, 120)
}
const summarizeHitForLog = (hit, idx) => ({
index: Number(idx ?? -1),
hitId: String(hit?.id || '').trim(),
hitUsername: String(hit?.username || '').trim(),
conversationName: String(hit?.conversationName || '').trim(),
senderUsername: String(hit?.senderUsername || '').trim(),
renderType: String(hit?.renderType || '').trim(),
createTime: Number(hit?.createTime || 0),
isSent: !!hit?.isSent
})
const buildRunSearchLogDetails = ({ source = 'unknown', reset = false } = {}) => {
const q = String(messageSearchQuery.value || '').trim()
return {
source: String(source || 'unknown'),
reset: !!reset,
scope: String(messageSearchScope.value || 'conversation'),
queryLength: q.length,
selectedContactUsername: String(selectedContact.value?.username || '').trim(),
sender: String(messageSearchSender.value || '').trim(),
sessionType: String(messageSearchSessionType.value || '').trim(),
rangeDays: String(messageSearchRangeDays.value || '').trim(),
startDate: String(messageSearchStartDate.value || '').trim(),
endDate: String(messageSearchEndDate.value || '').trim(),
offset: Number(messageSearchOffset.value || 0),
resultCount: Number(messageSearchResults.value?.length || 0)
}
}
const messageSearchOpen = ref(false)
const messageSearchQuery = ref('')
const messageSearchScope = ref('global') // conversation | global
@@ -129,7 +197,7 @@ try {
// 应用搜索历史
const applySearchHistory = async (query) => {
messageSearchQuery.value = query
await runMessageSearch({ reset: true })
await runMessageSearch({ reset: true, source: 'history-apply' })
}
const messageSearchIndexExists = computed(() => !!messageSearchIndexInfo.value?.exists)
@@ -274,11 +342,22 @@ closeMessageSearchSenderDropdown()
const fetchMessageSearchIndexStatus = async () => {
if (!selectedAccount.value) return null
logSearchPhase('search-index-status:start', {
queryLength: String(messageSearchQuery.value || '').trim().length
})
try {
const resp = await api.getChatSearchIndexStatus({ account: selectedAccount.value })
messageSearchIndexInfo.value = resp?.index || null
logSearchPhase('search-index-status:end', {
exists: !!messageSearchIndexInfo.value?.exists,
ready: !!messageSearchIndexInfo.value?.ready,
buildStatus: String(messageSearchIndexInfo.value?.build?.status || '').trim()
})
return messageSearchIndexInfo.value
} catch (e) {
logSearchPhase('search-index-status:error', {
error: String(e?.message || e || '')
})
return null
}
}
@@ -288,6 +367,7 @@ messageSearchSenderError.value = ''
if (!selectedAccount.value) {
messageSearchSenderOptions.value = []
messageSearchSenderOptionsKey.value = ''
logSearchPhase('search-senders:skip:no-account')
return []
}
@@ -303,6 +383,9 @@ if (scope === 'conversation') {
if (!selectedContact.value?.username) {
messageSearchSenderOptions.value = []
messageSearchSenderOptionsKey.value = ''
logSearchPhase('search-senders:skip:no-selected-contact', {
scope
})
return []
}
params.username = selectedContact.value.username
@@ -310,6 +393,10 @@ if (scope === 'conversation') {
if (msgQ.length < 2) {
messageSearchSenderOptions.value = []
messageSearchSenderOptionsKey.value = ''
logSearchPhase('search-senders:skip:query-too-short', {
scope,
queryLength: msgQ.length
})
return []
}
}
@@ -348,10 +435,21 @@ if (scope === 'global') {
}
messageSearchSenderLoading.value = true
logSearchPhase('search-senders:start', {
scope,
queryLength: msgQ.length,
username: String(params.username || '').trim()
})
try {
const resp = await api.listChatSearchSenders(params)
const status = String(resp?.status || 'success')
if (status !== 'success') {
logSearchPhase('search-senders:non-success', {
scope,
status,
queryLength: msgQ.length,
message: String(resp?.message || '')
})
if (status !== 'index_building') {
messageSearchSenderError.value = String(resp?.message || '加载发送者失败')
}
@@ -366,11 +464,22 @@ try {
if (cur && !list.some((s) => String(s?.username || '').trim() === cur)) {
messageSearchSender.value = ''
}
logSearchPhase('search-senders:end', {
scope,
queryLength: msgQ.length,
username: String(params.username || '').trim(),
optionCount: list.length
})
return list
} catch (e) {
messageSearchSenderError.value = e?.message || '加载发送者失败'
messageSearchSenderOptions.value = []
messageSearchSenderOptionsKey.value = ''
logSearchPhase('search-senders:error', {
scope,
queryLength: msgQ.length,
error: String(e?.message || e || '')
})
return []
} finally {
messageSearchSenderLoading.value = false
@@ -401,7 +510,7 @@ messageSearchIndexPollTimer = setInterval(async () => {
await fetchMessageSearchSenders()
}
if (String(messageSearchQuery.value || '').trim()) {
await runMessageSearch({ reset: true })
await runMessageSearch({ reset: true, source: 'index-poll-ready' })
}
}
}, 1200)
@@ -457,6 +566,33 @@ if (scope === 'global') {
}
return (text.charAt(0) || '?').toString()
}
const buildTransientSearchTargetContact = ({ username, displayName = '', avatar = '', isGroup = null } = {}) => {
const u = String(username || '').trim()
if (!u) return null
const name = String(displayName || u).trim() || u
return {
id: u,
username: u,
name,
avatar: String(avatar || '').trim() || null,
avatarColor: '#4B5563',
lastMessage: '',
lastMessageTime: '',
unreadCount: 0,
isGroup: typeof isGroup === 'boolean' ? isGroup : u.endsWith('@chatroom'),
isTop: false
}
}
const resolveSearchTargetContact = ({ username, displayName = '', avatar = '', isGroup = null } = {}) => {
const u = String(username || '').trim()
if (!u) return null
const existing = contacts.value.find((c) => String(c?.username || '').trim() === u)
if (existing) return existing
if (String(selectedContact.value?.username || '').trim() === u) return selectedContact.value
return buildTransientSearchTargetContact({ username: u, displayName, avatar, isGroup })
}
const searchContextBannerText = computed(() => {
if (!searchContext.value?.active) return ''
const kind = String(searchContext.value.kind || 'search')
@@ -596,7 +732,13 @@ for (let i = 0; i < 42; i++) {
}
return out
})
const closeMessageSearch = () => {
const closeMessageSearch = (reason = 'manual') => {
logSearchPhase('message-search:close', {
reason: String(reason || 'manual'),
queryLength: String(messageSearchQuery.value || '').trim().length,
resultCount: Number(messageSearchResults.value?.length || 0),
selectedIndex: Number(messageSearchSelectedIndex.value ?? -1)
})
messageSearchOpen.value = false
closeMessageSearchSenderDropdown()
messageSearchError.value = ''
@@ -771,29 +913,47 @@ if (messageSearchScope.value === 'conversation' && !selectedContact.value) {
}
const toggleMessageSearch = async () => {
messageSearchOpen.value = !messageSearchOpen.value
const nextOpen = !messageSearchOpen.value
logSearchPhase('message-search:toggle', {
nextOpen,
queryLength: String(messageSearchQuery.value || '').trim().length,
resultCount: Number(messageSearchResults.value?.length || 0)
})
messageSearchOpen.value = nextOpen
ensureMessageSearchScopeValid()
if (!messageSearchOpen.value) return
if (!messageSearchOpen.value) {
closeMessageSearch('toggle-close')
return
}
closeTimeSidebar()
await nextTick()
try {
messageSearchInputRef.value?.focus?.()
} catch {}
logSearchPhase('message-search:open:ready', {
scope: String(messageSearchScope.value || 'conversation'),
selectedContactUsername: String(selectedContact.value?.username || '').trim()
})
await fetchMessageSearchIndexStatus()
await fetchMessageSearchSenders()
if (String(messageSearchQuery.value || '').trim()) {
await runMessageSearch({ reset: true })
await runMessageSearch({ reset: true, source: 'toggle-open-with-query' })
}
}
let messageSearchReqId = 0
const runMessageSearch = async ({ reset } = {}) => {
if (!selectedAccount.value) return
const runMessageSearch = async ({ reset, source = 'unknown' } = {}) => {
if (!selectedAccount.value) {
logSearchPhase('runMessageSearch:skip:no-account', buildRunSearchLogDetails({ source, reset }))
return
}
ensureMessageSearchScopeValid()
const q = String(messageSearchQuery.value || '').trim()
logSearchPhase('runMessageSearch:start', buildRunSearchLogDetails({ source, reset }))
if (!q) {
logSearchPhase('runMessageSearch:skip:empty-query', buildRunSearchLogDetails({ source, reset }))
messageSearchResults.value = []
messageSearchHasMore.value = false
messageSearchError.value = ''
@@ -814,6 +974,10 @@ const reqId = ++messageSearchReqId
messageSearchLoading.value = true
messageSearchError.value = ''
messageSearchBackendStatus.value = ''
logSearchPhase('runMessageSearch:request:start', {
...buildRunSearchLogDetails({ source, reset }),
reqId
})
const scope = String(messageSearchScope.value || 'conversation')
@@ -858,6 +1022,7 @@ if (String(messageSearchSender.value || '').trim()) {
if (scope === 'conversation') {
if (!selectedContact.value?.username) {
logSearchPhase('runMessageSearch:skip:no-selected-contact', buildRunSearchLogDetails({ source, reset }))
messageSearchLoading.value = false
messageSearchError.value = '请选择一个会话再搜索'
return
@@ -867,7 +1032,15 @@ if (scope === 'conversation') {
try {
const resp = await api.searchChatMessages(params)
if (reqId !== messageSearchReqId) return
if (reqId !== messageSearchReqId) {
logSearchPhase('runMessageSearch:response:stale', {
...buildRunSearchLogDetails({ source, reset }),
reqId,
activeReqId: Number(messageSearchReqId || 0),
status: String(resp?.status || '').trim()
})
return
}
if (resp?.index) {
messageSearchIndexInfo.value = resp.index
@@ -877,6 +1050,11 @@ try {
messageSearchBackendStatus.value = status
if (status === 'index_building') {
logSearchPhase('runMessageSearch:response:index-building', {
...buildRunSearchLogDetails({ source, reset }),
reqId,
status
})
if (reset) {
messageSearchResults.value = []
messageSearchSelectedIndex.value = -1
@@ -888,6 +1066,12 @@ try {
}
if (status === 'index_error') {
logSearchPhase('runMessageSearch:response:index-error', {
...buildRunSearchLogDetails({ source, reset }),
reqId,
status,
message: String(resp?.message || '')
})
if (reset) {
messageSearchResults.value = []
messageSearchSelectedIndex.value = -1
@@ -900,6 +1084,12 @@ try {
}
if (status !== 'success') {
logSearchPhase('runMessageSearch:response:non-success', {
...buildRunSearchLogDetails({ source, reset }),
reqId,
status,
message: String(resp?.message || '')
})
if (reset) {
messageSearchResults.value = []
messageSearchSelectedIndex.value = -1
@@ -925,6 +1115,17 @@ try {
messageSearchSelectedIndex.value = 0
}
logSearchPhase('runMessageSearch:response:success', {
...buildRunSearchLogDetails({ source, reset }),
reqId,
status,
hitsCount: hits.length,
total: Number(resp?.total ?? resp?.totalInScan ?? 0),
hasMore: !!resp?.hasMore,
backendScope: String(resp?.scope || '').trim(),
backendUsername: String(resp?.username || '').trim()
})
// 保存搜索历史(仅在有结果时保存)
if (!privacyMode.value && reset && hits.length > 0) {
saveSearchHistory(q)
@@ -932,9 +1133,20 @@ try {
} catch (e) {
if (reqId !== messageSearchReqId) return
messageSearchError.value = e?.message || '搜索失败'
logSearchPhase('runMessageSearch:error', {
...buildRunSearchLogDetails({ source, reset }),
reqId,
error: String(e?.message || e || '')
})
} finally {
if (reqId === messageSearchReqId) {
messageSearchLoading.value = false
logSearchPhase('runMessageSearch:finalize', {
...buildRunSearchLogDetails({ source, reset }),
reqId,
loading: !!messageSearchLoading.value,
error: String(messageSearchError.value || '')
})
}
}
}
@@ -943,7 +1155,7 @@ const loadMoreSearchResults = async () => {
if (!messageSearchHasMore.value) return
if (messageSearchLoading.value) return
messageSearchOffset.value = Number(messageSearchOffset.value || 0) + messageSearchLimit
await runMessageSearch({ reset: false })
await runMessageSearch({ reset: false, source: 'load-more' })
}
const exitSearchContext = async () => {
@@ -980,19 +1192,69 @@ updateJumpToBottomState()
const locateSearchHit = async (hit) => {
if (!process.client) return
if (!selectedAccount.value) return
if (!hit?.id) return
if (!selectedAccount.value) {
logSearchPhase('locateSearchHit:skip:no-account', {
hitId: String(hit?.id || '').trim(),
hitUsername: String(hit?.username || '').trim()
})
return
}
if (!hit?.id) {
logSearchPhase('locateSearchHit:skip:missing-hit-id', {
hitKeys: Object.keys(hit || {})
})
return
}
const targetUsername = String(hit?.username || selectedContact.value?.username || '').trim()
if (!targetUsername) return
if (!targetUsername) {
logSearchPhase('locateSearchHit:skip:missing-target-username', {
hitId: String(hit?.id || '').trim(),
hitUsername: String(hit?.username || '').trim(),
selectedUsernameFallback: String(selectedContact.value?.username || '').trim()
})
return
}
const targetContact = contacts.value.find((c) => c?.username === targetUsername)
logSearchPhase('locateSearchHit:start', {
hitId: String(hit?.id || '').trim(),
hitUsername: String(hit?.username || '').trim(),
targetUsername,
conversationName: String(hit?.conversationName || '').trim()
})
const targetContact = resolveSearchTargetContact({
username: targetUsername,
displayName: String(hit?.conversationName || hit?.username || targetUsername).trim(),
avatar: String(hit?.conversationAvatar || hit?.senderAvatar || '').trim(),
isGroup: targetUsername.endsWith('@chatroom')
})
logSearchPhase('locateSearchHit:target-resolved', {
hitId: String(hit?.id || '').trim(),
targetUsername,
contactResolved: !!targetContact,
contactSource: targetContact
? (contacts.value.find((c) => String(c?.username || '').trim() === targetUsername) ? 'contacts' : 'transient')
: 'none'
})
if (targetContact && selectedContact.value?.username !== targetUsername) {
logSearchPhase('locateSearchHit:selectContact:start', {
hitId: String(hit?.id || '').trim(),
targetUsername
})
await selectContact(targetContact, { skipLoadMessages: true })
logSearchPhase('locateSearchHit:selectContact:done', {
hitId: String(hit?.id || '').trim(),
targetUsername
})
}
if (searchContext.value?.active && searchContext.value.username !== targetUsername) {
await exitSearchContext()
logSearchPhase('locateSearchHit:exitSearchContext:done', {
hitId: String(hit?.id || '').trim(),
targetUsername
})
}
if (!searchContext.value?.active) {
@@ -1019,8 +1281,20 @@ if (!searchContext.value?.active) {
searchContext.value.loadingBefore = false
searchContext.value.loadingAfter = false
}
logSearchPhase('locateSearchHit:search-context:ready', {
hitId: String(hit?.id || '').trim(),
targetUsername,
contextActive: !!searchContext.value?.active,
savedMessagesCount: Number(searchContext.value?.savedMessages?.length || 0)
})
try {
logSearchPhase('locateSearchHit:messagesAround:start', {
hitId: String(hit?.id || '').trim(),
targetUsername,
before: 35,
after: 35
})
const resp = await api.getChatMessagesAround({
account: selectedAccount.value,
username: targetUsername,
@@ -1033,13 +1307,37 @@ try {
const mapped = raw.map(normalizeMessage)
allMessages.value = { ...allMessages.value, [targetUsername]: mapped }
messagesMeta.value = { ...messagesMeta.value, [targetUsername]: { total: mapped.length, hasMore: false } }
logSearchPhase('locateSearchHit:messagesAround:end', {
hitId: String(hit?.id || '').trim(),
targetUsername,
messageCount: mapped.length,
anchorId: String(resp?.anchorId || hit?.id || '').trim(),
anchorIndex: Number(resp?.anchorIndex ?? -1)
})
searchContext.value.anchorId = String(resp?.anchorId || hit.id)
searchContext.value.anchorIndex = Number(resp?.anchorIndex ?? -1)
logSearchPhase('locateSearchHit:scroll:start', {
hitId: String(hit?.id || '').trim(),
targetUsername,
anchorId: String(searchContext.value.anchorId || '').trim(),
messageCount: mapped.length
})
const ok = await scrollToMessageId(searchContext.value.anchorId)
logSearchPhase('locateSearchHit:scroll:end', {
hitId: String(hit?.id || '').trim(),
targetUsername,
anchorId: String(searchContext.value.anchorId || '').trim(),
scrollFound: !!ok
})
if (ok) flashMessage(searchContext.value.anchorId)
} catch (e) {
logSearchPhase('locateSearchHit:error', {
hitId: String(hit?.id || '').trim(),
targetUsername,
error: String(e?.message || e || '')
})
window.alert(e?.message || '定位失败')
}
}
@@ -1051,7 +1349,12 @@ const u = String(targetUsername || selectedContact.value?.username || '').trim()
const anchor = String(anchorId || '').trim()
if (!u || !anchor) return
const targetContact = contacts.value.find((c) => c?.username === u)
const targetContact = resolveSearchTargetContact({
username: u,
displayName: String(selectedContact.value?.name || u).trim(),
avatar: String(selectedContact.value?.avatar || '').trim(),
isGroup: u.endsWith('@chatroom')
})
if (targetContact && selectedContact.value?.username !== u) {
await selectContact(targetContact, { skipLoadMessages: true })
}
@@ -1317,9 +1620,37 @@ try {
}
}
const onSearchHitPointerDown = (hit, idx, event) => {
logSearchPhase('search-result:pointerdown', {
...summarizeHitForLog(hit, idx),
button: Number(event?.button ?? -1),
detail: Number(event?.detail ?? 0),
target: describeEventTarget(event?.target),
currentTarget: describeEventTarget(event?.currentTarget)
})
}
const onSearchHitClickCapture = (hit, idx, event) => {
logSearchPhase('search-result:click-capture', {
...summarizeHitForLog(hit, idx),
button: Number(event?.button ?? -1),
detail: Number(event?.detail ?? 0),
target: describeEventTarget(event?.target),
currentTarget: describeEventTarget(event?.currentTarget)
})
}
const onSearchHitClick = async (hit, idx) => {
messageSearchSelectedIndex.value = Number(idx || 0)
logSearchPhase('onSearchHitClick', {
...summarizeHitForLog(hit, idx),
selectedIndex: Number(messageSearchSelectedIndex.value || 0)
})
await locateSearchHit(hit)
logSearchPhase('onSearchHitClick:done', {
...summarizeHitForLog(hit, idx),
selectedIndex: Number(messageSearchSelectedIndex.value || 0)
})
}
const onSearchNext = async () => {
@@ -1327,7 +1658,7 @@ const q = String(messageSearchQuery.value || '').trim()
if (!q) return
if (!messageSearchResults.value.length && !messageSearchLoading.value) {
await runMessageSearch({ reset: true })
await runMessageSearch({ reset: true, source: 'search-next-bootstrap' })
}
if (!messageSearchResults.value.length) return
@@ -1342,7 +1673,7 @@ const q = String(messageSearchQuery.value || '').trim()
if (!q) return
if (!messageSearchResults.value.length && !messageSearchLoading.value) {
await runMessageSearch({ reset: true })
await runMessageSearch({ reset: true, source: 'search-prev-bootstrap' })
}
if (!messageSearchResults.value.length) return
@@ -1355,13 +1686,27 @@ const openMessageSearch = async () => {
closeTimeSidebar()
messageSearchOpen.value = true
ensureMessageSearchScopeValid()
logSearchPhase('message-search:open:start', {
scope: String(messageSearchScope.value || 'conversation'),
queryLength: String(messageSearchQuery.value || '').trim().length
})
await nextTick()
try {
messageSearchInputRef.value?.focus?.()
} catch {}
await fetchMessageSearchIndexStatus()
logSearchPhase('message-search:open:end', {
scope: String(messageSearchScope.value || 'conversation'),
queryLength: String(messageSearchQuery.value || '').trim().length
})
}
watch(messageSearchScope, async () => {
watch(messageSearchScope, async (next, prev) => {
logSearchPhase('message-search-scope:change', {
previous: String(prev || '').trim(),
next: String(next || '').trim(),
open: !!messageSearchOpen.value,
queryLength: String(messageSearchQuery.value || '').trim().length
})
if (!messageSearchOpen.value) return
ensureMessageSearchScopeValid()
closeMessageSearchSenderDropdown()
@@ -1373,22 +1718,34 @@ messageSearchOffset.value = 0
messageSearchResults.value = []
messageSearchSelectedIndex.value = -1
if (String(messageSearchQuery.value || '').trim()) {
await runMessageSearch({ reset: true })
await runMessageSearch({ reset: true, source: 'scope-change' })
}
})
watch(messageSearchRangeDays, async () => {
watch(messageSearchRangeDays, async (next, prev) => {
logSearchPhase('message-search-range:change', {
previous: String(prev || '').trim(),
next: String(next || '').trim(),
open: !!messageSearchOpen.value,
queryLength: String(messageSearchQuery.value || '').trim().length
})
if (!messageSearchOpen.value) return
closeMessageSearchSenderDropdown()
messageSearchOffset.value = 0
messageSearchResults.value = []
messageSearchSelectedIndex.value = -1
if (String(messageSearchQuery.value || '').trim()) {
await runMessageSearch({ reset: true })
await runMessageSearch({ reset: true, source: 'range-change' })
}
})
watch(messageSearchSessionType, async () => {
watch(messageSearchSessionType, async (next, prev) => {
logSearchPhase('message-search-session-type:change', {
previous: String(prev || '').trim(),
next: String(next || '').trim(),
open: !!messageSearchOpen.value,
queryLength: String(messageSearchQuery.value || '').trim().length
})
if (!messageSearchOpen.value) return
if (String(messageSearchScope.value || '') !== 'global') return
closeMessageSearchSenderDropdown()
@@ -1400,11 +1757,18 @@ messageSearchOffset.value = 0
messageSearchResults.value = []
messageSearchSelectedIndex.value = -1
if (String(messageSearchQuery.value || '').trim()) {
await runMessageSearch({ reset: true })
await runMessageSearch({ reset: true, source: 'session-type-change' })
}
})
watch([messageSearchStartDate, messageSearchEndDate], async () => {
watch([messageSearchStartDate, messageSearchEndDate], async ([nextStart, nextEnd], [prevStart, prevEnd]) => {
logSearchPhase('message-search-custom-range:change', {
previousStart: String(prevStart || '').trim(),
previousEnd: String(prevEnd || '').trim(),
nextStart: String(nextStart || '').trim(),
nextEnd: String(nextEnd || '').trim(),
open: !!messageSearchOpen.value
})
if (!messageSearchOpen.value) return
if (String(messageSearchRangeDays.value || '') !== 'custom') return
closeMessageSearchSenderDropdown()
@@ -1412,34 +1776,62 @@ messageSearchOffset.value = 0
messageSearchResults.value = []
messageSearchSelectedIndex.value = -1
if (String(messageSearchQuery.value || '').trim()) {
await runMessageSearch({ reset: true })
await runMessageSearch({ reset: true, source: 'custom-range-change' })
}
})
watch(messageSearchSender, async () => {
watch(messageSearchSender, async (next, prev) => {
logSearchPhase('message-search-sender:change', {
previous: String(prev || '').trim(),
next: String(next || '').trim(),
open: !!messageSearchOpen.value,
queryLength: String(messageSearchQuery.value || '').trim().length
})
if (!messageSearchOpen.value) return
messageSearchOffset.value = 0
messageSearchResults.value = []
messageSearchSelectedIndex.value = -1
if (String(messageSearchQuery.value || '').trim()) {
await runMessageSearch({ reset: true })
await runMessageSearch({ reset: true, source: 'sender-change' })
}
})
watch(messageSearchQuery, () => {
watch(messageSearchQuery, (next, prev) => {
logSearchPhase('message-search-query:change', {
previousLength: String(prev || '').trim().length,
nextLength: String(next || '').trim().length,
open: !!messageSearchOpen.value
})
if (!messageSearchOpen.value) return
if (messageSearchDebounceTimer) clearTimeout(messageSearchDebounceTimer)
messageSearchDebounceTimer = null
const q = String(messageSearchQuery.value || '').trim()
if (q.length < 2) return
if (q.length < 2) {
logSearchPhase('message-search-query:debounce:skip-short', {
queryLength: q.length
})
return
}
logSearchPhase('message-search-query:debounce:scheduled', {
queryLength: q.length,
delayMs: 280
})
messageSearchDebounceTimer = setTimeout(() => {
runMessageSearch({ reset: true })
logSearchPhase('message-search-query:debounce:fire', {
queryLength: String(messageSearchQuery.value || '').trim().length
})
runMessageSearch({ reset: true, source: 'query-debounce' })
}, 280)
})
watch(
() => selectedContact.value?.username,
async () => {
logSearchPhase('message-search-selected-contact:change', {
open: !!messageSearchOpen.value,
scope: String(messageSearchScope.value || '').trim(),
selectedContactUsername: String(selectedContact.value?.username || '').trim()
})
if (!messageSearchOpen.value) return
if (String(messageSearchScope.value || '') !== 'conversation') return
closeMessageSearchSenderDropdown()
@@ -1448,11 +1840,31 @@ async () => {
messageSearchSenderOptionsKey.value = ''
await fetchMessageSearchSenders()
if (String(messageSearchQuery.value || '').trim()) {
await runMessageSearch({ reset: true })
await runMessageSearch({ reset: true, source: 'selected-contact-change' })
}
}
)
watch(messageSearchOpen, (next, prev) => {
logSearchPhase('message-search-open:change', {
previous: !!prev,
next: !!next,
queryLength: String(messageSearchQuery.value || '').trim().length,
resultCount: Number(messageSearchResults.value?.length || 0)
})
})
watch(messageSearchResults, (next, prev) => {
const nextList = Array.isArray(next) ? next : []
const prevList = Array.isArray(prev) ? prev : []
logSearchPhase('message-search-results:change', {
previousCount: prevList.length,
nextCount: nextList.length,
firstHitId: String(nextList[0]?.id || '').trim(),
selectedIndex: Number(messageSearchSelectedIndex.value ?? -1)
})
})
const autoLoadReady = ref(true)
let timeSidebarScrollSyncRaf = null
@@ -1664,6 +2076,8 @@ if (c.scrollTop <= 60 && autoLoadReady.value && hasMoreMessages.value && !isLoad
onTimeSidebarDayClick,
loadMoreSearchContextAfter,
loadMoreSearchContextBefore,
onSearchHitPointerDown,
onSearchHitClickCapture,
onSearchHitClick,
onSearchNext,
onSearchPrev,
+8 -4
View File
@@ -17,11 +17,12 @@ const buildAccountMediaUrl = (apiBase, path, parts) => {
return `${apiBase}${path}?${parts.filter(Boolean).join('&')}`
}
export const createMessageNormalizer = ({ apiBase, getSelectedAccount, getSelectedContact }) => {
export const createMessageNormalizer = ({ apiBase, getSelectedAccount, getSelectedContact, getLocalMediaVersion }) => {
return (msg) => {
const account = String(getSelectedAccount?.() || '').trim()
const contact = getSelectedContact?.() || null
const username = String(contact?.username || '').trim()
const localMediaVersion = Number(getLocalMediaVersion?.() || 0)
const isSent = !!msg.isSent
const sender = isSent ? '我' : (msg.senderDisplayName || msg.senderUsername || contact?.name || '')
const fallbackAvatar = (!isSent && !contact?.isGroup) ? (contact?.avatar || null) : null
@@ -66,7 +67,8 @@ export const createMessageNormalizer = ({ apiBase, getSelectedAccount, getSelect
`account=${encodeURIComponent(account)}`,
msg.imageMd5 ? `md5=${encodeURIComponent(msg.imageMd5)}` : '',
msg.imageFileId ? `file_id=${encodeURIComponent(msg.imageFileId)}` : '',
`username=${encodeURIComponent(username)}`
`username=${encodeURIComponent(username)}`,
localMediaVersion > 0 ? `v=${encodeURIComponent(String(localMediaVersion))}` : ''
])
})()
@@ -86,7 +88,8 @@ export const createMessageNormalizer = ({ apiBase, getSelectedAccount, getSelect
`account=${encodeURIComponent(account)}`,
msg.videoThumbMd5 ? `md5=${encodeURIComponent(msg.videoThumbMd5)}` : '',
msg.videoThumbFileId ? `file_id=${encodeURIComponent(msg.videoThumbFileId)}` : '',
`username=${encodeURIComponent(username)}`
`username=${encodeURIComponent(username)}`,
localMediaVersion > 0 ? `v=${encodeURIComponent(String(localMediaVersion))}` : ''
])
})()
@@ -158,7 +161,8 @@ export const createMessageNormalizer = ({ apiBase, getSelectedAccount, getSelect
return buildAccountMediaUrl(apiBase, '/chat/media/image', [
`account=${encodeURIComponent(account)}`,
`server_id=${encodeURIComponent(quoteServerIdStr)}`,
username ? `username=${encodeURIComponent(username)}` : ''
username ? `username=${encodeURIComponent(username)}` : '',
localMediaVersion > 0 ? `v=${encodeURIComponent(String(localMediaVersion))}` : ''
])
})()
+66 -2
View File
@@ -119,6 +119,23 @@ const nextMessageLoadToken = () => {
return messageLoadSequence
}
const buildTransientContact = ({ username, name = '', avatar = '', isGroup = null } = {}) => {
const u = String(username || '').trim()
const displayName = String(name || u).trim() || u
return {
id: u,
username: u,
name: displayName,
avatar: String(avatar || '').trim() || null,
avatarColor: '#4B5563',
lastMessage: '',
lastMessageTime: '',
unreadCount: 0,
isGroup: typeof isGroup === 'boolean' ? isGroup : u.endsWith('@chatroom'),
isTop: false
}
}
const buildChatPath = (username) => {
return username ? `/chat/${encodeURIComponent(username)}` : '/chat'
}
@@ -216,6 +233,7 @@ const {
loadMessages,
loadMoreMessages,
refreshSelectedMessages,
refreshCurrentMessageMedia,
queueRealtimeRefresh,
tryEnableRealtimeAuto,
resetMessageState,
@@ -333,14 +351,28 @@ const selectContact = async (contact, options = {}) => {
}
const applyRouteSelection = async (options = {}) => {
const selectionReason = String(options.reason || 'route-selection').trim() || 'route-selection'
const requested = routeUsername.value || ''
if ((!contacts.value || contacts.value.length === 0) && requested) {
if (selectedContact.value?.username === requested) {
return
}
await selectContact(buildTransientContact({ username: requested }), {
syncRoute: false,
deferLoadMessages: !!options.deferLoadMessages,
reason: `${selectionReason}:transient-route-empty-list`
})
return
}
if (!contacts.value || contacts.value.length === 0) {
selectedContact.value = null
return
}
const selectionReason = String(options.reason || 'route-selection').trim() || 'route-selection'
const requested = routeUsername.value || ''
if (requested) {
if (selectedContact.value?.username === requested) {
return
}
const matched = contacts.value.find((contact) => contact.username === requested)
if (matched) {
if (selectedContact.value?.username !== matched.username) {
@@ -352,6 +384,12 @@ const applyRouteSelection = async (options = {}) => {
}
return
}
await selectContact(buildTransientContact({ username: requested }), {
syncRoute: false,
deferLoadMessages: !!options.deferLoadMessages,
reason: `${selectionReason}:transient-route`
})
return
}
await selectContact(contacts.value[0], {
@@ -568,6 +606,28 @@ const onGlobalKeyDown = (event) => {
}
}
let lastResumeMediaRefreshAt = 0
const maybeRefreshMediaOnResume = () => {
if (!process.client) return
if (!selectedContact.value?.username) return
if (searchContext.value?.active) return
const now = Date.now()
if ((now - lastResumeMediaRefreshAt) < 1200) return
lastResumeMediaRefreshAt = now
void refreshCurrentMessageMedia()
}
const onWindowFocus = () => {
maybeRefreshMediaOnResume()
}
const onVisibilityChange = () => {
if (document.visibilityState !== 'visible') return
maybeRefreshMediaOnResume()
}
onMounted(async () => {
if (!process.client) return
@@ -585,6 +645,8 @@ onMounted(async () => {
document.addEventListener('touchmove', onFloatingWindowMouseMove)
document.addEventListener('touchend', onFloatingWindowMouseUp)
document.addEventListener('touchcancel', onFloatingWindowMouseUp)
window.addEventListener('focus', onWindowFocus)
document.addEventListener('visibilitychange', onVisibilityChange)
logChatBootstrap('loadContacts:start', {
selectedAccount: selectedAccount.value
@@ -635,6 +697,8 @@ onUnmounted(() => {
document.removeEventListener('touchmove', onFloatingWindowMouseMove)
document.removeEventListener('touchend', onFloatingWindowMouseUp)
document.removeEventListener('touchcancel', onFloatingWindowMouseUp)
window.removeEventListener('focus', onWindowFocus)
document.removeEventListener('visibilitychange', onVisibilityChange)
if (locateServerIdTimer) clearTimeout(locateServerIdTimer)
locateServerIdTimer = null
+8 -3
View File
@@ -3,6 +3,9 @@ from __future__ import annotations
import os
from pathlib import Path
ENV_DATA_DIR_KEY = "WECHAT_TOOL_DATA_DIR"
ENV_OUTPUT_DIR_KEY = "WECHAT_TOOL_OUTPUT_DIR"
def get_data_dir() -> Path:
"""Base writable directory for all runtime output (logs, databases, key store).
@@ -12,13 +15,16 @@ def get_data_dir() -> Path:
- Dev defaults to the current working directory (repo root).
"""
v = os.environ.get("WECHAT_TOOL_DATA_DIR", "").strip()
v = os.environ.get(ENV_DATA_DIR_KEY, "").strip()
if v:
return Path(v)
return Path(v).expanduser()
return Path.cwd()
def get_output_dir() -> Path:
v = os.environ.get(ENV_OUTPUT_DIR_KEY, "").strip()
if v:
return Path(v).expanduser()
return get_data_dir() / "output"
@@ -28,4 +34,3 @@ def get_output_databases_dir() -> Path:
def get_account_keys_path() -> Path:
return get_output_dir() / "account_keys.json"
+83 -6
View File
@@ -14,6 +14,7 @@ from fastapi import HTTPException
from .app_paths import get_output_databases_dir
from .logging_config import get_logger
from .sqlite_diagnostics import collect_sqlite_diagnostics, format_sqlite_diagnostics
try:
import zstandard as zstd # type: ignore
@@ -1755,9 +1756,10 @@ def _load_latest_message_previews(account_dir: Path, usernames: list[str]) -> di
session_db_path = Path(account_dir) / "session.db"
if session_db_path.exists() and remaining:
sconn = sqlite3.connect(str(session_db_path))
sconn.row_factory = sqlite3.Row
sconn: Optional[sqlite3.Connection] = None
try:
sconn = sqlite3.connect(str(session_db_path))
sconn.row_factory = sqlite3.Row
uniq = list(dict.fromkeys([u for u in remaining if u]))
chunk_size = 900
for i in range(0, len(uniq), chunk_size):
@@ -1786,10 +1788,24 @@ def _load_latest_message_previews(account_dir: Path, usernames: list[str]) -> di
if not u:
continue
expected_ts_by_user[u] = int(r["last_timestamp"] or 0)
except sqlite3.DatabaseError as e:
expected_ts_by_user = {}
logger.warning(
"[sessions.preview] session timestamp lookup failed account=%s db=%s usernames=%s sample_usernames=%s error=%s diag=%s",
account_dir.name,
str(session_db_path),
len(remaining),
sorted([u for u in remaining if u])[:5],
str(e),
format_sqlite_diagnostics(
collect_sqlite_diagnostics(session_db_path, quick_check=True, table_name="SessionTable")
),
)
except Exception:
expected_ts_by_user = {}
finally:
sconn.close()
if sconn is not None:
sconn.close()
if _DEBUG_SESSIONS:
logger.info(
@@ -1800,9 +1816,16 @@ def _load_latest_message_previews(account_dir: Path, usernames: list[str]) -> di
)
for db_path in db_paths:
conn = sqlite3.connect(str(db_path))
conn.row_factory = sqlite3.Row
conn: Optional[sqlite3.Connection] = None
stage = "connect"
stage_username = ""
stage_table = ""
try:
stage = "connect"
conn = sqlite3.connect(str(db_path))
conn.row_factory = sqlite3.Row
stage = "sqlite_master"
rows = conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()
names = [str(r[0]) for r in rows if r and r[0]]
lower_to_actual = {n.lower(): n for n in names}
@@ -1818,9 +1841,12 @@ def _load_latest_message_previews(account_dir: Path, usernames: list[str]) -> di
conn.text_factory = bytes
for u, tn in found.items():
stage_username = str(u)
stage_table = str(tn)
quoted = _quote_ident(tn)
try:
try:
stage = "latest_row_with_name2id"
r = conn.execute(
"SELECT "
"m.local_type, m.message_content, m.compress_content, m.create_time, m.sort_seq, m.local_id, "
@@ -1831,6 +1857,7 @@ def _load_latest_message_previews(account_dir: Path, usernames: list[str]) -> di
"LIMIT 1"
).fetchone()
except Exception:
stage = "latest_row_without_name2id"
r = conn.execute(
"SELECT "
"local_type, message_content, compress_content, create_time, sort_seq, local_id, '' AS sender_username "
@@ -1838,6 +1865,20 @@ def _load_latest_message_previews(account_dir: Path, usernames: list[str]) -> di
"ORDER BY sort_seq DESC, local_id DESC "
"LIMIT 1"
).fetchone()
except sqlite3.DatabaseError as e:
logger.warning(
"[sessions.preview] latest row query failed account=%s db=%s username=%s table=%s stage=%s error=%s diag=%s",
account_dir.name,
str(db_path),
str(u),
str(tn),
stage,
str(e),
format_sqlite_diagnostics(
collect_sqlite_diagnostics(db_path, quick_check=True, table_name=tn)
),
)
continue
except Exception as e:
if _DEBUG_SESSIONS:
logger.info(
@@ -1855,6 +1896,7 @@ def _load_latest_message_previews(account_dir: Path, usernames: list[str]) -> di
expected_ts = int(expected_ts_by_user.get(u) or 0)
if expected_ts > 0 and create_time > 0 and create_time < expected_ts:
try:
stage = "latest_row_by_create_time_with_name2id"
r2 = conn.execute(
"SELECT "
"m.local_type, m.message_content, m.compress_content, m.create_time, m.sort_seq, m.local_id, "
@@ -1866,6 +1908,7 @@ def _load_latest_message_previews(account_dir: Path, usernames: list[str]) -> di
).fetchone()
except Exception:
try:
stage = "latest_row_by_create_time_without_name2id"
r2 = conn.execute(
"SELECT "
"local_type, message_content, compress_content, create_time, sort_seq, local_id, '' AS sender_username "
@@ -1873,6 +1916,20 @@ def _load_latest_message_previews(account_dir: Path, usernames: list[str]) -> di
"ORDER BY COALESCE(create_time, 0) DESC, COALESCE(sort_seq, 0) DESC, local_id DESC "
"LIMIT 1"
).fetchone()
except sqlite3.DatabaseError as e:
logger.warning(
"[sessions.preview] latest row requery failed account=%s db=%s username=%s table=%s stage=%s error=%s diag=%s",
account_dir.name,
str(db_path),
str(u),
str(tn),
stage,
str(e),
format_sqlite_diagnostics(
collect_sqlite_diagnostics(db_path, quick_check=True, table_name=tn)
),
)
r2 = None
except Exception:
r2 = None
@@ -1900,8 +1957,28 @@ def _load_latest_message_previews(account_dir: Path, usernames: list[str]) -> di
prev = best.get(u)
if prev is None or sort_key > prev[0]:
best[u] = (sort_key, preview)
except sqlite3.DatabaseError as e:
logger.warning(
"[sessions.preview] malformed message db account=%s db=%s stage=%s username=%s table=%s remaining=%s sample_usernames=%s error=%s diag=%s",
account_dir.name,
str(db_path),
stage,
stage_username,
stage_table,
len(remaining),
sorted([u for u in remaining if u])[:5],
str(e),
format_sqlite_diagnostics(
collect_sqlite_diagnostics(db_path, quick_check=True, table_name=(stage_table or None))
),
)
continue
finally:
conn.close()
if conn is not None:
try:
conn.close()
except Exception:
pass
previews = {u: v[1] for u, v in best.items() if v and v[1]}
if _DEBUG_SESSIONS:
@@ -123,9 +123,62 @@ class ChatRealtimeAutoSyncService:
self._mu = threading.Lock()
self._states: dict[str, _AccountState] = {}
self._paused_accounts: dict[str, int] = {}
self._stop = threading.Event()
self._thread: Optional[threading.Thread] = None
def _is_account_paused_locked(self, account: str) -> bool:
key = str(account or "").strip()
if not key:
return False
return int(self._paused_accounts.get(key) or 0) > 0
def is_account_paused(self, account: str) -> bool:
with self._mu:
return self._is_account_paused_locked(account)
def pause_account(self, account: str, reason: str = "") -> int:
key = str(account or "").strip()
if not key:
return 0
with self._mu:
depth = int(self._paused_accounts.get(key) or 0) + 1
self._paused_accounts[key] = depth
st = self._states.get(key)
if st is not None:
st.due_at = 0.0
logger.info(
"[realtime-autosync] pause account=%s reason=%s depth=%s",
key,
str(reason or "").strip() or "-",
int(depth),
)
return depth
def resume_account(self, account: str, reason: str = "") -> int:
key = str(account or "").strip()
if not key:
return 0
with self._mu:
current = int(self._paused_accounts.get(key) or 0)
if current <= 1:
self._paused_accounts.pop(key, None)
depth = 0
else:
depth = current - 1
self._paused_accounts[key] = depth
logger.info(
"[realtime-autosync] resume account=%s reason=%s depth=%s",
key,
str(reason or "").strip() or "-",
int(depth),
)
return depth
def start(self) -> None:
if not self._enabled:
logger.info("[realtime-autosync] disabled by env WECHAT_TOOL_REALTIME_AUTOSYNC=0")
@@ -188,6 +241,12 @@ class ChatRealtimeAutoSyncService:
if self._stop.is_set():
break
if self.is_account_paused(acc):
with self._mu:
st = self._states.setdefault(acc, _AccountState())
st.due_at = 0.0
continue
try:
account_dir = _resolve_account_dir(acc)
except HTTPException:
@@ -238,6 +297,9 @@ class ChatRealtimeAutoSyncService:
for acc, st in self._states.items():
if running >= int(self._workers):
break
if self._is_account_paused_locked(acc):
st.due_at = 0.0
continue
if st.due_at <= 0 or st.due_at > now:
continue
if st.thread is not None and st.thread.is_alive():
@@ -278,6 +340,9 @@ class ChatRealtimeAutoSyncService:
try:
if self._stop.is_set() or (not account):
return
if self.is_account_paused(account):
logger.info("[realtime-autosync] sync skipped account=%s reason=paused", account)
return
res = self._sync_account(account)
inserted = int((res or {}).get("inserted_total") or (res or {}).get("insertedTotal") or 0)
synced = int((res or {}).get("synced") or (res or {}).get("sessionsSynced") or 0)
@@ -297,6 +362,8 @@ class ChatRealtimeAutoSyncService:
account = str(account or "").strip()
if not account:
return {"status": "skipped", "reason": "missing account"}
if self.is_account_paused(account):
return {"status": "skipped", "reason": "paused"}
try:
account_dir = _resolve_account_dir(account)
+167 -10
View File
@@ -77,6 +77,7 @@ from ..session_last_message import (
get_session_last_message_status,
load_session_last_messages,
)
from ..sqlite_diagnostics import collect_sqlite_diagnostics, format_sqlite_diagnostics
from ..wcdb_realtime import (
WCDBRealtimeError,
WCDB_REALTIME,
@@ -2022,12 +2023,18 @@ def _sync_chat_realtime_messages_for_table(
if backfill_limit > max_scan:
backfill_limit = max_scan
msg_conn = sqlite3.connect(str(msg_db_path))
msg_conn.row_factory = sqlite3.Row
msg_conn: Optional[sqlite3.Connection] = None
stage = "connect"
try:
stage = "connect"
msg_conn = sqlite3.connect(str(msg_db_path))
msg_conn.row_factory = sqlite3.Row
stage = "resolve_db_storage_paths"
msg_db_path_real, _res_db_path_real = _resolve_db_storage_message_paths(account_dir, msg_db_path.stem)
name2id_synced = False
try:
stage = "sync_name2id"
name2id_result = _sync_output_name2id_from_live(
msg_conn,
rt_conn=rt_conn,
@@ -2050,12 +2057,14 @@ def _sync_chat_realtime_messages_for_table(
)
quoted_table = _quote_ident(table_name)
stage = "max_local_id"
row = msg_conn.execute(f"SELECT MAX(local_id) AS mx FROM {quoted_table}").fetchone()
try:
max_local_id = int((row["mx"] if row is not None else 0) or 0)
except Exception:
max_local_id = 0
stage = "pragma_table_info"
cols = msg_conn.execute(f"PRAGMA table_info({quoted_table})").fetchall()
available_cols = {str(c[1] or "") for c in cols}
base_cols = [
@@ -2075,6 +2084,7 @@ def _sync_chat_realtime_messages_for_table(
placeholders = ",".join(["?"] * len(insert_cols))
insert_sql = f"INSERT OR IGNORE INTO {quoted_table} ({','.join(insert_cols)}) VALUES ({placeholders})"
stage = "collect_realtime_rows"
fetch_result = _collect_realtime_rows_for_session(
trace_id=None,
account_name=account_dir.name,
@@ -2094,6 +2104,7 @@ def _sync_chat_realtime_messages_for_table(
backfilled = 0
if new_rows:
if not name2id_synced:
stage = "upsert_name2id_fallback"
_best_effort_upsert_output_name2id_rows(
msg_conn,
account_name=account_dir.name,
@@ -2102,6 +2113,7 @@ def _sync_chat_realtime_messages_for_table(
values = [tuple(r.get(c) for c in insert_cols) for r in reversed(new_rows)]
insert_t0 = time.perf_counter()
stage = "insert_new_rows"
msg_conn.executemany(insert_sql, values)
msg_conn.commit()
insert_ms = (time.perf_counter() - insert_t0) * 1000.0
@@ -2131,6 +2143,7 @@ def _sync_chat_realtime_messages_for_table(
if update_values:
before_changes = msg_conn.total_changes
update_t0 = time.perf_counter()
stage = "backfill_packed_info"
msg_conn.executemany(
f"UPDATE {quoted_table} SET packed_info_data = ? WHERE local_id = ? AND (packed_info_data IS NULL OR length(packed_info_data) = 0)",
update_values,
@@ -2187,8 +2200,11 @@ def _sync_chat_realtime_messages_for_table(
if inserted and newest_ts:
session_db_path = account_dir / "session.db"
sconn = sqlite3.connect(str(session_db_path))
sconn: Optional[sqlite3.Connection] = None
try:
stage = "open_session_db"
sconn = sqlite3.connect(str(session_db_path))
stage = "update_session_table"
sconn.execute("INSERT OR IGNORE INTO SessionTable(username) VALUES (?)", (username,))
sconn.execute(
"""
@@ -2217,6 +2233,7 @@ def _sync_chat_realtime_messages_for_table(
),
)
stage = "update_session_last_message"
_ensure_session_last_message_table(sconn)
sconn.execute(
"""
@@ -2239,8 +2256,25 @@ def _sync_chat_realtime_messages_for_table(
),
)
sconn.commit()
except sqlite3.DatabaseError as e:
logger.warning(
"[realtime] malformed session db during sync account=%s username=%s session_db=%s stage=%s error=%s diag=%s",
account_dir.name,
username,
str(session_db_path),
stage,
str(e),
format_sqlite_diagnostics(
collect_sqlite_diagnostics(session_db_path, quick_check=True, table_name="SessionTable")
),
)
raise HTTPException(
status_code=500,
detail=f"Malformed session db during realtime sync: {session_db_path.name}",
)
finally:
sconn.close()
if sconn is not None:
sconn.close()
return {
"username": username,
@@ -2250,8 +2284,23 @@ def _sync_chat_realtime_messages_for_table(
"backfilled": int(backfilled),
"preview": preview or "",
}
except sqlite3.DatabaseError as e:
logger.warning(
"[realtime] malformed decrypted message db account=%s username=%s db=%s table=%s stage=%s error=%s diag=%s",
account_dir.name,
username,
str(msg_db_path),
table_name,
stage,
str(e),
format_sqlite_diagnostics(
collect_sqlite_diagnostics(msg_db_path, quick_check=True, table_name=table_name)
),
)
raise HTTPException(status_code=500, detail=f"Malformed decrypted message db: {msg_db_path.name}")
finally:
msg_conn.close()
if msg_conn is not None:
msg_conn.close()
@router.post("/api/chat/realtime/sync_all", summary="实时消息同步到解密库(全会话增量)")
@@ -2545,20 +2594,24 @@ def sync_chat_realtime_messages_all(
except HTTPException as e:
errors.append(f"{uname}: {str(e.detail or '')}".strip())
logger.warning(
"[%s] sync session failed account=%s username=%s err=%s",
"[%s] sync session failed account=%s username=%s db=%s table=%s err=%s",
trace_id,
account_dir.name,
uname,
str(msg_db_path),
str(table_name),
str(e.detail or "").strip(),
)
continue
except Exception as e:
errors.append(f"{uname}: {str(e)}".strip())
logger.exception(
"[%s] sync session crashed account=%s username=%s",
"[%s] sync session crashed account=%s username=%s db=%s table=%s",
trace_id,
account_dir.name,
uname,
str(msg_db_path),
str(table_name),
)
continue
@@ -4173,6 +4226,15 @@ def list_chat_sessions(
)
last_previews = load_session_last_messages(account_dir, usernames)
except Exception:
logger.exception(
"[sessions.list] session_last_message preview load failed account=%s preview_mode=%s usernames=%s diag=%s",
account_dir.name,
preview_mode,
len(usernames),
format_sqlite_diagnostics(
collect_sqlite_diagnostics(account_dir / "session.db", quick_check=True, table_name="session_last_message")
),
)
last_previews = {}
def _is_generic_location_preview(value: Any) -> bool:
@@ -4189,7 +4251,17 @@ def list_chat_sessions(
else [u for u in usernames if u and ((u not in last_previews) or _is_generic_location_preview(last_previews.get(u)))]
)
if targets:
legacy = _load_latest_message_previews(account_dir, targets)
try:
legacy = _load_latest_message_previews(account_dir, targets)
except Exception:
logger.exception(
"[sessions.list] legacy latest-message preview fallback failed account=%s preview_mode=%s targets=%s sample_targets=%s; falling back to session summaries",
account_dir.name,
preview_mode,
len(targets),
[str(u) for u in targets[:5]],
)
legacy = {}
for u, v in legacy.items():
if v:
last_previews[u] = v
@@ -6118,6 +6190,24 @@ async def _search_chat_messages_via_fts(
sender = None
session_type_norm = _normalize_session_type(session_type)
trace_id = f"msg-search-{int(time.time() * 1000)}-{threading.get_ident()}"
logger.info(
"[%s] chat search start account=%s scope=%s username=%s sender=%s q_len=%s token_count=%s limit=%s offset=%s start_time=%s end_time=%s render_types=%s include_hidden=%s include_official=%s",
trace_id,
str(account or "").strip(),
"conversation" if username else "global",
str(username or "").strip(),
str(sender or "").strip(),
len(str(q or "")),
len(tokens),
int(limit),
int(offset),
"" if start_ts is None else int(start_ts),
"" if end_ts is None else int(end_ts),
str(render_types or "").strip(),
bool(include_hidden),
bool(include_official),
)
account_dir = _resolve_account_dir(account)
contact_db_path = account_dir / "contact.db"
@@ -6142,6 +6232,14 @@ async def _search_chat_messages_via_fts(
index_ready = bool(index.get("ready"))
if build_status == "error":
logger.warning(
"[%s] chat search index_error account=%s scope=%s username=%s message=%s",
trace_id,
account_dir.name,
"conversation" if username else "global",
str(username or "").strip(),
str(build.get("error") or "Search index build failed."),
)
return {
"status": "index_error",
"account": account_dir.name,
@@ -6160,6 +6258,14 @@ async def _search_chat_messages_via_fts(
}
if not index_ready:
logger.info(
"[%s] chat search index_building account=%s scope=%s username=%s build_status=%s",
trace_id,
account_dir.name,
"conversation" if username else "global",
str(username or "").strip(),
build_status,
)
return {
"status": "index_building",
"account": account_dir.name,
@@ -6243,7 +6349,13 @@ async def _search_chat_messages_via_fts(
params + [int(limit), int(offset)],
).fetchall()
except Exception as e:
logger.exception("Chat search index query failed")
logger.exception(
"[%s] chat search index query failed account=%s scope=%s username=%s",
trace_id,
account_dir.name,
"conversation" if username else "global",
str(username or "").strip(),
)
return {
"status": "index_error",
"account": account_dir.name,
@@ -6551,7 +6663,7 @@ async def _search_chat_messages_via_fts(
wcdb_display_names=wcdb_display_names,
)
return {
response = {
"status": "success",
"account": account_dir.name,
"scope": scope,
@@ -6566,6 +6678,19 @@ async def _search_chat_messages_via_fts(
"index": index,
"hits": hits,
}
logger.info(
"[%s] chat search done account=%s scope=%s username=%s sender=%s total=%s hits=%s has_more=%s rows=%s",
trace_id,
account_dir.name,
scope,
str(username or "").strip(),
str(sender or "").strip(),
int(total),
len(hits),
bool(response["hasMore"]),
len(rows),
)
return response
@router.get("/api/chat/search", summary="搜索聊天记录(消息)")
@@ -6999,13 +7124,26 @@ async def get_chat_messages_around(
if after > 200:
after = 200
trace_id = f"msg-around-{int(time.time() * 1000)}-{threading.get_ident()}"
logger.info(
"[%s] chat messages around start account=%s username=%s anchor_id=%s before=%s after=%s",
trace_id,
str(account or "").strip(),
str(username or "").strip(),
str(anchor_id or "").strip(),
int(before),
int(after),
)
parts = str(anchor_id).split(":", 2)
if len(parts) != 3:
logger.warning("[%s] chat messages around invalid anchor format anchor_id=%s", trace_id, str(anchor_id or "").strip())
raise HTTPException(status_code=400, detail="Invalid anchor_id.")
anchor_db_stem, anchor_table_name_in, anchor_local_id_str = parts
try:
anchor_local_id = int(anchor_local_id_str)
except Exception:
logger.warning("[%s] chat messages around invalid anchor local_id anchor_id=%s", trace_id, str(anchor_id or "").strip())
raise HTTPException(status_code=400, detail="Invalid anchor_id.")
account_dir = _resolve_account_dir(account)
@@ -7021,6 +7159,13 @@ async def get_chat_messages_around(
anchor_db_path = p
break
if anchor_db_path is None:
logger.warning(
"[%s] chat messages around anchor db missing account=%s username=%s anchor_db=%s",
trace_id,
account_dir.name,
username,
anchor_db_stem,
)
raise HTTPException(status_code=404, detail="Anchor database not found.")
# Open resource DB once (optional), and reuse for all message DBs.
@@ -7419,6 +7564,18 @@ async def get_chat_messages_around(
head_image_db_path=head_image_db_path,
)
logger.info(
"[%s] chat messages around done account=%s username=%s anchor_id=%s canonical_anchor=%s anchor_index=%s returned=%s merged_total=%s",
trace_id,
account_dir.name,
username,
str(anchor_id or "").strip(),
anchor_id_canon,
int(anchor_index),
len(return_messages),
len(merged),
)
return {
"status": "success",
"account": account_dir.name,
+120 -13
View File
@@ -67,6 +67,87 @@ logger = get_logger(__name__)
router = APIRouter(route_class=PathFixRoute)
def _build_uncached_media_response(data: bytes, media_type: str) -> Response:
resp = Response(content=data, media_type=media_type)
resp.headers["Cache-Control"] = "no-store"
return resp
def _image_candidate_variant_rank(path: Path) -> int:
stem = str(path.stem or "").lower()
if stem.endswith(("_b", ".b")):
return 0
if stem.endswith(("_h", ".h")):
return 1
if stem.endswith(("_c", ".c")):
return 3
if stem.endswith(("_t", ".t")):
return 4
return 2
def _image_candidate_stat(path: Optional[Path]) -> tuple[int, float]:
if not path:
return 0, 0.0
try:
st = path.stat()
return int(st.st_size), float(st.st_mtime)
except Exception:
return 0, 0.0
def _should_prefer_live_image_candidates(
*,
cached_path: Optional[Path],
live_candidates: list[Path],
) -> bool:
if not live_candidates:
return False
if not cached_path:
return True
best_live = live_candidates[0]
live_rank = _image_candidate_variant_rank(best_live)
if live_rank < 2:
return True
cache_size, cache_mtime = _image_candidate_stat(cached_path)
live_size, live_mtime = _image_candidate_stat(best_live)
if live_rank == 2 and live_size > cache_size:
return True
if live_rank == 2 and live_size >= cache_size and live_mtime > cache_mtime:
return True
return False
def _write_cached_chat_image(account_dir: Path, md5: str, data: bytes) -> None:
md5_norm = str(md5 or "").strip().lower()
if (not md5_norm) or (not data):
return
ext = _detect_image_extension(data)
out_path = _get_decrypted_resource_path(account_dir, md5_norm, ext)
out_path.parent.mkdir(parents=True, exist_ok=True)
for stale_ext in ("jpg", "png", "gif", "webp", "dat"):
stale_path = _get_decrypted_resource_path(account_dir, md5_norm, stale_ext)
if stale_path == out_path:
continue
try:
if stale_path.exists():
stale_path.unlink()
except Exception:
pass
try:
if out_path.exists() and out_path.read_bytes() == data:
return
except Exception:
pass
out_path.write_bytes(data)
def _resolve_avatar_remote_url(*, account_dir: Path, username: str) -> str:
u = str(username or "").strip()
if not u:
@@ -1311,20 +1392,26 @@ async def get_chat_image(
if md5_from_msg:
md5 = md5_from_msg
# md5 模式:优先从解密资源目录读取(更快)
cached_path: Optional[Path] = None
cached_data = b""
cached_media_type = "application/octet-stream"
# md5 模式:优先检查解密资源目录;如果微信目录里已经有更高质量版本,会在后面自动升级。
if md5:
decrypted_path = _try_find_decrypted_resource(account_dir, str(md5).lower())
if decrypted_path:
data = decrypted_path.read_bytes()
media_type = _detect_image_media_type(data[:32])
if media_type != "application/octet-stream" and _is_probably_valid_image(data, media_type):
return Response(content=data, media_type=media_type)
cached_path = decrypted_path
cached_data = data
cached_media_type = media_type
# Corrupted cached file (e.g. wrong ext / partial data): remove and regenerate from source.
try:
if decrypted_path.suffix.lower() in {".jpg", ".jpeg", ".png", ".gif", ".webp"}:
elif decrypted_path.suffix.lower() in {".jpg", ".jpeg", ".png", ".gif", ".webp"}:
try:
decrypted_path.unlink()
except Exception:
pass
except Exception:
pass
# 回退:从微信数据目录实时定位并解密
wxid_dir = _resolve_account_wxid_dir(account_dir)
@@ -1414,11 +1501,36 @@ async def get_chat_image(
break
if not p:
if cached_path:
return _build_uncached_media_response(cached_data, cached_media_type)
raise HTTPException(status_code=404, detail="Image not found.")
candidates.extend(_iter_media_source_candidates(p))
candidates = _order_media_candidates(candidates)
if cached_path:
try:
cached_key = str(cached_path.resolve())
except Exception:
cached_key = str(cached_path)
live_candidates: list[Path] = []
seen_live: set[str] = set()
for candidate in candidates:
try:
key = str(candidate.resolve())
except Exception:
key = str(candidate)
if key == cached_key or key in seen_live:
continue
seen_live.add(key)
live_candidates.append(candidate)
if _should_prefer_live_image_candidates(cached_path=cached_path, live_candidates=live_candidates):
candidates = [*live_candidates, cached_path]
else:
candidates = [cached_path, *live_candidates]
logger.info(f"chat_image: md5={md5} file_id={file_id} candidates={len(candidates)} first={p}")
data = b""
@@ -1443,19 +1555,14 @@ async def get_chat_image(
# 仅在 md5 有效时缓存到 resource 目录;file_id 可能非常长,避免写入超长文件名
if md5 and media_type.startswith("image/"):
try:
out_md5 = str(md5).lower()
ext = _detect_image_extension(data)
out_path = _get_decrypted_resource_path(account_dir, out_md5, ext)
out_path.parent.mkdir(parents=True, exist_ok=True)
if not out_path.exists():
out_path.write_bytes(data)
_write_cached_chat_image(account_dir, str(md5), data)
except Exception:
pass
logger.info(
f"chat_image: md5={md5} file_id={file_id} chosen={chosen} media_type={media_type} bytes={len(data)}"
)
return Response(content=data, media_type=media_type)
return _build_uncached_media_response(data, media_type)
@router.get("/api/chat/media/emoji", summary="获取表情消息资源")
+319 -159
View File
@@ -5,12 +5,14 @@ import json
import os
import time
from pathlib import Path
from typing import Any
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from starlette.responses import StreamingResponse
from ..app_paths import get_output_databases_dir
from ..chat_realtime_autosync import CHAT_REALTIME_AUTOSYNC
from ..logging_config import get_logger
from ..path_fix import PathFixRoute
from ..key_store import upsert_account_keys_in_store
@@ -21,6 +23,96 @@ logger = get_logger(__name__)
router = APIRouter(route_class=PathFixRoute)
def _normalize_decrypt_guard_accounts(accounts: Any) -> list[str]:
if not accounts:
return []
out: list[str] = []
seen: set[str] = set()
for account in accounts:
key = str(account or "").strip()
if (not key) or (key in seen):
continue
seen.add(key)
out.append(key)
out.sort()
return out
def _resolve_decrypt_guard_accounts(db_storage_path: str) -> list[str]:
try:
scan_result = scan_account_databases_from_path(db_storage_path)
except Exception:
logger.exception("[decrypt] pre-scan accounts failed db_storage_path=%s", db_storage_path)
return []
if scan_result.get("status") == "error":
return []
return _normalize_decrypt_guard_accounts((scan_result.get("account_databases") or {}).keys())
def _get_realtime_sync_all_lock(account: str):
from .chat import _realtime_sync_all_lock
return _realtime_sync_all_lock(account)
def _release_decrypt_account_guards(guards: list[tuple[str, Any]], *, reason: str) -> None:
for account, lock in reversed(list(guards or [])):
try:
lock.release()
logger.info("[decrypt] released realtime sync_all lock account=%s reason=%s", account, reason)
except Exception:
logger.exception("[decrypt] release realtime sync_all lock failed account=%s reason=%s", account, reason)
try:
CHAT_REALTIME_AUTOSYNC.resume_account(account, reason=reason)
logger.info("[decrypt] resumed realtime autosync for account after decrypt account=%s reason=%s", account, reason)
except Exception:
logger.exception(
"[decrypt] resume realtime autosync failed account=%s reason=%s",
account,
reason,
)
def _acquire_decrypt_account_guards(accounts: Any, *, reason: str) -> list[tuple[str, Any]]:
guards: list[tuple[str, Any]] = []
for account in _normalize_decrypt_guard_accounts(accounts):
paused = False
try:
CHAT_REALTIME_AUTOSYNC.pause_account(account, reason=reason)
paused = True
logger.info("[decrypt] paused realtime autosync for account during decrypt account=%s reason=%s", account, reason)
lock = _get_realtime_sync_all_lock(account)
logger.info("[decrypt] waiting realtime sync_all lock account=%s reason=%s", account, reason)
lock.acquire()
logger.info("[decrypt] acquired realtime sync_all lock account=%s reason=%s", account, reason)
guards.append((account, lock))
except Exception:
if paused:
try:
CHAT_REALTIME_AUTOSYNC.resume_account(account, reason=f"{reason}:acquire_failed")
logger.info(
"[decrypt] resumed realtime autosync after guard acquire failure account=%s reason=%s",
account,
f"{reason}:acquire_failed",
)
except Exception:
logger.exception(
"[decrypt] resume realtime autosync after guard acquire failure failed account=%s reason=%s",
account,
reason,
)
_release_decrypt_account_guards(guards, reason=reason)
raise
return guards
class DecryptRequest(BaseModel):
"""解密请求模型"""
@@ -48,17 +140,28 @@ async def decrypt_databases(request: DecryptRequest):
logger.warning(f"密钥格式无效: 长度={len(request.key) if request.key else 0}")
raise HTTPException(status_code=400, detail="密钥格式无效,必须是64位十六进制字符串")
# 使用新的解密API
results = decrypt_wechat_databases(
db_storage_path=request.db_storage_path,
key=request.key,
)
guard_accounts = _resolve_decrypt_guard_accounts(request.db_storage_path)
guards = _acquire_decrypt_account_guards(guard_accounts, reason="decrypt:post")
try:
# 使用新的解密API
results = decrypt_wechat_databases(
db_storage_path=request.db_storage_path,
key=request.key,
)
finally:
_release_decrypt_account_guards(guards, reason="decrypt:post")
if results["status"] == "error":
logger.error(f"解密失败: {results['message']}")
raise HTTPException(status_code=400, detail=results["message"])
logger.info(f"解密完成: 成功 {results['successful_count']}/{results['total_databases']} 个数据库")
if int(results.get("diagnostic_warning_count") or 0) > 0:
logger.warning(
"解密完成但检测到诊断告警: warning_dbs=%s total=%s",
int(results.get("diagnostic_warning_count") or 0),
int(results.get("total_databases") or 0),
)
# 成功解密后,按账号保存数据库密钥(用于前端自动回填)
try:
@@ -77,6 +180,7 @@ async def decrypt_databases(request: DecryptRequest):
"processed_files": results["processed_files"],
"failed_files": results["failed_files"],
"account_results": results.get("account_results", {}),
"diagnostic_warning_count": int(results.get("diagnostic_warning_count") or 0),
}
except HTTPException:
@@ -141,105 +245,146 @@ async def decrypt_databases_stream(
account_sources = scan_result.get("account_sources", {})
total_databases = sum(len(dbs) for dbs in account_databases.values())
yield _sse({"type": "start", "total": total_databases, "message": f"开始解密 {total_databases} 个数据库"})
await asyncio.sleep(0)
# 3) Init output dir & decryptor.
base_output_dir = get_output_databases_dir()
base_output_dir.mkdir(parents=True, exist_ok=True)
decrypt_guards: list[tuple[str, Any]] = []
try:
decryptor = WeChatDatabaseDecryptor(k)
except ValueError as e:
yield _sse({"type": "error", "message": f"密钥错误: {e}"})
return
# 4) Decrypt per account, stream progress.
success_count = 0
fail_count = 0
processed_files: list[str] = []
failed_files: list[str] = []
account_results: dict = {}
overall_current = 0
for account, dbs in account_databases.items():
account_output_dir = base_output_dir / account
account_output_dir.mkdir(parents=True, exist_ok=True)
# Save a hint for later UI (same as non-stream endpoint).
try:
source_info = account_sources.get(account, {})
source_db_storage_path = str(source_info.get("db_storage_path") or p)
wxid_dir = str(source_info.get("wxid_dir") or "")
(account_output_dir / "_source.json").write_text(
json.dumps({"db_storage_path": source_db_storage_path, "wxid_dir": wxid_dir}, ensure_ascii=False, indent=2),
encoding="utf-8",
)
except Exception:
pass
account_success = 0
account_processed: list[str] = []
account_failed: list[str] = []
for db_info in dbs:
if await request.is_disconnected():
return
overall_current += 1
db_path = str(db_info.get("path") or "")
db_name = str(db_info.get("name") or "")
current_file = f"{account}/{db_name}" if account else db_name
# Emit a "processing" event so UI updates immediately for large db files.
guard_accounts = _normalize_decrypt_guard_accounts(account_databases.keys())
if guard_accounts:
yield _sse(
{
"type": "progress",
"current": overall_current,
"total": total_databases,
"success_count": success_count,
"fail_count": fail_count,
"current_file": current_file,
"status": "processing",
"message": "解密中...",
"type": "phase",
"phase": "decrypt_guard",
"message": "正在暂停实时同步并等待解密写锁...",
}
)
await asyncio.sleep(0)
decrypt_guards = await asyncio.to_thread(
_acquire_decrypt_account_guards,
guard_accounts,
reason="decrypt:sse",
)
output_path = account_output_dir / db_name
task = asyncio.create_task(asyncio.to_thread(decryptor.decrypt_database, db_path, str(output_path)))
yield _sse({"type": "start", "total": total_databases, "message": f"开始解密 {total_databases} 个数据库"})
await asyncio.sleep(0)
# Wait with heartbeat (can't yield while awaiting the thread directly).
last_heartbeat = time.time()
while not task.done():
# 3) Init output dir & decryptor.
base_output_dir = get_output_databases_dir()
base_output_dir.mkdir(parents=True, exist_ok=True)
try:
decryptor = WeChatDatabaseDecryptor(k)
except ValueError as e:
yield _sse({"type": "error", "message": f"密钥错误: {e}"})
return
# 4) Decrypt per account, stream progress.
success_count = 0
fail_count = 0
processed_files: list[str] = []
failed_files: list[str] = []
account_results: dict = {}
diagnostic_warning_count = 0
overall_current = 0
for account, dbs in account_databases.items():
account_output_dir = base_output_dir / account
account_output_dir.mkdir(parents=True, exist_ok=True)
# Save a hint for later UI (same as non-stream endpoint).
try:
source_info = account_sources.get(account, {})
source_db_storage_path = str(source_info.get("db_storage_path") or p)
wxid_dir = str(source_info.get("wxid_dir") or "")
(account_output_dir / "_source.json").write_text(
json.dumps(
{"db_storage_path": source_db_storage_path, "wxid_dir": wxid_dir},
ensure_ascii=False,
indent=2,
),
encoding="utf-8",
)
except Exception:
pass
account_success = 0
account_processed: list[str] = []
account_failed: list[str] = []
account_db_diagnostics: dict[str, dict] = {}
account_diagnostic_warning_count = 0
for db_info in dbs:
if await request.is_disconnected():
return
now = time.time()
if now - last_heartbeat > 15:
last_heartbeat = now
# SSE comment heartbeat; browsers ignore but keeps proxies alive.
yield ": ping\n\n"
await asyncio.sleep(0.6)
try:
ok = bool(task.result())
except Exception:
ok = False
if ok:
account_success += 1
success_count += 1
account_processed.append(str(output_path))
processed_files.append(str(output_path))
status = "success"
msg = "解密成功"
else:
account_failed.append(db_path)
failed_files.append(db_path)
fail_count += 1
status = "fail"
msg = "解密失败"
overall_current += 1
db_path = str(db_info.get("path") or "")
db_name = str(db_info.get("name") or "")
current_file = f"{account}/{db_name}" if account else db_name
yield _sse(
{
# Emit a "processing" event so UI updates immediately for large db files.
yield _sse(
{
"type": "progress",
"current": overall_current,
"total": total_databases,
"success_count": success_count,
"fail_count": fail_count,
"current_file": current_file,
"status": "processing",
"message": "解密中...",
}
)
output_path = account_output_dir / db_name
task = asyncio.create_task(asyncio.to_thread(decryptor.decrypt_database, db_path, str(output_path)))
# Wait with heartbeat (can't yield while awaiting the thread directly).
last_heartbeat = time.time()
while not task.done():
if await request.is_disconnected():
return
now = time.time()
if now - last_heartbeat > 15:
last_heartbeat = now
# SSE comment heartbeat; browsers ignore but keeps proxies alive.
yield ": ping\n\n"
await asyncio.sleep(0.6)
try:
ok = bool(task.result())
except Exception:
ok = False
db_diagnostic = dict(getattr(decryptor, "last_result", {}) or {})
if not db_diagnostic:
db_diagnostic = {
"db_path": str(db_path),
"db_name": str(db_name),
"output_path": str(output_path),
"success": bool(ok),
}
db_diagnostic["account"] = str(account)
account_db_diagnostics[db_name] = db_diagnostic
if (
(not bool(db_diagnostic.get("success", ok)))
or int(db_diagnostic.get("failed_pages") or 0) > 0
or str(db_diagnostic.get("diagnostic_status") or "") != "ok"
):
account_diagnostic_warning_count += 1
if ok:
account_success += 1
success_count += 1
account_processed.append(str(output_path))
processed_files.append(str(output_path))
status = "success"
msg = "解密成功"
else:
account_failed.append(db_path)
failed_files.append(db_path)
fail_count += 1
status = "fail"
msg = "解密失败"
payload = {
"type": "progress",
"current": overall_current,
"total": total_databases,
@@ -249,78 +394,93 @@ async def decrypt_databases_stream(
"status": status,
"message": msg,
}
)
if db_diagnostic:
payload["diagnostic_status"] = str(db_diagnostic.get("diagnostic_status") or "")
payload["page_failures"] = int(db_diagnostic.get("failed_pages") or 0)
if db_diagnostic.get("failed_page_samples"):
payload["failed_page_samples"] = db_diagnostic.get("failed_page_samples")
if db_diagnostic.get("diagnostics"):
payload["diagnostics"] = db_diagnostic.get("diagnostics")
if overall_current % 5 == 0:
yield _sse(payload)
if overall_current % 5 == 0:
await asyncio.sleep(0)
account_results[account] = {
"total": len(dbs),
"success": account_success,
"failed": len(dbs) - account_success,
"output_dir": str(account_output_dir),
"processed_files": account_processed,
"failed_files": account_failed,
"db_diagnostics": account_db_diagnostics,
"diagnostic_warning_count": int(account_diagnostic_warning_count),
}
diagnostic_warning_count += int(account_diagnostic_warning_count)
# Build cache table (keep behavior consistent with the POST endpoint).
if os.environ.get("WECHAT_TOOL_BUILD_SESSION_LAST_MESSAGE", "1") != "0":
yield _sse(
{
"type": "phase",
"phase": "session_last_message",
"account": account,
"message": "正在构建会话缓存(最后一条消息)...",
}
)
await asyncio.sleep(0)
account_results[account] = {
"total": len(dbs),
"success": account_success,
"failed": len(dbs) - account_success,
"output_dir": str(account_output_dir),
"processed_files": account_processed,
"failed_files": account_failed,
try:
from ..session_last_message import build_session_last_message_table
task = asyncio.create_task(
asyncio.to_thread(
build_session_last_message_table,
account_output_dir,
rebuild=True,
include_hidden=True,
include_official=True,
)
)
last_heartbeat = time.time()
while not task.done():
if await request.is_disconnected():
return
now = time.time()
if now - last_heartbeat > 15:
last_heartbeat = now
yield ": ping\n\n"
await asyncio.sleep(0.6)
account_results[account]["session_last_message"] = task.result()
except Exception as e:
account_results[account]["session_last_message"] = {"status": "error", "message": str(e)}
status = "completed" if success_count > 0 else "failed"
result = {
"status": status,
"total_databases": total_databases,
"success_count": success_count,
"failure_count": total_databases - success_count,
"output_directory": str(base_output_dir.absolute()),
"message": f"解密完成: 成功 {success_count}/{total_databases}",
"processed_files": processed_files,
"failed_files": failed_files,
"account_results": account_results,
"diagnostic_warning_count": int(diagnostic_warning_count),
}
# Build cache table (keep behavior consistent with the POST endpoint).
if os.environ.get("WECHAT_TOOL_BUILD_SESSION_LAST_MESSAGE", "1") != "0":
yield _sse(
{
"type": "phase",
"phase": "session_last_message",
"account": account,
"message": "正在构建会话缓存(最后一条消息)...",
}
)
await asyncio.sleep(0)
# Save db key for frontend autofill.
try:
for account in (account_results or {}).keys():
upsert_account_keys_in_store(str(account), db_key=k)
except Exception:
pass
try:
from ..session_last_message import build_session_last_message_table
task = asyncio.create_task(
asyncio.to_thread(
build_session_last_message_table,
account_output_dir,
rebuild=True,
include_hidden=True,
include_official=True,
)
)
last_heartbeat = time.time()
while not task.done():
if await request.is_disconnected():
return
now = time.time()
if now - last_heartbeat > 15:
last_heartbeat = now
yield ": ping\n\n"
await asyncio.sleep(0.6)
account_results[account]["session_last_message"] = task.result()
except Exception as e:
account_results[account]["session_last_message"] = {"status": "error", "message": str(e)}
status = "completed" if success_count > 0 else "failed"
result = {
"status": status,
"total_databases": total_databases,
"success_count": success_count,
"failure_count": total_databases - success_count,
"output_directory": str(base_output_dir.absolute()),
"message": f"解密完成: 成功 {success_count}/{total_databases}",
"processed_files": processed_files,
"failed_files": failed_files,
"account_results": account_results,
}
# Save db key for frontend autofill.
try:
for account in (account_results or {}).keys():
upsert_account_keys_in_store(str(account), db_key=k)
except Exception:
pass
yield _sse({"type": "complete", **result})
yield _sse({"type": "complete", **result})
finally:
if decrypt_guards:
await asyncio.to_thread(_release_decrypt_account_guards, decrypt_guards, reason="decrypt:sse")
headers = {"Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no"}
return StreamingResponse(generate_progress(), media_type="text/event-stream", headers=headers)
@@ -0,0 +1,150 @@
from __future__ import annotations
import json
import sqlite3
from pathlib import Path
from typing import Any, Mapping, Optional
SQLITE_HEADER = b"SQLite format 3\x00"
def _clean_text(value: Any, *, limit: int = 240) -> str:
text = " ".join(str(value or "").split()).strip()
if len(text) > limit:
return text[: limit - 3] + "..."
return text
def _clean_error(exc: BaseException, *, limit: int = 240) -> str:
text = _clean_text(exc, limit=limit)
if text:
return f"{type(exc).__name__}: {text}"
return type(exc).__name__
def _quote_ident(name: str) -> str:
return '"' + str(name or "").replace('"', '""') + '"'
def collect_sqlite_diagnostics(
path: str | Path,
*,
quick_check: bool = True,
table_name: Optional[str] = None,
table_sample_limit: int = 5,
) -> dict[str, Any]:
db_path = Path(path)
diagnostics: dict[str, Any] = {
"path": str(db_path),
"exists": bool(db_path.exists()),
}
if not diagnostics["exists"]:
return diagnostics
try:
diagnostics["size"] = int(db_path.stat().st_size)
except Exception as exc:
diagnostics["size_error"] = _clean_error(exc)
try:
with db_path.open("rb") as f:
header = f.read(len(SQLITE_HEADER))
diagnostics["header_ok"] = header == SQLITE_HEADER
diagnostics["header_hex"] = header.hex()
except Exception as exc:
diagnostics["header_error"] = _clean_error(exc)
if not quick_check:
return diagnostics
conn: sqlite3.Connection | None = None
try:
conn = sqlite3.connect(str(db_path))
try:
row = conn.execute("PRAGMA page_size").fetchone()
diagnostics["page_size"] = int((row[0] if row is not None else 0) or 0)
except Exception as exc:
diagnostics["page_size_error"] = _clean_error(exc)
try:
row = conn.execute("PRAGMA page_count").fetchone()
diagnostics["page_count"] = int((row[0] if row is not None else 0) or 0)
except Exception as exc:
diagnostics["page_count_error"] = _clean_error(exc)
try:
rows = conn.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name").fetchall()
table_names = [str(row[0]) for row in rows if row and row[0]]
diagnostics["table_count"] = len(table_names)
if table_names:
diagnostics["tables_sample"] = table_names[: max(int(table_sample_limit or 0), 1)]
except Exception as exc:
diagnostics["table_list_error"] = _clean_error(exc)
if table_name:
diagnostics["target_table"] = str(table_name)
try:
cols = conn.execute(f"PRAGMA table_info({_quote_ident(table_name)})").fetchall()
diagnostics["target_table_exists"] = bool(cols)
if cols:
diagnostics["target_table_columns"] = [
str(col[1])
for col in cols[:8]
if len(col) > 1 and str(col[1] or "").strip()
]
except Exception as exc:
diagnostics["target_table_error"] = _clean_error(exc)
try:
rows = conn.execute("PRAGMA quick_check").fetchall()
values = [_clean_text(row[0]) for row in rows if row and row[0] is not None]
if values:
diagnostics["quick_check"] = values[0] if len(values) == 1 else values[:5]
diagnostics["quick_check_ok"] = len(values) == 1 and values[0].lower() == "ok"
if len(values) > 5:
diagnostics["quick_check_truncated"] = len(values) - 5
else:
diagnostics["quick_check"] = ""
diagnostics["quick_check_ok"] = None
except Exception as exc:
diagnostics["quick_check_error"] = _clean_error(exc)
diagnostics["quick_check_ok"] = False
except Exception as exc:
diagnostics["connect_error"] = _clean_error(exc)
finally:
if conn is not None:
try:
conn.close()
except Exception:
pass
return diagnostics
def sqlite_diagnostics_status(diagnostics: Mapping[str, Any]) -> str:
if not diagnostics:
return "not_run"
if not diagnostics.get("exists", False):
return "missing"
if diagnostics.get("header_ok") is False:
return "bad_header"
if diagnostics.get("connect_error"):
return "connect_error"
if diagnostics.get("quick_check_error"):
return "quick_check_error"
if diagnostics.get("quick_check_ok") is False:
return "quick_check_failed"
if diagnostics.get("quick_check_ok") is True:
return "ok"
return "header_only"
def format_sqlite_diagnostics(diagnostics: Mapping[str, Any]) -> str:
compact: dict[str, Any] = {}
for key, value in diagnostics.items():
if value in (None, "", [], {}):
continue
compact[str(key)] = value
return json.dumps(compact, ensure_ascii=False, sort_keys=True)
+123 -6
View File
@@ -21,6 +21,7 @@ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from .app_paths import get_output_databases_dir
from .sqlite_diagnostics import collect_sqlite_diagnostics, sqlite_diagnostics_status
# 注意:不再支持默认密钥,所有密钥必须通过参数传入
@@ -221,6 +222,7 @@ class WeChatDatabaseDecryptor:
self.key_bytes = bytes.fromhex(key_hex)
except ValueError:
raise ValueError("密钥必须是有效的十六进制字符串")
self.last_result: dict = {}
def decrypt_database(self, db_path: str, output_path: str) -> bool:
"""解密微信4.x版本数据库
@@ -234,6 +236,79 @@ class WeChatDatabaseDecryptor:
from .logging_config import get_logger
logger = get_logger(__name__)
result = {
"db_path": str(db_path),
"db_name": Path(str(db_path)).name,
"output_path": str(output_path),
"success": False,
"copied_as_sqlite": False,
"input_size": 0,
"output_size": 0,
"total_pages": 0,
"successful_pages": 0,
"failed_pages": 0,
"failed_page_samples": [],
"failure_reasons": {},
"diagnostics": {},
"diagnostic_status": "not_run",
"error": "",
}
self.last_result = result
def _append_failed_page(page_num: int, reason: str, error: str = "") -> None:
result["failure_reasons"][reason] = int(result["failure_reasons"].get(reason) or 0) + 1
if len(result["failed_page_samples"]) >= 8:
return
item = {"page": int(page_num), "reason": str(reason)}
err = " ".join(str(error or "").split()).strip()
if err:
item["error"] = err[:200]
result["failed_page_samples"].append(item)
def _finalize(success: bool, error: str = "") -> bool:
result["success"] = bool(success)
if error:
result["error"] = " ".join(str(error).split()).strip()
output_file = Path(str(output_path))
if output_file.exists():
try:
result["output_size"] = int(output_file.stat().st_size)
except Exception:
pass
diagnostics = collect_sqlite_diagnostics(output_file, quick_check=True)
result["diagnostics"] = diagnostics
result["diagnostic_status"] = sqlite_diagnostics_status(diagnostics)
payload = {
"db_name": result["db_name"],
"db_path": result["db_path"],
"output_path": result["output_path"],
"success": result["success"],
"copied_as_sqlite": result["copied_as_sqlite"],
"input_size": result["input_size"],
"output_size": result["output_size"],
"total_pages": result["total_pages"],
"successful_pages": result["successful_pages"],
"failed_pages": result["failed_pages"],
"failure_reasons": result["failure_reasons"],
"failed_page_samples": result["failed_page_samples"],
"diagnostic_status": result["diagnostic_status"],
"diagnostics": result["diagnostics"],
"error": result["error"],
}
log_fn = logger.info
if (
(not result["success"])
or int(result["failed_pages"] or 0) > 0
or str(result["diagnostic_status"] or "") != "ok"
):
log_fn = logger.warning
log_fn("[decrypt.diagnostic] %s", json.dumps(payload, ensure_ascii=False, sort_keys=True))
self.last_result = result
return bool(success)
logger.info(f"开始解密数据库: {db_path}")
try:
@@ -241,17 +316,19 @@ class WeChatDatabaseDecryptor:
encrypted_data = f.read()
logger.info(f"读取文件大小: {len(encrypted_data)} bytes")
result["input_size"] = int(len(encrypted_data))
if len(encrypted_data) < 4096:
logger.warning(f"文件太小,跳过解密: {db_path}")
return False
return _finalize(False, "file_too_small")
# 检查是否已经是解密的数据库
if encrypted_data.startswith(SQLITE_HEADER):
logger.info(f"文件已是SQLite格式,直接复制: {db_path}")
with open(output_path, 'wb') as f:
f.write(encrypted_data)
return True
result["copied_as_sqlite"] = True
return _finalize(True)
# 提取salt (前16字节)
salt = encrypted_data[:16]
@@ -295,6 +372,7 @@ class WeChatDatabaseDecryptor:
total_pages = len(encrypted_data) // page_size
successful_pages = 0
failed_pages = 0
result["total_pages"] = int(total_pages)
# 逐页解密
for cur_page in range(total_pages):
@@ -329,6 +407,7 @@ class WeChatDatabaseDecryptor:
if stored_hmac != expected_hmac:
logger.warning(f"页面 {page_num} HMAC验证失败")
failed_pages += 1
_append_failed_page(page_num, "hmac")
continue
# 提取IV和加密数据用于AES解密
@@ -354,20 +433,32 @@ class WeChatDatabaseDecryptor:
except Exception as e:
logger.error(f"页面 {page_num} AES解密失败: {e}")
failed_pages += 1
_append_failed_page(page_num, "aes", str(e))
continue
logger.info(f"解密完成: 成功 {successful_pages} 页, 失败 {failed_pages}")
result["successful_pages"] = int(successful_pages)
result["failed_pages"] = int(failed_pages)
# 写入解密后的文件
with open(output_path, 'wb') as f:
f.write(decrypted_data)
logger.info(f"解密文件大小: {len(decrypted_data)} bytes")
return True
if failed_pages > 0:
logger.warning(
"解密输出包含页失败: db=%s total_pages=%s failed_pages=%s failure_reasons=%s samples=%s",
result["db_name"],
int(total_pages),
int(failed_pages),
json.dumps(result["failure_reasons"], ensure_ascii=False, sort_keys=True),
json.dumps(result["failed_page_samples"], ensure_ascii=False),
)
return _finalize(True)
except Exception as e:
logger.error(f"解密失败: {db_path}, 错误: {e}")
return False
return _finalize(False, str(e))
def decrypt_wechat_databases(db_storage_path: str = None, key: str = None) -> dict:
"""
@@ -493,6 +584,7 @@ def decrypt_wechat_databases(db_storage_path: str = None, key: str = None) -> di
processed_files = []
failed_files = []
account_results = {}
diagnostic_warning_count = 0
for account_name, databases in account_databases.items():
logger.info(f"开始解密账号 {account_name}{len(databases)} 个数据库")
@@ -523,6 +615,8 @@ def decrypt_wechat_databases(db_storage_path: str = None, key: str = None) -> di
account_success = 0
account_processed = []
account_failed = []
account_db_diagnostics = {}
account_diagnostic_warning_count = 0
for db_info in databases:
db_path = db_info['path']
@@ -533,7 +627,26 @@ def decrypt_wechat_databases(db_storage_path: str = None, key: str = None) -> di
# 解密数据库
logger.info(f"解密 {account_name}/{db_name}")
if decryptor.decrypt_database(db_path, str(output_path)):
ok = decryptor.decrypt_database(db_path, str(output_path))
db_diagnostic = dict(getattr(decryptor, "last_result", {}) or {})
if not db_diagnostic:
db_diagnostic = {
"db_path": str(db_path),
"db_name": str(db_name),
"output_path": str(output_path),
"success": bool(ok),
}
db_diagnostic["account"] = str(account_name)
account_db_diagnostics[db_name] = db_diagnostic
if (
(not bool(db_diagnostic.get("success", ok)))
or int(db_diagnostic.get("failed_pages") or 0) > 0
or str(db_diagnostic.get("diagnostic_status") or "") != "ok"
):
account_diagnostic_warning_count += 1
if ok:
account_success += 1
success_count += 1
account_processed.append(str(output_path))
@@ -551,8 +664,11 @@ def decrypt_wechat_databases(db_storage_path: str = None, key: str = None) -> di
"failed": len(databases) - account_success,
"output_dir": str(account_output_dir),
"processed_files": account_processed,
"failed_files": account_failed
"failed_files": account_failed,
"db_diagnostics": account_db_diagnostics,
"diagnostic_warning_count": int(account_diagnostic_warning_count),
}
diagnostic_warning_count += int(account_diagnostic_warning_count)
# 构建“会话最后一条消息”缓存表:把耗时挪到解密阶段,后续会话列表直接查表
if os.environ.get("WECHAT_TOOL_BUILD_SESSION_LAST_MESSAGE", "1") != "0":
@@ -586,6 +702,7 @@ def decrypt_wechat_databases(db_storage_path: str = None, key: str = None) -> di
"failed_files": failed_files,
"account_results": account_results, # 新增:按账号的详细结果
"detected_accounts": detected_accounts,
"diagnostic_warning_count": int(diagnostic_warning_count),
}
logger.info("=" * 60)
@@ -0,0 +1,210 @@
import hashlib
import importlib
import json
import logging
import os
import sqlite3
import sys
import unittest
from pathlib import Path
from tempfile import TemporaryDirectory
from fastapi import FastAPI
from fastapi.testclient import TestClient
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
class TestChatMediaImageCacheUpgrade(unittest.TestCase):
def _seed_contact_db(self, path: Path, *, account: str, username: str) -> None:
conn = sqlite3.connect(str(path))
try:
conn.execute(
"""
CREATE TABLE contact (
username TEXT,
remark TEXT,
nick_name TEXT,
alias TEXT,
local_type INTEGER,
verify_flag INTEGER,
big_head_url TEXT,
small_head_url TEXT
)
"""
)
conn.execute(
"""
CREATE TABLE stranger (
username TEXT,
remark TEXT,
nick_name TEXT,
alias TEXT,
local_type INTEGER,
verify_flag INTEGER,
big_head_url TEXT,
small_head_url TEXT
)
"""
)
conn.execute(
"INSERT INTO contact VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(account, "", "", "", 1, 0, "", ""),
)
conn.execute(
"INSERT INTO contact VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(username, "", "测试好友", "", 1, 0, "", ""),
)
conn.commit()
finally:
conn.close()
def _seed_session_db(self, path: Path, *, username: str) -> None:
conn = sqlite3.connect(str(path))
try:
conn.execute(
"""
CREATE TABLE SessionTable (
username TEXT,
is_hidden INTEGER,
sort_timestamp INTEGER
)
"""
)
conn.execute("INSERT INTO SessionTable VALUES (?, ?, ?)", (username, 0, 1735689600))
conn.commit()
finally:
conn.close()
def _seed_source_info(self, account_dir: Path, *, wxid_dir: Path) -> None:
payload = {
"wxid_dir": str(wxid_dir),
"db_storage_path": "",
}
(account_dir / "_source.json").write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
def _seed_cached_resource(self, account_dir: Path, *, md5: str, payload: bytes) -> Path:
resource_dir = account_dir / "resource" / md5[:2]
resource_dir.mkdir(parents=True, exist_ok=True)
target = resource_dir / f"{md5}.jpg"
target.write_bytes(payload)
return target
def _seed_live_variant(self, wxid_dir: Path, *, username: str, md5: str, suffix: str, payload: bytes) -> Path:
chat_hash = hashlib.md5(username.encode("utf-8")).hexdigest()
target = wxid_dir / "msg" / "attach" / chat_hash / "2026-03" / "Img" / f"{md5}{suffix}.dat"
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(payload)
return target
def _build_client(self):
import wechat_decrypt_tool.logging_config as logging_config
import wechat_decrypt_tool.app_paths as app_paths
import wechat_decrypt_tool.media_helpers as media_helpers
import wechat_decrypt_tool.routers.chat_media as chat_media
logging.shutdown()
importlib.reload(logging_config)
importlib.reload(app_paths)
importlib.reload(media_helpers)
importlib.reload(chat_media)
app = FastAPI()
app.include_router(chat_media.router)
return TestClient(app)
def test_live_high_variant_replaces_stale_cached_thumb(self):
with TemporaryDirectory() as td:
root = Path(td)
account = "wxid_test"
username = "wxid_friend"
md5 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
account_dir = root / "output" / "databases" / account
wxid_dir = root / "wxid_source"
account_dir.mkdir(parents=True, exist_ok=True)
wxid_dir.mkdir(parents=True, exist_ok=True)
self._seed_contact_db(account_dir / "contact.db", account=account, username=username)
self._seed_session_db(account_dir / "session.db", username=username)
self._seed_source_info(account_dir, wxid_dir=wxid_dir)
cached_thumb = b"\xff\xd8\xff\xd9"
live_original = b"\xff\xd8\xff\xe0" + (b"\x00" * 48) + b"\xff\xd9"
cache_path = self._seed_cached_resource(account_dir, md5=md5, payload=cached_thumb)
self._seed_live_variant(wxid_dir, username=username, md5=md5, suffix="_h", payload=live_original)
prev_data = os.environ.get("WECHAT_TOOL_DATA_DIR")
client = None
try:
os.environ["WECHAT_TOOL_DATA_DIR"] = str(root)
client = self._build_client()
resp = client.get(
"/api/chat/media/image",
params={"account": account, "md5": md5, "username": username},
)
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.content, live_original)
self.assertEqual(resp.headers.get("cache-control"), "no-store")
self.assertEqual(cache_path.read_bytes(), live_original)
finally:
try:
client.close()
except Exception:
pass
logging.shutdown()
if prev_data is None:
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
else:
os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data
def test_cached_original_is_not_downgraded_by_live_thumb(self):
with TemporaryDirectory() as td:
root = Path(td)
account = "wxid_test"
username = "wxid_friend"
md5 = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
account_dir = root / "output" / "databases" / account
wxid_dir = root / "wxid_source"
account_dir.mkdir(parents=True, exist_ok=True)
wxid_dir.mkdir(parents=True, exist_ok=True)
self._seed_contact_db(account_dir / "contact.db", account=account, username=username)
self._seed_session_db(account_dir / "session.db", username=username)
self._seed_source_info(account_dir, wxid_dir=wxid_dir)
cached_original = b"\xff\xd8\xff\xe0" + (b"\x11" * 64) + b"\xff\xd9"
live_thumb = b"\xff\xd8\xff\xd9"
cache_path = self._seed_cached_resource(account_dir, md5=md5, payload=cached_original)
self._seed_live_variant(wxid_dir, username=username, md5=md5, suffix="_t", payload=live_thumb)
prev_data = os.environ.get("WECHAT_TOOL_DATA_DIR")
client = None
try:
os.environ["WECHAT_TOOL_DATA_DIR"] = str(root)
client = self._build_client()
resp = client.get(
"/api/chat/media/image",
params={"account": account, "md5": md5, "username": username},
)
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.content, cached_original)
self.assertEqual(resp.headers.get("cache-control"), "no-store")
self.assertEqual(cache_path.read_bytes(), cached_original)
finally:
try:
client.close()
except Exception:
pass
logging.shutdown()
if prev_data is None:
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
else:
os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data
if __name__ == "__main__":
unittest.main()
+93
View File
@@ -0,0 +1,93 @@
import importlib
import logging
import os
import sys
import unittest
from pathlib import Path
from tempfile import TemporaryDirectory
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
def _close_logging_handlers() -> None:
for logger_name in ("", "uvicorn", "uvicorn.access", "uvicorn.error", "fastapi"):
lg = logging.getLogger(logger_name)
for handler in lg.handlers[:]:
try:
handler.close()
except Exception:
pass
try:
lg.removeHandler(handler)
except Exception:
pass
class TestOutputDirOverride(unittest.TestCase):
def setUp(self) -> None:
self._prev_data_dir = os.environ.get("WECHAT_TOOL_DATA_DIR")
self._prev_output_dir = os.environ.get("WECHAT_TOOL_OUTPUT_DIR")
self._data_dir = TemporaryDirectory()
self._output_dir = TemporaryDirectory()
os.environ["WECHAT_TOOL_DATA_DIR"] = self._data_dir.name
os.environ["WECHAT_TOOL_OUTPUT_DIR"] = self._output_dir.name
import wechat_decrypt_tool.app_paths as app_paths
import wechat_decrypt_tool.key_store as key_store
import wechat_decrypt_tool.logging_config as logging_config
import wechat_decrypt_tool.runtime_settings as runtime_settings
importlib.reload(app_paths)
importlib.reload(logging_config)
importlib.reload(runtime_settings)
importlib.reload(key_store)
self.app_paths = app_paths
self.key_store = key_store
self.logging_config = logging_config
self.runtime_settings = runtime_settings
def tearDown(self) -> None:
_close_logging_handlers()
if self._prev_data_dir is None:
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
else:
os.environ["WECHAT_TOOL_DATA_DIR"] = self._prev_data_dir
if self._prev_output_dir is None:
os.environ.pop("WECHAT_TOOL_OUTPUT_DIR", None)
else:
os.environ["WECHAT_TOOL_OUTPUT_DIR"] = self._prev_output_dir
self._data_dir.cleanup()
self._output_dir.cleanup()
def test_app_paths_prefers_output_dir_override(self) -> None:
self.assertEqual(self.app_paths.get_output_dir(), Path(self._output_dir.name))
self.assertEqual(
self.app_paths.get_output_databases_dir(),
Path(self._output_dir.name) / "databases",
)
def test_logging_runtime_settings_and_key_store_use_output_override(self) -> None:
log_file = self.logging_config.setup_logging()
self.assertTrue(log_file.is_relative_to(Path(self._output_dir.name) / "logs"))
self.runtime_settings.write_backend_port_setting(12001)
runtime_settings_path = Path(self._output_dir.name) / "runtime_settings.json"
self.assertTrue(runtime_settings_path.exists())
self.assertEqual(self.runtime_settings.read_backend_port_setting(), 12001)
self.key_store.upsert_account_keys_in_store("wxid_test", db_key="abc123")
key_store_path = Path(self._output_dir.name) / "account_keys.json"
self.assertTrue(key_store_path.exists())
self.assertEqual(
self.key_store.get_account_keys_from_store("wxid_test").get("db_key"),
"abc123",
)
if __name__ == "__main__":
unittest.main()