mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-06-18 15:54:08 +08:00
Compare commits
17 Commits
@@ -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>如需定制功能,请联系 QQ:2977094657。</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>
|
||||
|
||||
## 致谢
|
||||
|
||||
本项目的开发过程中参考了以下优秀的开源项目和资源:
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
; This file is included for both installer and uninstaller builds.
|
||||
; Guard installer-only pages/functions to avoid "function not referenced" warnings
|
||||
; when electron-builder compiles the standalone uninstaller.
|
||||
!define /ifndef WDA_DEFAULT_SETTINGS_PATH "$APPDATA\${APP_FILENAME}\desktop-settings.json"
|
||||
!define /ifndef WDA_DEFAULT_OUTPUT_DIR "$APPDATA\${APP_FILENAME}\output"
|
||||
!ifdef APP_PRODUCT_FILENAME
|
||||
!define /ifndef WDA_PRODUCT_SETTINGS_PATH "$APPDATA\${APP_PRODUCT_FILENAME}\desktop-settings.json"
|
||||
!define /ifndef WDA_PRODUCT_OUTPUT_DIR "$APPDATA\${APP_PRODUCT_FILENAME}\output"
|
||||
!else
|
||||
!define /ifndef WDA_PRODUCT_SETTINGS_PATH ""
|
||||
!define /ifndef WDA_PRODUCT_OUTPUT_DIR ""
|
||||
!endif
|
||||
!ifdef APP_PACKAGE_NAME
|
||||
!define /ifndef WDA_PACKAGE_SETTINGS_PATH "$APPDATA\${APP_PACKAGE_NAME}\desktop-settings.json"
|
||||
!define /ifndef WDA_PACKAGE_OUTPUT_DIR "$APPDATA\${APP_PACKAGE_NAME}\output"
|
||||
!else
|
||||
!define /ifndef WDA_PACKAGE_SETTINGS_PATH ""
|
||||
!define /ifndef WDA_PACKAGE_OUTPUT_DIR ""
|
||||
!endif
|
||||
!ifndef BUILD_UNINSTALLER
|
||||
!include nsDialogs.nsh
|
||||
!include LogicLib.nsh
|
||||
@@ -13,6 +29,10 @@
|
||||
!define /ifndef MUI_DIRECTORYPAGE_TEXT_DESTINATION "安装位置:"
|
||||
|
||||
Var WDA_InstallDirPage
|
||||
Var WDA_OutputDirPage
|
||||
Var WDA_OutputDirInput
|
||||
Var WDA_OutputDirBrowseButton
|
||||
Var WDA_SelectedOutputDir
|
||||
|
||||
!macro customInit
|
||||
; Safety: older versions created an `output` junction inside the install directory that points to the
|
||||
@@ -22,17 +42,10 @@ Var WDA_InstallDirPage
|
||||
!macroend
|
||||
|
||||
!macro customInstall
|
||||
; Provide a safe, non-junction way for users to locate the real per-user output directory.
|
||||
; The actual data is NOT stored inside $INSTDIR (it is wiped on update/reinstall).
|
||||
; `open-output.cmd` uses %APPDATA% so it works for the current user.
|
||||
FileOpen $0 "$INSTDIR\output-location.txt" w
|
||||
FileWrite $0 "WeChatDataAnalysis output folder (per user):$\r$\n%APPDATA%\\${APP_PACKAGE_NAME}\\output$\r$\n"
|
||||
FileClose $0
|
||||
|
||||
FileOpen $1 "$INSTDIR\open-output.cmd" w
|
||||
; NSIS escaping: use $\" to output a literal quote character into the .cmd file.
|
||||
FileWrite $1 "@echo off$\r$\nexplorer $\"%APPDATA%\\${APP_PACKAGE_NAME}\\output$\"$\r$\n"
|
||||
FileClose $1
|
||||
${If} $WDA_SelectedOutputDir == ""
|
||||
Call WDA_InitOutputDirSelection
|
||||
${EndIf}
|
||||
Call WDA_WritePendingOutputDirSetting
|
||||
!macroend
|
||||
|
||||
Function WDA_RemoveLegacyOutputLink
|
||||
@@ -47,9 +60,27 @@ FunctionEnd
|
||||
; the final install location (includes the app sub-folder).
|
||||
!ifdef allowToChangeInstallationDirectory
|
||||
Page custom WDA_InstallDirPageCreate WDA_InstallDirPageLeave
|
||||
Page custom WDA_OutputDirPageCreate WDA_OutputDirPageLeave
|
||||
!endif
|
||||
!macroend
|
||||
|
||||
Function WDA_InitOutputDirSelection
|
||||
StrCpy $WDA_SelectedOutputDir "${WDA_DEFAULT_OUTPUT_DIR}"
|
||||
nsExec::ExecToStack '"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -Command "& { param([string] $$defaultSettingsPath, [string] $$defaultOutputPath, [string] $$legacySettingsPath1, [string] $$legacySettingsPath2) $$candidates = @($$defaultSettingsPath, $$legacySettingsPath1, $$legacySettingsPath2) | Where-Object { -not [string]::IsNullOrWhiteSpace($$_) } | Select-Object -Unique; $$settingsPath = $$defaultSettingsPath; foreach ($$candidate in $$candidates) { if (Test-Path -LiteralPath $$candidate) { $$settingsPath = $$candidate; break } }; $$result = $$defaultOutputPath; if (Test-Path -LiteralPath $$settingsPath) { try { $$json = Get-Content -LiteralPath $$settingsPath -Raw | ConvertFrom-Json; $$value = [string] $$json.pendingOutputDir; if ([string]::IsNullOrWhiteSpace($$value)) { $$value = [string] $$json.outputDir }; if ($$value -eq '''') { $$result = $$defaultOutputPath } elseif (-not [string]::IsNullOrWhiteSpace($$value)) { $$result = $$value } } catch {} }; [Console]::OutputEncoding = [System.Text.Encoding]::UTF8; [Console]::Write($$result) }" "${WDA_DEFAULT_SETTINGS_PATH}" "${WDA_DEFAULT_OUTPUT_DIR}" "${WDA_PRODUCT_SETTINGS_PATH}" "${WDA_PACKAGE_SETTINGS_PATH}"'
|
||||
Pop $0
|
||||
Pop $1
|
||||
${If} $0 == "0"
|
||||
${AndIf} $1 != ""
|
||||
StrCpy $WDA_SelectedOutputDir "$1"
|
||||
${EndIf}
|
||||
FunctionEnd
|
||||
|
||||
Function WDA_WritePendingOutputDirSetting
|
||||
nsExec::ExecToStack '"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -Command "& { param([string] $$defaultSettingsPath, [string] $$defaultOutputPath, [string] $$selectedOutputPath, [string] $$legacySettingsPath1, [string] $$legacySettingsPath2) $$candidates = @($$defaultSettingsPath, $$legacySettingsPath1, $$legacySettingsPath2) | Where-Object { -not [string]::IsNullOrWhiteSpace($$_) } | Select-Object -Unique; $$sourceSettingsPath = $$defaultSettingsPath; foreach ($$candidate in $$candidates) { if (Test-Path -LiteralPath $$candidate) { $$sourceSettingsPath = $$candidate; break } }; if ([string]::IsNullOrWhiteSpace($$selectedOutputPath)) { $$selectedOutputPath = $$defaultOutputPath }; $$pending = if ([string]::Equals($$selectedOutputPath, $$defaultOutputPath, [System.StringComparison]::OrdinalIgnoreCase)) { '''' } else { $$selectedOutputPath }; $$obj = @{}; if (Test-Path -LiteralPath $$sourceSettingsPath) { try { $$existing = Get-Content -LiteralPath $$sourceSettingsPath -Raw | ConvertFrom-Json; if ($$null -ne $$existing) { $$existing.PSObject.Properties | ForEach-Object { $$obj[$$_.Name] = $$_.Value } } } catch {} }; $$obj[''pendingOutputDir''] = $$pending; $$dir = Split-Path -Parent $$defaultSettingsPath; New-Item -ItemType Directory -Force -Path $$dir | Out-Null; $$json = [PSCustomObject] $$obj | ConvertTo-Json -Depth 10; Set-Content -LiteralPath $$defaultSettingsPath -Value $$json -Encoding UTF8 }" "${WDA_DEFAULT_SETTINGS_PATH}" "${WDA_DEFAULT_OUTPUT_DIR}" "$WDA_SelectedOutputDir" "${WDA_PRODUCT_SETTINGS_PATH}" "${WDA_PACKAGE_SETTINGS_PATH}"'
|
||||
Pop $0
|
||||
Pop $1
|
||||
FunctionEnd
|
||||
|
||||
Function WDA_EnsureAppSubDir
|
||||
; Normalize $INSTDIR to always end with "\${APP_FILENAME}" (avoid cluttering a parent folder).
|
||||
StrCpy $0 "$INSTDIR"
|
||||
@@ -105,6 +136,48 @@ FunctionEnd
|
||||
Function WDA_InstallDirPageLeave
|
||||
FunctionEnd
|
||||
|
||||
Function WDA_OutputDirBrowse
|
||||
nsDialogs::SelectFolderDialog "选择 output 目录" "$WDA_SelectedOutputDir"
|
||||
Pop $0
|
||||
${If} $0 != error
|
||||
StrCpy $WDA_SelectedOutputDir "$0"
|
||||
${NSD_SetText} $WDA_OutputDirInput "$0"
|
||||
${EndIf}
|
||||
FunctionEnd
|
||||
|
||||
Function WDA_OutputDirPageCreate
|
||||
Call WDA_InitOutputDirSelection
|
||||
|
||||
nsDialogs::Create 1018
|
||||
Pop $WDA_OutputDirPage
|
||||
|
||||
${If} $WDA_OutputDirPage == error
|
||||
Abort
|
||||
${EndIf}
|
||||
|
||||
${NSD_CreateLabel} 0u 0u 100% 24u "请选择 output 目录(保存解密数据库、导出内容、缓存、日志等)。"
|
||||
Pop $0
|
||||
|
||||
${NSD_CreateText} 0u 28u 78% 12u "$WDA_SelectedOutputDir"
|
||||
Pop $WDA_OutputDirInput
|
||||
|
||||
${NSD_CreateButton} 82% 27u 18% 14u "浏览..."
|
||||
Pop $WDA_OutputDirBrowseButton
|
||||
${NSD_OnClick} $WDA_OutputDirBrowseButton WDA_OutputDirBrowse
|
||||
|
||||
${NSD_CreateLabel} 0u 52u 100% 28u "安装器只记录你的选择;真正的数据迁移会在首次启动应用时执行。若目标目录已有内容,应用会阻止切换并提示处理。"
|
||||
Pop $0
|
||||
|
||||
nsDialogs::Show
|
||||
FunctionEnd
|
||||
|
||||
Function WDA_OutputDirPageLeave
|
||||
${NSD_GetText} $WDA_OutputDirInput $WDA_SelectedOutputDir
|
||||
${If} $WDA_SelectedOutputDir == ""
|
||||
StrCpy $WDA_SelectedOutputDir "${WDA_DEFAULT_OUTPUT_DIR}"
|
||||
${EndIf}
|
||||
FunctionEnd
|
||||
|
||||
!endif
|
||||
|
||||
!ifdef BUILD_UNINSTALLER
|
||||
@@ -177,6 +250,12 @@ FunctionEnd
|
||||
RMDir /r "$APPDATA\${APP_PACKAGE_NAME}"
|
||||
!endif
|
||||
|
||||
IfFileExists "$INSTDIR\output-location.path" 0 WDA_SkipCustomOutputDelete
|
||||
nsExec::ExecToStack '"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -Command "& { param([string] $$pathFile, [string] $$defaultPath1, [string] $$defaultPath2, [string] $$defaultPath3) if (Test-Path -LiteralPath $$pathFile) { $$target = (Get-Content -LiteralPath $$pathFile -Raw).Trim(); $$defaults = @($$defaultPath1, $$defaultPath2, $$defaultPath3) | Where-Object { -not [string]::IsNullOrWhiteSpace($$_) }; $$isDefault = $$false; foreach ($$defaultPath in $$defaults) { if ([string]::Equals($$target, $$defaultPath, [System.StringComparison]::OrdinalIgnoreCase)) { $$isDefault = $$true; break } }; if (-not $$isDefault -and -not [string]::IsNullOrWhiteSpace($$target) -and (Test-Path -LiteralPath $$target)) { Remove-Item -LiteralPath $$target -Recurse -Force -ErrorAction SilentlyContinue } } }" "$INSTDIR\output-location.path" "${WDA_DEFAULT_OUTPUT_DIR}" "${WDA_PRODUCT_OUTPUT_DIR}" "${WDA_PACKAGE_OUTPUT_DIR}"'
|
||||
Pop $0
|
||||
Pop $1
|
||||
WDA_SkipCustomOutputDelete:
|
||||
|
||||
${if} $installMode == "all"
|
||||
SetShellVarContext all
|
||||
${endif}
|
||||
|
||||
+369
-22
@@ -21,6 +21,13 @@ const fs = require("fs");
|
||||
const http = require("http");
|
||||
const net = require("net");
|
||||
const path = require("path");
|
||||
const {
|
||||
getDefaultOutputDirPath,
|
||||
getEffectiveOutputDirPath,
|
||||
migrateOutputDirectory,
|
||||
normalizeDirectoryPath,
|
||||
rollbackOutputDirectoryChange,
|
||||
} = require("./output-dir.cjs");
|
||||
|
||||
const DEFAULT_BACKEND_HOST = String(process.env.WECHAT_TOOL_HOST || "127.0.0.1").trim() || "127.0.0.1";
|
||||
const DEFAULT_BACKEND_PORT = parsePort(process.env.WECHAT_TOOL_PORT) ?? 10392;
|
||||
@@ -32,6 +39,7 @@ let tray = null;
|
||||
let isQuitting = false;
|
||||
let desktopSettings = null;
|
||||
let backendPortChangeInProgress = false;
|
||||
let outputDirChangeInProgress = false;
|
||||
|
||||
const gotSingleInstanceLock = app.requestSingleInstanceLock();
|
||||
if (!gotSingleInstanceLock) {
|
||||
@@ -216,9 +224,76 @@ function resolveDataDir() {
|
||||
}
|
||||
|
||||
function getUserDataDir() {
|
||||
// Backwards-compat: we historically used Electron's userData directory for runtime storage.
|
||||
// Keep this name but resolve to the effective data dir (can be overridden via env).
|
||||
return resolveDataDir();
|
||||
try {
|
||||
const dir = app.getPath("userData");
|
||||
if (!dir) return null;
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function safeNormalizeDirectory(value) {
|
||||
try {
|
||||
return normalizeDirectoryPath(value || "");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function getDefaultOutputDir() {
|
||||
const dataDir = resolveDataDir();
|
||||
if (!dataDir) return null;
|
||||
try {
|
||||
return getDefaultOutputDirPath(dataDir);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function syncOutputDirEnv(nextDir) {
|
||||
const normalized = safeNormalizeDirectory(nextDir);
|
||||
if (normalized) process.env.WECHAT_TOOL_OUTPUT_DIR = normalized;
|
||||
else delete process.env.WECHAT_TOOL_OUTPUT_DIR;
|
||||
}
|
||||
|
||||
function normalizePendingOutputDirValue(value) {
|
||||
if (value == null) return null;
|
||||
const text = String(value).trim();
|
||||
if (!text) return "";
|
||||
try {
|
||||
return normalizeDirectoryPath(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveOutputDir() {
|
||||
const dataDir = resolveDataDir();
|
||||
if (!dataDir) return null;
|
||||
|
||||
const envOutputDir = safeNormalizeDirectory(process.env.WECHAT_TOOL_OUTPUT_DIR || "");
|
||||
const settingsOutputDir = app.isPackaged ? safeNormalizeDirectory(loadDesktopSettings()?.outputDir || "") : "";
|
||||
|
||||
let chosen = null;
|
||||
try {
|
||||
chosen = getEffectiveOutputDirPath({
|
||||
dataDir,
|
||||
envOutputDir,
|
||||
settingsOutputDir,
|
||||
});
|
||||
} catch {
|
||||
chosen = getDefaultOutputDir();
|
||||
}
|
||||
if (!chosen) return null;
|
||||
|
||||
try {
|
||||
fs.mkdirSync(chosen, { recursive: true });
|
||||
} catch {}
|
||||
|
||||
syncOutputDirEnv(chosen);
|
||||
return chosen;
|
||||
}
|
||||
|
||||
function sanitizeAccountName(account) {
|
||||
@@ -261,7 +336,8 @@ function resolveAccountDirInOutput(account) {
|
||||
const dataDir = resolveDataDir();
|
||||
if (!dataDir) throw new Error("无法定位数据目录");
|
||||
|
||||
const outputDir = path.join(dataDir, "output");
|
||||
const outputDir = resolveOutputDir();
|
||||
if (!outputDir) throw new Error("无法定位 output 目录");
|
||||
const databasesDir = path.join(outputDir, "databases");
|
||||
const accountName = sanitizeAccountName(account);
|
||||
|
||||
@@ -311,8 +387,8 @@ function getAccountInfoFromDisk(account) {
|
||||
};
|
||||
}
|
||||
|
||||
function removeAccountFromKeyStore(dataDir, accountName) {
|
||||
const keyStorePath = path.join(dataDir, "output", "account_keys.json");
|
||||
function removeAccountFromKeyStore(outputDir, accountName) {
|
||||
const keyStorePath = path.join(outputDir, "account_keys.json");
|
||||
try {
|
||||
if (!fs.existsSync(keyStorePath)) return false;
|
||||
const raw = fs.readFileSync(keyStorePath, { encoding: "utf8" });
|
||||
@@ -328,7 +404,7 @@ function removeAccountFromKeyStore(dataDir, accountName) {
|
||||
}
|
||||
|
||||
async function deleteAccountDataFromDisk(account) {
|
||||
const { dataDir, outputDir, databasesDir, accountName, accountDir } = resolveAccountDirInOutput(account);
|
||||
const { outputDir, databasesDir, accountName, accountDir } = resolveAccountDirInOutput(account);
|
||||
if (!fs.existsSync(accountDir) || !fs.statSync(accountDir).isDirectory()) {
|
||||
throw new Error("账号数据不存在");
|
||||
}
|
||||
@@ -348,7 +424,7 @@ async function deleteAccountDataFromDisk(account) {
|
||||
} catch {}
|
||||
|
||||
fs.rmSync(accountDir, { recursive: true, force: true });
|
||||
const removedKeyCache = removeAccountFromKeyStore(dataDir, accountName);
|
||||
const removedKeyCache = removeAccountFromKeyStore(outputDir, accountName);
|
||||
const accounts = listDecryptedAccountsOnDisk(databasesDir);
|
||||
result = {
|
||||
status: "success",
|
||||
@@ -394,10 +470,8 @@ function ensureOutputLink() {
|
||||
if (!app.isPackaged) return;
|
||||
|
||||
const exeDir = getExeDir();
|
||||
const dataDir = resolveDataDir();
|
||||
if (!exeDir || !dataDir) return;
|
||||
|
||||
const target = path.join(dataDir, "output");
|
||||
const target = resolveOutputDir();
|
||||
if (!exeDir || !target) return;
|
||||
const legacyLinkPath = path.join(exeDir, "output");
|
||||
|
||||
// Ensure the real output dir exists.
|
||||
@@ -443,6 +517,11 @@ function ensureOutputLink() {
|
||||
fs.writeFileSync(p, text, { encoding: "utf8" });
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
const p = path.join(exeDir, "output-location.path");
|
||||
fs.writeFileSync(p, `${target}\n`, { encoding: "utf8" });
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
const p = path.join(exeDir, "open-output.cmd");
|
||||
const text = `@echo off\r\nexplorer \"${target}\"\r\n`;
|
||||
@@ -510,6 +589,12 @@ function loadDesktopSettings() {
|
||||
ignoredUpdateVersion: "",
|
||||
// Backend (FastAPI) listens on this port. Used in packaged builds.
|
||||
backendPort: DEFAULT_BACKEND_PORT,
|
||||
// Custom output dir; empty string means use the default dataDir/output.
|
||||
outputDir: "",
|
||||
// Pending output dir written by the installer before the next app startup.
|
||||
pendingOutputDir: null,
|
||||
// Last startup/apply failure when changing output dir.
|
||||
lastOutputDirError: "",
|
||||
// Tracks the packaged UI build so we can invalidate Chromium's HTTP cache
|
||||
// after upgrades without wiping user data/localStorage.
|
||||
lastSeenUiBuildId: "",
|
||||
@@ -530,6 +615,12 @@ function loadDesktopSettings() {
|
||||
const parsed = JSON.parse(raw || "{}");
|
||||
desktopSettings = { ...defaults, ...(parsed && typeof parsed === "object" ? parsed : {}) };
|
||||
desktopSettings.backendPort = parsePort(desktopSettings.backendPort) ?? defaults.backendPort;
|
||||
desktopSettings.outputDir = safeNormalizeDirectory(desktopSettings.outputDir || "");
|
||||
desktopSettings.pendingOutputDir =
|
||||
parsed && typeof parsed === "object" && Object.prototype.hasOwnProperty.call(parsed, "pendingOutputDir")
|
||||
? normalizePendingOutputDirValue(parsed.pendingOutputDir)
|
||||
: defaults.pendingOutputDir;
|
||||
desktopSettings.lastOutputDirError = String(desktopSettings.lastOutputDirError || "").trim();
|
||||
} catch (err) {
|
||||
desktopSettings = { ...defaults };
|
||||
logMain(`[main] failed to load settings: ${err?.message || err}`);
|
||||
@@ -551,6 +642,82 @@ function persistDesktopSettings() {
|
||||
}
|
||||
}
|
||||
|
||||
function snapshotOutputDirSettings() {
|
||||
loadDesktopSettings();
|
||||
return {
|
||||
outputDir: desktopSettings.outputDir,
|
||||
pendingOutputDir: desktopSettings.pendingOutputDir,
|
||||
lastOutputDirError: desktopSettings.lastOutputDirError,
|
||||
};
|
||||
}
|
||||
|
||||
function restoreOutputDirSettings(snapshot) {
|
||||
loadDesktopSettings();
|
||||
desktopSettings.outputDir = safeNormalizeDirectory(snapshot?.outputDir || "");
|
||||
desktopSettings.pendingOutputDir = normalizePendingOutputDirValue(snapshot?.pendingOutputDir);
|
||||
desktopSettings.lastOutputDirError = String(snapshot?.lastOutputDirError || "").trim();
|
||||
const effectiveOutputDir = desktopSettings.outputDir || getDefaultOutputDir() || "";
|
||||
syncOutputDirEnv(effectiveOutputDir);
|
||||
persistDesktopSettings();
|
||||
}
|
||||
|
||||
function setOutputDirSetting(nextDir) {
|
||||
loadDesktopSettings();
|
||||
const defaultDir = getDefaultOutputDir();
|
||||
const normalized = safeNormalizeDirectory(nextDir || "");
|
||||
if (!normalized || (defaultDir && normalized === defaultDir)) {
|
||||
desktopSettings.outputDir = "";
|
||||
} else {
|
||||
desktopSettings.outputDir = normalized;
|
||||
}
|
||||
syncOutputDirEnv(desktopSettings.outputDir || defaultDir || "");
|
||||
persistDesktopSettings();
|
||||
return desktopSettings.outputDir;
|
||||
}
|
||||
|
||||
function setPendingOutputDirSetting(nextDir) {
|
||||
loadDesktopSettings();
|
||||
desktopSettings.pendingOutputDir = normalizePendingOutputDirValue(nextDir);
|
||||
persistDesktopSettings();
|
||||
return desktopSettings.pendingOutputDir;
|
||||
}
|
||||
|
||||
function clearPendingOutputDirSetting() {
|
||||
loadDesktopSettings();
|
||||
desktopSettings.pendingOutputDir = null;
|
||||
persistDesktopSettings();
|
||||
}
|
||||
|
||||
function setOutputDirLastError(message) {
|
||||
loadDesktopSettings();
|
||||
desktopSettings.lastOutputDirError = String(message || "").trim();
|
||||
persistDesktopSettings();
|
||||
return desktopSettings.lastOutputDirError;
|
||||
}
|
||||
|
||||
function getOutputDirInfo() {
|
||||
loadDesktopSettings();
|
||||
const defaultPath = getDefaultOutputDir() || "";
|
||||
const currentPath = resolveOutputDir() || defaultPath;
|
||||
const hasPending = desktopSettings.pendingOutputDir !== null;
|
||||
const pendingPath =
|
||||
desktopSettings.pendingOutputDir === null
|
||||
? ""
|
||||
: desktopSettings.pendingOutputDir === ""
|
||||
? defaultPath
|
||||
: safeNormalizeDirectory(desktopSettings.pendingOutputDir);
|
||||
return {
|
||||
path: currentPath || "",
|
||||
defaultPath,
|
||||
isDefault: !!currentPath && !!defaultPath && currentPath === defaultPath,
|
||||
pendingPath,
|
||||
hasPending,
|
||||
lastError: String(desktopSettings.lastOutputDirError || "").trim(),
|
||||
canChange: !!app.isPackaged,
|
||||
changeUnavailableReason: app.isPackaged ? "" : "开发模式不支持界面修改 output 目录",
|
||||
};
|
||||
}
|
||||
|
||||
function getCloseBehavior() {
|
||||
const v = String(loadDesktopSettings()?.closeBehavior || "").trim().toLowerCase();
|
||||
return v === "exit" ? "exit" : "tray";
|
||||
@@ -576,6 +743,137 @@ function setIgnoredUpdateVersion(version) {
|
||||
return desktopSettings.ignoredUpdateVersion;
|
||||
}
|
||||
|
||||
async function applyOutputDirChange(nextValue) {
|
||||
if (!app.isPackaged) {
|
||||
throw new Error("开发模式不支持界面修改 output 目录");
|
||||
}
|
||||
|
||||
const defaultPath = getDefaultOutputDir();
|
||||
const currentPath = resolveOutputDir();
|
||||
if (!defaultPath || !currentPath) {
|
||||
throw new Error("无法定位 output 目录");
|
||||
}
|
||||
|
||||
const rawText = String(nextValue ?? "").trim();
|
||||
const nextPath = rawText ? normalizeDirectoryPath(rawText) : defaultPath;
|
||||
const previousSettings = snapshotOutputDirSettings();
|
||||
|
||||
if (nextPath === currentPath) {
|
||||
setOutputDirSetting(nextPath);
|
||||
clearPendingOutputDirSetting();
|
||||
setOutputDirLastError("");
|
||||
ensureOutputLink();
|
||||
const info = getOutputDirInfo();
|
||||
return {
|
||||
success: true,
|
||||
changed: false,
|
||||
path: info.path,
|
||||
defaultPath: info.defaultPath,
|
||||
isDefault: info.isDefault,
|
||||
pendingPath: info.pendingPath,
|
||||
backupPath: "",
|
||||
sourceWasEmpty: false,
|
||||
message: "output 目录未变化",
|
||||
};
|
||||
}
|
||||
|
||||
const wasBackendRunning = !!backendProc;
|
||||
let migration = null;
|
||||
let settingsSwitched = false;
|
||||
|
||||
try {
|
||||
if (wasBackendRunning) {
|
||||
await stopBackendAndWait({ timeoutMs: 10_000 });
|
||||
}
|
||||
|
||||
migration = migrateOutputDirectory({
|
||||
currentDir: currentPath,
|
||||
nextDir: nextPath,
|
||||
});
|
||||
|
||||
setOutputDirSetting(nextPath);
|
||||
clearPendingOutputDirSetting();
|
||||
setOutputDirLastError("");
|
||||
settingsSwitched = true;
|
||||
ensureOutputLink();
|
||||
|
||||
if (wasBackendRunning) {
|
||||
startBackend();
|
||||
await waitForBackend({ timeoutMs: 30_000 });
|
||||
}
|
||||
|
||||
const info = getOutputDirInfo();
|
||||
return {
|
||||
success: true,
|
||||
changed: true,
|
||||
path: info.path,
|
||||
defaultPath: info.defaultPath,
|
||||
isDefault: info.isDefault,
|
||||
pendingPath: info.pendingPath,
|
||||
backupPath: migration?.backupDir || "",
|
||||
sourceWasEmpty: !!migration?.sourceWasEmpty,
|
||||
message: migration?.sourceWasEmpty ? "output 目录已切换" : "output 目录已迁移并切换",
|
||||
};
|
||||
} catch (err) {
|
||||
const message = err?.message || String(err);
|
||||
let rollbackMessage = "";
|
||||
if (migration?.changed) {
|
||||
try {
|
||||
rollbackOutputDirectoryChange({
|
||||
previousDir: currentPath,
|
||||
currentDir: nextPath,
|
||||
backupDir: migration.backupDir,
|
||||
sourceWasEmpty: migration.sourceWasEmpty,
|
||||
});
|
||||
} catch (rollbackErr) {
|
||||
logMain(`[main] output dir rollback failed: ${rollbackErr?.message || rollbackErr}`);
|
||||
rollbackMessage = `;回滚失败:${rollbackErr?.message || rollbackErr}`;
|
||||
if (migration?.backupDir) {
|
||||
rollbackMessage += `;备份目录:${migration.backupDir}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (settingsSwitched) {
|
||||
restoreOutputDirSettings(previousSettings);
|
||||
} else {
|
||||
syncOutputDirEnv(currentPath);
|
||||
}
|
||||
ensureOutputLink();
|
||||
|
||||
if (wasBackendRunning) {
|
||||
try {
|
||||
startBackend();
|
||||
await waitForBackend({ timeoutMs: 30_000 });
|
||||
} catch (restartErr) {
|
||||
throw new Error(
|
||||
`切换 output 目录失败:${message}${rollbackMessage};且旧后端恢复失败:${restartErr?.message || restartErr}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (rollbackMessage) {
|
||||
throw new Error(`切换 output 目录失败:${message}${rollbackMessage}`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function applyPendingOutputDirOnStartup() {
|
||||
if (!app.isPackaged) return;
|
||||
loadDesktopSettings();
|
||||
if (desktopSettings.pendingOutputDir === null) return;
|
||||
|
||||
try {
|
||||
await applyOutputDirChange(desktopSettings.pendingOutputDir);
|
||||
} catch (err) {
|
||||
clearPendingOutputDirSetting();
|
||||
setOutputDirLastError(`安装时设置的 output 目录未能应用:${err?.message || err}`);
|
||||
ensureOutputLink();
|
||||
logMain(`[main] failed to apply pending output dir: ${err?.message || err}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshRendererCacheForPackagedUi() {
|
||||
if (!app.isPackaged) return;
|
||||
|
||||
@@ -1171,11 +1469,11 @@ function startBackend() {
|
||||
}
|
||||
|
||||
if (app.isPackaged) {
|
||||
if (!env.WECHAT_TOOL_DATA_DIR) {
|
||||
env.WECHAT_TOOL_DATA_DIR = app.getPath("userData");
|
||||
}
|
||||
env.WECHAT_TOOL_DATA_DIR = resolveDataDir() || app.getPath("userData");
|
||||
env.WECHAT_TOOL_OUTPUT_DIR = resolveOutputDir() || getDefaultOutputDir() || path.join(env.WECHAT_TOOL_DATA_DIR, "output");
|
||||
try {
|
||||
fs.mkdirSync(env.WECHAT_TOOL_DATA_DIR, { recursive: true });
|
||||
fs.mkdirSync(env.WECHAT_TOOL_OUTPUT_DIR, { recursive: true });
|
||||
} catch {}
|
||||
|
||||
const backendExe = getPackagedBackendPath();
|
||||
@@ -1689,16 +1987,31 @@ function registerWindowIpc() {
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("app:getOutputDirInfo", () => {
|
||||
try {
|
||||
return getOutputDirInfo();
|
||||
} catch (err) {
|
||||
logMain(`[main] app:getOutputDirInfo failed: ${err?.message || err}`);
|
||||
return {
|
||||
path: "",
|
||||
defaultPath: "",
|
||||
isDefault: true,
|
||||
pendingPath: "",
|
||||
hasPending: false,
|
||||
lastError: err?.message || String(err),
|
||||
canChange: !!app.isPackaged,
|
||||
changeUnavailableReason: app.isPackaged ? "" : "开发模式不支持界面修改 output 目录",
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("app:getOutputDir", () => {
|
||||
const dir = resolveDataDir();
|
||||
if (!dir) return "";
|
||||
return path.join(dir, "output");
|
||||
return resolveOutputDir() || "";
|
||||
});
|
||||
|
||||
ipcMain.handle("app:openOutputDir", async () => {
|
||||
const dir = resolveDataDir();
|
||||
if (!dir) throw new Error("无法定位数据目录");
|
||||
const outDir = path.join(dir, "output");
|
||||
const outDir = resolveOutputDir();
|
||||
if (!outDir) throw new Error("无法定位 output 目录");
|
||||
try {
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
} catch {}
|
||||
@@ -1713,6 +2026,28 @@ function registerWindowIpc() {
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("app:setOutputDir", async (_event, nextDir) => {
|
||||
if (outputDirChangeInProgress) {
|
||||
return {
|
||||
success: false,
|
||||
error: "output 目录切换中,请稍后重试",
|
||||
};
|
||||
}
|
||||
outputDirChangeInProgress = true;
|
||||
try {
|
||||
return await applyOutputDirChange(nextDir);
|
||||
} catch (err) {
|
||||
const message = err?.message || String(err);
|
||||
logMain(`[main] app:setOutputDir failed: ${message}`);
|
||||
return {
|
||||
success: false,
|
||||
error: message,
|
||||
};
|
||||
} finally {
|
||||
outputDirChangeInProgress = false;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("app:getAccountInfo", async (_event, account) => {
|
||||
try {
|
||||
return getAccountInfoFromDisk(account);
|
||||
@@ -1796,6 +2131,8 @@ async function main() {
|
||||
// Resolve/create the data dir early so we can log reliably and place helper files
|
||||
// next to the installed exe for easier access.
|
||||
resolveDataDir();
|
||||
loadDesktopSettings();
|
||||
await applyPendingOutputDirOnStartup();
|
||||
ensureOutputLink();
|
||||
await ensureBackendPortAvailableOnStartup();
|
||||
|
||||
@@ -1876,10 +2213,20 @@ if (gotSingleInstanceLock) {
|
||||
stopBackend();
|
||||
try {
|
||||
const dir = getUserDataDir();
|
||||
const outputDir = resolveOutputDir();
|
||||
if (dir) {
|
||||
const detailLines = [
|
||||
`启动失败:${err?.message || err}`,
|
||||
"",
|
||||
`桌面日志目录:${dir}`,
|
||||
"文件:desktop-main.log / backend-stdio.log",
|
||||
];
|
||||
if (outputDir) {
|
||||
detailLines.push("", `当前 output 目录:${outputDir}`, "其中 output\\logs\\... 也在这里");
|
||||
}
|
||||
dialog.showErrorBox(
|
||||
"WeChatDataAnalysis 启动失败",
|
||||
`启动失败:${err?.message || err}\n\n请查看日志目录:\n${dir}\n\n文件:desktop-main.log / backend-stdio.log / output\\\\logs\\\\...`
|
||||
detailLines.join("\n")
|
||||
);
|
||||
shell.openPath(dir);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const SENTINEL_NAMES = [
|
||||
"account_keys.json",
|
||||
"runtime_settings.json",
|
||||
"message_edits.db",
|
||||
"databases",
|
||||
"exports",
|
||||
"logs",
|
||||
];
|
||||
|
||||
function normalizeDirectoryPath(value) {
|
||||
const text = String(value || "").trim();
|
||||
if (!text) return "";
|
||||
const expanded = text.replace(/^~(?=$|[\\/])/, process.env.USERPROFILE || process.env.HOME || "~");
|
||||
if (!path.isAbsolute(expanded)) {
|
||||
throw new Error("output 目录必须使用绝对路径");
|
||||
}
|
||||
return path.resolve(expanded);
|
||||
}
|
||||
|
||||
function getDefaultOutputDirPath(dataDir) {
|
||||
const base = normalizeDirectoryPath(dataDir);
|
||||
if (!base) throw new Error("无法定位数据目录");
|
||||
return path.join(base, "output");
|
||||
}
|
||||
|
||||
function getEffectiveOutputDirPath({ dataDir, envOutputDir, settingsOutputDir }) {
|
||||
const envPath = normalizeDirectoryPath(envOutputDir || "");
|
||||
if (envPath) return envPath;
|
||||
|
||||
const settingsPath = normalizeDirectoryPath(settingsOutputDir || "");
|
||||
if (settingsPath) return settingsPath;
|
||||
|
||||
return getDefaultOutputDirPath(dataDir);
|
||||
}
|
||||
|
||||
function hasDirectoryContents(dirPath) {
|
||||
try {
|
||||
return fs.readdirSync(dirPath).length > 0;
|
||||
} catch (err) {
|
||||
if (err && err.code === "ENOENT") return false;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function pathExists(dirPath) {
|
||||
try {
|
||||
fs.accessSync(dirPath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isDirectory(dirPath) {
|
||||
try {
|
||||
return fs.statSync(dirPath).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isPathInside(parentPath, candidatePath) {
|
||||
const parent = path.resolve(parentPath);
|
||||
const candidate = path.resolve(candidatePath);
|
||||
if (parent === candidate) return false;
|
||||
const relative = path.relative(parent, candidate);
|
||||
return !!relative && !relative.startsWith("..") && !path.isAbsolute(relative);
|
||||
}
|
||||
|
||||
function collectSentinels(sourceDir) {
|
||||
const sentinels = [];
|
||||
for (const name of SENTINEL_NAMES) {
|
||||
const sourcePath = path.join(sourceDir, name);
|
||||
if (!pathExists(sourcePath)) continue;
|
||||
sentinels.push({
|
||||
name,
|
||||
isDir: isDirectory(sourcePath),
|
||||
size: !isDirectory(sourcePath) ? fs.statSync(sourcePath).size : null,
|
||||
});
|
||||
}
|
||||
return sentinels;
|
||||
}
|
||||
|
||||
function verifyCopiedOutputTree(sourceDir, copiedDir) {
|
||||
const sentinels = collectSentinels(sourceDir);
|
||||
for (const item of sentinels) {
|
||||
const copiedPath = path.join(copiedDir, item.name);
|
||||
if (!pathExists(copiedPath)) {
|
||||
throw new Error(`迁移校验失败:缺少 ${item.name}`);
|
||||
}
|
||||
if (item.isDir) {
|
||||
if (!isDirectory(copiedPath)) {
|
||||
throw new Error(`迁移校验失败:${item.name} 不是目录`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const copiedStat = fs.statSync(copiedPath);
|
||||
if (copiedStat.size !== item.size) {
|
||||
throw new Error(`迁移校验失败:${item.name} 大小不一致`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function makeTimestamp(now = new Date()) {
|
||||
const parts = [
|
||||
now.getFullYear(),
|
||||
String(now.getMonth() + 1).padStart(2, "0"),
|
||||
String(now.getDate()).padStart(2, "0"),
|
||||
String(now.getHours()).padStart(2, "0"),
|
||||
String(now.getMinutes()).padStart(2, "0"),
|
||||
String(now.getSeconds()).padStart(2, "0"),
|
||||
];
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
function makeUniqueSiblingPath(basePath, suffix, now = new Date()) {
|
||||
const stamp = makeTimestamp(now);
|
||||
let attempt = 0;
|
||||
while (true) {
|
||||
const candidate = `${basePath}.${suffix}-${stamp}${attempt ? `-${attempt}` : ""}`;
|
||||
if (!pathExists(candidate)) return candidate;
|
||||
attempt += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function ensureTargetIsUsable(targetDir) {
|
||||
if (!pathExists(targetDir)) return;
|
||||
if (!isDirectory(targetDir)) {
|
||||
throw new Error("目标 output 路径已存在且不是目录");
|
||||
}
|
||||
if (hasDirectoryContents(targetDir)) {
|
||||
throw new Error("目标 output 目录已有内容,请先清空后再重试");
|
||||
}
|
||||
}
|
||||
|
||||
function migrateOutputDirectory({ currentDir, nextDir, now = new Date() }) {
|
||||
const currentPath = normalizeDirectoryPath(currentDir);
|
||||
const targetPath = normalizeDirectoryPath(nextDir);
|
||||
if (!currentPath || !targetPath) {
|
||||
throw new Error("output 路径不能为空");
|
||||
}
|
||||
if (currentPath === targetPath) {
|
||||
return {
|
||||
changed: false,
|
||||
currentDir: currentPath,
|
||||
targetDir: targetPath,
|
||||
sourceWasEmpty: !hasDirectoryContents(currentPath),
|
||||
backupDir: "",
|
||||
};
|
||||
}
|
||||
if (isPathInside(currentPath, targetPath) || isPathInside(targetPath, currentPath)) {
|
||||
throw new Error("新旧 output 路径不能互相包含");
|
||||
}
|
||||
|
||||
ensureTargetIsUsable(targetPath);
|
||||
|
||||
const sourceExists = pathExists(currentPath);
|
||||
if (sourceExists && !isDirectory(currentPath)) {
|
||||
throw new Error("当前 output 路径不是目录");
|
||||
}
|
||||
const sourceWasEmpty = !sourceExists || !hasDirectoryContents(currentPath);
|
||||
if (sourceWasEmpty) {
|
||||
fs.mkdirSync(targetPath, { recursive: true });
|
||||
return {
|
||||
changed: true,
|
||||
currentDir: currentPath,
|
||||
targetDir: targetPath,
|
||||
sourceWasEmpty: true,
|
||||
backupDir: "",
|
||||
};
|
||||
}
|
||||
|
||||
const tempTarget = makeUniqueSiblingPath(targetPath, "migrating", now);
|
||||
const backupDir = makeUniqueSiblingPath(currentPath, "backup", now);
|
||||
|
||||
fs.cpSync(currentPath, tempTarget, {
|
||||
recursive: true,
|
||||
force: false,
|
||||
errorOnExist: true,
|
||||
preserveTimestamps: true,
|
||||
});
|
||||
|
||||
try {
|
||||
verifyCopiedOutputTree(currentPath, tempTarget);
|
||||
if (pathExists(targetPath)) {
|
||||
fs.rmSync(targetPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
fs.renameSync(currentPath, backupDir);
|
||||
try {
|
||||
fs.renameSync(tempTarget, targetPath);
|
||||
} catch (err) {
|
||||
try {
|
||||
if (!pathExists(currentPath) && pathExists(backupDir)) {
|
||||
fs.renameSync(backupDir, currentPath);
|
||||
}
|
||||
} catch {}
|
||||
throw err;
|
||||
}
|
||||
} catch (err) {
|
||||
try {
|
||||
if (pathExists(tempTarget)) {
|
||||
fs.rmSync(tempTarget, { recursive: true, force: true });
|
||||
}
|
||||
} catch {}
|
||||
throw err;
|
||||
}
|
||||
|
||||
return {
|
||||
changed: true,
|
||||
currentDir: currentPath,
|
||||
targetDir: targetPath,
|
||||
sourceWasEmpty: false,
|
||||
backupDir,
|
||||
};
|
||||
}
|
||||
|
||||
function rollbackOutputDirectoryChange({ previousDir, currentDir, backupDir, sourceWasEmpty }) {
|
||||
const previousPath = normalizeDirectoryPath(previousDir);
|
||||
const currentPath = normalizeDirectoryPath(currentDir);
|
||||
|
||||
try {
|
||||
if (currentPath && pathExists(currentPath)) {
|
||||
fs.rmSync(currentPath, { recursive: true, force: true });
|
||||
}
|
||||
} catch {}
|
||||
|
||||
if (sourceWasEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
const backupPath = normalizeDirectoryPath(backupDir);
|
||||
if (!backupPath || !pathExists(backupPath)) return;
|
||||
|
||||
try {
|
||||
if (!pathExists(previousPath)) {
|
||||
fs.renameSync(backupPath, previousPath);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getDefaultOutputDirPath,
|
||||
getEffectiveOutputDirPath,
|
||||
hasDirectoryContents,
|
||||
migrateOutputDirectory,
|
||||
normalizeDirectoryPath,
|
||||
rollbackOutputDirectoryChange,
|
||||
};
|
||||
@@ -82,7 +82,9 @@ contextBridge.exposeInMainWorld("wechatDesktop", {
|
||||
chooseDirectory: (options = {}) => ipcRenderer.invoke("dialog:chooseDirectory", options),
|
||||
|
||||
// Data/output folder helpers
|
||||
getOutputDirInfo: () => ipcRenderer.invoke("app:getOutputDirInfo"),
|
||||
getOutputDir: () => ipcRenderer.invoke("app:getOutputDir"),
|
||||
setOutputDir: (dir) => ipcRenderer.invoke("app:setOutputDir", String(dir ?? "")),
|
||||
openOutputDir: () => ipcRenderer.invoke("app:openOutputDir"),
|
||||
getAccountInfo: (account) => ipcRenderer.invoke("app:getAccountInfo", String(account || "")),
|
||||
deleteAccountData: (account) => ipcRenderer.invoke("app:deleteAccountData", String(account || "")),
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
|
||||
const {
|
||||
getDefaultOutputDirPath,
|
||||
getEffectiveOutputDirPath,
|
||||
migrateOutputDirectory,
|
||||
normalizeDirectoryPath,
|
||||
rollbackOutputDirectoryChange,
|
||||
} = require("../src/output-dir.cjs");
|
||||
|
||||
function makeTempDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), "wda-output-"));
|
||||
}
|
||||
|
||||
function cleanupDir(dirPath) {
|
||||
try {
|
||||
fs.rmSync(dirPath, { recursive: true, force: true });
|
||||
} catch {}
|
||||
}
|
||||
|
||||
test("normalizeDirectoryPath requires absolute paths", () => {
|
||||
assert.throws(() => normalizeDirectoryPath("relative/path"), /绝对路径/);
|
||||
});
|
||||
|
||||
test("getEffectiveOutputDirPath prefers env, then settings, then default", () => {
|
||||
const root = makeTempDir();
|
||||
const envDir = path.join(root, "env-output");
|
||||
const settingsDir = path.join(root, "settings-output");
|
||||
const defaultDir = path.join(root, "data", "output");
|
||||
|
||||
try {
|
||||
assert.equal(
|
||||
getEffectiveOutputDirPath({
|
||||
dataDir: path.join(root, "data"),
|
||||
envOutputDir: envDir,
|
||||
settingsOutputDir: settingsDir,
|
||||
}),
|
||||
path.resolve(envDir)
|
||||
);
|
||||
assert.equal(
|
||||
getEffectiveOutputDirPath({
|
||||
dataDir: path.join(root, "data"),
|
||||
envOutputDir: "",
|
||||
settingsOutputDir: settingsDir,
|
||||
}),
|
||||
path.resolve(settingsDir)
|
||||
);
|
||||
assert.equal(getDefaultOutputDirPath(path.join(root, "data")), path.resolve(defaultDir));
|
||||
} finally {
|
||||
cleanupDir(root);
|
||||
}
|
||||
});
|
||||
|
||||
test("migrateOutputDirectory switches empty source to a new directory", () => {
|
||||
const root = makeTempDir();
|
||||
const currentDir = path.join(root, "current-output");
|
||||
const nextDir = path.join(root, "custom-output");
|
||||
|
||||
try {
|
||||
fs.mkdirSync(currentDir, { recursive: true });
|
||||
const result = migrateOutputDirectory({ currentDir, nextDir });
|
||||
assert.equal(result.changed, true);
|
||||
assert.equal(result.sourceWasEmpty, true);
|
||||
assert.equal(result.backupDir, "");
|
||||
assert.ok(fs.existsSync(nextDir));
|
||||
assert.equal(fs.existsSync(currentDir), true);
|
||||
} finally {
|
||||
cleanupDir(root);
|
||||
}
|
||||
});
|
||||
|
||||
test("migrateOutputDirectory blocks non-empty targets", () => {
|
||||
const root = makeTempDir();
|
||||
const currentDir = path.join(root, "current-output");
|
||||
const nextDir = path.join(root, "custom-output");
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.join(currentDir, "logs"), { recursive: true });
|
||||
fs.writeFileSync(path.join(currentDir, "runtime_settings.json"), "{}");
|
||||
fs.mkdirSync(nextDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(nextDir, "existing.txt"), "occupied");
|
||||
|
||||
assert.throws(
|
||||
() => migrateOutputDirectory({ currentDir, nextDir }),
|
||||
/已有内容/
|
||||
);
|
||||
} finally {
|
||||
cleanupDir(root);
|
||||
}
|
||||
});
|
||||
|
||||
test("migrateOutputDirectory blocks invalid current paths", () => {
|
||||
const root = makeTempDir();
|
||||
const currentDir = path.join(root, "current-output");
|
||||
const nextDir = path.join(root, "custom-output");
|
||||
|
||||
try {
|
||||
fs.writeFileSync(currentDir, "not-a-directory");
|
||||
assert.throws(
|
||||
() => migrateOutputDirectory({ currentDir, nextDir }),
|
||||
/不是目录/
|
||||
);
|
||||
} finally {
|
||||
cleanupDir(root);
|
||||
}
|
||||
});
|
||||
|
||||
test("migrateOutputDirectory copies data and leaves the old directory as a backup", () => {
|
||||
const root = makeTempDir();
|
||||
const currentDir = path.join(root, "current-output");
|
||||
const nextDir = path.join(root, "custom-output");
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.join(currentDir, "databases", "wxid_test"), { recursive: true });
|
||||
fs.writeFileSync(path.join(currentDir, "runtime_settings.json"), "{\"backend_port\":10392}");
|
||||
fs.writeFileSync(path.join(currentDir, "databases", "wxid_test", "session.db"), "session");
|
||||
fs.writeFileSync(path.join(currentDir, "databases", "wxid_test", "contact.db"), "contact");
|
||||
|
||||
const result = migrateOutputDirectory({ currentDir, nextDir, now: new Date("2026-03-30T08:00:00Z") });
|
||||
assert.equal(result.changed, true);
|
||||
assert.equal(result.sourceWasEmpty, false);
|
||||
assert.match(path.basename(result.backupDir), /^current-output\.backup-\d{14}$/);
|
||||
assert.ok(fs.existsSync(nextDir));
|
||||
assert.ok(fs.existsSync(path.join(nextDir, "runtime_settings.json")));
|
||||
assert.ok(fs.existsSync(path.join(nextDir, "databases", "wxid_test", "session.db")));
|
||||
assert.ok(fs.existsSync(result.backupDir));
|
||||
assert.equal(fs.existsSync(currentDir), false);
|
||||
} finally {
|
||||
cleanupDir(root);
|
||||
}
|
||||
});
|
||||
|
||||
test("rollbackOutputDirectoryChange restores the previous directory", () => {
|
||||
const root = makeTempDir();
|
||||
const previousDir = path.join(root, "current-output");
|
||||
const currentDir = path.join(root, "custom-output");
|
||||
const backupDir = path.join(root, "current-output.backup-20260330080100");
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.join(currentDir, "databases"), { recursive: true });
|
||||
fs.writeFileSync(path.join(currentDir, "databases", "temp.db"), "temp");
|
||||
fs.mkdirSync(path.join(backupDir, "databases"), { recursive: true });
|
||||
fs.writeFileSync(path.join(backupDir, "databases", "session.db"), "restored");
|
||||
|
||||
rollbackOutputDirectoryChange({
|
||||
previousDir,
|
||||
currentDir,
|
||||
backupDir,
|
||||
sourceWasEmpty: false,
|
||||
});
|
||||
|
||||
assert.equal(fs.existsSync(currentDir), false);
|
||||
assert.ok(fs.existsSync(path.join(previousDir, "databases", "session.db")));
|
||||
assert.equal(fs.existsSync(backupDir), false);
|
||||
} finally {
|
||||
cleanupDir(root);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))}` : ''
|
||||
])
|
||||
})()
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
@@ -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):
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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> 为空;前端会按需渲染为不可点击卡片。
|
||||
|
||||
@@ -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)
|
||||
|
||||
# 记录初始化信息
|
||||
|
||||
@@ -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}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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="获取表情消息资源")
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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">'
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user