mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-06-18 15:54:08 +08:00
Compare commits
5 Commits
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 || "")),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))}` : ''
|
||||
])
|
||||
})()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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="获取表情消息资源")
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user