Compare commits

..

12 Commits

23 changed files with 2828 additions and 327 deletions
+90 -11
View File
@@ -1,6 +1,22 @@
; This file is included for both installer and uninstaller builds.
; Guard installer-only pages/functions to avoid "function not referenced" warnings
; when electron-builder compiles the standalone uninstaller.
!define /ifndef WDA_DEFAULT_SETTINGS_PATH "$APPDATA\${APP_FILENAME}\desktop-settings.json"
!define /ifndef WDA_DEFAULT_OUTPUT_DIR "$APPDATA\${APP_FILENAME}\output"
!ifdef APP_PRODUCT_FILENAME
!define /ifndef WDA_PRODUCT_SETTINGS_PATH "$APPDATA\${APP_PRODUCT_FILENAME}\desktop-settings.json"
!define /ifndef WDA_PRODUCT_OUTPUT_DIR "$APPDATA\${APP_PRODUCT_FILENAME}\output"
!else
!define /ifndef WDA_PRODUCT_SETTINGS_PATH ""
!define /ifndef WDA_PRODUCT_OUTPUT_DIR ""
!endif
!ifdef APP_PACKAGE_NAME
!define /ifndef WDA_PACKAGE_SETTINGS_PATH "$APPDATA\${APP_PACKAGE_NAME}\desktop-settings.json"
!define /ifndef WDA_PACKAGE_OUTPUT_DIR "$APPDATA\${APP_PACKAGE_NAME}\output"
!else
!define /ifndef WDA_PACKAGE_SETTINGS_PATH ""
!define /ifndef WDA_PACKAGE_OUTPUT_DIR ""
!endif
!ifndef BUILD_UNINSTALLER
!include nsDialogs.nsh
!include LogicLib.nsh
@@ -13,6 +29,10 @@
!define /ifndef MUI_DIRECTORYPAGE_TEXT_DESTINATION "安装位置:"
Var WDA_InstallDirPage
Var WDA_OutputDirPage
Var WDA_OutputDirInput
Var WDA_OutputDirBrowseButton
Var WDA_SelectedOutputDir
!macro customInit
; Safety: older versions created an `output` junction inside the install directory that points to the
@@ -22,17 +42,10 @@ Var WDA_InstallDirPage
!macroend
!macro customInstall
; Provide a safe, non-junction way for users to locate the real per-user output directory.
; The actual data is NOT stored inside $INSTDIR (it is wiped on update/reinstall).
; `open-output.cmd` uses %APPDATA% so it works for the current user.
FileOpen $0 "$INSTDIR\output-location.txt" w
FileWrite $0 "WeChatDataAnalysis output folder (per user):$\r$\n%APPDATA%\\${APP_PACKAGE_NAME}\\output$\r$\n"
FileClose $0
FileOpen $1 "$INSTDIR\open-output.cmd" w
; NSIS escaping: use $\" to output a literal quote character into the .cmd file.
FileWrite $1 "@echo off$\r$\nexplorer $\"%APPDATA%\\${APP_PACKAGE_NAME}\\output$\"$\r$\n"
FileClose $1
${If} $WDA_SelectedOutputDir == ""
Call WDA_InitOutputDirSelection
${EndIf}
Call WDA_WritePendingOutputDirSetting
!macroend
Function WDA_RemoveLegacyOutputLink
@@ -47,9 +60,27 @@ FunctionEnd
; the final install location (includes the app sub-folder).
!ifdef allowToChangeInstallationDirectory
Page custom WDA_InstallDirPageCreate WDA_InstallDirPageLeave
Page custom WDA_OutputDirPageCreate WDA_OutputDirPageLeave
!endif
!macroend
Function WDA_InitOutputDirSelection
StrCpy $WDA_SelectedOutputDir "${WDA_DEFAULT_OUTPUT_DIR}"
nsExec::ExecToStack '"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -Command "& { param([string] $$defaultSettingsPath, [string] $$defaultOutputPath, [string] $$legacySettingsPath1, [string] $$legacySettingsPath2) $$candidates = @($$defaultSettingsPath, $$legacySettingsPath1, $$legacySettingsPath2) | Where-Object { -not [string]::IsNullOrWhiteSpace($$_) } | Select-Object -Unique; $$settingsPath = $$defaultSettingsPath; foreach ($$candidate in $$candidates) { if (Test-Path -LiteralPath $$candidate) { $$settingsPath = $$candidate; break } }; $$result = $$defaultOutputPath; if (Test-Path -LiteralPath $$settingsPath) { try { $$json = Get-Content -LiteralPath $$settingsPath -Raw | ConvertFrom-Json; $$value = [string] $$json.pendingOutputDir; if ([string]::IsNullOrWhiteSpace($$value)) { $$value = [string] $$json.outputDir }; if ($$value -eq '''') { $$result = $$defaultOutputPath } elseif (-not [string]::IsNullOrWhiteSpace($$value)) { $$result = $$value } } catch {} }; [Console]::OutputEncoding = [System.Text.Encoding]::UTF8; [Console]::Write($$result) }" "${WDA_DEFAULT_SETTINGS_PATH}" "${WDA_DEFAULT_OUTPUT_DIR}" "${WDA_PRODUCT_SETTINGS_PATH}" "${WDA_PACKAGE_SETTINGS_PATH}"'
Pop $0
Pop $1
${If} $0 == "0"
${AndIf} $1 != ""
StrCpy $WDA_SelectedOutputDir "$1"
${EndIf}
FunctionEnd
Function WDA_WritePendingOutputDirSetting
nsExec::ExecToStack '"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -Command "& { param([string] $$defaultSettingsPath, [string] $$defaultOutputPath, [string] $$selectedOutputPath, [string] $$legacySettingsPath1, [string] $$legacySettingsPath2) $$candidates = @($$defaultSettingsPath, $$legacySettingsPath1, $$legacySettingsPath2) | Where-Object { -not [string]::IsNullOrWhiteSpace($$_) } | Select-Object -Unique; $$sourceSettingsPath = $$defaultSettingsPath; foreach ($$candidate in $$candidates) { if (Test-Path -LiteralPath $$candidate) { $$sourceSettingsPath = $$candidate; break } }; if ([string]::IsNullOrWhiteSpace($$selectedOutputPath)) { $$selectedOutputPath = $$defaultOutputPath }; $$pending = if ([string]::Equals($$selectedOutputPath, $$defaultOutputPath, [System.StringComparison]::OrdinalIgnoreCase)) { '''' } else { $$selectedOutputPath }; $$obj = @{}; if (Test-Path -LiteralPath $$sourceSettingsPath) { try { $$existing = Get-Content -LiteralPath $$sourceSettingsPath -Raw | ConvertFrom-Json; if ($$null -ne $$existing) { $$existing.PSObject.Properties | ForEach-Object { $$obj[$$_.Name] = $$_.Value } } } catch {} }; $$obj[''pendingOutputDir''] = $$pending; $$dir = Split-Path -Parent $$defaultSettingsPath; New-Item -ItemType Directory -Force -Path $$dir | Out-Null; $$json = [PSCustomObject] $$obj | ConvertTo-Json -Depth 10; Set-Content -LiteralPath $$defaultSettingsPath -Value $$json -Encoding UTF8 }" "${WDA_DEFAULT_SETTINGS_PATH}" "${WDA_DEFAULT_OUTPUT_DIR}" "$WDA_SelectedOutputDir" "${WDA_PRODUCT_SETTINGS_PATH}" "${WDA_PACKAGE_SETTINGS_PATH}"'
Pop $0
Pop $1
FunctionEnd
Function WDA_EnsureAppSubDir
; Normalize $INSTDIR to always end with "\${APP_FILENAME}" (avoid cluttering a parent folder).
StrCpy $0 "$INSTDIR"
@@ -105,6 +136,48 @@ FunctionEnd
Function WDA_InstallDirPageLeave
FunctionEnd
Function WDA_OutputDirBrowse
nsDialogs::SelectFolderDialog "选择 output 目录" "$WDA_SelectedOutputDir"
Pop $0
${If} $0 != error
StrCpy $WDA_SelectedOutputDir "$0"
${NSD_SetText} $WDA_OutputDirInput "$0"
${EndIf}
FunctionEnd
Function WDA_OutputDirPageCreate
Call WDA_InitOutputDirSelection
nsDialogs::Create 1018
Pop $WDA_OutputDirPage
${If} $WDA_OutputDirPage == error
Abort
${EndIf}
${NSD_CreateLabel} 0u 0u 100% 24u "请选择 output 目录(保存解密数据库、导出内容、缓存、日志等)。"
Pop $0
${NSD_CreateText} 0u 28u 78% 12u "$WDA_SelectedOutputDir"
Pop $WDA_OutputDirInput
${NSD_CreateButton} 82% 27u 18% 14u "浏览..."
Pop $WDA_OutputDirBrowseButton
${NSD_OnClick} $WDA_OutputDirBrowseButton WDA_OutputDirBrowse
${NSD_CreateLabel} 0u 52u 100% 28u "安装器只记录你的选择;真正的数据迁移会在首次启动应用时执行。若目标目录已有内容,应用会阻止切换并提示处理。"
Pop $0
nsDialogs::Show
FunctionEnd
Function WDA_OutputDirPageLeave
${NSD_GetText} $WDA_OutputDirInput $WDA_SelectedOutputDir
${If} $WDA_SelectedOutputDir == ""
StrCpy $WDA_SelectedOutputDir "${WDA_DEFAULT_OUTPUT_DIR}"
${EndIf}
FunctionEnd
!endif
!ifdef BUILD_UNINSTALLER
@@ -177,6 +250,12 @@ FunctionEnd
RMDir /r "$APPDATA\${APP_PACKAGE_NAME}"
!endif
IfFileExists "$INSTDIR\output-location.path" 0 WDA_SkipCustomOutputDelete
nsExec::ExecToStack '"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -Command "& { param([string] $$pathFile, [string] $$defaultPath1, [string] $$defaultPath2, [string] $$defaultPath3) if (Test-Path -LiteralPath $$pathFile) { $$target = (Get-Content -LiteralPath $$pathFile -Raw).Trim(); $$defaults = @($$defaultPath1, $$defaultPath2, $$defaultPath3) | Where-Object { -not [string]::IsNullOrWhiteSpace($$_) }; $$isDefault = $$false; foreach ($$defaultPath in $$defaults) { if ([string]::Equals($$target, $$defaultPath, [System.StringComparison]::OrdinalIgnoreCase)) { $$isDefault = $$true; break } }; if (-not $$isDefault -and -not [string]::IsNullOrWhiteSpace($$target) -and (Test-Path -LiteralPath $$target)) { Remove-Item -LiteralPath $$target -Recurse -Force -ErrorAction SilentlyContinue } } }" "$INSTDIR\output-location.path" "${WDA_DEFAULT_OUTPUT_DIR}" "${WDA_PRODUCT_OUTPUT_DIR}" "${WDA_PACKAGE_OUTPUT_DIR}"'
Pop $0
Pop $1
WDA_SkipCustomOutputDelete:
${if} $installMode == "all"
SetShellVarContext all
${endif}
+369 -22
View File
@@ -21,6 +21,13 @@ const fs = require("fs");
const http = require("http");
const net = require("net");
const path = require("path");
const {
getDefaultOutputDirPath,
getEffectiveOutputDirPath,
migrateOutputDirectory,
normalizeDirectoryPath,
rollbackOutputDirectoryChange,
} = require("./output-dir.cjs");
const DEFAULT_BACKEND_HOST = String(process.env.WECHAT_TOOL_HOST || "127.0.0.1").trim() || "127.0.0.1";
const DEFAULT_BACKEND_PORT = parsePort(process.env.WECHAT_TOOL_PORT) ?? 10392;
@@ -32,6 +39,7 @@ let tray = null;
let isQuitting = false;
let desktopSettings = null;
let backendPortChangeInProgress = false;
let outputDirChangeInProgress = false;
const gotSingleInstanceLock = app.requestSingleInstanceLock();
if (!gotSingleInstanceLock) {
@@ -216,9 +224,76 @@ function resolveDataDir() {
}
function getUserDataDir() {
// Backwards-compat: we historically used Electron's userData directory for runtime storage.
// Keep this name but resolve to the effective data dir (can be overridden via env).
return resolveDataDir();
try {
const dir = app.getPath("userData");
if (!dir) return null;
fs.mkdirSync(dir, { recursive: true });
return dir;
} catch {
return null;
}
}
function safeNormalizeDirectory(value) {
try {
return normalizeDirectoryPath(value || "");
} catch {
return "";
}
}
function getDefaultOutputDir() {
const dataDir = resolveDataDir();
if (!dataDir) return null;
try {
return getDefaultOutputDirPath(dataDir);
} catch {
return null;
}
}
function syncOutputDirEnv(nextDir) {
const normalized = safeNormalizeDirectory(nextDir);
if (normalized) process.env.WECHAT_TOOL_OUTPUT_DIR = normalized;
else delete process.env.WECHAT_TOOL_OUTPUT_DIR;
}
function normalizePendingOutputDirValue(value) {
if (value == null) return null;
const text = String(value).trim();
if (!text) return "";
try {
return normalizeDirectoryPath(text);
} catch {
return null;
}
}
function resolveOutputDir() {
const dataDir = resolveDataDir();
if (!dataDir) return null;
const envOutputDir = safeNormalizeDirectory(process.env.WECHAT_TOOL_OUTPUT_DIR || "");
const settingsOutputDir = app.isPackaged ? safeNormalizeDirectory(loadDesktopSettings()?.outputDir || "") : "";
let chosen = null;
try {
chosen = getEffectiveOutputDirPath({
dataDir,
envOutputDir,
settingsOutputDir,
});
} catch {
chosen = getDefaultOutputDir();
}
if (!chosen) return null;
try {
fs.mkdirSync(chosen, { recursive: true });
} catch {}
syncOutputDirEnv(chosen);
return chosen;
}
function sanitizeAccountName(account) {
@@ -261,7 +336,8 @@ function resolveAccountDirInOutput(account) {
const dataDir = resolveDataDir();
if (!dataDir) throw new Error("无法定位数据目录");
const outputDir = path.join(dataDir, "output");
const outputDir = resolveOutputDir();
if (!outputDir) throw new Error("无法定位 output 目录");
const databasesDir = path.join(outputDir, "databases");
const accountName = sanitizeAccountName(account);
@@ -311,8 +387,8 @@ function getAccountInfoFromDisk(account) {
};
}
function removeAccountFromKeyStore(dataDir, accountName) {
const keyStorePath = path.join(dataDir, "output", "account_keys.json");
function removeAccountFromKeyStore(outputDir, accountName) {
const keyStorePath = path.join(outputDir, "account_keys.json");
try {
if (!fs.existsSync(keyStorePath)) return false;
const raw = fs.readFileSync(keyStorePath, { encoding: "utf8" });
@@ -328,7 +404,7 @@ function removeAccountFromKeyStore(dataDir, accountName) {
}
async function deleteAccountDataFromDisk(account) {
const { dataDir, outputDir, databasesDir, accountName, accountDir } = resolveAccountDirInOutput(account);
const { outputDir, databasesDir, accountName, accountDir } = resolveAccountDirInOutput(account);
if (!fs.existsSync(accountDir) || !fs.statSync(accountDir).isDirectory()) {
throw new Error("账号数据不存在");
}
@@ -348,7 +424,7 @@ async function deleteAccountDataFromDisk(account) {
} catch {}
fs.rmSync(accountDir, { recursive: true, force: true });
const removedKeyCache = removeAccountFromKeyStore(dataDir, accountName);
const removedKeyCache = removeAccountFromKeyStore(outputDir, accountName);
const accounts = listDecryptedAccountsOnDisk(databasesDir);
result = {
status: "success",
@@ -394,10 +470,8 @@ function ensureOutputLink() {
if (!app.isPackaged) return;
const exeDir = getExeDir();
const dataDir = resolveDataDir();
if (!exeDir || !dataDir) return;
const target = path.join(dataDir, "output");
const target = resolveOutputDir();
if (!exeDir || !target) return;
const legacyLinkPath = path.join(exeDir, "output");
// Ensure the real output dir exists.
@@ -443,6 +517,11 @@ function ensureOutputLink() {
fs.writeFileSync(p, text, { encoding: "utf8" });
} catch {}
try {
const p = path.join(exeDir, "output-location.path");
fs.writeFileSync(p, `${target}\n`, { encoding: "utf8" });
} catch {}
try {
const p = path.join(exeDir, "open-output.cmd");
const text = `@echo off\r\nexplorer \"${target}\"\r\n`;
@@ -510,6 +589,12 @@ function loadDesktopSettings() {
ignoredUpdateVersion: "",
// Backend (FastAPI) listens on this port. Used in packaged builds.
backendPort: DEFAULT_BACKEND_PORT,
// Custom output dir; empty string means use the default dataDir/output.
outputDir: "",
// Pending output dir written by the installer before the next app startup.
pendingOutputDir: null,
// Last startup/apply failure when changing output dir.
lastOutputDirError: "",
// Tracks the packaged UI build so we can invalidate Chromium's HTTP cache
// after upgrades without wiping user data/localStorage.
lastSeenUiBuildId: "",
@@ -530,6 +615,12 @@ function loadDesktopSettings() {
const parsed = JSON.parse(raw || "{}");
desktopSettings = { ...defaults, ...(parsed && typeof parsed === "object" ? parsed : {}) };
desktopSettings.backendPort = parsePort(desktopSettings.backendPort) ?? defaults.backendPort;
desktopSettings.outputDir = safeNormalizeDirectory(desktopSettings.outputDir || "");
desktopSettings.pendingOutputDir =
parsed && typeof parsed === "object" && Object.prototype.hasOwnProperty.call(parsed, "pendingOutputDir")
? normalizePendingOutputDirValue(parsed.pendingOutputDir)
: defaults.pendingOutputDir;
desktopSettings.lastOutputDirError = String(desktopSettings.lastOutputDirError || "").trim();
} catch (err) {
desktopSettings = { ...defaults };
logMain(`[main] failed to load settings: ${err?.message || err}`);
@@ -551,6 +642,82 @@ function persistDesktopSettings() {
}
}
function snapshotOutputDirSettings() {
loadDesktopSettings();
return {
outputDir: desktopSettings.outputDir,
pendingOutputDir: desktopSettings.pendingOutputDir,
lastOutputDirError: desktopSettings.lastOutputDirError,
};
}
function restoreOutputDirSettings(snapshot) {
loadDesktopSettings();
desktopSettings.outputDir = safeNormalizeDirectory(snapshot?.outputDir || "");
desktopSettings.pendingOutputDir = normalizePendingOutputDirValue(snapshot?.pendingOutputDir);
desktopSettings.lastOutputDirError = String(snapshot?.lastOutputDirError || "").trim();
const effectiveOutputDir = desktopSettings.outputDir || getDefaultOutputDir() || "";
syncOutputDirEnv(effectiveOutputDir);
persistDesktopSettings();
}
function setOutputDirSetting(nextDir) {
loadDesktopSettings();
const defaultDir = getDefaultOutputDir();
const normalized = safeNormalizeDirectory(nextDir || "");
if (!normalized || (defaultDir && normalized === defaultDir)) {
desktopSettings.outputDir = "";
} else {
desktopSettings.outputDir = normalized;
}
syncOutputDirEnv(desktopSettings.outputDir || defaultDir || "");
persistDesktopSettings();
return desktopSettings.outputDir;
}
function setPendingOutputDirSetting(nextDir) {
loadDesktopSettings();
desktopSettings.pendingOutputDir = normalizePendingOutputDirValue(nextDir);
persistDesktopSettings();
return desktopSettings.pendingOutputDir;
}
function clearPendingOutputDirSetting() {
loadDesktopSettings();
desktopSettings.pendingOutputDir = null;
persistDesktopSettings();
}
function setOutputDirLastError(message) {
loadDesktopSettings();
desktopSettings.lastOutputDirError = String(message || "").trim();
persistDesktopSettings();
return desktopSettings.lastOutputDirError;
}
function getOutputDirInfo() {
loadDesktopSettings();
const defaultPath = getDefaultOutputDir() || "";
const currentPath = resolveOutputDir() || defaultPath;
const hasPending = desktopSettings.pendingOutputDir !== null;
const pendingPath =
desktopSettings.pendingOutputDir === null
? ""
: desktopSettings.pendingOutputDir === ""
? defaultPath
: safeNormalizeDirectory(desktopSettings.pendingOutputDir);
return {
path: currentPath || "",
defaultPath,
isDefault: !!currentPath && !!defaultPath && currentPath === defaultPath,
pendingPath,
hasPending,
lastError: String(desktopSettings.lastOutputDirError || "").trim(),
canChange: !!app.isPackaged,
changeUnavailableReason: app.isPackaged ? "" : "开发模式不支持界面修改 output 目录",
};
}
function getCloseBehavior() {
const v = String(loadDesktopSettings()?.closeBehavior || "").trim().toLowerCase();
return v === "exit" ? "exit" : "tray";
@@ -576,6 +743,137 @@ function setIgnoredUpdateVersion(version) {
return desktopSettings.ignoredUpdateVersion;
}
async function applyOutputDirChange(nextValue) {
if (!app.isPackaged) {
throw new Error("开发模式不支持界面修改 output 目录");
}
const defaultPath = getDefaultOutputDir();
const currentPath = resolveOutputDir();
if (!defaultPath || !currentPath) {
throw new Error("无法定位 output 目录");
}
const rawText = String(nextValue ?? "").trim();
const nextPath = rawText ? normalizeDirectoryPath(rawText) : defaultPath;
const previousSettings = snapshotOutputDirSettings();
if (nextPath === currentPath) {
setOutputDirSetting(nextPath);
clearPendingOutputDirSetting();
setOutputDirLastError("");
ensureOutputLink();
const info = getOutputDirInfo();
return {
success: true,
changed: false,
path: info.path,
defaultPath: info.defaultPath,
isDefault: info.isDefault,
pendingPath: info.pendingPath,
backupPath: "",
sourceWasEmpty: false,
message: "output 目录未变化",
};
}
const wasBackendRunning = !!backendProc;
let migration = null;
let settingsSwitched = false;
try {
if (wasBackendRunning) {
await stopBackendAndWait({ timeoutMs: 10_000 });
}
migration = migrateOutputDirectory({
currentDir: currentPath,
nextDir: nextPath,
});
setOutputDirSetting(nextPath);
clearPendingOutputDirSetting();
setOutputDirLastError("");
settingsSwitched = true;
ensureOutputLink();
if (wasBackendRunning) {
startBackend();
await waitForBackend({ timeoutMs: 30_000 });
}
const info = getOutputDirInfo();
return {
success: true,
changed: true,
path: info.path,
defaultPath: info.defaultPath,
isDefault: info.isDefault,
pendingPath: info.pendingPath,
backupPath: migration?.backupDir || "",
sourceWasEmpty: !!migration?.sourceWasEmpty,
message: migration?.sourceWasEmpty ? "output 目录已切换" : "output 目录已迁移并切换",
};
} catch (err) {
const message = err?.message || String(err);
let rollbackMessage = "";
if (migration?.changed) {
try {
rollbackOutputDirectoryChange({
previousDir: currentPath,
currentDir: nextPath,
backupDir: migration.backupDir,
sourceWasEmpty: migration.sourceWasEmpty,
});
} catch (rollbackErr) {
logMain(`[main] output dir rollback failed: ${rollbackErr?.message || rollbackErr}`);
rollbackMessage = `;回滚失败:${rollbackErr?.message || rollbackErr}`;
if (migration?.backupDir) {
rollbackMessage += `;备份目录:${migration.backupDir}`;
}
}
}
if (settingsSwitched) {
restoreOutputDirSettings(previousSettings);
} else {
syncOutputDirEnv(currentPath);
}
ensureOutputLink();
if (wasBackendRunning) {
try {
startBackend();
await waitForBackend({ timeoutMs: 30_000 });
} catch (restartErr) {
throw new Error(
`切换 output 目录失败:${message}${rollbackMessage};且旧后端恢复失败:${restartErr?.message || restartErr}`
);
}
}
if (rollbackMessage) {
throw new Error(`切换 output 目录失败:${message}${rollbackMessage}`);
}
throw err;
}
}
async function applyPendingOutputDirOnStartup() {
if (!app.isPackaged) return;
loadDesktopSettings();
if (desktopSettings.pendingOutputDir === null) return;
try {
await applyOutputDirChange(desktopSettings.pendingOutputDir);
} catch (err) {
clearPendingOutputDirSetting();
setOutputDirLastError(`安装时设置的 output 目录未能应用:${err?.message || err}`);
ensureOutputLink();
logMain(`[main] failed to apply pending output dir: ${err?.message || err}`);
}
}
async function refreshRendererCacheForPackagedUi() {
if (!app.isPackaged) return;
@@ -1171,11 +1469,11 @@ function startBackend() {
}
if (app.isPackaged) {
if (!env.WECHAT_TOOL_DATA_DIR) {
env.WECHAT_TOOL_DATA_DIR = app.getPath("userData");
}
env.WECHAT_TOOL_DATA_DIR = resolveDataDir() || app.getPath("userData");
env.WECHAT_TOOL_OUTPUT_DIR = resolveOutputDir() || getDefaultOutputDir() || path.join(env.WECHAT_TOOL_DATA_DIR, "output");
try {
fs.mkdirSync(env.WECHAT_TOOL_DATA_DIR, { recursive: true });
fs.mkdirSync(env.WECHAT_TOOL_OUTPUT_DIR, { recursive: true });
} catch {}
const backendExe = getPackagedBackendPath();
@@ -1689,16 +1987,31 @@ function registerWindowIpc() {
}
});
ipcMain.handle("app:getOutputDirInfo", () => {
try {
return getOutputDirInfo();
} catch (err) {
logMain(`[main] app:getOutputDirInfo failed: ${err?.message || err}`);
return {
path: "",
defaultPath: "",
isDefault: true,
pendingPath: "",
hasPending: false,
lastError: err?.message || String(err),
canChange: !!app.isPackaged,
changeUnavailableReason: app.isPackaged ? "" : "开发模式不支持界面修改 output 目录",
};
}
});
ipcMain.handle("app:getOutputDir", () => {
const dir = resolveDataDir();
if (!dir) return "";
return path.join(dir, "output");
return resolveOutputDir() || "";
});
ipcMain.handle("app:openOutputDir", async () => {
const dir = resolveDataDir();
if (!dir) throw new Error("无法定位数据目录");
const outDir = path.join(dir, "output");
const outDir = resolveOutputDir();
if (!outDir) throw new Error("无法定位 output 目录");
try {
fs.mkdirSync(outDir, { recursive: true });
} catch {}
@@ -1713,6 +2026,28 @@ function registerWindowIpc() {
}
});
ipcMain.handle("app:setOutputDir", async (_event, nextDir) => {
if (outputDirChangeInProgress) {
return {
success: false,
error: "output 目录切换中,请稍后重试",
};
}
outputDirChangeInProgress = true;
try {
return await applyOutputDirChange(nextDir);
} catch (err) {
const message = err?.message || String(err);
logMain(`[main] app:setOutputDir failed: ${message}`);
return {
success: false,
error: message,
};
} finally {
outputDirChangeInProgress = false;
}
});
ipcMain.handle("app:getAccountInfo", async (_event, account) => {
try {
return getAccountInfoFromDisk(account);
@@ -1796,6 +2131,8 @@ async function main() {
// Resolve/create the data dir early so we can log reliably and place helper files
// next to the installed exe for easier access.
resolveDataDir();
loadDesktopSettings();
await applyPendingOutputDirOnStartup();
ensureOutputLink();
await ensureBackendPortAvailableOnStartup();
@@ -1876,10 +2213,20 @@ if (gotSingleInstanceLock) {
stopBackend();
try {
const dir = getUserDataDir();
const outputDir = resolveOutputDir();
if (dir) {
const detailLines = [
`启动失败:${err?.message || err}`,
"",
`桌面日志目录:${dir}`,
"文件:desktop-main.log / backend-stdio.log",
];
if (outputDir) {
detailLines.push("", `当前 output 目录:${outputDir}`, "其中 output\\logs\\... 也在这里");
}
dialog.showErrorBox(
"WeChatDataAnalysis 启动失败",
`启动失败:${err?.message || err}\n\n请查看日志目录:\n${dir}\n\n文件:desktop-main.log / backend-stdio.log / output\\\\logs\\\\...`
detailLines.join("\n")
);
shell.openPath(dir);
}
+252
View File
@@ -0,0 +1,252 @@
const fs = require("fs");
const path = require("path");
const SENTINEL_NAMES = [
"account_keys.json",
"runtime_settings.json",
"message_edits.db",
"databases",
"exports",
"logs",
];
function normalizeDirectoryPath(value) {
const text = String(value || "").trim();
if (!text) return "";
const expanded = text.replace(/^~(?=$|[\\/])/, process.env.USERPROFILE || process.env.HOME || "~");
if (!path.isAbsolute(expanded)) {
throw new Error("output 目录必须使用绝对路径");
}
return path.resolve(expanded);
}
function getDefaultOutputDirPath(dataDir) {
const base = normalizeDirectoryPath(dataDir);
if (!base) throw new Error("无法定位数据目录");
return path.join(base, "output");
}
function getEffectiveOutputDirPath({ dataDir, envOutputDir, settingsOutputDir }) {
const envPath = normalizeDirectoryPath(envOutputDir || "");
if (envPath) return envPath;
const settingsPath = normalizeDirectoryPath(settingsOutputDir || "");
if (settingsPath) return settingsPath;
return getDefaultOutputDirPath(dataDir);
}
function hasDirectoryContents(dirPath) {
try {
return fs.readdirSync(dirPath).length > 0;
} catch (err) {
if (err && err.code === "ENOENT") return false;
throw err;
}
}
function pathExists(dirPath) {
try {
fs.accessSync(dirPath);
return true;
} catch {
return false;
}
}
function isDirectory(dirPath) {
try {
return fs.statSync(dirPath).isDirectory();
} catch {
return false;
}
}
function isPathInside(parentPath, candidatePath) {
const parent = path.resolve(parentPath);
const candidate = path.resolve(candidatePath);
if (parent === candidate) return false;
const relative = path.relative(parent, candidate);
return !!relative && !relative.startsWith("..") && !path.isAbsolute(relative);
}
function collectSentinels(sourceDir) {
const sentinels = [];
for (const name of SENTINEL_NAMES) {
const sourcePath = path.join(sourceDir, name);
if (!pathExists(sourcePath)) continue;
sentinels.push({
name,
isDir: isDirectory(sourcePath),
size: !isDirectory(sourcePath) ? fs.statSync(sourcePath).size : null,
});
}
return sentinels;
}
function verifyCopiedOutputTree(sourceDir, copiedDir) {
const sentinels = collectSentinels(sourceDir);
for (const item of sentinels) {
const copiedPath = path.join(copiedDir, item.name);
if (!pathExists(copiedPath)) {
throw new Error(`迁移校验失败:缺少 ${item.name}`);
}
if (item.isDir) {
if (!isDirectory(copiedPath)) {
throw new Error(`迁移校验失败:${item.name} 不是目录`);
}
continue;
}
const copiedStat = fs.statSync(copiedPath);
if (copiedStat.size !== item.size) {
throw new Error(`迁移校验失败:${item.name} 大小不一致`);
}
}
}
function makeTimestamp(now = new Date()) {
const parts = [
now.getFullYear(),
String(now.getMonth() + 1).padStart(2, "0"),
String(now.getDate()).padStart(2, "0"),
String(now.getHours()).padStart(2, "0"),
String(now.getMinutes()).padStart(2, "0"),
String(now.getSeconds()).padStart(2, "0"),
];
return parts.join("");
}
function makeUniqueSiblingPath(basePath, suffix, now = new Date()) {
const stamp = makeTimestamp(now);
let attempt = 0;
while (true) {
const candidate = `${basePath}.${suffix}-${stamp}${attempt ? `-${attempt}` : ""}`;
if (!pathExists(candidate)) return candidate;
attempt += 1;
}
}
function ensureTargetIsUsable(targetDir) {
if (!pathExists(targetDir)) return;
if (!isDirectory(targetDir)) {
throw new Error("目标 output 路径已存在且不是目录");
}
if (hasDirectoryContents(targetDir)) {
throw new Error("目标 output 目录已有内容,请先清空后再重试");
}
}
function migrateOutputDirectory({ currentDir, nextDir, now = new Date() }) {
const currentPath = normalizeDirectoryPath(currentDir);
const targetPath = normalizeDirectoryPath(nextDir);
if (!currentPath || !targetPath) {
throw new Error("output 路径不能为空");
}
if (currentPath === targetPath) {
return {
changed: false,
currentDir: currentPath,
targetDir: targetPath,
sourceWasEmpty: !hasDirectoryContents(currentPath),
backupDir: "",
};
}
if (isPathInside(currentPath, targetPath) || isPathInside(targetPath, currentPath)) {
throw new Error("新旧 output 路径不能互相包含");
}
ensureTargetIsUsable(targetPath);
const sourceExists = pathExists(currentPath);
if (sourceExists && !isDirectory(currentPath)) {
throw new Error("当前 output 路径不是目录");
}
const sourceWasEmpty = !sourceExists || !hasDirectoryContents(currentPath);
if (sourceWasEmpty) {
fs.mkdirSync(targetPath, { recursive: true });
return {
changed: true,
currentDir: currentPath,
targetDir: targetPath,
sourceWasEmpty: true,
backupDir: "",
};
}
const tempTarget = makeUniqueSiblingPath(targetPath, "migrating", now);
const backupDir = makeUniqueSiblingPath(currentPath, "backup", now);
fs.cpSync(currentPath, tempTarget, {
recursive: true,
force: false,
errorOnExist: true,
preserveTimestamps: true,
});
try {
verifyCopiedOutputTree(currentPath, tempTarget);
if (pathExists(targetPath)) {
fs.rmSync(targetPath, { recursive: true, force: true });
}
fs.renameSync(currentPath, backupDir);
try {
fs.renameSync(tempTarget, targetPath);
} catch (err) {
try {
if (!pathExists(currentPath) && pathExists(backupDir)) {
fs.renameSync(backupDir, currentPath);
}
} catch {}
throw err;
}
} catch (err) {
try {
if (pathExists(tempTarget)) {
fs.rmSync(tempTarget, { recursive: true, force: true });
}
} catch {}
throw err;
}
return {
changed: true,
currentDir: currentPath,
targetDir: targetPath,
sourceWasEmpty: false,
backupDir,
};
}
function rollbackOutputDirectoryChange({ previousDir, currentDir, backupDir, sourceWasEmpty }) {
const previousPath = normalizeDirectoryPath(previousDir);
const currentPath = normalizeDirectoryPath(currentDir);
try {
if (currentPath && pathExists(currentPath)) {
fs.rmSync(currentPath, { recursive: true, force: true });
}
} catch {}
if (sourceWasEmpty) {
return;
}
const backupPath = normalizeDirectoryPath(backupDir);
if (!backupPath || !pathExists(backupPath)) return;
try {
if (!pathExists(previousPath)) {
fs.renameSync(backupPath, previousPath);
}
} catch {}
}
module.exports = {
getDefaultOutputDirPath,
getEffectiveOutputDirPath,
hasDirectoryContents,
migrateOutputDirectory,
normalizeDirectoryPath,
rollbackOutputDirectoryChange,
};
+2
View File
@@ -82,7 +82,9 @@ contextBridge.exposeInMainWorld("wechatDesktop", {
chooseDirectory: (options = {}) => ipcRenderer.invoke("dialog:chooseDirectory", options),
// Data/output folder helpers
getOutputDirInfo: () => ipcRenderer.invoke("app:getOutputDirInfo"),
getOutputDir: () => ipcRenderer.invoke("app:getOutputDir"),
setOutputDir: (dir) => ipcRenderer.invoke("app:setOutputDir", String(dir ?? "")),
openOutputDir: () => ipcRenderer.invoke("app:openOutputDir"),
getAccountInfo: (account) => ipcRenderer.invoke("app:getAccountInfo", String(account || "")),
deleteAccountData: (account) => ipcRenderer.invoke("app:deleteAccountData", String(account || "")),
+162
View File
@@ -0,0 +1,162 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("fs");
const os = require("os");
const path = require("path");
const {
getDefaultOutputDirPath,
getEffectiveOutputDirPath,
migrateOutputDirectory,
normalizeDirectoryPath,
rollbackOutputDirectoryChange,
} = require("../src/output-dir.cjs");
function makeTempDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), "wda-output-"));
}
function cleanupDir(dirPath) {
try {
fs.rmSync(dirPath, { recursive: true, force: true });
} catch {}
}
test("normalizeDirectoryPath requires absolute paths", () => {
assert.throws(() => normalizeDirectoryPath("relative/path"), /绝对路径/);
});
test("getEffectiveOutputDirPath prefers env, then settings, then default", () => {
const root = makeTempDir();
const envDir = path.join(root, "env-output");
const settingsDir = path.join(root, "settings-output");
const defaultDir = path.join(root, "data", "output");
try {
assert.equal(
getEffectiveOutputDirPath({
dataDir: path.join(root, "data"),
envOutputDir: envDir,
settingsOutputDir: settingsDir,
}),
path.resolve(envDir)
);
assert.equal(
getEffectiveOutputDirPath({
dataDir: path.join(root, "data"),
envOutputDir: "",
settingsOutputDir: settingsDir,
}),
path.resolve(settingsDir)
);
assert.equal(getDefaultOutputDirPath(path.join(root, "data")), path.resolve(defaultDir));
} finally {
cleanupDir(root);
}
});
test("migrateOutputDirectory switches empty source to a new directory", () => {
const root = makeTempDir();
const currentDir = path.join(root, "current-output");
const nextDir = path.join(root, "custom-output");
try {
fs.mkdirSync(currentDir, { recursive: true });
const result = migrateOutputDirectory({ currentDir, nextDir });
assert.equal(result.changed, true);
assert.equal(result.sourceWasEmpty, true);
assert.equal(result.backupDir, "");
assert.ok(fs.existsSync(nextDir));
assert.equal(fs.existsSync(currentDir), true);
} finally {
cleanupDir(root);
}
});
test("migrateOutputDirectory blocks non-empty targets", () => {
const root = makeTempDir();
const currentDir = path.join(root, "current-output");
const nextDir = path.join(root, "custom-output");
try {
fs.mkdirSync(path.join(currentDir, "logs"), { recursive: true });
fs.writeFileSync(path.join(currentDir, "runtime_settings.json"), "{}");
fs.mkdirSync(nextDir, { recursive: true });
fs.writeFileSync(path.join(nextDir, "existing.txt"), "occupied");
assert.throws(
() => migrateOutputDirectory({ currentDir, nextDir }),
/已有内容/
);
} finally {
cleanupDir(root);
}
});
test("migrateOutputDirectory blocks invalid current paths", () => {
const root = makeTempDir();
const currentDir = path.join(root, "current-output");
const nextDir = path.join(root, "custom-output");
try {
fs.writeFileSync(currentDir, "not-a-directory");
assert.throws(
() => migrateOutputDirectory({ currentDir, nextDir }),
/不是目录/
);
} finally {
cleanupDir(root);
}
});
test("migrateOutputDirectory copies data and leaves the old directory as a backup", () => {
const root = makeTempDir();
const currentDir = path.join(root, "current-output");
const nextDir = path.join(root, "custom-output");
try {
fs.mkdirSync(path.join(currentDir, "databases", "wxid_test"), { recursive: true });
fs.writeFileSync(path.join(currentDir, "runtime_settings.json"), "{\"backend_port\":10392}");
fs.writeFileSync(path.join(currentDir, "databases", "wxid_test", "session.db"), "session");
fs.writeFileSync(path.join(currentDir, "databases", "wxid_test", "contact.db"), "contact");
const result = migrateOutputDirectory({ currentDir, nextDir, now: new Date("2026-03-30T08:00:00Z") });
assert.equal(result.changed, true);
assert.equal(result.sourceWasEmpty, false);
assert.match(path.basename(result.backupDir), /^current-output\.backup-\d{14}$/);
assert.ok(fs.existsSync(nextDir));
assert.ok(fs.existsSync(path.join(nextDir, "runtime_settings.json")));
assert.ok(fs.existsSync(path.join(nextDir, "databases", "wxid_test", "session.db")));
assert.ok(fs.existsSync(result.backupDir));
assert.equal(fs.existsSync(currentDir), false);
} finally {
cleanupDir(root);
}
});
test("rollbackOutputDirectoryChange restores the previous directory", () => {
const root = makeTempDir();
const previousDir = path.join(root, "current-output");
const currentDir = path.join(root, "custom-output");
const backupDir = path.join(root, "current-output.backup-20260330080100");
try {
fs.mkdirSync(path.join(currentDir, "databases"), { recursive: true });
fs.writeFileSync(path.join(currentDir, "databases", "temp.db"), "temp");
fs.mkdirSync(path.join(backupDir, "databases"), { recursive: true });
fs.writeFileSync(path.join(backupDir, "databases", "session.db"), "restored");
rollbackOutputDirectoryChange({
previousDir,
currentDir,
backupDir,
sourceWasEmpty: false,
});
assert.equal(fs.existsSync(currentDir), false);
assert.ok(fs.existsSync(path.join(previousDir, "databases", "session.db")));
assert.equal(fs.existsSync(backupDir), false);
} finally {
cleanupDir(root);
}
});
+394
View File
@@ -0,0 +1,394 @@
<template>
<div class="biz-page h-full min-h-0 flex overflow-hidden" style="background-color: var(--app-shell-bg)">
<div :class="['w-[300px] lg:w-[320px] border-r flex flex-col flex-shrink-0 z-10', isDark ? 'bg-[#1e1e1e] border-[#333]' : 'bg-white border-gray-200']">
<div class="p-3 border-b" :class="isDark ? 'border-[#333]' : 'border-gray-200'" style="background-color: var(--app-surface-muted)">
<div class="contact-search-wrapper flex-1">
<input
v-model="searchQuery"
type="text"
class="contact-search-input"
placeholder="搜索服务号"
/>
</div>
</div>
<div class="flex-1 overflow-y-auto min-h-0">
<div v-if="loadingAccounts" class="flex justify-center py-4">
<span class="text-sm" :class="isDark ? 'text-gray-500' : 'text-gray-400'">加载中...</span>
</div>
<div v-else class="pb-4">
<div
v-for="item in filteredAccounts"
:key="item.username"
@click="selectAccount(item)"
class="flex items-center gap-3 px-4 py-3 cursor-pointer transition-colors border-b"
:class="[
isDark ? 'border-[#333]' : 'border-gray-50',
selectedBizAccount?.username === item.username
? (isDark ? 'bg-[#333]' : 'bg-[#E5E5E5]') // 选中状态
: item.username === 'gh_3dfda90e39d6'
? (isDark ? 'bg-[#2a2a2a] hover:bg-[#333]' : 'bg-[#F2F2F2] hover:bg-[#EAEAEA]') // 微信支付专门的底色
: (isDark ? 'hover:bg-[#252525]' : 'hover:bg-gray-50') // 普通悬浮色
]"
>
<img v-if="item.avatar" :src="api.getBizProxyImageUrl(item.avatar)" :class="['w-10 h-10 rounded-md object-cover flex-shrink-0', isDark ? 'bg-[#333]' : 'bg-gray-200']" alt=""/>
<div v-else class="w-10 h-10 rounded-md bg-[#03C160] text-white flex items-center justify-center text-lg font-medium flex-shrink-0 shadow-sm">
{{ (item.name || item.username).charAt(0).toUpperCase() }}
</div>
<div class="flex-1 min-w-0 flex flex-col justify-center gap-0.5">
<div class="flex justify-between items-center">
<h3 class="text-sm truncate" :class="isDark ? 'text-gray-100' : 'text-gray-900'">{{ item.name || item.username }}</h3>
<span v-if="item.formatted_last_time" class="text-[11px] flex-shrink-0 ml-2" :class="isDark ? 'text-gray-500' : 'text-gray-400'">
{{ item.formatted_last_time }}
</span>
</div>
<div
class="text-[10px] px-1.5 py-0.5 rounded w-max mt-0.5"
:class="[
item.type === 1 ? (isDark ? 'text-[#03C160] bg-[#03C160]/20' : 'text-[#03C160] bg-[#03C160]/10') : // 服务号
item.type === 0 ? (isDark ? 'text-blue-400 bg-blue-900/40' : 'text-blue-500 bg-blue-50') : // 公众号
item.type === 2 ? (isDark ? 'text-orange-400 bg-orange-900/40' : 'text-orange-500 bg-orange-50') : // 企业号
(isDark ? 'text-gray-400 bg-gray-700/50' : 'text-gray-400 bg-gray-100') // 未知
]"
>
{{ {1: '服务号', 0: '公众号', 2: '企业号', 3: '未知'}[item.type] || '未知' }}
</div>
</div>
</div>
</div>
</div>
</div>
<div class="flex-1 flex flex-col min-h-0 min-w-0" :class="isDark ? 'bg-[#121212]' : 'bg-[#F5F5F5]'">
<div v-if="selectedBizAccount" class="flex-1 flex flex-col min-h-0 relative">
<div class="h-14 border-b flex items-center px-5 shrink-0 z-10" :class="isDark ? 'bg-[#121212] border-[#333]' : 'bg-[#F5F5F5] border-gray-200'">
<h2 class="text-base" :class="isDark ? 'text-gray-100' : 'text-gray-900'">{{ selectedBizAccount.name }}</h2>
</div>
<div class="flex-1 overflow-y-auto px-4 py-6 flex flex-col-reverse" @scroll="handleScroll" ref="messageListRef">
<div class="h-4 shrink-0" aria-hidden="true"></div>
<div v-if="!hasMore" class="text-center text-xs py-4 w-full" :class="isDark ? 'text-gray-500' : 'text-gray-400'">没有更多消息了</div>
<div v-if="loadingMessages" class="text-center text-xs py-4 w-full" :class="isDark ? 'text-gray-500' : 'text-gray-400'">正在加载...</div>
<div class="w-full max-w-[400px] mx-auto flex flex-col-reverse gap-6">
<div v-for="msg in messages" :key="msg.local_id" class="w-full">
<div v-if="selectedBizAccount.username === 'gh_3dfda90e39d6'" class="rounded-xl shadow-sm p-5 border" :class="isDark ? 'bg-[#1e1e1e] border-[#333]' : 'bg-white border-gray-100'">
<div class="flex items-center text-sm mb-5" :class="isDark ? 'text-gray-400' : 'text-gray-500'">
<img v-if="msg.merchant_icon" :src="api.getBizProxyImageUrl(msg.merchant_icon)" class="w-6 h-6 rounded-full mr-2 object-cover" alt=""/>
<div v-else class="w-6 h-6 rounded-full mr-2 flex items-center justify-center" :class="isDark ? 'bg-green-900/40 text-green-400' : 'bg-green-100 text-green-600'">¥</div>
<span>{{ msg.merchant_name || '微信支付' }}</span>
</div>
<div class="text-center mb-6">
<h3 class="text-[22px] font-medium mb-1" :class="isDark ? 'text-gray-100' : 'text-gray-900'">{{ msg.title }}</h3>
</div>
<div class="text-[13px] whitespace-pre-wrap leading-relaxed" :class="isDark ? 'text-gray-400' : 'text-gray-500'">
{{ msg.description }}
</div>
<div class="mt-4 pt-3 border-t text-[12px] text-right" :class="isDark ? 'border-[#333] text-gray-500' : 'border-gray-100 text-gray-400'">
{{ msg.formatted_time }}
</div>
</div>
<div v-else class="rounded-xl shadow-sm overflow-hidden border" :class="isDark ? 'bg-[#1e1e1e] border-[#333]' : 'bg-white border-gray-100'">
<a :href="msg.url" target="_blank" class="block relative group cursor-pointer">
<img :src="msg.cover ? api.getBizProxyImageUrl(msg.cover) : defaultImage" :class="['w-full h-[180px] object-cover', isDark ? 'bg-[#333]' : 'bg-gray-100']" alt=""/>
<div class="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-3 pt-8">
<h3 class="text-white text-[15px] font-medium leading-snug line-clamp-2 group-hover:underline">
{{ msg.title }}
</h3>
</div>
</a>
<div v-if="msg.des" class="px-4 py-3 text-[13px] border-b" :class="isDark ? 'text-gray-400 border-[#333]' : 'text-gray-500 border-gray-50'">
{{ msg.des }}
</div>
<div v-if="msg.content_list && msg.content_list.length > 1" class="flex flex-col">
<a
v-for="(item, idx) in msg.content_list.slice(1)"
:key="idx"
:href="item.url"
target="_blank"
class="flex items-center justify-between p-3 border-t hover:bg-opacity-50 cursor-pointer group"
:class="isDark ? 'border-[#333] hover:bg-[#252525]' : 'border-gray-100 hover:bg-gray-50'"
>
<span class="text-[14px] leading-snug line-clamp-2 pr-3 group-hover:underline" :class="isDark ? 'text-gray-200' : 'text-gray-800'">
{{ item.title }}
</span>
<img :src="item.cover ? api.getBizProxyImageUrl(item.cover) : defaultImage" :class="['w-12 h-12 rounded object-cover flex-shrink-0 border', isDark ? 'bg-[#333] border-[#444]' : 'bg-gray-100 border-gray-100']" alt=""/>
</a>
</div>
</div>
</div>
</div>
</div>
</div>
<div v-else class="flex-1 flex items-center justify-center">
<div class="text-center">
<div class="w-20 h-20 mx-auto mb-5 rounded-2xl flex items-center justify-center" :class="isDark ? 'bg-[#2a2a2a]' : 'bg-gray-200/50'">
<svg class="w-10 h-10" :class="isDark ? 'text-gray-600' : 'text-gray-400'" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9.5L18.5 7H20" />
</svg>
</div>
<p class="text-sm" :class="isDark ? 'text-gray-500' : 'text-gray-400'">请选择一个服务号查看消息</p>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { useApi } from '~/composables/useApi'
const api = useApi()
import { storeToRefs } from 'pinia'
import { useThemeStore } from '~/stores/theme'
import { useChatAccountsStore } from '~/stores/chatAccounts'
import { useChatRealtimeStore } from '~/stores/chatRealtime'
const accounts = ref([])
const loadingAccounts = ref(false)
const searchQuery = ref('')
const selectedBizAccount = ref(null)
const themeStore = useThemeStore()
const chatAccountsStore = useChatAccountsStore()
const realtimeStore = useChatRealtimeStore()
const { isDark } = storeToRefs(themeStore)
const { selectedAccount: selectedDbAccount } = storeToRefs(chatAccountsStore)
const { enabled: realtimeEnabled, changeSeq } = storeToRefs(realtimeStore)
const messages = ref([])
const loadingMessages = ref(false)
const offset = ref(0)
const limit = 20
const hasMore = ref(true)
const messageListRef = ref(null)
let realtimeRefreshFuture = null
let realtimeRefreshQueued = false
// 默认占位图
// const defaultAvatar = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0MCIgaGVpZ2h0PSI0MCIgdmlld0JveD0iMCAwIDQwIDQwIj48cmVjdCB3aWR0aD0iNDAiIGhlaWdodD0iNDAiIGZpbGw9IiNlNWU3ZWIiLz48L3N2Zz4='
const defaultImage = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0MDAiIGhlaWdodD0iMTgwIj48cmVjdCB3aWR0aD0iNDAwIiBoZWlnaHQ9IjE4MCIgZmlsbD0iI2Y1ZjVmNSIvPjwvc3ZnPg=='
const getCurrentAccountParam = () => {
const account = String(selectedDbAccount.value || '').trim()
return account || undefined
}
const resetMessagesState = () => {
messages.value = []
offset.value = 0
hasMore.value = true
}
const fetchAccounts = async ({ preserveSelection = true } = {}) => {
loadingAccounts.value = true
const previousUsername = preserveSelection ? String(selectedBizAccount.value?.username || '').trim() : ''
try {
const res = await api.listBizAccounts({ account: getCurrentAccountParam() })
const nextAccounts = Array.isArray(res?.data) ? res.data : []
accounts.value = nextAccounts
if (previousUsername) {
selectedBizAccount.value = nextAccounts.find(item => item.username === previousUsername) || null
} else if (!selectedBizAccount.value?.username) {
selectedBizAccount.value = null
}
} catch (err) {
accounts.value = []
selectedBizAccount.value = null
console.error('获取服务号失败:', err)
} finally {
loadingAccounts.value = false
}
}
// 搜索过滤
const filteredAccounts = computed(() => {
if (!searchQuery.value) return accounts.value
const q = searchQuery.value.toLowerCase()
return accounts.value.filter(a =>
(a.name && a.name.toLowerCase().includes(q)) ||
(a.username && a.username.toLowerCase().includes(q))
)
})
// 点击选择服务号
const selectAccount = async (account) => {
if (selectedBizAccount.value?.username === account.username) return
selectedBizAccount.value = account
// 重置消息状态
resetMessagesState()
await loadMessages()
}
// 加载消息
const loadMessages = async () => {
if (loadingMessages.value || !hasMore.value || !selectedBizAccount.value) return
loadingMessages.value = true
try {
const username = selectedBizAccount.value.username
const params = {
account: getCurrentAccountParam(),
username,
offset: offset.value,
limit,
}
let res
if (username === 'gh_3dfda90e39d6') {
res = await api.listBizPayRecords(params)
} else {
res = await api.listBizMessages(params)
}
if (res && res.data) {
if (res.data.length < limit) {
hasMore.value = false
}
// 追加数据
messages.value.push(...res.data)
offset.value += limit
}
} catch (err) {
console.error('加载消息失败:', err)
} finally {
loadingMessages.value = false
}
}
const reloadSelectedMessages = async () => {
if (!selectedBizAccount.value) return
resetMessagesState()
await loadMessages()
}
const syncAllBizRealtime = async ({ forceReload = false } = {}) => {
const priorityUsername = String(selectedBizAccount.value?.username || '').trim()
if (!realtimeEnabled.value) {
if (forceReload) {
await reloadSelectedMessages()
}
return
}
try {
const result = await api.syncChatRealtimeAll({
account: getCurrentAccountParam(),
max_scan: 200,
priority_username: priorityUsername,
priority_max_scan: 400,
include_hidden: true,
include_official: true,
only_official: true,
backfill_limit: 0,
})
const hasDelta = Number(result?.insertedTotal || 0) > 0 || Number(result?.sessionsUpdated || 0) > 0
await fetchAccounts({ preserveSelection: true })
if (selectedBizAccount.value?.username) {
if (hasDelta || forceReload) {
await reloadSelectedMessages()
}
} else if (forceReload) {
resetMessagesState()
}
} catch (err) {
console.error('实时同步服务号失败:', err)
if (forceReload) {
await fetchAccounts({ preserveSelection: true })
await reloadSelectedMessages()
}
}
}
const queueRealtimeBizRefresh = () => {
if (!realtimeEnabled.value) return
if (realtimeRefreshFuture) {
realtimeRefreshQueued = true
return
}
realtimeRefreshFuture = syncAllBizRealtime().finally(() => {
realtimeRefreshFuture = null
if (realtimeRefreshQueued) {
realtimeRefreshQueued = false
queueRealtimeBizRefresh()
}
})
}
// 向上滚动加载逻辑
// 因为容器设置了 flex-col-reverse,所以 scrollTop 越靠近负值(或0取决于浏览器)越是到了历史消息端
// 但比较通用兼容的做法是监听 scroll,距离顶部或底部小于阈值时触发
const handleScroll = (e) => {
const target = e.target
// 针对 flex-col-reverse: 滚动到底部实际上是视觉上的最上方(历史消息)
// 当 scrollHeight - Math.abs(scrollTop) - clientHeight < 50 时加载
if (target.scrollHeight - Math.abs(target.scrollTop) - target.clientHeight < 50) {
loadMessages()
}
}
watch(selectedDbAccount, async (next, prev) => {
if (String(next || '').trim() === String(prev || '').trim()) return
selectedBizAccount.value = null
resetMessagesState()
searchQuery.value = ''
if (!String(next || '').trim()) {
accounts.value = []
return
}
await fetchAccounts({ preserveSelection: false })
if (realtimeEnabled.value) {
await syncAllBizRealtime({ forceReload: true })
}
})
watch(changeSeq, (next, prev) => {
if (!realtimeEnabled.value) return
if (next === prev) return
queueRealtimeBizRefresh()
})
watch(realtimeEnabled, async (enabled, wasEnabled) => {
if (enabled && !wasEnabled) {
await syncAllBizRealtime({ forceReload: true })
}
})
onMounted(async () => {
await chatAccountsStore.ensureLoaded()
await fetchAccounts({ preserveSelection: false })
if (realtimeEnabled.value) {
await syncAllBizRealtime({ forceReload: true })
}
})
</script>
<style scoped>
/* 隐藏滚动条但允许滚动(可选) */
.overflow-y-auto::-webkit-scrollbar {
width: 6px;
}
.overflow-y-auto::-webkit-scrollbar-track {
background: transparent;
}
.overflow-y-auto::-webkit-scrollbar-thumb {
background-color: rgba(0,0,0,0.1);
border-radius: 10px;
}
</style>
+170 -15
View File
@@ -4,9 +4,9 @@
class="settings-dialog fixed inset-0 z-[120] flex items-center justify-center bg-black/40 px-4 py-4 backdrop-blur-md sm:py-8"
@click.self="handleClose"
>
<div class="settings-dialog-panel flex h-[80vh] min-h-[380px] w-full max-w-[760px] overflow-hidden rounded-[10px] border border-[#e2e2e2] bg-white shadow-2xl">
<div class="settings-dialog-panel flex h-[80vh] min-h-[380px] w-full max-w-[880px] overflow-hidden rounded-[10px] border border-[#e2e2e2] bg-white shadow-2xl">
<!-- Sidebar -->
<aside class="flex w-[180px] shrink-0 flex-col bg-[#fcfcfc] border-r border-[#eeeeee]">
<aside class="flex w-[160px] shrink-0 flex-col bg-[#fcfcfc] border-r border-[#eeeeee]">
<div class="mt-4 mb-2 flex items-center px-4 gap-2">
<div class="flex h-6 w-6 items-center justify-center rounded-[5px] bg-[#e7f5ee] text-[#07b75b]">
<svg class="h-[15px] w-[15px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
@@ -149,19 +149,74 @@
</div>
<div class="px-3.5 py-3">
<div class="flex flex-col gap-1.5 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0 flex-1">
<div class="text-[13px] font-medium text-[#222]">output 目录</div>
<div class="mt-0.5 text-[11px] text-[#909090] break-words">{{ desktopOutputDirText }}</div>
<div class="flex flex-col gap-2.5">
<div class="flex flex-col gap-1.5 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0 flex-1">
<div class="text-[13px] font-medium text-[#222]">output 目录</div>
<div class="mt-0.5 text-[11px] text-[#909090] break-words">
当前{{ desktopOutputDirText }}
<span class="ml-1 text-[#666]">{{ desktopOutputDirIsDefault ? '(默认位置)' : '(自定义位置)' }}</span>
</div>
<div class="mt-0.5 text-[11px] text-[#909090] break-words">默认{{ desktopOutputDirDefaultText }}</div>
<div v-if="desktopOutputDirPendingText" class="mt-0.5 text-[11px] text-amber-700 break-words">
待应用{{ desktopOutputDirPendingText }}
</div>
<div v-if="desktopOutputDirUnavailableReason" class="mt-1 text-[11px] text-amber-700 break-words">
{{ desktopOutputDirUnavailableReason }}
</div>
</div>
<button
type="button"
class="shrink-0 rounded-[6px] border border-[#e2e2e2] bg-white px-2 py-1 text-[12px] text-[#222] transition hover:bg-[#f9f9f9] disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!isDesktopEnv || desktopOutputDirLoading || desktopOutputDirApplying"
@click="onDesktopOpenOutputDir"
>
打开当前 output
</button>
</div>
<div class="flex flex-col gap-1.5 sm:flex-row sm:items-center">
<input
v-model="desktopOutputDirInput"
type="text"
spellcheck="false"
class="min-w-0 flex-1 rounded-[6px] border border-[#e2e2e2] bg-white px-2.5 py-1.5 text-[12px] text-[#333] outline-none transition focus:border-[#07b75b] focus:ring-1 focus:ring-[#07b75b]/30"
:disabled="desktopOutputDirControlsDisabled"
:placeholder="desktopOutputDirCanChange ? '选择新的 output 目录' : '当前环境不支持修改 output 目录'"
@keyup.enter="onDesktopOutputDirApply"
/>
<div class="flex shrink-0 items-center gap-1.5">
<button
type="button"
class="rounded-[6px] border border-[#e2e2e2] bg-white px-2 py-1 text-[12px] text-[#222] transition hover:bg-[#f9f9f9] disabled:cursor-not-allowed disabled:opacity-50"
:disabled="desktopOutputDirControlsDisabled"
@click="onDesktopChooseOutputDir"
>
选择文件夹
</button>
<button
type="button"
class="rounded-[6px] border border-[#e2e2e2] bg-white px-2 py-1 text-[12px] text-[#222] transition hover:bg-[#f9f9f9] disabled:cursor-not-allowed disabled:opacity-50"
:disabled="desktopOutputDirControlsDisabled"
@click="onDesktopOutputDirApply"
>
{{ desktopOutputDirApplying ? '迁移中...' : '应用' }}
</button>
<button
type="button"
class="rounded-[6px] border border-[#e2e2e2] bg-white px-2 py-1 text-[12px] text-[#222] transition hover:bg-[#f9f9f9] disabled:cursor-not-allowed disabled:opacity-50"
:disabled="desktopOutputDirControlsDisabled"
@click="onDesktopOutputDirReset"
>
恢复默认
</button>
</div>
</div>
<div v-if="desktopOutputDirCanChange" class="text-[11px] text-[#909090]">
修改后会迁移整个 output 目录如果目标目录已有内容会先阻止并提示
</div>
<div v-if="desktopOutputDirMessage" class="rounded-[6px] border border-[#d8efe2] bg-[#f4fbf7] px-2.5 py-1.5 text-[11px] text-[#1b6b43] whitespace-pre-wrap">
{{ desktopOutputDirMessage }}
</div>
<button
type="button"
class="shrink-0 rounded-[6px] border border-[#e2e2e2] bg-white px-2 py-1 text-[12px] text-[#222] transition hover:bg-[#f9f9f9] disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!isDesktopEnv || desktopOutputDirLoading"
@click="onDesktopOpenOutputDir"
>
打开 output
</button>
</div>
<div v-if="desktopOutputDirError" class="mt-1.5 text-[11px] text-red-600 whitespace-pre-wrap">
{{ desktopOutputDirError }}
@@ -345,13 +400,33 @@ const desktopBackendPortError = ref('')
const desktopBackendPortDefault = ref(10392)
const desktopOutputDir = ref('')
const desktopOutputDirDefault = ref('')
const desktopOutputDirInput = ref('')
const desktopOutputDirPending = ref('')
const desktopOutputDirLoading = ref(false)
const desktopOutputDirApplying = ref(false)
const desktopOutputDirError = ref('')
const desktopOutputDirMessage = ref('')
const desktopOutputDirIsDefault = ref(true)
const desktopOutputDirCanChange = ref(true)
const desktopOutputDirUnavailableReason = ref('')
const desktopOutputDirText = computed(() => {
if (!isDesktopEnv.value) return '仅桌面端可用'
const v = String(desktopOutputDir.value || '').trim()
return v || '—'
})
const desktopOutputDirDefaultText = computed(() => {
if (!isDesktopEnv.value) return '仅桌面端可用'
const v = String(desktopOutputDirDefault.value || '').trim()
return v || '—'
})
const desktopOutputDirPendingText = computed(() => {
const v = String(desktopOutputDirPending.value || '').trim()
return v || ''
})
const desktopOutputDirControlsDisabled = computed(() => (
!isDesktopEnv.value || !desktopOutputDirCanChange.value || desktopOutputDirLoading.value || desktopOutputDirApplying.value
))
const desktopLogFilePath = ref('')
const desktopLogFileLoading = ref(false)
@@ -530,12 +605,33 @@ const refreshDesktopBackendPort = async () => {
const refreshDesktopOutputDir = async () => {
if (!process.client || typeof window === 'undefined') return
if (!window.wechatDesktop?.getOutputDir) return
if (!window.wechatDesktop?.getOutputDir && !window.wechatDesktop?.getOutputDirInfo) return
desktopOutputDirLoading.value = true
desktopOutputDirError.value = ''
try {
if (window.wechatDesktop?.getOutputDirInfo) {
const info = await window.wechatDesktop.getOutputDirInfo()
desktopOutputDir.value = String(info?.path || '').trim()
desktopOutputDirDefault.value = String(info?.defaultPath || '').trim()
desktopOutputDirPending.value = String(info?.pendingPath || '').trim()
desktopOutputDirIsDefault.value = !!info?.isDefault
desktopOutputDirCanChange.value = info?.canChange !== false
desktopOutputDirUnavailableReason.value = String(info?.changeUnavailableReason || '').trim()
desktopOutputDirInput.value = desktopOutputDir.value || desktopOutputDirDefault.value
if (info?.lastError) {
desktopOutputDirError.value = String(info.lastError || '').trim()
}
return
}
const v = await window.wechatDesktop.getOutputDir()
desktopOutputDir.value = String(v || '').trim()
desktopOutputDirDefault.value = desktopOutputDir.value
desktopOutputDirPending.value = ''
desktopOutputDirIsDefault.value = true
desktopOutputDirCanChange.value = false
desktopOutputDirUnavailableReason.value = '当前桌面环境不支持修改 output 目录'
desktopOutputDirInput.value = desktopOutputDir.value
} catch (e) {
desktopOutputDirError.value = e?.message || '读取 output 目录失败'
} finally {
@@ -558,6 +654,62 @@ const onDesktopOpenOutputDir = async () => {
}
}
const onDesktopChooseOutputDir = async () => {
if (!process.client || typeof window === 'undefined') return
if (!window.wechatDesktop?.chooseDirectory) return
desktopOutputDirError.value = ''
desktopOutputDirMessage.value = ''
try {
const result = await window.wechatDesktop.chooseDirectory({ title: '选择新的 output 目录' })
if (result && !result.canceled && Array.isArray(result.filePaths) && result.filePaths.length > 0) {
desktopOutputDirInput.value = String(result.filePaths[0] || '').trim()
}
} catch (e) {
desktopOutputDirError.value = e?.message || '选择 output 目录失败'
}
}
const applyDesktopOutputDir = async (nextDir) => {
if (!process.client || typeof window === 'undefined') return
if (!window.wechatDesktop?.setOutputDir) {
desktopOutputDirError.value = '当前桌面环境不支持修改 output 目录'
return
}
if (!desktopOutputDirCanChange.value) {
desktopOutputDirError.value = desktopOutputDirUnavailableReason.value || '开发模式不支持界面修改 output 目录'
return
}
desktopOutputDirApplying.value = true
desktopOutputDirError.value = ''
desktopOutputDirMessage.value = ''
try {
const res = await window.wechatDesktop.setOutputDir(String(nextDir ?? '').trim())
if (res?.success === false) {
desktopOutputDirError.value = String(res?.error || '修改 output 目录失败').trim()
await refreshDesktopOutputDir()
return
}
await refreshDesktopOutputDir()
desktopOutputDirMessage.value = String(
res?.message || (res?.changed === false ? 'output 目录未变化' : 'output 目录已更新')
).trim()
} catch (e) {
desktopOutputDirError.value = e?.message || '修改 output 目录失败'
await refreshDesktopOutputDir()
} finally {
desktopOutputDirApplying.value = false
}
}
const onDesktopOutputDirApply = async () => {
await applyDesktopOutputDir(desktopOutputDirInput.value)
}
const onDesktopOutputDirReset = async () => {
desktopOutputDirInput.value = desktopOutputDirDefault.value
await applyDesktopOutputDir('')
}
const refreshBackendLogFileInfo = async () => {
if (!process.client || typeof window === 'undefined') return
desktopLogFileLoading.value = true
@@ -702,6 +854,9 @@ const onDesktopCheckUpdates = async () => {
watch(() => props.open, async (isOpen) => {
if (!isOpen) return
await refreshBackendLogFileInfo()
if (isDesktopEnv.value) {
await refreshDesktopOutputDir()
}
}, { immediate: true })
onMounted(async () => {
+24 -26
View File
@@ -101,6 +101,21 @@
</div>
</div>
<div
class="sidebar-rail-action w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
title="服务号"
@click="goBiz"
>
<div class="sidebar-rail-plate w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent">
<div class="sidebar-rail-icon w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]" :class="{ 'sidebar-rail-icon-active': isBizRoute }">
<svg class="w-full h-full" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M11 5L6 9H2v6h4l5 4V5z"></path>
<path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"></path>
</svg>
</div>
</div>
</div>
<!-- Wrapped -->
<div
class="sidebar-rail-action w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
@@ -479,34 +494,17 @@ const isChatRoute = computed(() => route.path?.startsWith('/chat'))
const isEditsRoute = computed(() => route.path?.startsWith('/edits'))
const isSnsRoute = computed(() => route.path?.startsWith('/sns'))
const isContactsRoute = computed(() => route.path?.startsWith('/contacts'))
const isBizRoute = computed(() => route.path?.startsWith('/biz')) // 新增
const isWrappedRoute = computed(() => route.path?.startsWith('/wrapped'))
const goChat = async () => {
await navigateTo('/chat')
}
const goEdits = async () => {
await navigateTo('/edits')
}
const goSns = async () => {
await navigateTo('/sns')
}
const goContacts = async () => {
await navigateTo('/contacts')
}
const goWrapped = async () => {
await navigateTo('/wrapped')
}
const goGuide = async () => {
await navigateTo('/')
}
const goSettings = () => {
openSettingsDialog()
}
const goChat = async () => { await navigateTo('/chat') }
const goEdits = async () => { await navigateTo('/edits') }
const goSns = async () => { await navigateTo('/sns') }
const goContacts = async () => { await navigateTo('/contacts') }
const goBiz = async () => { await navigateTo('/biz') }
const goWrapped = async () => { await navigateTo('/wrapped') }
const goGuide = async () => { await navigateTo('/') }
const goSettings = () => { openSettingsDialog() }
const onWindowKeydown = (event) => {
if (event?.key !== 'Escape') return
@@ -69,7 +69,7 @@
<div v-else-if="contacts.length === 0" class="session-list-status px-3 py-2 text-sm">
暂无会话
</div>
<template v-else>
<div v-else class="pb-4">
<div v-for="contact in filteredContacts" :key="contact.id"
class="session-list-item px-3 cursor-pointer transition-colors duration-150 h-[calc(80px/var(--dpr))] flex items-center"
:class="{
@@ -118,7 +118,7 @@
</div>
</div>
</div>
</template>
</div>
</div>
</div>
+41 -1
View File
@@ -74,6 +74,7 @@ export const useChatMessages = ({
let highlightTimer = null
const messageTypeFilter = ref('all')
const localMediaVersion = ref(0)
const messageTypeFilterOptions = [
{ value: 'all', label: '全部' },
{ value: 'text', label: '文本' },
@@ -95,9 +96,39 @@ export const useChatMessages = ({
const normalizeMessage = createMessageNormalizer({
apiBase,
getSelectedAccount: () => selectedAccount.value,
getSelectedContact: () => selectedContact.value
getSelectedContact: () => selectedContact.value,
getLocalMediaVersion: () => localMediaVersion.value
})
const bumpLocalMediaVersion = () => {
localMediaVersion.value = (localMediaVersion.value + 1) % 1000000000
return localMediaVersion.value
}
const renormalizeLoadedMessages = (username) => {
const key = String(username || '').trim()
if (!key) return
const existing = allMessages.value[key]
if (!Array.isArray(existing) || !existing.length) return
const refreshed = dedupeMessagesById(existing.map((message) => {
const normalized = normalizeMessage(message)
return {
...message,
...normalized,
_emojiDownloading: !!message?._emojiDownloading,
_emojiDownloaded: typeof message?._emojiDownloaded === 'boolean' ? message._emojiDownloaded : normalized._emojiDownloaded,
_quoteImageError: false,
_quoteThumbError: false
}
}))
allMessages.value = {
...allMessages.value,
[key]: refreshed
}
}
const messages = computed(() => {
if (!selectedContact.value) return []
return allMessages.value[selectedContact.value.username] || []
@@ -534,9 +565,17 @@ export const useChatMessages = ({
const refreshSelectedMessages = async () => {
if (!selectedContact.value) return
bumpLocalMediaVersion()
await loadMessages({ username: selectedContact.value.username, reset: true })
}
const refreshCurrentMessageMedia = async () => {
if (!selectedContact.value?.username) return
bumpLocalMediaVersion()
renormalizeLoadedMessages(selectedContact.value.username)
await nextTick()
}
const refreshRealtimeIncremental = async () => {
if (!realtimeEnabled.value || !selectedAccount.value || !selectedContact.value?.username) return
if (searchContext.value?.active || isLoadingMessages.value) return
@@ -912,6 +951,7 @@ export const useChatMessages = ({
loadMessages,
loadMoreMessages,
refreshSelectedMessages,
refreshCurrentMessageMedia,
refreshRealtimeIncremental,
queueRealtimeRefresh,
tryEnableRealtimeAuto,
+44
View File
@@ -205,6 +205,8 @@ export const useApi = () => {
if (params && params.priority_max_scan != null) query.set('priority_max_scan', String(params.priority_max_scan))
if (params && params.include_hidden != null) query.set('include_hidden', String(!!params.include_hidden))
if (params && params.include_official != null) query.set('include_official', String(!!params.include_official))
if (params && params.only_official != null) query.set('only_official', String(!!params.only_official))
if (params && params.backfill_limit != null) query.set('backfill_limit', String(params.backfill_limit))
const url = '/chat/realtime/sync_all' + (query.toString() ? `?${query.toString()}` : '')
return await request(url, { method: 'POST' })
}
@@ -561,6 +563,44 @@ export const useApi = () => {
return await request('/get_image_key')
}
// 枚举服务号信息
const listBizAccounts = async (params = {}) => {
const query = new URLSearchParams()
if (params && params.account) query.set('account', params.account)
const url = '/biz/list' + (query.toString() ? `?${query.toString()}` : '')
return await request(url)
}
// 获取普通服务号消息
const listBizMessages = async (params = {}) => {
const query = new URLSearchParams()
if (params && params.account) query.set('account', params.account)
if (params && params.username) query.set('username', params.username)
if (params && params.limit != null) query.set('limit', String(params.limit))
if (params && params.offset != null) query.set('offset', String(params.offset))
const url = '/biz/messages' + (query.toString() ? `?${query.toString()}` : '')
return await request(url)
}
// 获取微信支付记录
const listBizPayRecords = async (params = {}) => {
const query = new URLSearchParams()
if (params && params.account) query.set('account', params.account)
if (params && params.limit != null) query.set('limit', String(params.limit))
if (params && params.offset != null) query.set('offset', String(params.offset))
const url = '/biz/pay_records' + (query.toString() ? `?${query.toString()}` : '')
return await request(url)
}
const getBizProxyImageUrl = (url) => {
if (!url) return ''
if (url.startsWith('data:')) return url // 如果已经是 base64,不处理
const query = new URLSearchParams()
query.set('url', url)
const base = baseURL ? baseURL.replace(/\/$/, '') : ''
return `${base}/biz/proxy_image?${query.toString()}`
}
return {
detectWechat,
detectCurrentAccount,
@@ -616,5 +656,9 @@ export const useApi = () => {
getKeys,
getImageKey,
getWxStatus,
listBizAccounts,
listBizMessages,
listBizPayRecords,
getBizProxyImageUrl,
}
}
+8 -4
View File
@@ -17,11 +17,12 @@ const buildAccountMediaUrl = (apiBase, path, parts) => {
return `${apiBase}${path}?${parts.filter(Boolean).join('&')}`
}
export const createMessageNormalizer = ({ apiBase, getSelectedAccount, getSelectedContact }) => {
export const createMessageNormalizer = ({ apiBase, getSelectedAccount, getSelectedContact, getLocalMediaVersion }) => {
return (msg) => {
const account = String(getSelectedAccount?.() || '').trim()
const contact = getSelectedContact?.() || null
const username = String(contact?.username || '').trim()
const localMediaVersion = Number(getLocalMediaVersion?.() || 0)
const isSent = !!msg.isSent
const sender = isSent ? '我' : (msg.senderDisplayName || msg.senderUsername || contact?.name || '')
const fallbackAvatar = (!isSent && !contact?.isGroup) ? (contact?.avatar || null) : null
@@ -66,7 +67,8 @@ export const createMessageNormalizer = ({ apiBase, getSelectedAccount, getSelect
`account=${encodeURIComponent(account)}`,
msg.imageMd5 ? `md5=${encodeURIComponent(msg.imageMd5)}` : '',
msg.imageFileId ? `file_id=${encodeURIComponent(msg.imageFileId)}` : '',
`username=${encodeURIComponent(username)}`
`username=${encodeURIComponent(username)}`,
localMediaVersion > 0 ? `v=${encodeURIComponent(String(localMediaVersion))}` : ''
])
})()
@@ -86,7 +88,8 @@ export const createMessageNormalizer = ({ apiBase, getSelectedAccount, getSelect
`account=${encodeURIComponent(account)}`,
msg.videoThumbMd5 ? `md5=${encodeURIComponent(msg.videoThumbMd5)}` : '',
msg.videoThumbFileId ? `file_id=${encodeURIComponent(msg.videoThumbFileId)}` : '',
`username=${encodeURIComponent(username)}`
`username=${encodeURIComponent(username)}`,
localMediaVersion > 0 ? `v=${encodeURIComponent(String(localMediaVersion))}` : ''
])
})()
@@ -158,7 +161,8 @@ export const createMessageNormalizer = ({ apiBase, getSelectedAccount, getSelect
return buildAccountMediaUrl(apiBase, '/chat/media/image', [
`account=${encodeURIComponent(account)}`,
`server_id=${encodeURIComponent(quoteServerIdStr)}`,
username ? `username=${encodeURIComponent(username)}` : ''
username ? `username=${encodeURIComponent(username)}` : '',
localMediaVersion > 0 ? `v=${encodeURIComponent(String(localMediaVersion))}` : ''
])
})()
+16
View File
@@ -0,0 +1,16 @@
<template>
<div class="h-full min-h-0 flex overflow-hidden bg-white">
<div class="flex-1 min-w-0">
<BizMessages />
</div>
</div>
</template>
<script setup>
import BizMessages from "../components/BizMessages.vue";
useHead({
title: '服务号消息 - WeChatDataAnalysis'
})
</script>
+27
View File
@@ -216,6 +216,7 @@ const {
loadMessages,
loadMoreMessages,
refreshSelectedMessages,
refreshCurrentMessageMedia,
queueRealtimeRefresh,
tryEnableRealtimeAuto,
resetMessageState,
@@ -568,6 +569,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 +608,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 +660,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
+2
View File
@@ -36,6 +36,7 @@ from .routers.wrapped import router as _wrapped_router
from .request_logging import log_server_errors_middleware
from .sns_stage_timing import add_sns_stage_timing_headers
from .wcdb_realtime import WCDB_REALTIME, shutdown as _wcdb_shutdown
from .routers.biz import router as _biz_router
app = FastAPI(
title="微信数据库解密工具",
@@ -96,6 +97,7 @@ app.include_router(_chat_media_router)
app.include_router(_sns_router)
app.include_router(_sns_export_router)
app.include_router(_wrapped_router)
app.include_router(_biz_router)
class _SPAStaticFiles(StaticFiles):
+8 -3
View File
@@ -3,6 +3,9 @@ from __future__ import annotations
import os
from pathlib import Path
ENV_DATA_DIR_KEY = "WECHAT_TOOL_DATA_DIR"
ENV_OUTPUT_DIR_KEY = "WECHAT_TOOL_OUTPUT_DIR"
def get_data_dir() -> Path:
"""Base writable directory for all runtime output (logs, databases, key store).
@@ -12,13 +15,16 @@ def get_data_dir() -> Path:
- Dev defaults to the current working directory (repo root).
"""
v = os.environ.get("WECHAT_TOOL_DATA_DIR", "").strip()
v = os.environ.get(ENV_DATA_DIR_KEY, "").strip()
if v:
return Path(v)
return Path(v).expanduser()
return Path.cwd()
def get_output_dir() -> Path:
v = os.environ.get(ENV_OUTPUT_DIR_KEY, "").strip()
if v:
return Path(v).expanduser()
return get_data_dir() / "output"
@@ -28,4 +34,3 @@ def get_output_databases_dir() -> Path:
def get_account_keys_path() -> Path:
return get_output_dir() / "account_keys.json"
+64 -8
View File
@@ -41,6 +41,22 @@ class ColoredFormatter(logging.Formatter):
return formatted
def _can_use_logging_stream(stream) -> bool:
try:
if stream is None or getattr(stream, "closed", False):
return False
except Exception:
return False
try:
stream.write("")
stream.flush()
except Exception:
return False
return True
class WeChatLogger:
"""微信解密工具统一日志管理器"""
@@ -64,6 +80,12 @@ class WeChatLogger:
if env_level:
log_level = env_level
console_logging_env = str(os.environ.get("WECHAT_TOOL_ENABLE_CONSOLE_LOG", "") or "").strip().lower()
console_logging_forced = console_logging_env in {"1", "true", "yes", "on"}
console_logging_disabled = console_logging_env in {"0", "false", "no", "off"}
level = getattr(logging, str(log_level or "INFO").upper(), logging.INFO)
# 创建日志目录
now = datetime.now()
from .app_paths import get_output_dir
@@ -73,10 +95,41 @@ class WeChatLogger:
# 设置日志文件名
date_str = now.strftime("%d")
self.log_file = log_dir / f"{date_str}_wechat_tool.log"
desired_log_file = log_dir / f"{date_str}_wechat_tool.log"
root_logger = logging.getLogger()
wants_console_handler = _can_use_logging_stream(sys.stdout)
if getattr(sys, "frozen", False) and not console_logging_forced:
wants_console_handler = False
if console_logging_disabled:
wants_console_handler = False
if WeChatLogger._initialized:
current_log_file = Path(getattr(self, "log_file", desired_log_file))
has_expected_file_handler = False
has_stream_handler = False
for handler in root_logger.handlers:
if isinstance(handler, logging.FileHandler):
try:
if Path(handler.baseFilename).resolve() == desired_log_file.resolve():
has_expected_file_handler = True
except Exception:
if Path(handler.baseFilename) == desired_log_file:
has_expected_file_handler = True
elif isinstance(handler, logging.StreamHandler):
has_stream_handler = True
if (
current_log_file == desired_log_file
and root_logger.level == level
and has_expected_file_handler
and (has_stream_handler or not wants_console_handler)
):
self.log_file = desired_log_file
return self.log_file
self.log_file = desired_log_file
# 清除现有的处理器
root_logger = logging.getLogger()
for handler in root_logger.handlers[:]:
root_logger.removeHandler(handler)
try:
@@ -100,18 +153,20 @@ class WeChatLogger:
# 文件处理器
file_handler = logging.FileHandler(self.log_file, encoding='utf-8')
file_handler.setFormatter(file_formatter)
level = getattr(logging, str(log_level or "INFO").upper(), logging.INFO)
file_handler.setLevel(level)
# 控制台处理器
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setFormatter(console_formatter)
console_handler.setLevel(level)
console_handler = None
if wants_console_handler:
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setFormatter(console_formatter)
console_handler.setLevel(level)
# 配置根日志器
root_logger.setLevel(level)
root_logger.addHandler(file_handler)
root_logger.addHandler(console_handler)
if console_handler is not None:
root_logger.addHandler(console_handler)
# 只为uvicorn日志器添加文件处理器,保持其原有的控制台处理器(带颜色)
uvicorn_logger = logging.getLogger("uvicorn")
@@ -158,7 +213,8 @@ class WeChatLogger:
except Exception:
pass
fastapi_logger.addHandler(file_handler)
fastapi_logger.addHandler(console_handler)
if console_handler is not None:
fastapi_logger.addHandler(console_handler)
fastapi_logger.setLevel(level)
# 记录初始化信息
+370
View File
@@ -0,0 +1,370 @@
import hashlib
import sqlite3
import time
import xml.etree.ElementTree as ET
from pathlib import Path
from typing import Optional, Any, Dict, List
import urllib
from fastapi import APIRouter, HTTPException, Response
from pydantic import BaseModel
from ..chat_helpers import _resolve_account_dir
from ..path_fix import PathFixRoute
from ..logging_config import get_logger
try:
import zstandard as zstd
except Exception:
zstd = None
logger = get_logger(__name__)
router = APIRouter(route_class=PathFixRoute)
def decompress_zstd_content(data: bytes, source_id: str, local_id: int) -> Optional[bytes]:
"""Zstandard 解压逻辑"""
if not data or not data.startswith(b'\x28\xb5\x2f\xfd'):
return None
try:
if zstd:
dctx = zstd.ZstdDecompressor()
return dctx.decompress(data, max_output_size=10 * 1024 * 1024)
except Exception as e:
error_msg = f"❌ [解压失败] 服务号id: {source_id}, local_id: {local_id} -> {e}"
print(error_msg)
logger.error(error_msg)
return None
def extract_xml_from_db_content(content: Any, source_id: str, local_id: int) -> str:
"""提取并解压数据库内容"""
if not content:
return ""
if isinstance(content, memoryview):
content = content.tobytes()
elif isinstance(content, str):
content = content.encode('utf-8', errors='ignore')
if isinstance(content, bytes):
decompressed = decompress_zstd_content(content, source_id, local_id)
if decompressed:
return decompressed.decode('utf-8', errors='ignore')
# 若不是 zstd 压缩或解压失败,尝试直接 decode
try:
return content.decode('utf-8', errors='ignore')
except Exception:
return ""
return ""
def parse_wechat_xml_to_struct(xml_str: str, source_id: str, local_id: int) -> Optional[Dict[str, Any]]:
"""解析微信服务号 XML 到 Dict"""
if not xml_str.strip():
return None
try:
root = ET.fromstring(xml_str)
def get_tag_text(element, path, default=""):
node = element.find(path)
return node.text if node is not None and node.text else default
main_cover = get_tag_text(root, ".//appmsg/thumburl")
if not main_cover:
main_cover = get_tag_text(root, ".//topnew/cover")
result = {
"title": get_tag_text(root, ".//appmsg/title"),
"des": get_tag_text(root, ".//appmsg/des"),
"url": get_tag_text(root, ".//appmsg/url"),
"cover": main_cover,
"content_list": []
}
items = root.findall(".//mmreader/category/item")
for item in items:
item_struct = {
"title": get_tag_text(item, "title"),
"url": get_tag_text(item, "url"),
"cover": get_tag_text(item, "cover"),
"summary": get_tag_text(item, "summary")
}
if item_struct["title"]:
result["content_list"].append(item_struct)
return result
except Exception as e:
error_msg = f"❌ [解析XML失败] 服务号id: {source_id}, local_id: {local_id} -> {e}"
print(error_msg)
logger.error(error_msg)
return None
def parse_pay_xml(xml_str: str, local_id: int) -> Optional[Dict[str, Any]]:
"""解析微信支付 XML"""
if not xml_str.strip():
return None
try:
root = ET.fromstring(xml_str)
def get_text(path):
node = root.find(path)
return node.text if node is not None else ""
record = {
"title": get_text(".//appmsg/title"),
"description": get_text(".//appmsg/des"),
"merchant_name": get_text(".//template_header/display_name"),
"merchant_icon": get_text(".//template_header/icon_url"),
"timestamp": int(get_text(".//pub_time") or 0),
"formatted_time": ""
}
return record
except Exception as e:
error_msg = f"❌ [解析微信支付XML失败] 支付id: gh_3dfda90e39d6, local_id: {local_id} -> {e}"
print(error_msg)
logger.error(error_msg)
return None
@router.get("/api/biz/proxy_image", summary="代理请求微信服务号图片")
def proxy_biz_image(url: str):
if not url:
return Response(status_code=400)
try:
req = urllib.request.Request(url, headers={
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
})
with urllib.request.urlopen(req, timeout=10) as response:
content = response.read()
content_type = response.headers.get('Content-Type', 'image/jpeg')
return Response(content=content, media_type=content_type)
except Exception as e:
logger.error(f"[biz] 代理图片失败: {url} -> {e}")
return Response(status_code=500)
# 接口 1:获取全部的服务号/公众号的信息
@router.get("/api/biz/list", summary="获取全部服务号/公众号列表")
def get_biz_account_list(account: Optional[str] = None):
account_dir = _resolve_account_dir(account)
biz_ids = set()
biz_latest_time = {}
# 1. 遍历 biz_message_*.db
for db_file in account_dir.glob("biz_message*.db"):
try:
conn = sqlite3.connect(str(db_file))
cursor = conn.cursor()
cursor.execute("PRAGMA table_info(Name2Id)")
cols = [row[1].lower() for row in cursor.fetchall()]
user_col = "username" if "username" in cols else "user_name" if "user_name" in cols else ""
if user_col:
rows = cursor.execute(f"SELECT {user_col} FROM Name2Id").fetchall()
for r in rows:
if r[0]:
uname = r[0]
biz_ids.add(uname)
# 顺便查询该号的最后一条消息时间
md5_id = hashlib.md5(uname.encode('utf-8')).hexdigest().lower()
table_name = f"Msg_{md5_id}"
try:
time_res = conn.execute(f"SELECT MAX(create_time) FROM {table_name}").fetchone()
if time_res and time_res[0]:
current_max = biz_latest_time.get(uname, 0)
biz_latest_time[uname] = max(current_max, time_res[0])
except Exception:
pass
conn.close()
except Exception as e:
logger.warning(f"读取 Name2Id 失败 {db_file}: {e}")
contact_db_path = account_dir / "contact.db"
contact_info = {}
if contact_db_path.exists() and biz_ids:
try:
conn = sqlite3.connect(str(contact_db_path))
cursor = conn.cursor()
placeholders = ",".join(["?"] * len(biz_ids))
# 先查 contact 表
query_contact = f"SELECT username, remark, nick_name, alias, big_head_url FROM contact WHERE username IN ({placeholders})"
rows_contact = cursor.execute(query_contact, list(biz_ids)).fetchall()
for r in rows_contact:
uname = r[0]
name = r[1] or r[2] or r[3] or uname
contact_info[uname] = {
"username": uname,
"name": name,
"avatar": r[4],
"type": 3 # 默认给个 3(未知)
}
# 再查 biz_info 表获取类型
try:
query_biz = f"SELECT username, type FROM biz_info WHERE username IN ({placeholders})"
rows_biz = cursor.execute(query_biz, list(biz_ids)).fetchall()
for r in rows_biz:
uname = r[0]
biz_type = r[1]
# 如果查到了且是 0, 1, 2,就更新进去,否则保留 3
if uname in contact_info:
if biz_type in (0, 1, 2):
contact_info[uname]["type"] = biz_type
else:
contact_info[uname]["type"] = 3
except Exception as e:
logger.warning(f"读取 biz_info 失败: {e}")
conn.close()
except Exception as e:
logger.warning(f"读取 contact.db 失败: {e}")
# 3. 组装结果(不在 contact_info 里的直接丢弃)
result = []
for uid in biz_ids:
if uid in contact_info:
info = contact_info[uid]
info["last_time"] = biz_latest_time.get(uid, 0)
if info["last_time"]:
# 格式化日期给前端展示用
info["formatted_last_time"] = time.strftime("%Y-%m-%d", time.localtime(info["last_time"]))
else:
info["formatted_last_time"] = ""
result.append(info)
# 4. 按最后一条消息的时间降序排列
result.sort(key=lambda x: x.get("last_time", 0), reverse=True)
return {"status": "success", "total": len(result), "data": result}
# 接口 2:获取普通服务号/公众号的 json 消息 (已修复表名比对 bug)
@router.get("/api/biz/messages", summary="获取指定服务号的消息")
def get_biz_messages(username: str, account: Optional[str] = None, limit: int = 50, offset: int = 0):
if username == "gh_3dfda90e39d6":
raise HTTPException(status_code=400, detail="微信支付记录请请求 /api/biz/pay_records 接口")
account_dir = _resolve_account_dir(account)
md5_id = hashlib.md5(username.encode('utf-8')).hexdigest().lower()
table_name = f"Msg_{md5_id}"
target_db = None
for db_file in account_dir.glob("biz_message*.db"):
conn = sqlite3.connect(str(db_file))
try:
# 必须用 table_name.lower(),否则永远匹配不上
res = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND lower(name)=?",
(table_name.lower(),)).fetchone()
if res:
target_db = db_file
break
except Exception:
pass
finally:
conn.close()
if not target_db:
return {"status": "success", "data": [], "message": f"未找到 {username} 的消息历史"}
# ... (后续数据库查询逻辑保持不变) ...
messages = []
try:
conn = sqlite3.connect(str(target_db))
cursor = conn.cursor()
query = f"""
SELECT local_id, create_time, message_content
FROM [{table_name}]
WHERE local_type != 1
ORDER BY create_time DESC
LIMIT ? OFFSET ?
"""
rows = cursor.execute(query, (limit, offset)).fetchall()
for local_id, c_time, content in rows:
raw_xml = extract_xml_from_db_content(content, username, local_id)
if not raw_xml:
continue
struct_data = parse_wechat_xml_to_struct(raw_xml, username, local_id)
if struct_data:
struct_data["local_id"] = local_id
struct_data["create_time"] = c_time
messages.append(struct_data)
conn.close()
except Exception as e:
logger.error(f"[biz] 数据库查询出错: {e}")
return {"status": "error", "message": str(e)}
return {"status": "success", "data": messages}
# 接口 3:返回微信支付的 json 消息 (已修复表名比对 bug)
@router.get("/api/biz/pay_records", summary="获取微信支付记录")
def get_wechat_pay_records(account: Optional[str] = None, limit: int = 50, offset: int = 0):
username = "gh_3dfda90e39d6"
account_dir = _resolve_account_dir(account)
md5_id = hashlib.md5(username.encode('utf-8')).hexdigest().lower()
table_name = f"Msg_{md5_id}"
target_db = None
for db_file in account_dir.glob("biz_message*.db"):
conn = sqlite3.connect(str(db_file))
try:
# 必须用 table_name.lower()
res = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND lower(name)=?",
(table_name.lower(),)).fetchone()
if res:
target_db = db_file
break
except Exception:
pass
finally:
conn.close()
if not target_db:
return {"status": "success", "data": [], "message": "未找到微信支付的消息历史"}
messages = []
try:
conn = sqlite3.connect(str(target_db))
cursor = conn.cursor()
query = f"""
SELECT local_id, create_time, message_content
FROM [{table_name}]
WHERE local_type = 21474836529 OR local_type != 1
ORDER BY create_time DESC
LIMIT ? OFFSET ?
"""
rows = cursor.execute(query, (limit, offset)).fetchall()
for local_id, c_time, content in rows:
raw_xml = extract_xml_from_db_content(content, username, local_id)
if not raw_xml:
continue
parsed_data = parse_pay_xml(raw_xml, local_id)
if parsed_data:
parsed_data["local_id"] = local_id
parsed_data["create_time"] = c_time
if not parsed_data["timestamp"]:
parsed_data["timestamp"] = c_time
parsed_data["formatted_time"] = time.strftime(
"%Y-%m-%d %H:%M:%S", time.localtime(parsed_data["timestamp"])
)
messages.append(parsed_data)
conn.close()
except Exception as e:
logger.error(f"[biz] 查询微信支付数据库出错: {e}")
return {"status": "error", "message": str(e)}
return {"status": "success", "data": messages}
+326 -222
View File
@@ -1391,6 +1391,299 @@ def _load_contact_top_flags(contact_db_path: Path, usernames: list[str]) -> dict
conn.close()
def _coerce_realtime_blobish_value(value: Any) -> Any:
if value is None:
return None
if isinstance(value, memoryview):
value = value.tobytes()
if isinstance(value, bytearray):
return bytes(value)
if isinstance(value, bytes):
try:
s = value.decode("ascii").strip()
except Exception:
return value
if not s:
return value
b = _hex_to_bytes(s)
if b is not None:
return b
if (len(s) % 2 == 0) and (_HEX_RE.fullmatch(s) is not None):
try:
return bytes.fromhex(s)
except Exception:
return value
return value
if isinstance(value, str):
s = value.strip()
if not s:
return value
b = _hex_to_bytes(s)
if b is not None:
return b
if (len(s) % 2 == 0) and (_HEX_RE.fullmatch(s) is not None):
try:
return bytes.fromhex(s)
except Exception:
return value
return value
return value
def _normalize_realtime_message_item(item: dict[str, Any]) -> dict[str, Any]:
def _pick(*keys: str) -> Any:
return _pick_case_insensitive_value(item, *keys)
message_content = _coerce_realtime_blobish_value(
_pick("message_content", "messageContent", "MessageContent")
)
if message_content is None:
message_content = ""
return {
"local_id": int(_pick("local_id", "localId") or 0),
"server_id": int(_pick("server_id", "serverId", "MsgSvrID") or 0),
"local_type": int(_pick("local_type", "localType", "Type", "type") or 0),
"sort_seq": int(_pick("sort_seq", "sortSeq", "SortSeq") or 0),
"real_sender_id": int(_pick("real_sender_id", "realSenderId") or 0),
"create_time": int(_pick("create_time", "createTime", "CreateTime") or 0),
"message_content": message_content,
"compress_content": _coerce_realtime_blobish_value(
_pick("compress_content", "compressContent", "CompressContent")
),
"packed_info_data": _coerce_realtime_blobish_value(
_pick("packed_info_data", "packedInfoData", "PackedInfoData")
),
"sender_username": str(
_pick("sender_username", "senderUsername", "sender", "SenderUsername") or ""
).strip(),
}
def _collect_realtime_rows_for_session(
*,
trace_id: Optional[str],
account_name: str,
rt_conn: Any,
username: str,
msg_db_path_real: Path,
table_name: str,
max_local_id: int,
max_scan: int,
backfill_limit: int,
) -> dict[str, Any]:
label = f"[{trace_id}]" if trace_id else "[realtime]"
log_fn = logger.info if trace_id else logger.debug
uname = str(username or "").strip()
use_biz_exec_query = uname.startswith("gh_") and ("biz_message" in str(msg_db_path_real.name).lower())
if use_biz_exec_query:
try:
quoted_table = _quote_ident(table_name)
select_cols = (
"local_id",
"server_id",
"local_type",
"sort_seq",
"real_sender_id",
"create_time",
"message_content",
"compress_content",
"packed_info_data",
)
select_sql = ", ".join([_quote_ident(col) for col in select_cols])
if int(max_local_id) > 0:
sql_new = (
f"SELECT {select_sql} FROM {quoted_table} "
f"WHERE local_id > {int(max_local_id)} "
f"ORDER BY local_id ASC LIMIT {int(max_scan)}"
)
else:
sql_new = f"SELECT {select_sql} FROM {quoted_table} ORDER BY local_id DESC LIMIT {int(max_scan)}"
log_fn(
"%s wcdb_exec_query biz account=%s username=%s mode=new_rows max_local_id=%s limit=%s",
label,
account_name,
uname,
int(max_local_id),
int(max_scan),
)
wcdb_t0 = time.perf_counter()
with rt_conn.lock:
raw_new_rows = _wcdb_exec_query(rt_conn.handle, kind="message", path=str(msg_db_path_real), sql=sql_new)
wcdb_ms = (time.perf_counter() - wcdb_t0) * 1000.0
logger.info(
"%s wcdb_exec_query biz done account=%s username=%s mode=new_rows rows=%s ms=%.1f",
label,
account_name,
uname,
len(raw_new_rows or []),
wcdb_ms,
)
if wcdb_ms > 2000:
logger.warning(
"%s wcdb_exec_query biz slow account=%s username=%s mode=new_rows ms=%.1f",
label,
account_name,
uname,
wcdb_ms,
)
normalized_new_rows: list[dict[str, Any]] = []
for item in raw_new_rows or []:
if not isinstance(item, dict):
continue
norm = _normalize_realtime_message_item(item)
if int(norm.get("local_id") or 0) <= 0:
continue
normalized_new_rows.append(norm)
if int(max_local_id) > 0:
new_rows = list(reversed(normalized_new_rows))
else:
new_rows = normalized_new_rows
backfill_rows: list[dict[str, Any]] = []
scanned = len(raw_new_rows or [])
if int(backfill_limit) > 0 and int(max_local_id) > 0:
sql_backfill = (
f"SELECT {select_sql} FROM {quoted_table} "
f"WHERE local_id <= {int(max_local_id)} "
f"ORDER BY local_id DESC LIMIT {int(backfill_limit)}"
)
log_fn(
"%s wcdb_exec_query biz account=%s username=%s mode=backfill limit=%s",
label,
account_name,
uname,
int(backfill_limit),
)
backfill_t0 = time.perf_counter()
with rt_conn.lock:
raw_backfill_rows = _wcdb_exec_query(
rt_conn.handle,
kind="message",
path=str(msg_db_path_real),
sql=sql_backfill,
)
backfill_ms = (time.perf_counter() - backfill_t0) * 1000.0
logger.info(
"%s wcdb_exec_query biz done account=%s username=%s mode=backfill rows=%s ms=%.1f",
label,
account_name,
uname,
len(raw_backfill_rows or []),
backfill_ms,
)
if backfill_ms > 2000:
logger.warning(
"%s wcdb_exec_query biz slow account=%s username=%s mode=backfill ms=%.1f",
label,
account_name,
uname,
backfill_ms,
)
scanned += len(raw_backfill_rows or [])
for item in raw_backfill_rows or []:
if not isinstance(item, dict):
continue
norm = _normalize_realtime_message_item(item)
if int(norm.get("local_id") or 0) <= 0:
continue
backfill_rows.append(norm)
return {
"fetchMode": "biz_exec_query",
"scanned": int(scanned),
"new_rows": new_rows,
"backfill_rows": backfill_rows,
}
except Exception as e:
logger.warning(
"%s wcdb_exec_query biz failed account=%s username=%s err=%s fallback=wcdb_get_messages",
label,
account_name,
uname,
str(e),
)
batch_size = 200
scanned = 0
offset = 0
new_rows: list[dict[str, Any]] = []
backfill_rows: list[dict[str, Any]] = []
reached_existing = False
stop = False
while scanned < int(max_scan):
take = min(batch_size, int(max_scan) - scanned)
log_fn(
"%s wcdb_get_messages account=%s username=%s take=%s offset=%s",
label,
account_name,
uname,
int(take),
int(offset),
)
wcdb_t0 = time.perf_counter()
with rt_conn.lock:
raw_rows = _wcdb_get_messages(rt_conn.handle, uname, limit=take, offset=offset)
wcdb_ms = (time.perf_counter() - wcdb_t0) * 1000.0
log_fn(
"%s wcdb_get_messages done account=%s username=%s rows=%s ms=%.1f",
label,
account_name,
uname,
len(raw_rows or []),
wcdb_ms,
)
if wcdb_ms > 2000:
logger.warning(
"%s wcdb_get_messages slow account=%s username=%s ms=%.1f",
label,
account_name,
uname,
wcdb_ms,
)
if not raw_rows:
break
scanned += len(raw_rows)
offset += len(raw_rows)
for item in raw_rows:
if not isinstance(item, dict):
continue
norm = _normalize_realtime_message_item(item)
lid = int(norm.get("local_id") or 0)
if lid <= 0:
continue
if (not reached_existing) and lid > int(max_local_id):
new_rows.append(norm)
continue
reached_existing = True
if int(backfill_limit) <= 0:
stop = True
break
backfill_rows.append(norm)
if len(backfill_rows) >= int(backfill_limit):
stop = True
break
if stop or len(raw_rows) < take:
break
return {
"fetchMode": "wcdb_get_messages",
"scanned": int(scanned),
"new_rows": new_rows,
"backfill_rows": backfill_rows,
}
@router.post("/api/chat/realtime/sync", summary="实时消息同步到解密库(按会话增量)")
def sync_chat_realtime_messages(
request: Request,
@@ -1511,118 +1804,20 @@ def sync_chat_realtime_messages(
placeholders = ",".join(["?"] * len(insert_cols))
insert_sql = f"INSERT OR IGNORE INTO {quoted_table} ({','.join(insert_cols)}) VALUES ({placeholders})"
def pick(item: dict[str, Any], *keys: str) -> Any:
for k in keys:
if k in item and item[k] is not None:
return item[k]
lk = k.lower()
for kk in item.keys():
if str(kk).lower() == lk and item[kk] is not None:
return item[kk]
return None
def normalize_blob(value: Any) -> Optional[bytes]:
if value is None:
return None
if isinstance(value, memoryview):
return value.tobytes()
if isinstance(value, (bytes, bytearray)):
return bytes(value)
if isinstance(value, str):
s = value.strip()
if s.lower().startswith("0x"):
s = s[2:]
if s and re.fullmatch(r"[0-9a-fA-F]+", s) and (len(s) % 2 == 0):
try:
return bytes.fromhex(s)
except Exception:
return None
return s.encode("utf-8", errors="ignore")
return None
def normalize(item: dict[str, Any]) -> dict[str, Any]:
return {
"local_id": int(pick(item, "local_id", "localId") or 0),
"server_id": int(pick(item, "server_id", "serverId", "MsgSvrID") or 0),
"local_type": int(pick(item, "local_type", "localType", "Type", "type") or 0),
"sort_seq": int(pick(item, "sort_seq", "sortSeq", "SortSeq") or 0),
"real_sender_id": int(pick(item, "real_sender_id", "realSenderId") or 0),
"create_time": int(pick(item, "create_time", "createTime", "CreateTime") or 0),
"message_content": pick(item, "message_content", "messageContent", "MessageContent") or "",
"compress_content": pick(item, "compress_content", "compressContent", "CompressContent"),
"packed_info_data": normalize_blob(pick(item, "packed_info_data", "packedInfoData")),
"sender_username": str(
pick(item, "sender_username", "senderUsername", "sender", "SenderUsername") or ""
).strip(),
}
batch_size = 200
scanned = 0
offset = 0
new_rows: list[dict[str, Any]] = []
backfill_rows: list[dict[str, Any]] = []
reached_existing = False
stop = False
while scanned < int(max_scan):
take = min(batch_size, int(max_scan) - scanned)
logger.info(
"[%s] wcdb_get_messages account=%s username=%s take=%s offset=%s",
trace_id,
account_dir.name,
username,
int(take),
int(offset),
)
wcdb_t0 = time.perf_counter()
with rt_conn.lock:
raw_rows = _wcdb_get_messages(rt_conn.handle, username, limit=take, offset=offset)
wcdb_ms = (time.perf_counter() - wcdb_t0) * 1000.0
logger.info(
"[%s] wcdb_get_messages done account=%s username=%s rows=%s ms=%.1f",
trace_id,
account_dir.name,
username,
len(raw_rows or []),
wcdb_ms,
)
if wcdb_ms > 2000:
logger.warning(
"[%s] wcdb_get_messages slow account=%s username=%s ms=%.1f",
trace_id,
account_dir.name,
username,
wcdb_ms,
)
if not raw_rows:
break
scanned += len(raw_rows)
offset += len(raw_rows)
for item in raw_rows:
if not isinstance(item, dict):
continue
norm = normalize(item)
lid = int(norm.get("local_id") or 0)
if lid <= 0:
continue
if (not reached_existing) and lid > max_local_id:
new_rows.append(norm)
continue
reached_existing = True
if int(backfill_limit) <= 0:
stop = True
break
backfill_rows.append(norm)
if len(backfill_rows) >= int(backfill_limit):
stop = True
break
if stop or len(raw_rows) < take:
break
fetch_result = _collect_realtime_rows_for_session(
trace_id=trace_id,
account_name=account_dir.name,
rt_conn=rt_conn,
username=username,
msg_db_path_real=msg_db_path_real,
table_name=table_name,
max_local_id=max_local_id,
max_scan=int(max_scan),
backfill_limit=int(backfill_limit),
)
scanned = int(fetch_result.get("scanned") or 0)
new_rows = list(fetch_result.get("new_rows") or [])
backfill_rows = list(fetch_result.get("backfill_rows") or [])
inserted = 0
backfilled = 0
@@ -1880,115 +2075,20 @@ 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})"
def pick(item: dict[str, Any], *keys: str) -> Any:
for k in keys:
if k in item and item[k] is not None:
return item[k]
lk = k.lower()
for kk in item.keys():
if str(kk).lower() == lk and item[kk] is not None:
return item[kk]
return None
def normalize_blob(value: Any) -> Optional[bytes]:
if value is None:
return None
if isinstance(value, memoryview):
return value.tobytes()
if isinstance(value, (bytes, bytearray)):
return bytes(value)
if isinstance(value, str):
s = value.strip()
if s.lower().startswith("0x"):
s = s[2:]
if s and re.fullmatch(r"[0-9a-fA-F]+", s) and (len(s) % 2 == 0):
try:
return bytes.fromhex(s)
except Exception:
return None
return s.encode("utf-8", errors="ignore")
return None
def normalize(item: dict[str, Any]) -> dict[str, Any]:
return {
"local_id": int(pick(item, "local_id", "localId") or 0),
"server_id": int(pick(item, "server_id", "serverId", "MsgSvrID") or 0),
"local_type": int(pick(item, "local_type", "localType", "Type", "type") or 0),
"sort_seq": int(pick(item, "sort_seq", "sortSeq", "SortSeq") or 0),
"real_sender_id": int(pick(item, "real_sender_id", "realSenderId") or 0),
"create_time": int(pick(item, "create_time", "createTime", "CreateTime") or 0),
"message_content": pick(item, "message_content", "messageContent", "MessageContent") or "",
"compress_content": pick(item, "compress_content", "compressContent", "CompressContent"),
"packed_info_data": normalize_blob(pick(item, "packed_info_data", "packedInfoData")),
"sender_username": str(
pick(item, "sender_username", "senderUsername", "sender", "SenderUsername") or ""
).strip(),
}
batch_size = 200
scanned = 0
offset = 0
new_rows: list[dict[str, Any]] = []
backfill_rows: list[dict[str, Any]] = []
reached_existing = False
stop = False
while scanned < int(max_scan):
take = min(batch_size, int(max_scan) - scanned)
logger.debug(
"[realtime] wcdb_get_messages account=%s username=%s take=%s offset=%s",
account_dir.name,
username,
int(take),
int(offset),
)
wcdb_t0 = time.perf_counter()
with rt_conn.lock:
raw_rows = _wcdb_get_messages(rt_conn.handle, username, limit=take, offset=offset)
wcdb_ms = (time.perf_counter() - wcdb_t0) * 1000.0
logger.debug(
"[realtime] wcdb_get_messages done account=%s username=%s rows=%s ms=%.1f",
account_dir.name,
username,
len(raw_rows or []),
wcdb_ms,
)
if wcdb_ms > 2000:
logger.warning(
"[realtime] wcdb_get_messages slow account=%s username=%s ms=%.1f",
account_dir.name,
username,
wcdb_ms,
)
if not raw_rows:
break
scanned += len(raw_rows)
offset += len(raw_rows)
for item in raw_rows:
if not isinstance(item, dict):
continue
norm = normalize(item)
lid = int(norm.get("local_id") or 0)
if lid <= 0:
continue
if (not reached_existing) and lid > max_local_id:
new_rows.append(norm)
continue
reached_existing = True
if int(backfill_limit) <= 0:
stop = True
break
backfill_rows.append(norm)
if len(backfill_rows) >= int(backfill_limit):
stop = True
break
if stop or len(raw_rows) < take:
break
fetch_result = _collect_realtime_rows_for_session(
trace_id=None,
account_name=account_dir.name,
rt_conn=rt_conn,
username=username,
msg_db_path_real=msg_db_path_real,
table_name=table_name,
max_local_id=max_local_id,
max_scan=int(max_scan),
backfill_limit=int(backfill_limit),
)
scanned = int(fetch_result.get("scanned") or 0)
new_rows = list(fetch_result.get("new_rows") or [])
backfill_rows = list(fetch_result.get("backfill_rows") or [])
inserted = 0
backfilled = 0
@@ -2163,6 +2263,7 @@ def sync_chat_realtime_messages_all(
priority_max_scan: int = 600,
include_hidden: bool = True,
include_official: bool = True,
only_official: bool = False,
backfill_limit: int = 200,
):
"""
@@ -2173,13 +2274,14 @@ def sync_chat_realtime_messages_all(
account_dir = _resolve_account_dir(account)
trace_id = f"rt-syncall-{int(time.time() * 1000)}-{threading.get_ident()}"
logger.info(
"[%s] realtime sync_all start account=%s max_scan=%s priority=%s include_hidden=%s include_official=%s",
"[%s] realtime sync_all start account=%s max_scan=%s priority=%s include_hidden=%s include_official=%s only_official=%s",
trace_id,
account_dir.name,
int(max_scan),
str(priority_username or "").strip(),
bool(include_hidden),
bool(include_official),
bool(only_official),
)
if max_scan < 20:
@@ -2241,6 +2343,8 @@ def sync_chat_realtime_messages_all(
hidden_val = 0
if not include_hidden and hidden_val == 1:
continue
if only_official and not uname.startswith("gh_"):
continue
if not _should_keep_session(uname, include_official=include_official):
continue
+120 -13
View File
@@ -67,6 +67,87 @@ logger = get_logger(__name__)
router = APIRouter(route_class=PathFixRoute)
def _build_uncached_media_response(data: bytes, media_type: str) -> Response:
resp = Response(content=data, media_type=media_type)
resp.headers["Cache-Control"] = "no-store"
return resp
def _image_candidate_variant_rank(path: Path) -> int:
stem = str(path.stem or "").lower()
if stem.endswith(("_b", ".b")):
return 0
if stem.endswith(("_h", ".h")):
return 1
if stem.endswith(("_c", ".c")):
return 3
if stem.endswith(("_t", ".t")):
return 4
return 2
def _image_candidate_stat(path: Optional[Path]) -> tuple[int, float]:
if not path:
return 0, 0.0
try:
st = path.stat()
return int(st.st_size), float(st.st_mtime)
except Exception:
return 0, 0.0
def _should_prefer_live_image_candidates(
*,
cached_path: Optional[Path],
live_candidates: list[Path],
) -> bool:
if not live_candidates:
return False
if not cached_path:
return True
best_live = live_candidates[0]
live_rank = _image_candidate_variant_rank(best_live)
if live_rank < 2:
return True
cache_size, cache_mtime = _image_candidate_stat(cached_path)
live_size, live_mtime = _image_candidate_stat(best_live)
if live_rank == 2 and live_size > cache_size:
return True
if live_rank == 2 and live_size >= cache_size and live_mtime > cache_mtime:
return True
return False
def _write_cached_chat_image(account_dir: Path, md5: str, data: bytes) -> None:
md5_norm = str(md5 or "").strip().lower()
if (not md5_norm) or (not data):
return
ext = _detect_image_extension(data)
out_path = _get_decrypted_resource_path(account_dir, md5_norm, ext)
out_path.parent.mkdir(parents=True, exist_ok=True)
for stale_ext in ("jpg", "png", "gif", "webp", "dat"):
stale_path = _get_decrypted_resource_path(account_dir, md5_norm, stale_ext)
if stale_path == out_path:
continue
try:
if stale_path.exists():
stale_path.unlink()
except Exception:
pass
try:
if out_path.exists() and out_path.read_bytes() == data:
return
except Exception:
pass
out_path.write_bytes(data)
def _resolve_avatar_remote_url(*, account_dir: Path, username: str) -> str:
u = str(username or "").strip()
if not u:
@@ -1311,20 +1392,26 @@ async def get_chat_image(
if md5_from_msg:
md5 = md5_from_msg
# md5 模式:优先从解密资源目录读取(更快)
cached_path: Optional[Path] = None
cached_data = b""
cached_media_type = "application/octet-stream"
# md5 模式:优先检查解密资源目录;如果微信目录里已经有更高质量版本,会在后面自动升级。
if md5:
decrypted_path = _try_find_decrypted_resource(account_dir, str(md5).lower())
if decrypted_path:
data = decrypted_path.read_bytes()
media_type = _detect_image_media_type(data[:32])
if media_type != "application/octet-stream" and _is_probably_valid_image(data, media_type):
return Response(content=data, media_type=media_type)
cached_path = decrypted_path
cached_data = data
cached_media_type = media_type
# Corrupted cached file (e.g. wrong ext / partial data): remove and regenerate from source.
try:
if decrypted_path.suffix.lower() in {".jpg", ".jpeg", ".png", ".gif", ".webp"}:
elif decrypted_path.suffix.lower() in {".jpg", ".jpeg", ".png", ".gif", ".webp"}:
try:
decrypted_path.unlink()
except Exception:
pass
except Exception:
pass
# 回退:从微信数据目录实时定位并解密
wxid_dir = _resolve_account_wxid_dir(account_dir)
@@ -1414,11 +1501,36 @@ async def get_chat_image(
break
if not p:
if cached_path:
return _build_uncached_media_response(cached_data, cached_media_type)
raise HTTPException(status_code=404, detail="Image not found.")
candidates.extend(_iter_media_source_candidates(p))
candidates = _order_media_candidates(candidates)
if cached_path:
try:
cached_key = str(cached_path.resolve())
except Exception:
cached_key = str(cached_path)
live_candidates: list[Path] = []
seen_live: set[str] = set()
for candidate in candidates:
try:
key = str(candidate.resolve())
except Exception:
key = str(candidate)
if key == cached_key or key in seen_live:
continue
seen_live.add(key)
live_candidates.append(candidate)
if _should_prefer_live_image_candidates(cached_path=cached_path, live_candidates=live_candidates):
candidates = [*live_candidates, cached_path]
else:
candidates = [cached_path, *live_candidates]
logger.info(f"chat_image: md5={md5} file_id={file_id} candidates={len(candidates)} first={p}")
data = b""
@@ -1443,19 +1555,14 @@ async def get_chat_image(
# 仅在 md5 有效时缓存到 resource 目录;file_id 可能非常长,避免写入超长文件名
if md5 and media_type.startswith("image/"):
try:
out_md5 = str(md5).lower()
ext = _detect_image_extension(data)
out_path = _get_decrypted_resource_path(account_dir, out_md5, ext)
out_path.parent.mkdir(parents=True, exist_ok=True)
if not out_path.exists():
out_path.write_bytes(data)
_write_cached_chat_image(account_dir, str(md5), data)
except Exception:
pass
logger.info(
f"chat_image: md5={md5} file_id={file_id} chosen={chosen} media_type={media_type} bytes={len(data)}"
)
return Response(content=data, media_type=media_type)
return _build_uncached_media_response(data, media_type)
@router.get("/api/chat/media/emoji", summary="获取表情消息资源")
+34
View File
@@ -26,6 +26,32 @@ _DEFAULT_WCDB_API_DLL = _NATIVE_DIR / "wcdb_api.dll"
_WCDB_API_DLL_SELECTED: Optional[Path] = None
def _iter_runtime_wcdb_api_dll_paths() -> tuple[Path, ...]:
candidates: list[Path] = []
seen: set[str] = set()
def add_anchor(anchor: str | Path | None) -> None:
if not anchor:
return
try:
base = Path(anchor).resolve()
except Exception:
base = Path(anchor)
candidate = base / "native" / "wcdb_api.dll"
key = str(candidate).replace("/", "\\").rstrip("\\").lower()
if key in seen:
return
seen.add(key)
candidates.append(candidate)
add_anchor(os.environ.get("WECHAT_TOOL_DATA_DIR", "").strip())
add_anchor(Path.cwd())
if getattr(sys, "frozen", False):
add_anchor(Path(sys.executable).resolve().parent)
return tuple(candidates)
def _is_project_wcdb_api_dll_path(path: Path) -> bool:
try:
resolved = path.resolve(strict=False)
@@ -40,6 +66,14 @@ def _is_project_wcdb_api_dll_path(path: Path) -> bool:
if resolved == default_resolved:
return True
for candidate in _iter_runtime_wcdb_api_dll_paths():
try:
if resolved == candidate.resolve(strict=False):
return True
except Exception:
if resolved == candidate:
return True
parts = tuple(str(part).lower() for part in resolved.parts)
allowed_suffixes = (
("backend", "native", "wcdb_api.dll"),
@@ -0,0 +1,210 @@
import hashlib
import importlib
import json
import logging
import os
import sqlite3
import sys
import unittest
from pathlib import Path
from tempfile import TemporaryDirectory
from fastapi import FastAPI
from fastapi.testclient import TestClient
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
class TestChatMediaImageCacheUpgrade(unittest.TestCase):
def _seed_contact_db(self, path: Path, *, account: str, username: str) -> None:
conn = sqlite3.connect(str(path))
try:
conn.execute(
"""
CREATE TABLE contact (
username TEXT,
remark TEXT,
nick_name TEXT,
alias TEXT,
local_type INTEGER,
verify_flag INTEGER,
big_head_url TEXT,
small_head_url TEXT
)
"""
)
conn.execute(
"""
CREATE TABLE stranger (
username TEXT,
remark TEXT,
nick_name TEXT,
alias TEXT,
local_type INTEGER,
verify_flag INTEGER,
big_head_url TEXT,
small_head_url TEXT
)
"""
)
conn.execute(
"INSERT INTO contact VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(account, "", "", "", 1, 0, "", ""),
)
conn.execute(
"INSERT INTO contact VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(username, "", "测试好友", "", 1, 0, "", ""),
)
conn.commit()
finally:
conn.close()
def _seed_session_db(self, path: Path, *, username: str) -> None:
conn = sqlite3.connect(str(path))
try:
conn.execute(
"""
CREATE TABLE SessionTable (
username TEXT,
is_hidden INTEGER,
sort_timestamp INTEGER
)
"""
)
conn.execute("INSERT INTO SessionTable VALUES (?, ?, ?)", (username, 0, 1735689600))
conn.commit()
finally:
conn.close()
def _seed_source_info(self, account_dir: Path, *, wxid_dir: Path) -> None:
payload = {
"wxid_dir": str(wxid_dir),
"db_storage_path": "",
}
(account_dir / "_source.json").write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
def _seed_cached_resource(self, account_dir: Path, *, md5: str, payload: bytes) -> Path:
resource_dir = account_dir / "resource" / md5[:2]
resource_dir.mkdir(parents=True, exist_ok=True)
target = resource_dir / f"{md5}.jpg"
target.write_bytes(payload)
return target
def _seed_live_variant(self, wxid_dir: Path, *, username: str, md5: str, suffix: str, payload: bytes) -> Path:
chat_hash = hashlib.md5(username.encode("utf-8")).hexdigest()
target = wxid_dir / "msg" / "attach" / chat_hash / "2026-03" / "Img" / f"{md5}{suffix}.dat"
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(payload)
return target
def _build_client(self):
import wechat_decrypt_tool.logging_config as logging_config
import wechat_decrypt_tool.app_paths as app_paths
import wechat_decrypt_tool.media_helpers as media_helpers
import wechat_decrypt_tool.routers.chat_media as chat_media
logging.shutdown()
importlib.reload(logging_config)
importlib.reload(app_paths)
importlib.reload(media_helpers)
importlib.reload(chat_media)
app = FastAPI()
app.include_router(chat_media.router)
return TestClient(app)
def test_live_high_variant_replaces_stale_cached_thumb(self):
with TemporaryDirectory() as td:
root = Path(td)
account = "wxid_test"
username = "wxid_friend"
md5 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
account_dir = root / "output" / "databases" / account
wxid_dir = root / "wxid_source"
account_dir.mkdir(parents=True, exist_ok=True)
wxid_dir.mkdir(parents=True, exist_ok=True)
self._seed_contact_db(account_dir / "contact.db", account=account, username=username)
self._seed_session_db(account_dir / "session.db", username=username)
self._seed_source_info(account_dir, wxid_dir=wxid_dir)
cached_thumb = b"\xff\xd8\xff\xd9"
live_original = b"\xff\xd8\xff\xe0" + (b"\x00" * 48) + b"\xff\xd9"
cache_path = self._seed_cached_resource(account_dir, md5=md5, payload=cached_thumb)
self._seed_live_variant(wxid_dir, username=username, md5=md5, suffix="_h", payload=live_original)
prev_data = os.environ.get("WECHAT_TOOL_DATA_DIR")
client = None
try:
os.environ["WECHAT_TOOL_DATA_DIR"] = str(root)
client = self._build_client()
resp = client.get(
"/api/chat/media/image",
params={"account": account, "md5": md5, "username": username},
)
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.content, live_original)
self.assertEqual(resp.headers.get("cache-control"), "no-store")
self.assertEqual(cache_path.read_bytes(), live_original)
finally:
try:
client.close()
except Exception:
pass
logging.shutdown()
if prev_data is None:
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
else:
os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data
def test_cached_original_is_not_downgraded_by_live_thumb(self):
with TemporaryDirectory() as td:
root = Path(td)
account = "wxid_test"
username = "wxid_friend"
md5 = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
account_dir = root / "output" / "databases" / account
wxid_dir = root / "wxid_source"
account_dir.mkdir(parents=True, exist_ok=True)
wxid_dir.mkdir(parents=True, exist_ok=True)
self._seed_contact_db(account_dir / "contact.db", account=account, username=username)
self._seed_session_db(account_dir / "session.db", username=username)
self._seed_source_info(account_dir, wxid_dir=wxid_dir)
cached_original = b"\xff\xd8\xff\xe0" + (b"\x11" * 64) + b"\xff\xd9"
live_thumb = b"\xff\xd8\xff\xd9"
cache_path = self._seed_cached_resource(account_dir, md5=md5, payload=cached_original)
self._seed_live_variant(wxid_dir, username=username, md5=md5, suffix="_t", payload=live_thumb)
prev_data = os.environ.get("WECHAT_TOOL_DATA_DIR")
client = None
try:
os.environ["WECHAT_TOOL_DATA_DIR"] = str(root)
client = self._build_client()
resp = client.get(
"/api/chat/media/image",
params={"account": account, "md5": md5, "username": username},
)
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.content, cached_original)
self.assertEqual(resp.headers.get("cache-control"), "no-store")
self.assertEqual(cache_path.read_bytes(), cached_original)
finally:
try:
client.close()
except Exception:
pass
logging.shutdown()
if prev_data is None:
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
else:
os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data
if __name__ == "__main__":
unittest.main()
+93
View File
@@ -0,0 +1,93 @@
import importlib
import logging
import os
import sys
import unittest
from pathlib import Path
from tempfile import TemporaryDirectory
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
def _close_logging_handlers() -> None:
for logger_name in ("", "uvicorn", "uvicorn.access", "uvicorn.error", "fastapi"):
lg = logging.getLogger(logger_name)
for handler in lg.handlers[:]:
try:
handler.close()
except Exception:
pass
try:
lg.removeHandler(handler)
except Exception:
pass
class TestOutputDirOverride(unittest.TestCase):
def setUp(self) -> None:
self._prev_data_dir = os.environ.get("WECHAT_TOOL_DATA_DIR")
self._prev_output_dir = os.environ.get("WECHAT_TOOL_OUTPUT_DIR")
self._data_dir = TemporaryDirectory()
self._output_dir = TemporaryDirectory()
os.environ["WECHAT_TOOL_DATA_DIR"] = self._data_dir.name
os.environ["WECHAT_TOOL_OUTPUT_DIR"] = self._output_dir.name
import wechat_decrypt_tool.app_paths as app_paths
import wechat_decrypt_tool.key_store as key_store
import wechat_decrypt_tool.logging_config as logging_config
import wechat_decrypt_tool.runtime_settings as runtime_settings
importlib.reload(app_paths)
importlib.reload(logging_config)
importlib.reload(runtime_settings)
importlib.reload(key_store)
self.app_paths = app_paths
self.key_store = key_store
self.logging_config = logging_config
self.runtime_settings = runtime_settings
def tearDown(self) -> None:
_close_logging_handlers()
if self._prev_data_dir is None:
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
else:
os.environ["WECHAT_TOOL_DATA_DIR"] = self._prev_data_dir
if self._prev_output_dir is None:
os.environ.pop("WECHAT_TOOL_OUTPUT_DIR", None)
else:
os.environ["WECHAT_TOOL_OUTPUT_DIR"] = self._prev_output_dir
self._data_dir.cleanup()
self._output_dir.cleanup()
def test_app_paths_prefers_output_dir_override(self) -> None:
self.assertEqual(self.app_paths.get_output_dir(), Path(self._output_dir.name))
self.assertEqual(
self.app_paths.get_output_databases_dir(),
Path(self._output_dir.name) / "databases",
)
def test_logging_runtime_settings_and_key_store_use_output_override(self) -> None:
log_file = self.logging_config.setup_logging()
self.assertTrue(log_file.is_relative_to(Path(self._output_dir.name) / "logs"))
self.runtime_settings.write_backend_port_setting(12001)
runtime_settings_path = Path(self._output_dir.name) / "runtime_settings.json"
self.assertTrue(runtime_settings_path.exists())
self.assertEqual(self.runtime_settings.read_backend_port_setting(), 12001)
self.key_store.upsert_account_keys_in_store("wxid_test", db_key="abc123")
key_store_path = Path(self._output_dir.name) / "account_keys.json"
self.assertTrue(key_store_path.exists())
self.assertEqual(
self.key_store.get_account_keys_from_store("wxid_test").get("db_key"),
"abc123",
)
if __name__ == "__main__":
unittest.main()