Compare commits

...

17 Commits

36 changed files with 3395 additions and 887 deletions
+1 -10
View File
@@ -6,6 +6,7 @@
<h1>WeChatDataAnalysis - 微信数据库解密与分析工具</h1>
<p>微信4.x数据解密并生成年度总结,高仿微信,支持实时更新,导出聊天记录,朋友圈等大量便捷功能</p>
<p><b>特别致谢</b><a href="https://github.com/H3CoF6">H3CoF6</a>(密钥与朋友圈等核心内容的技术支持)、<a href="https://github.com/ycccccccy/echotrace">echotrace</a>、<a href="https://github.com/hicccc77/WeFlow">WeFlow</a>(本项目大量功能参考其实现)</p>
<p>如需定制功能,请联系 QQ2977094657。</p>
<img src="https://img.shields.io/github/v/tag/LifeArchiveProject/WeChatDataAnalysis" alt="Version" />
<img src="https://img.shields.io/github/stars/LifeArchiveProject/WeChatDataAnalysis" alt="Stars" />
<img src="https://gh-down-badges.linkof.link/LifeArchiveProject/WeChatDataAnalysis" alt="Downloads" />
@@ -192,16 +193,6 @@ npm run dist
3. **数据隐私**: 解密后的数据包含个人隐私信息,请谨慎处理
4. **合法使用**: 请遵守相关法律法规,不得用于非法目的
## 修改消息
支持在聊天页对单条消息进行本地修改(如修改消息文本/字段、修复为我发送、反转本地气泡方向),并在“修改记录”页查看原始与当前对比,支持单条恢复或按会话一键恢复。
该功能只修改本机本地数据库(`db_storage` 与解密副本),不会调用远端回写接口。
<p align="center">
<img src="frontend/public/edit.gif" alt="本地消息修改" width="800" />
</p>
## 致谢
本项目的开发过程中参考了以下优秀的开源项目和资源:
+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);
}
});
+126
View File
@@ -1139,6 +1139,132 @@
flex-shrink: 0;
}
.wechat-link-card-finder {
width: 135px;
min-width: 135px;
max-width: 135px;
border: none;
box-shadow: none;
outline: none;
cursor: pointer;
text-decoration: none;
}
.wechat-link-card-finder.wechat-link-card--disabled {
cursor: default;
}
.wechat-link-finder-cover {
width: 135px;
height: 185px;
position: relative;
overflow: hidden;
border-radius: 4px;
background: var(--app-surface-muted);
}
.wechat-link-finder-cover--empty {
background: linear-gradient(180deg, #37cc6a 0%, #118f42 100%);
}
.wechat-link-finder-cover-img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
display: block;
}
.wechat-link-finder-cover-placeholder {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: rgba(255, 255, 255, 0.92);
}
.wechat-link-finder-cover-placeholder svg {
width: 34px;
height: 34px;
}
.wechat-link-finder-cover-shade {
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgba(0, 0, 0, 0.04) 0%, rgba(0, 0, 0, 0.12) 42%, rgba(0, 0, 0, 0.68) 100%);
}
.wechat-link-finder-play {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -66%);
width: 40px;
height: 40px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.42);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
}
.wechat-link-finder-play svg {
width: 20px;
height: 20px;
margin-left: 2px;
}
.wechat-link-finder-meta {
position: absolute;
left: 8px;
right: 8px;
bottom: 8px;
display: flex;
flex-direction: column;
gap: 0;
}
.wechat-link-finder-author {
display: flex;
align-items: center;
gap: 5px;
min-width: 0;
padding: 5px 7px;
border-radius: 999px;
background: rgba(0, 0, 0, 0.28);
backdrop-filter: blur(6px);
}
.wechat-link-finder-author-avatar {
width: 18px;
height: 18px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.wechat-link-finder-author-avatar-img {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
.wechat-link-finder-author-name {
min-width: 0;
flex: 1 1 auto;
font-size: 10px;
color: rgba(255, 255, 255, 0.96);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.28);
}
/* 隐私模式模糊效果 */
.privacy-blur {
filter: blur(9px);
+6
View File
@@ -1437,14 +1437,20 @@
.session-list-item-name {
color: var(--session-list-name);
font-weight: 400;
font-synthesis: none;
}
.session-list-item-time {
color: var(--session-list-meta);
font-weight: 400;
font-synthesis: none;
}
.session-list-item-preview {
color: var(--session-list-preview);
font-weight: 400;
font-synthesis: none;
}
.contact-search-wrapper {
+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
+68 -3
View File
@@ -2,6 +2,8 @@
import { defineComponent, h, ref, watch } from 'vue'
import miniProgramIconUrl from '~/assets/images/wechat/mini-program.svg'
const finderLogoUrl = '/assets/images/wechat/channels-logo.svg'
export default defineComponent({
name: 'LinkCard',
props: {
@@ -51,7 +53,11 @@ export default defineComponent({
return text ? (Array.from(text)[0] || '') : ''
})()
const fromAvatarUrl = String(props.fromAvatar || '').trim()
const headingText = String(props.heading || href || '').trim()
let abstractText = String(props.abstract || '').trim()
if (abstractText && headingText && abstractText === headingText) abstractText = ''
const isMiniProgram = String(props.linkType || '').trim() === 'mini_program'
const isFinder = String(props.linkType || '').trim() === 'finder'
const isCoverVariant = !isMiniProgram && String(props.variant || '').trim() === 'cover'
const Tag = canNavigate ? 'a' : 'div'
@@ -140,9 +146,68 @@ export default defineComponent({
)
}
const headingText = String(props.heading || href || '').trim()
let abstractText = String(props.abstract || '').trim()
if (abstractText && headingText && abstractText === headingText) abstractText = ''
if (isFinder) {
return h(
Tag,
{
...(canNavigate ? { href, target: '_blank', rel: 'noreferrer' } : { role: 'group', 'aria-disabled': 'true' }),
class: [
'wechat-link-card-finder',
!canNavigate ? 'wechat-link-card--disabled' : '',
'wechat-special-card',
'msg-radius',
props.isSent ? 'wechat-special-sent-side' : ''
].filter(Boolean).join(' '),
style: {
width: '135px',
minWidth: '135px',
maxWidth: '135px',
display: 'flex',
flexDirection: 'column',
boxSizing: 'border-box',
flex: '0 0 auto',
border: 'none',
boxShadow: 'none',
textDecoration: 'none',
outline: 'none'
}
},
[
h('div', { class: ['wechat-link-finder-cover', !props.preview ? 'wechat-link-finder-cover--empty' : ''].filter(Boolean).join(' ') }, [
props.preview
? h('img', {
src: props.preview,
alt: props.heading || '视频号封面',
class: 'wechat-link-finder-cover-img',
referrerpolicy: 'no-referrer'
})
: h('div', { class: 'wechat-link-finder-cover-placeholder', 'aria-hidden': 'true' }, [
h('svg', { viewBox: '0 0 24 24', fill: 'currentColor' }, [
h('path', { d: 'M8 5v14l11-7z' })
])
]),
h('div', { class: 'wechat-link-finder-cover-shade', 'aria-hidden': 'true' }),
h('div', { class: 'wechat-link-finder-play', 'aria-hidden': 'true' }, [
h('svg', { viewBox: '0 0 24 24', fill: 'currentColor' }, [
h('path', { d: 'M8 5v14l11-7z' })
])
]),
h('div', { class: 'wechat-link-finder-meta' }, [
h('div', { class: 'wechat-link-finder-author' }, [
h('div', { class: 'wechat-link-finder-author-avatar', 'aria-hidden': 'true' }, [
h('img', {
src: finderLogoUrl,
alt: '',
class: 'wechat-link-finder-author-avatar-img'
})
]),
h('div', { class: 'wechat-link-finder-author-name' }, fromText || '视频号')
])
])
])
]
)
}
if (isMiniProgram) {
return h(
@@ -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="{
@@ -98,7 +98,7 @@
<!-- 联系人信息 -->
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between">
<h3 class="session-list-item-name text-sm font-medium truncate" :class="{ 'privacy-blur': privacyMode }">{{ contact.name }}</h3>
<h3 class="session-list-item-name text-sm truncate" :class="{ 'privacy-blur': privacyMode }">{{ contact.name }}</h3>
<div class="flex items-center flex-shrink-0 ml-2">
<span class="session-list-item-time text-xs">{{ contact.lastMessageTime }}</span>
</div>
@@ -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
-3
View File
@@ -73,9 +73,6 @@
</svg>
点击按钮将自动获取数据库图片双重密钥您也可以手动输入已知的64位密钥使用<a href="https://github.com/ycccccccy/wx_key" target="_blank" class="text-[#07C160] hover:text-[#06AD56]">wx_key</a>等工具获取
</p>
<div class="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs leading-5 text-amber-900">
提示数据库密钥跟随账号 + 设备下发同一账号在另一台电脑生成的聊天记录复制到当前设备后通常无法在当前设备重新获取原设备对应的密钥因此也无法直接解密
</div>
</div>
<!-- 数据库路径输入 -->
@@ -0,0 +1,5 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg t="1774499781741" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="7897" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200">
<path d="M118.81813333 106.3936c87.27893333-26.2144 192.03413333 75.1616 358.46826667 346.9312 18.70506667 30.5152 34.7136 55.46666667 35.60106667 55.5008 0.88746667 0 17.74933333-26.4192 37.41013333-58.70933333 165.51253333-271.53066667 270.336-371.3024 359.35573333-342.08426667 56.55893333 18.56853333 80.41813333 73.18186667 80.21333334 183.63733333-0.4096 214.86933333-103.69706667 551.59466667-188.0064 612.89813334-69.4272 50.44906667-173.1584-13.07306667-269.85813334-165.30773334-9.59146667-15.1552-18.36373333-27.57973333-19.42186666-27.61386666-1.05813333 0-9.59146667 12.32213333-18.944 27.4432-50.96106667 82.39786667-113.4592 146.26133333-167.04853334 170.7008-26.04373333 11.8784-71.33866667 13.5168-90.45333333 3.24266666-52.08746667-27.98933333-110.72853333-149.504-156.16-323.72053333C7.3728 310.95466667 21.504 135.68 118.81813333 106.3936zM848.31573333 217.088c-55.26186667 42.93973333-126.49813333 138.58133333-230.8096 309.93066667l-49.2544 80.82773333 16.86186667 30.17386667c42.35946667 75.94666667 91.30666667 139.81013333 130.79893333 170.66666666 26.76053333 20.95786667 35.60106667 16.55466667 58.9824-29.4912 73.5232-144.55466667 136.192-440.7296 115.712-547.19146666-6.144-32.0512-15.80373333-35.46453333-42.2912-14.91626667zM143.73546667 207.9744c-19.72906667 19.49013333-14.60906667 145.8176 10.99093333 271.90613333 30.89066667 152.23466667 95.91466667 329.3184 124.5184 339.2512 27.81866667 9.65973333 104.31146667-77.824 164.38613333-188.0064l13.14133334-24.13226666-42.15466667-69.18826667c-112.98133333-185.344-186.64106667-284.3648-240.8448-323.72053333-16.65706667-12.0832-22.7328-13.312-30.03733333-6.10986667z" fill="#FF9908" p-id="7898"></path>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

+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
View File
@@ -1220,6 +1220,70 @@ def _parse_app_message(text: str) -> dict[str, Any]:
"linkStyle": link_style,
}
if app_type == 51:
# 视频号分享(Finder / Channels
# 常见特征:
# - title 是「当前版本不支持展示该内容,请升级至最新版本。」
# - 真正标题在 <finderFeed><desc> 或其它 finder 节点里
finder_feed = _extract_xml_tag_text(text, "finderFeed")
finder_desc = (
(_extract_xml_tag_text(finder_feed, "desc") if finder_feed else "")
or _extract_xml_tag_text(text, "finderdesc")
or des
)
finder_nickname = (
_extract_xml_tag_text(text, "findernickname")
or _extract_xml_tag_text(text, "finder_nickname")
or (_extract_xml_tag_text(finder_feed, "nickname") if finder_feed else "")
or (_extract_xml_tag_text(finder_feed, "findernickname") if finder_feed else "")
)
finder_username = (
_extract_xml_tag_text(text, "finderusername")
or _extract_xml_tag_text(text, "finder_username")
or (_extract_xml_tag_text(finder_feed, "username") if finder_feed else "")
or (_extract_xml_tag_text(finder_feed, "finderusername") if finder_feed else "")
)
thumb_url = _normalize_xml_url(
_extract_xml_tag_or_attr(text, "thumburl")
or _extract_xml_tag_or_attr(text, "cdnthumburl")
or _extract_xml_tag_or_attr(text, "coverurl")
or _extract_xml_tag_or_attr(text, "cover")
or (_extract_xml_tag_or_attr(finder_feed, "thumbUrl") if finder_feed else "")
or (_extract_xml_tag_or_attr(finder_feed, "thumburl") if finder_feed else "")
or (_extract_xml_tag_or_attr(finder_feed, "coverUrl") if finder_feed else "")
or (_extract_xml_tag_or_attr(finder_feed, "coverurl") if finder_feed else "")
)
finder_url = url or _normalize_xml_url(
(_extract_xml_tag_text(finder_feed, "url") if finder_feed else "")
or (_extract_xml_tag_text(text, "playurl"))
or (_extract_xml_tag_text(text, "dataurl"))
)
display_title = str(title or "").strip()
if (not display_title) or ("不支持" in display_title):
display_title = str(finder_desc or "").strip()
if not display_title:
display_title = str(des or "").strip()
display_title = display_title or "[视频号]"
summary_text = str(finder_desc or "").strip() or display_title
from_display = str(finder_nickname or source_display_name or "").strip() or "视频号"
from_u = str(finder_username or source_username or "").strip()
return {
"renderType": "link",
"content": summary_text,
"title": display_title,
"url": finder_url or "",
"thumbUrl": thumb_url or "",
"from": from_display,
"fromUsername": from_u,
"linkType": "finder",
"linkStyle": "finder",
}
if app_type in (33, 36):
# 小程序分享(WeChat v4 常见:local_type = 49 + (33<<32) / 49 + (36<<32)
# 注:部分 payload 的 <url> 为空;前端会按需渲染为不可点击卡片。
+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}
+340 -234
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,127 +1804,30 @@ 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
if new_rows and (not name2id_synced):
_best_effort_upsert_output_name2id_rows(
msg_conn,
account_name=account_dir.name,
rows=new_rows,
)
if new_rows:
if not name2id_synced:
_best_effort_upsert_output_name2id_rows(
msg_conn,
account_name=account_dir.name,
rows=new_rows,
)
# Insert older -> newer to keep sqlite btree locality similar to existing data.
values = [tuple(r.get(c) for c in insert_cols) for r in reversed(new_rows)]
@@ -1879,124 +2075,30 @@ 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
if new_rows and (not name2id_synced):
_best_effort_upsert_output_name2id_rows(
msg_conn,
account_name=account_dir.name,
rows=new_rows,
)
if new_rows:
if not name2id_synced:
_best_effort_upsert_output_name2id_rows(
msg_conn,
account_name=account_dir.name,
rows=new_rows,
)
values = [tuple(r.get(c) for c in insert_cols) for r in reversed(new_rows)]
insert_t0 = time.perf_counter()
@@ -2161,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,
):
"""
@@ -2171,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:
@@ -2239,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="获取表情消息资源")
+3 -27
View File
@@ -14,12 +14,7 @@ from ..app_paths import get_output_databases_dir
from ..logging_config import get_logger
from ..path_fix import PathFixRoute
from ..key_store import upsert_account_keys_in_store
from ..wechat_decrypt import (
WeChatDatabaseDecryptor,
build_decrypt_result_message,
decrypt_wechat_databases,
scan_account_databases_from_path,
)
from ..wechat_decrypt import WeChatDatabaseDecryptor, decrypt_wechat_databases, scan_account_databases_from_path
logger = get_logger(__name__)
@@ -81,7 +76,6 @@ async def decrypt_databases(request: DecryptRequest):
"message": results["message"],
"processed_files": results["processed_files"],
"failed_files": results["failed_files"],
"failure_details": results.get("failure_details", []),
"account_results": results.get("account_results", {}),
}
@@ -165,7 +159,6 @@ async def decrypt_databases_stream(
fail_count = 0
processed_files: list[str] = []
failed_files: list[str] = []
failure_details: list[dict] = []
account_results: dict = {}
overall_current = 0
@@ -188,7 +181,6 @@ async def decrypt_databases_stream(
account_success = 0
account_processed: list[str] = []
account_failed: list[str] = []
account_failure_details: list[dict] = []
for db_info in dbs:
if await request.is_disconnected():
@@ -240,20 +232,11 @@ async def decrypt_databases_stream(
status = "success"
msg = "解密成功"
else:
failure_detail = {
"account": account,
"file": db_path,
"name": db_name,
"code": str(decryptor.last_error_code or "").strip(),
"reason": str(decryptor.last_error_message or "").strip() or "解密失败",
}
account_failed.append(db_path)
account_failure_details.append(failure_detail)
failed_files.append(db_path)
failure_details.append(failure_detail)
fail_count += 1
status = "fail"
msg = failure_detail["reason"]
msg = "解密失败"
yield _sse(
{
@@ -278,7 +261,6 @@ async def decrypt_databases_stream(
"output_dir": str(account_output_dir),
"processed_files": account_processed,
"failed_files": account_failed,
"failure_details": account_failure_details,
}
# Build cache table (keep behavior consistent with the POST endpoint).
@@ -325,15 +307,9 @@ async def decrypt_databases_stream(
"success_count": success_count,
"failure_count": total_databases - success_count,
"output_directory": str(base_output_dir.absolute()),
"message": build_decrypt_result_message(
total_databases=total_databases,
success_count=success_count,
failed_count=total_databases - success_count,
failure_details=failure_details,
),
"message": f"解密完成: 成功 {success_count}/{total_databases}",
"processed_files": processed_files,
"failed_files": failed_files,
"failure_details": failure_details,
"account_results": account_results,
}
+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"),
+130 -232
View File
@@ -13,12 +13,12 @@ import hashlib
import hmac
import os
import json
import shutil
import tempfile
from pathlib import Path
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from .app_paths import get_output_databases_dir
@@ -26,117 +26,6 @@ from .app_paths import get_output_databases_dir
# SQLite文件头
SQLITE_HEADER = b"SQLite format 3\x00"
PAGE_SIZE = 4096
KEY_SIZE = 32
SALT_SIZE = 16
IV_SIZE = 16
HMAC_SIZE = 64
RESERVE_SIZE = 80
KEY_MISMATCH_GUIDANCE = (
"请在当前设备登录该账号后重新获取密钥;"
"如果聊天记录是从另一台设备复制过来的,当前设备通常无法获取原设备对应的密钥。"
)
def _derive_mac_key(raw_key: bytes, salt: bytes) -> bytes:
mac_salt = bytes(b ^ 0x3A for b in salt)
return hashlib.pbkdf2_hmac("sha512", raw_key, mac_salt, 2, dklen=KEY_SIZE)
def _derive_sqlcipher_enc_key(key_material: bytes, salt: bytes) -> bytes:
return hashlib.pbkdf2_hmac("sha512", key_material, salt, 256000, dklen=KEY_SIZE)
def _resolve_page1_key_material(key_material: bytes, page1: bytes) -> tuple[bytes, bytes, str] | None:
salt = page1[:SALT_SIZE]
stored_page1_hmac = page1[PAGE_SIZE - HMAC_SIZE : PAGE_SIZE]
candidates = [
("raw_enc_key", key_material, _derive_mac_key(key_material, salt)),
]
derived_key = _derive_sqlcipher_enc_key(key_material, salt)
candidates.append(("sqlcipher_passphrase", derived_key, _derive_mac_key(derived_key, salt)))
for mode, enc_key, mac_key in candidates:
expected_page1_hmac = _compute_page_hmac(mac_key, page1, 1)
if stored_page1_hmac == expected_page1_hmac:
return enc_key, mac_key, mode
return None
def _compute_page_hmac(mac_key: bytes, page: bytes, page_num: int) -> bytes:
offset = SALT_SIZE if page_num == 1 else 0
data_end = PAGE_SIZE - RESERVE_SIZE + IV_SIZE
mac = hmac.new(mac_key, digestmod=hashlib.sha512)
mac.update(page[offset:data_end])
mac.update(page_num.to_bytes(4, "little"))
return mac.digest()
def _decrypt_page(raw_key: bytes, page: bytes, page_num: int) -> bytes:
iv = page[PAGE_SIZE - RESERVE_SIZE : PAGE_SIZE - RESERVE_SIZE + IV_SIZE]
offset = SALT_SIZE if page_num == 1 else 0
encrypted = page[offset : PAGE_SIZE - RESERVE_SIZE]
cipher = Cipher(
algorithms.AES(raw_key),
modes.CBC(iv),
backend=default_backend(),
)
decryptor = cipher.decryptor()
decrypted = decryptor.update(encrypted) + decryptor.finalize()
if page_num == 1:
return SQLITE_HEADER + decrypted + (b"\x00" * RESERVE_SIZE)
return decrypted + (b"\x00" * RESERVE_SIZE)
def _failure_matches_key_mismatch(detail: dict | None) -> bool:
if not isinstance(detail, dict):
return False
code = str(detail.get("code") or "").strip().lower()
reason = str(detail.get("reason") or "").strip()
if code == "key_mismatch":
return True
return ("密钥" in reason and "不匹配" in reason) or ("当前数据库密钥不正确" in reason)
def build_decrypt_result_message(
total_databases: int,
success_count: int,
failed_count: int,
failure_details: list[dict] | None = None,
) -> str:
total = max(int(total_databases or 0), 0)
success = max(int(success_count or 0), 0)
failed = max(int(failed_count or 0), 0)
details = list(failure_details or [])
if total == 0:
return "未找到可解密的数据库文件"
if failed == 0:
return f"解密完成: 成功 {success}/{total}"
key_mismatch_count = sum(1 for item in details if _failure_matches_key_mismatch(item))
if success == 0 and failed == total:
if key_mismatch_count == failed:
return (
f"解密失败:当前数据库密钥不正确,或该密钥不属于当前账号/当前设备(0/{total} 成功)。"
+ KEY_MISMATCH_GUIDANCE
)
return f"解密失败:0/{total} 个数据库解密成功,请检查密钥、账号与数据库路径是否匹配。"
if key_mismatch_count > 0:
return (
f"解密完成:成功 {success}/{total},失败 {failed}/{total}"
"失败文件中包含密钥不匹配的数据库,请确认使用的是当前账号在当前设备上的密钥。"
)
return f"解密完成:成功 {success}/{total},失败 {failed}/{total}"
def _normalize_account_name(name: str) -> str:
@@ -332,123 +221,153 @@ class WeChatDatabaseDecryptor:
self.key_bytes = bytes.fromhex(key_hex)
except ValueError:
raise ValueError("密钥必须是有效的十六进制字符串")
self.last_error_code = ""
self.last_error_message = ""
def _set_last_error(self, code: str, message: str) -> None:
self.last_error_code = str(code or "").strip()
self.last_error_message = str(message or "").strip()
def _clear_last_error(self) -> None:
self.last_error_code = ""
self.last_error_message = ""
def decrypt_database(self, db_path: str, output_path: str) -> bool:
"""解密微信4.x版本数据库
兼容两种输入形态
- raw enc_key部分内存扫描/工具直接返回
- SQLCipher 口令/基础 key需先用数据库 salt 做一轮 PBKDF2
使用SQLCipher 4.0参数:
- PBKDF2-SHA512, 256000轮迭代
- AES-256-CBC加密
- HMAC-SHA512验证
- 页面大小4096字节
"""
from .logging_config import get_logger
logger = get_logger(__name__)
logger.info(f"开始解密数据库: {db_path}")
tmp_output_path = ""
self._clear_last_error()
try:
file_size = os.path.getsize(db_path)
logger.info(f"读取文件大小: {file_size} bytes")
with open(db_path, 'rb') as f:
encrypted_data = f.read()
logger.info(f"读取文件大小: {len(encrypted_data)} bytes")
if file_size < PAGE_SIZE:
message = f"数据库文件小,无法解密: {db_path}"
self._set_last_error("file_too_small", message)
logger.warning(message)
return False
output_dir = Path(output_path).parent
output_dir.mkdir(parents=True, exist_ok=True)
with open(db_path, "rb") as source:
page1 = source.read(PAGE_SIZE)
if len(page1) < PAGE_SIZE:
message = f"数据库首页大小不足,无法解密: {db_path}"
self._set_last_error("page_too_small", message)
logger.warning(message)
if len(encrypted_data) < 4096:
logger.warning(f"文件小,跳过解密: {db_path}")
return False
# 检查是否已经是解密的数据库
if page1.startswith(SQLITE_HEADER):
if encrypted_data.startswith(SQLITE_HEADER):
logger.info(f"文件已是SQLite格式,直接复制: {db_path}")
fd, tmp_output_path = tempfile.mkstemp(
prefix=f".{Path(output_path).name}.",
suffix=".tmp",
dir=str(output_dir),
)
os.close(fd)
with open(db_path, "rb") as src, open(tmp_output_path, "wb") as dst:
shutil.copyfileobj(src, dst, length=1024 * 1024)
os.replace(tmp_output_path, output_path)
tmp_output_path = ""
with open(output_path, 'wb') as f:
f.write(encrypted_data)
return True
resolved_key_material = _resolve_page1_key_material(self.key_bytes, page1)
if resolved_key_material is None:
message = f"当前数据库密钥不正确,或该密钥不属于当前账号/当前设备: {db_path}"
self._set_last_error("key_mismatch", message)
logger.error(f"页面 1 HMAC验证失败,密钥与数据库不匹配: {db_path}")
return False
enc_key, mac_key, key_mode = resolved_key_material
logger.info(f"页面 1 HMAC验证通过: mode={key_mode} path={db_path}")
total_pages = (file_size + PAGE_SIZE - 1) // PAGE_SIZE
successful_pages = 0
fd, tmp_output_path = tempfile.mkstemp(
prefix=f".{Path(output_path).name}.",
suffix=".tmp",
dir=str(output_dir),
# 提取salt (前16字节)
salt = encrypted_data[:16]
# 计算mac_salt (salt XOR 0x3a)
mac_salt = bytes(b ^ 0x3a for b in salt)
# 使用PBKDF2-SHA512派生密钥
kdf = PBKDF2HMAC(
algorithm=hashes.SHA512(),
length=32,
salt=salt,
iterations=256000,
backend=default_backend()
)
os.close(fd)
with open(db_path, "rb") as source, open(tmp_output_path, "wb") as target:
for page_num in range(1, total_pages + 1):
page = source.read(PAGE_SIZE)
if not page:
break
if len(page) < PAGE_SIZE:
logger.warning(f"页面 {page_num} 大小不足: {len(page)} bytes,自动补齐到 {PAGE_SIZE} bytes")
page = page + (b"\x00" * (PAGE_SIZE - len(page)))
stored_hmac = page[PAGE_SIZE - HMAC_SIZE : PAGE_SIZE]
expected_hmac = _compute_page_hmac(mac_key, page, page_num)
if stored_hmac != expected_hmac:
message = f"数据库校验失败,文件可能损坏或密钥不匹配: {db_path}"
self._set_last_error("page_hmac_mismatch", message)
logger.error(f"页面 {page_num} HMAC验证失败,终止解密: {db_path}")
return False
target.write(_decrypt_page(enc_key, page, page_num))
derived_key = kdf.derive(self.key_bytes)
# 派生MAC密钥
mac_kdf = PBKDF2HMAC(
algorithm=hashes.SHA512(),
length=32,
salt=mac_salt,
iterations=2,
backend=default_backend()
)
mac_key = mac_kdf.derive(derived_key)
# 解密数据
decrypted_data = bytearray()
decrypted_data.extend(SQLITE_HEADER)
page_size = 4096
iv_size = 16
hmac_size = 64 # SHA512的HMAC是64字节
# 计算保留区域大小 (对齐到AES块大小)
reserve_size = iv_size + hmac_size
if reserve_size % 16 != 0:
reserve_size = ((reserve_size // 16) + 1) * 16
total_pages = len(encrypted_data) // page_size
successful_pages = 0
failed_pages = 0
# 逐页解密
for cur_page in range(total_pages):
start = cur_page * page_size
end = start + page_size
page = encrypted_data[start:end]
page_num = cur_page + 1 # 页面编号从1开始
if len(page) < page_size:
logger.warning(f"页面 {page_num} 大小不足: {len(page)} bytes")
break
# 确定偏移量:第一页(cur_page == 0)需要跳过salt
offset = 16 if cur_page == 0 else 0 # SALT_SIZE = 16
# 提取存储的HMAC
hmac_start = page_size - reserve_size + iv_size
hmac_end = hmac_start + hmac_size
stored_hmac = page[hmac_start:hmac_end]
# 按照wechat-dump-rs的方式验证HMAC
data_end = page_size - reserve_size + iv_size
hmac_data = page[offset:data_end]
# 分步计算HMAC:先更新数据,再更新页面编号
mac = hmac.new(mac_key, digestmod=hashlib.sha512)
mac.update(hmac_data) # 包含加密数据+IV
mac.update(page_num.to_bytes(4, 'little')) # 页面编号(小端序)
expected_hmac = mac.digest()
if stored_hmac != expected_hmac:
logger.warning(f"页面 {page_num} HMAC验证失败")
failed_pages += 1
continue
# 提取IV和加密数据用于AES解密
iv = page[page_size - reserve_size:page_size - reserve_size + iv_size]
encrypted_page = page[offset:page_size - reserve_size]
# AES-CBC解密
try:
cipher = Cipher(
algorithms.AES(derived_key),
modes.CBC(iv),
backend=default_backend()
)
decryptor = cipher.decryptor()
decrypted_page = decryptor.update(encrypted_page) + decryptor.finalize()
# 按照wechat-dump-rs的方式重组页面数据
decrypted_data.extend(decrypted_page)
decrypted_data.extend(page[page_size - reserve_size:]) # 保留区域
successful_pages += 1
except Exception as e:
logger.error(f"页面 {page_num} AES解密失败: {e}")
failed_pages += 1
continue
logger.info(f"解密完成: 成功 {successful_pages} 页, 失败 0")
os.replace(tmp_output_path, output_path)
tmp_output_path = ""
logger.info(f"解密文件大小: {os.path.getsize(output_path)} bytes")
self._clear_last_error()
logger.info(f"解密完成: 成功 {successful_pages} 页, 失败 {failed_pages}")
# 写入解密后的文件
with open(output_path, 'wb') as f:
f.write(decrypted_data)
logger.info(f"解密文件大小: {len(decrypted_data)} bytes")
return True
except Exception as e:
self._set_last_error("exception", f"解密过程中发生异常: {e}")
logger.error(f"解密失败: {db_path}, 错误: {e}")
return False
finally:
if tmp_output_path:
try:
os.remove(tmp_output_path)
except OSError:
pass
def decrypt_wechat_databases(db_storage_path: str = None, key: str = None) -> dict:
"""
@@ -573,7 +492,6 @@ def decrypt_wechat_databases(db_storage_path: str = None, key: str = None) -> di
success_count = 0
processed_files = []
failed_files = []
failure_details = []
account_results = {}
for account_name, databases in account_databases.items():
@@ -605,7 +523,6 @@ def decrypt_wechat_databases(db_storage_path: str = None, key: str = None) -> di
account_success = 0
account_processed = []
account_failed = []
account_failure_details = []
for db_info in databases:
db_path = db_info['path']
@@ -625,16 +542,7 @@ def decrypt_wechat_databases(db_storage_path: str = None, key: str = None) -> di
else:
account_failed.append(db_path)
failed_files.append(db_path)
failure_detail = {
"account": account_name,
"file": db_path,
"name": db_name,
"code": str(decryptor.last_error_code or "").strip(),
"reason": str(decryptor.last_error_message or "").strip() or "解密失败",
}
account_failure_details.append(failure_detail)
failure_details.append(failure_detail)
logger.error(f"解密失败: {account_name}/{db_name} reason={failure_detail['reason']}")
logger.error(f"解密失败: {account_name}/{db_name}")
# 记录账号解密结果
account_results[account_name] = {
@@ -643,8 +551,7 @@ def decrypt_wechat_databases(db_storage_path: str = None, key: str = None) -> di
"failed": len(databases) - account_success,
"output_dir": str(account_output_dir),
"processed_files": account_processed,
"failed_files": account_failed,
"failure_details": account_failure_details,
"failed_files": account_failed
}
# 构建“会话最后一条消息”缓存表:把耗时挪到解密阶段,后续会话列表直接查表
@@ -668,23 +575,15 @@ def decrypt_wechat_databases(db_storage_path: str = None, key: str = None) -> di
logger.info(f"账号 {account_name} 解密完成: 成功 {account_success}/{len(databases)}")
# 返回结果
failed_count = total_databases - success_count
message = build_decrypt_result_message(
total_databases=total_databases,
success_count=success_count,
failed_count=failed_count,
failure_details=failure_details,
)
result = {
"status": "success" if success_count > 0 else "error",
"message": message,
"message": f"解密完成: 成功 {success_count}/{total_databases}",
"total_databases": total_databases,
"successful_count": success_count,
"failed_count": failed_count,
"failed_count": total_databases - success_count,
"output_directory": str(base_output_dir.absolute()),
"processed_files": processed_files,
"failed_files": failed_files,
"failure_details": failure_details,
"account_results": account_results, # 新增:按账号的详细结果
"detected_accounts": detected_accounts,
}
@@ -692,9 +591,8 @@ def decrypt_wechat_databases(db_storage_path: str = None, key: str = None) -> di
logger.info("=" * 60)
logger.info("解密任务完成!")
logger.info(f"成功: {success_count}/{total_databases}")
logger.info(f"失败: {failed_count}/{total_databases}")
logger.info(f"失败: {total_databases - success_count}/{total_databases}")
logger.info(f"输出目录: {base_output_dir.absolute()}")
logger.info(f"结果说明: {message}")
logger.info("=" * 60)
return result
@@ -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()
+119
View File
@@ -112,6 +112,125 @@ class TestChatRealtimeName2IdSync(unittest.TestCase):
],
)
def test_sync_still_inserts_new_messages_when_name2id_is_up_to_date(self):
with TemporaryDirectory() as td:
account_dir = Path(td) / "acc"
account_dir.mkdir(parents=True, exist_ok=True)
username = "wxid_friend"
table_name = f"Msg_{hashlib.md5(username.encode('utf-8')).hexdigest()}"
msg_db_path = account_dir / "message_0.db"
conn = sqlite3.connect(str(msg_db_path))
try:
conn.execute("CREATE TABLE Name2Id (user_name TEXT, is_session INTEGER DEFAULT 1)")
conn.execute(
"""
CREATE TABLE "{table_name}" (
local_id INTEGER PRIMARY KEY,
server_id INTEGER,
local_type INTEGER,
sort_seq INTEGER,
real_sender_id INTEGER,
create_time INTEGER,
message_content TEXT,
compress_content BLOB,
packed_info_data BLOB
)
""".format(table_name=table_name)
)
conn.execute("INSERT INTO Name2Id(rowid, user_name, is_session) VALUES (1, ?, 1)", ("acc",))
conn.execute("INSERT INTO Name2Id(rowid, user_name, is_session) VALUES (2, ?, 1)", (username,))
conn.execute(
f'INSERT INTO "{table_name}" '
"(local_id, server_id, local_type, sort_seq, real_sender_id, create_time, message_content, compress_content, packed_info_data) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(10, 10010, 1, 10, 2, 1710000010, "old", None, None),
)
conn.commit()
finally:
conn.close()
session_conn = sqlite3.connect(str(account_dir / "session.db"))
try:
session_conn.execute(
"""
CREATE TABLE SessionTable (
username TEXT PRIMARY KEY,
summary TEXT DEFAULT '',
last_timestamp INTEGER DEFAULT 0,
sort_timestamp INTEGER DEFAULT 0,
last_msg_locald_id INTEGER DEFAULT 0,
last_msg_type INTEGER DEFAULT 0,
last_msg_sub_type INTEGER DEFAULT 0,
last_msg_sender TEXT DEFAULT ''
)
"""
)
session_conn.commit()
finally:
session_conn.close()
def _fake_exec_query(_handle, *, kind, path, sql):
self.assertEqual(kind, "message")
self.assertTrue(str(path).endswith("message_0.db"))
if "COUNT(1)" in sql:
return [{"c": 2, "mx": 2}]
raise AssertionError(f"Unexpected SQL: {sql}")
live_messages = [
{
"local_id": 11,
"server_id": 10011,
"local_type": 1,
"sort_seq": 11,
"real_sender_id": 2,
"create_time": 1710000011,
"message_content": "new message",
"compress_content": None,
"sender_username": username,
}
]
with (
patch.object(
chat_router,
"_resolve_db_storage_message_paths",
return_value=(Path(td) / "live_message_0.db", Path(td) / "message_resource.db"),
),
patch.object(chat_router, "_wcdb_exec_query", side_effect=_fake_exec_query),
patch.object(chat_router, "_wcdb_get_messages", side_effect=[list(live_messages)]),
patch.object(chat_router, "_best_effort_upsert_output_name2id_rows") as mock_upsert_name2id,
):
result = chat_router._sync_chat_realtime_messages_for_table(
account_dir=account_dir,
rt_conn=_DummyConn(),
username=username,
msg_db_path=msg_db_path,
table_name=table_name,
max_scan=50,
backfill_limit=0,
)
self.assertEqual(result.get("inserted"), 1)
mock_upsert_name2id.assert_not_called()
conn = sqlite3.connect(str(msg_db_path))
try:
rows = conn.execute(
f'SELECT local_id, server_id, real_sender_id, create_time, message_content FROM "{table_name}" ORDER BY local_id ASC'
).fetchall()
finally:
conn.close()
self.assertEqual(
rows,
[
(10, 10010, 2, 1710000010, "old"),
(11, 10011, 2, 1710000011, "new message"),
],
)
if __name__ == "__main__":
unittest.main()
-100
View File
@@ -3,44 +3,14 @@ import os
import sys
import unittest
import importlib
import hashlib
import hmac
from pathlib import Path
from tempfile import TemporaryDirectory
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
def _encrypt_page(raw_key: bytes, plain_page: bytes, page_num: int, salt: bytes, iv: bytes) -> bytes:
from wechat_decrypt_tool.wechat_decrypt import PAGE_SIZE, RESERVE_SIZE, SALT_SIZE, _derive_mac_key
if page_num == 1:
encrypted_input = plain_page[SALT_SIZE : PAGE_SIZE - RESERVE_SIZE]
prefix = salt
else:
encrypted_input = plain_page[: PAGE_SIZE - RESERVE_SIZE]
prefix = b""
cipher = Cipher(
algorithms.AES(raw_key),
modes.CBC(iv),
backend=default_backend(),
)
encryptor = cipher.encryptor()
encrypted = encryptor.update(encrypted_input) + encryptor.finalize()
page_without_hmac = prefix + encrypted + iv
mac = hmac.new(_derive_mac_key(raw_key, salt), digestmod=hashlib.sha512)
mac.update(page_without_hmac[SALT_SIZE if page_num == 1 else 0 :])
mac.update(page_num.to_bytes(4, "little"))
return page_without_hmac + mac.digest()
class TestDecryptStreamSSE(unittest.TestCase):
def test_decrypt_stream_reports_progress(self):
from fastapi import FastAPI
@@ -115,76 +85,6 @@ class TestDecryptStreamSSE(unittest.TestCase):
else:
os.environ["WECHAT_TOOL_BUILD_SESSION_LAST_MESSAGE"] = prev_build_cache
def test_decrypt_stream_reports_key_scope_error_for_wrong_key(self):
from fastapi import FastAPI
from fastapi.testclient import TestClient
from wechat_decrypt_tool.wechat_decrypt import PAGE_SIZE, RESERVE_SIZE, SQLITE_HEADER
good_key = bytes.fromhex("00112233445566778899aabbccddeefffedcba98765432100123456789abcdef")
bad_key = "ffeeddccbbaa998877665544332211000123456789abcdeffedcba9876543210"
salt = bytes.fromhex("11223344556677889900aabbccddeeff")
iv1 = bytes.fromhex("0102030405060708090a0b0c0d0e0f10")
plain_page = SQLITE_HEADER + (b"A" * (PAGE_SIZE - RESERVE_SIZE - len(SQLITE_HEADER))) + (b"\x00" * RESERVE_SIZE)
encrypted_db = _encrypt_page(good_key, plain_page, 1, salt, iv1)
with TemporaryDirectory() as td:
root = Path(td)
prev_data_dir = os.environ.get("WECHAT_TOOL_DATA_DIR")
prev_build_cache = os.environ.get("WECHAT_TOOL_BUILD_SESSION_LAST_MESSAGE")
try:
os.environ["WECHAT_TOOL_DATA_DIR"] = str(root)
os.environ["WECHAT_TOOL_BUILD_SESSION_LAST_MESSAGE"] = "0"
import wechat_decrypt_tool.app_paths as app_paths
import wechat_decrypt_tool.routers.decrypt as decrypt_router
importlib.reload(app_paths)
importlib.reload(decrypt_router)
db_storage = root / "xwechat_files" / "wxid_wrong_key_user" / "db_storage"
db_storage.mkdir(parents=True, exist_ok=True)
(db_storage / "MSG0.db").write_bytes(encrypted_db)
app = FastAPI()
app.include_router(decrypt_router.router)
client = TestClient(app)
events: list[dict] = []
with client.stream(
"GET",
"/api/decrypt_stream",
params={"key": bad_key, "db_storage_path": str(db_storage)},
) as resp:
self.assertEqual(resp.status_code, 200)
for line in resp.iter_lines():
if not line:
continue
if isinstance(line, bytes):
line = line.decode("utf-8", errors="ignore")
line = str(line)
if line.startswith(":") or not line.startswith("data: "):
continue
payload = json.loads(line[len("data: ") :])
events.append(payload)
if payload.get("type") in {"complete", "error"}:
break
self.assertEqual(events[-1].get("type"), "complete")
self.assertEqual(events[-1].get("status"), "failed")
self.assertIn("当前数据库密钥不正确", events[-1].get("message", ""))
self.assertIn("另一台设备复制", events[-1].get("message", ""))
finally:
if prev_data_dir is None:
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
else:
os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data_dir
if prev_build_cache is None:
os.environ.pop("WECHAT_TOOL_BUILD_SESSION_LAST_MESSAGE", None)
else:
os.environ["WECHAT_TOOL_BUILD_SESSION_LAST_MESSAGE"] = prev_build_cache
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()
+30
View File
@@ -118,6 +118,36 @@ class TestParseAppMessage(unittest.TestCase):
self.assertEqual(parsed.get("linkType"), "official_article")
self.assertEqual(parsed.get("linkStyle"), "cover")
def test_finder_type_51_uses_nested_desc_and_cover(self):
raw_text = (
'<msg><appmsg appid="" sdkver="0">'
'<title>当前版本不支持展示该内容,请升级至最新版本。</title>'
'<des></des>'
'<type>51</type>'
'<url></url>'
'<finderFeed>'
'<nickname><![CDATA[央视新闻]]></nickname>'
'<username><![CDATA[finder_cctv_news]]></username>'
'<desc><![CDATA[微信视频号全金融行业今公布发布]]></desc>'
'<mediaList><media>'
'<coverUrl><![CDATA[https://finder.video.qq.com/cover.jpg]]></coverUrl>'
'<url><![CDATA[https://channels.weixin.qq.com/web/pages/feed?feedid=abc]]></url>'
'</media></mediaList>'
'</finderFeed>'
'</appmsg></msg>'
)
parsed = _parse_app_message(raw_text)
self.assertEqual(parsed.get("renderType"), "link")
self.assertEqual(parsed.get("linkType"), "finder")
self.assertEqual(parsed.get("title"), "微信视频号全金融行业今公布发布")
self.assertEqual(parsed.get("content"), "微信视频号全金融行业今公布发布")
self.assertEqual(parsed.get("from"), "央视新闻")
self.assertEqual(parsed.get("fromUsername"), "finder_cctv_news")
self.assertEqual(parsed.get("thumbUrl"), "https://finder.video.qq.com/cover.jpg")
self.assertEqual(parsed.get("url"), "https://channels.weixin.qq.com/web/pages/feed?feedid=abc")
def test_quote_type_5_nested_xml_refermsg_uses_inner_title(self):
raw_text = (
'<msg><appmsg appid="" sdkver="0">'
-172
View File
@@ -1,172 +0,0 @@
import hashlib
import hmac
import os
import sys
import tempfile
import unittest
from pathlib import Path
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
from wechat_decrypt_tool.wechat_decrypt import (
PAGE_SIZE,
RESERVE_SIZE,
SALT_SIZE,
SQLITE_HEADER,
WeChatDatabaseDecryptor,
_derive_mac_key,
_derive_sqlcipher_enc_key,
decrypt_wechat_databases,
)
def _encrypt_page(
raw_key: bytes,
plain_page: bytes,
page_num: int,
salt: bytes,
iv: bytes,
*,
sqlcipher_passphrase: bool = False,
) -> bytes:
enc_key = _derive_sqlcipher_enc_key(raw_key, salt) if sqlcipher_passphrase else raw_key
if page_num == 1:
encrypted_input = plain_page[SALT_SIZE : PAGE_SIZE - RESERVE_SIZE]
prefix = salt
else:
encrypted_input = plain_page[: PAGE_SIZE - RESERVE_SIZE]
prefix = b""
cipher = Cipher(
algorithms.AES(enc_key),
modes.CBC(iv),
backend=default_backend(),
)
encryptor = cipher.encryptor()
encrypted = encryptor.update(encrypted_input) + encryptor.finalize()
page_without_hmac = prefix + encrypted + iv
mac = hmac.new(_derive_mac_key(enc_key, salt), digestmod=hashlib.sha512)
mac.update(page_without_hmac[SALT_SIZE if page_num == 1 else 0 :])
mac.update(page_num.to_bytes(4, "little"))
return page_without_hmac + mac.digest()
def _build_plain_page(body_byte: int, *, first_page: bool) -> bytes:
if first_page:
payload = SQLITE_HEADER + bytes([body_byte]) * (PAGE_SIZE - RESERVE_SIZE - len(SQLITE_HEADER))
else:
payload = bytes([body_byte]) * (PAGE_SIZE - RESERVE_SIZE)
return payload + (b"\x00" * RESERVE_SIZE)
class WeChatDecryptRawKeyTests(unittest.TestCase):
def test_decrypt_database_uses_raw_enc_key(self):
raw_key = bytes.fromhex("00112233445566778899aabbccddeefffedcba98765432100123456789abcdef")
salt = bytes.fromhex("11223344556677889900aabbccddeeff")
iv1 = bytes.fromhex("0102030405060708090a0b0c0d0e0f10")
iv2 = bytes.fromhex("1112131415161718191a1b1c1d1e1f20")
page1 = _build_plain_page(0x41, first_page=True)
page2 = _build_plain_page(0x42, first_page=False)
encrypted_db = _encrypt_page(raw_key, page1, 1, salt, iv1) + _encrypt_page(raw_key, page2, 2, salt, iv2)
with tempfile.TemporaryDirectory() as tmpdir:
src = Path(tmpdir) / "source.db"
dst = Path(tmpdir) / "out.db"
src.write_bytes(encrypted_db)
decryptor = WeChatDatabaseDecryptor(raw_key.hex())
self.assertTrue(decryptor.decrypt_database(str(src), str(dst)))
self.assertEqual(dst.read_bytes(), page1 + page2)
def test_decrypt_database_falls_back_to_sqlcipher_passphrase_mode(self):
passphrase_key = bytes.fromhex("9f5dd0d3b6d0477ea5045c9e380ee272e53927993eb548dd98a022e842d5f7bd")
salt = bytes.fromhex("50f4090ef6897e146f94109f13743e34")
iv1 = bytes.fromhex("0102030405060708090a0b0c0d0e0f10")
iv2 = bytes.fromhex("1112131415161718191a1b1c1d1e1f20")
page1 = _build_plain_page(0x41, first_page=True)
page2 = _build_plain_page(0x42, first_page=False)
encrypted_db = _encrypt_page(
passphrase_key,
page1,
1,
salt,
iv1,
sqlcipher_passphrase=True,
) + _encrypt_page(
passphrase_key,
page2,
2,
salt,
iv2,
sqlcipher_passphrase=True,
)
with tempfile.TemporaryDirectory() as tmpdir:
src = Path(tmpdir) / "source.db"
dst = Path(tmpdir) / "out.db"
src.write_bytes(encrypted_db)
decryptor = WeChatDatabaseDecryptor(passphrase_key.hex())
self.assertTrue(decryptor.decrypt_database(str(src), str(dst)))
self.assertEqual(dst.read_bytes(), page1 + page2)
def test_decrypt_database_keeps_existing_output_on_hmac_failure(self):
good_key = bytes.fromhex("00112233445566778899aabbccddeefffedcba98765432100123456789abcdef")
bad_key_hex = "ffeeddccbbaa998877665544332211000123456789abcdeffedcba9876543210"
salt = bytes.fromhex("11223344556677889900aabbccddeeff")
iv1 = bytes.fromhex("0102030405060708090a0b0c0d0e0f10")
page1 = _build_plain_page(0x41, first_page=True)
encrypted_db = _encrypt_page(good_key, page1, 1, salt, iv1)
with tempfile.TemporaryDirectory() as tmpdir:
src = Path(tmpdir) / "source.db"
dst = Path(tmpdir) / "out.db"
src.write_bytes(encrypted_db)
dst.write_bytes(b"keep-existing-output")
decryptor = WeChatDatabaseDecryptor(bad_key_hex)
self.assertFalse(decryptor.decrypt_database(str(src), str(dst)))
self.assertEqual(dst.read_bytes(), b"keep-existing-output")
def test_decrypt_wechat_databases_reports_key_scope_message(self):
good_key = bytes.fromhex("00112233445566778899aabbccddeefffedcba98765432100123456789abcdef")
bad_key_hex = "ffeeddccbbaa998877665544332211000123456789abcdeffedcba9876543210"
salt = bytes.fromhex("11223344556677889900aabbccddeeff")
iv1 = bytes.fromhex("0102030405060708090a0b0c0d0e0f10")
page1 = _build_plain_page(0x41, first_page=True)
encrypted_db = _encrypt_page(good_key, page1, 1, salt, iv1)
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
db_storage = root / "xwechat_files" / "wxid_scope_user" / "db_storage"
db_storage.mkdir(parents=True, exist_ok=True)
(db_storage / "MSG0.db").write_bytes(encrypted_db)
prev_data_dir = os.environ.get("WECHAT_TOOL_DATA_DIR")
try:
os.environ["WECHAT_TOOL_DATA_DIR"] = str(root)
result = decrypt_wechat_databases(str(db_storage), bad_key_hex)
finally:
if prev_data_dir is None:
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
else:
os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data_dir
self.assertEqual(result["status"], "error")
self.assertIn("当前数据库密钥不正确", result["message"])
self.assertIn("账号/当前设备", result["message"])
self.assertIn("另一台设备复制", result["message"])
if __name__ == "__main__":
unittest.main()