Compare commits

..

12 Commits

13 changed files with 1648 additions and 91 deletions
+1 -2
View File
@@ -58,7 +58,7 @@ jobs:
working-directory: desktop
shell: pwsh
run: |
npm version $env:VERSION --no-git-tag-version
npm version $env:VERSION --no-git-tag-version --allow-same-version
- name: Build Windows installer
working-directory: desktop
@@ -75,4 +75,3 @@ jobs:
files: |
desktop/dist/*Setup*.exe
desktop/dist/*Setup*.exe.blockmap
+18 -6
View File
@@ -8,6 +8,7 @@
<p><b>特别致谢</b><a href="https://github.com/ycccccccy/echotrace">echotrace</a>(本项目大量功能参考其实现,提供了重要技术支持)</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" />
<img src="https://img.shields.io/github/forks/LifeArchiveProject/WeChatDataAnalysis" alt="Forks" />
<img src="https://img.shields.io/github/license/LifeArchiveProject/WeChatDataAnalysis" alt="License" />
<img src="https://img.shields.io/badge/Python-3776AB?logo=Python&logoColor=white" alt="Python" />
@@ -86,27 +87,38 @@
## 快速开始
### 1. 克隆项目
### 1. 下载并安装 EXE(Windows,推荐)
1. 打开 Release 页面(最新版):https://github.com/LifeArchiveProject/WeChatDataAnalysis/releases/latest
2. 下载 `WeChatDataAnalysis.Setup.<version>.exe` 并运行安装
3. 安装完成后启动 `WeChatDataAnalysis`
> 如果 Windows 弹出“未知发布者/更多信息”等提示,请确认下载来源为本仓库 Release 后再选择“仍要运行”。
### 2. 从源码运行(开发者/高级用户)
#### 2.1 克隆项目
```bash
git clone https://github.com/2977094657/WeChatDataAnalysis
git clone https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
cd WeChatDataAnalysis
```
### 2. 安装后端依赖
#### 2.2 安装后端依赖
```bash
# 使用uv (推荐)
uv sync
```
### 3. 安装前端依赖
#### 2.3 安装前端依赖
```bash
cd frontend
npm install
```
### 4. 启动服务
#### 2.4 启动服务
#### 启动后端API服务
```bash
@@ -121,7 +133,7 @@ cd frontend
npm run dev
```
### 5. 访问应用
#### 2.5 访问应用
- 前端界面: http://localhost:3000
- API服务: http://localhost:8000
+75
View File
@@ -78,3 +78,78 @@ Function WDA_InstallDirPageLeave
FunctionEnd
!endif
!ifdef BUILD_UNINSTALLER
!include nsDialogs.nsh
!include LogicLib.nsh
Var WDA_UninstallOptionsPage
Var WDA_UninstallDeleteDataCheckbox
Var /GLOBAL WDA_DeleteUserData
!macro customUnInit
; Default: keep user data (also applies to silent uninstall / update uninstall).
StrCpy $WDA_DeleteUserData "0"
!macroend
!macro customUnWelcomePage
!insertmacro MUI_UNPAGE_WELCOME
; Optional page: allow user to choose whether to delete app data.
UninstPage custom un.WDA_UninstallOptionsCreate un.WDA_UninstallOptionsLeave
!macroend
Function un.WDA_UninstallOptionsCreate
nsDialogs::Create 1018
Pop $WDA_UninstallOptionsPage
${If} $WDA_UninstallOptionsPage == error
Abort
${EndIf}
${NSD_CreateLabel} 0u 0u 100% 24u "卸载选项:"
Pop $0
${NSD_CreateCheckbox} 0u 24u 100% 12u "同时删除用户数据(导出的聊天记录、日志、配置等)"
Pop $WDA_UninstallDeleteDataCheckbox
; Safer default: do not delete.
${NSD_Uncheck} $WDA_UninstallDeleteDataCheckbox
nsDialogs::Show
FunctionEnd
Function un.WDA_UninstallOptionsLeave
${NSD_GetState} $WDA_UninstallDeleteDataCheckbox $0
${If} $0 == ${BST_CHECKED}
StrCpy $WDA_DeleteUserData "1"
${Else}
StrCpy $WDA_DeleteUserData "0"
${EndIf}
FunctionEnd
!macro customUnInstall
; If this is an update uninstall, never delete user data.
${ifNot} ${isUpdated}
${if} $WDA_DeleteUserData == "1"
; Electron always stores user data per-user. If the app was installed for all users,
; switch to current user context to remove the correct AppData directory.
${if} $installMode == "all"
SetShellVarContext current
${endif}
RMDir /r "$APPDATA\${APP_FILENAME}"
!ifdef APP_PRODUCT_FILENAME
RMDir /r "$APPDATA\${APP_PRODUCT_FILENAME}"
!endif
; Electron may use package.json "name" for some storage (cache, indexeddb, etc.).
!ifdef APP_PACKAGE_NAME
RMDir /r "$APPDATA\${APP_PACKAGE_NAME}"
!endif
${if} $installMode == "all"
SetShellVarContext all
${endif}
${endif}
${endif}
!macroend
!endif
Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

+402 -6
View File
@@ -1,4 +1,13 @@
const { app, BrowserWindow, Menu, ipcMain, globalShortcut } = require("electron");
const {
app,
BrowserWindow,
Menu,
Tray,
ipcMain,
globalShortcut,
dialog,
shell,
} = require("electron");
const { spawn } = require("child_process");
const fs = require("fs");
const http = require("http");
@@ -9,6 +18,298 @@ const BACKEND_PORT = Number(process.env.WECHAT_TOOL_PORT || "8000");
const BACKEND_HEALTH_URL = `http://${BACKEND_HOST}:${BACKEND_PORT}/api/health`;
let backendProc = null;
let backendStdioStream = null;
let resolvedDataDir = null;
let mainWindow = null;
let tray = null;
let isQuitting = false;
let desktopSettings = null;
function nowIso() {
return new Date().toISOString();
}
function resolveDataDir() {
if (resolvedDataDir) return resolvedDataDir;
const fromEnv = String(process.env.WECHAT_TOOL_DATA_DIR || "").trim();
const fallback = (() => {
try {
return app.getPath("userData");
} catch {
return null;
}
})();
const chosen = fromEnv || fallback;
if (!chosen) return null;
try {
fs.mkdirSync(chosen, { recursive: true });
} catch {}
resolvedDataDir = chosen;
process.env.WECHAT_TOOL_DATA_DIR = chosen;
return chosen;
}
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();
}
function getExeDir() {
try {
return path.dirname(process.execPath);
} catch {
return null;
}
}
function ensureOutputLink() {
// Users often expect an `output/` folder near the installed exe. We keep the real data
// in the per-user data dir, and (when possible) create a Windows junction next to the exe.
if (!app.isPackaged) return;
const exeDir = getExeDir();
const dataDir = resolveDataDir();
if (!exeDir || !dataDir) return;
const target = path.join(dataDir, "output");
const linkPath = path.join(exeDir, "output");
// If the target doesn't exist yet, create it so the link points somewhere real.
try {
fs.mkdirSync(target, { recursive: true });
} catch {}
// If something already exists at linkPath, do not overwrite it.
try {
if (fs.existsSync(linkPath)) return;
} catch {
return;
}
try {
fs.symlinkSync(target, linkPath, "junction");
logMain(`[main] created output link: ${linkPath} -> ${target}`);
} catch (err) {
logMain(`[main] failed to create output link: ${err?.message || err}`);
}
}
function getMainLogPath() {
const dir = getUserDataDir();
if (!dir) return null;
return path.join(dir, "desktop-main.log");
}
function logMain(line) {
const p = getMainLogPath();
if (!p) return;
try {
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.appendFileSync(p, `[${nowIso()}] ${line}\n`, { encoding: "utf8" });
} catch {}
}
function getDesktopSettingsPath() {
const dir = getUserDataDir();
if (!dir) return null;
return path.join(dir, "desktop-settings.json");
}
function loadDesktopSettings() {
if (desktopSettings) return desktopSettings;
const defaults = {
// 'tray' (default): closing the window hides it to the system tray.
// 'exit': closing the window quits the app.
closeBehavior: "tray",
};
const p = getDesktopSettingsPath();
if (!p) {
desktopSettings = { ...defaults };
return desktopSettings;
}
try {
if (!fs.existsSync(p)) {
desktopSettings = { ...defaults };
return desktopSettings;
}
const raw = fs.readFileSync(p, { encoding: "utf8" });
const parsed = JSON.parse(raw || "{}");
desktopSettings = { ...defaults, ...(parsed && typeof parsed === "object" ? parsed : {}) };
} catch (err) {
desktopSettings = { ...defaults };
logMain(`[main] failed to load settings: ${err?.message || err}`);
}
return desktopSettings;
}
function persistDesktopSettings() {
const p = getDesktopSettingsPath();
if (!p) return;
if (!desktopSettings) return;
try {
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, JSON.stringify(desktopSettings, null, 2), { encoding: "utf8" });
} catch (err) {
logMain(`[main] failed to persist settings: ${err?.message || err}`);
}
}
function getCloseBehavior() {
const v = String(loadDesktopSettings()?.closeBehavior || "").trim().toLowerCase();
return v === "exit" ? "exit" : "tray";
}
function setCloseBehavior(next) {
const v = String(next || "").trim().toLowerCase();
loadDesktopSettings();
desktopSettings.closeBehavior = v === "exit" ? "exit" : "tray";
persistDesktopSettings();
return desktopSettings.closeBehavior;
}
function getTrayIconPath() {
// Prefer an icon shipped in `src/` so it works both in dev and packaged (asar) builds.
const shipped = path.join(__dirname, "icon.ico");
try {
if (fs.existsSync(shipped)) return shipped;
} catch {}
// Dev fallback (not available in packaged builds).
const dev = path.resolve(__dirname, "..", "build", "icon.ico");
try {
if (fs.existsSync(dev)) return dev;
} catch {}
return null;
}
function showMainWindow() {
if (!mainWindow) return;
try {
mainWindow.setSkipTaskbar(false);
} catch {}
try {
if (mainWindow.isMinimized()) mainWindow.restore();
} catch {}
try {
mainWindow.show();
} catch {}
try {
mainWindow.focus();
} catch {}
}
function createTray() {
if (tray) return tray;
if (!app.isPackaged) return null;
const iconPath = getTrayIconPath();
if (!iconPath) {
logMain("[main] tray icon not found; disabling tray behavior");
return null;
}
try {
tray = new Tray(iconPath);
} catch (err) {
tray = null;
logMain(`[main] failed to create tray: ${err?.message || err}`);
return null;
}
try {
tray.setToolTip("WeChatDataAnalysis");
} catch {}
try {
tray.setContextMenu(
Menu.buildFromTemplate([
{
label: "显示",
click: () => showMainWindow(),
},
{
label: "退出",
click: () => {
isQuitting = true;
app.quit();
},
},
])
);
} catch {}
try {
tray.on("click", () => showMainWindow());
tray.on("double-click", () => showMainWindow());
} catch {}
return tray;
}
function destroyTray() {
if (!tray) return;
try {
tray.destroy();
} catch {}
tray = null;
}
function ensureTrayForCloseBehavior() {
const behavior = getCloseBehavior();
if (behavior === "tray") createTray();
else destroyTray();
}
function getBackendStdioLogPath(dataDir) {
return path.join(dataDir, "backend-stdio.log");
}
function attachBackendStdio(proc, logPath) {
// In packaged builds, stdout/stderr are often the only place we can see early crash
// reasons (missing DLLs, import errors) before the Python logger initializes.
try {
fs.mkdirSync(path.dirname(logPath), { recursive: true });
} catch {}
try {
backendStdioStream = fs.createWriteStream(logPath, { flags: "a" });
backendStdioStream.write(`[${nowIso()}] [main] backend stdio -> ${logPath}\n`);
} catch {
backendStdioStream = null;
return;
}
const write = (prefix, chunk) => {
if (!backendStdioStream) return;
try {
const text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk);
backendStdioStream.write(`[${nowIso()}] ${prefix} ${text}`);
if (!text.endsWith("\n")) backendStdioStream.write("\n");
} catch {}
};
if (proc.stdout) proc.stdout.on("data", (d) => write("[backend:stdout]", d));
if (proc.stderr) proc.stderr.on("data", (d) => write("[backend:stderr]", d));
proc.on("error", (err) => write("[backend:error]", err?.stack || String(err)));
proc.on("close", (code, signal) => {
write("[backend:close]", `code=${code} signal=${signal}`);
try {
backendStdioStream?.end();
} catch {}
backendStdioStream = null;
});
}
function repoRoot() {
// desktop/src -> desktop -> repo root
@@ -27,6 +328,8 @@ function startBackend() {
...process.env,
WECHAT_TOOL_HOST: BACKEND_HOST,
WECHAT_TOOL_PORT: String(BACKEND_PORT),
// Make sure Python prints UTF-8 to stdout/stderr.
PYTHONIOENCODING: process.env.PYTHONIOENCODING || "utf-8",
};
// In packaged mode we expect to provide the generated Nuxt output dir via env.
@@ -51,9 +354,10 @@ function startBackend() {
backendProc = spawn(backendExe, [], {
cwd: env.WECHAT_TOOL_DATA_DIR,
env,
stdio: "ignore",
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
});
attachBackendStdio(backendProc, getBackendStdioLogPath(env.WECHAT_TOOL_DATA_DIR));
} else {
backendProc = spawn("uv", ["run", "main.py"], {
cwd: repoRoot(),
@@ -67,6 +371,7 @@ function startBackend() {
backendProc = null;
// eslint-disable-next-line no-console
console.log(`[backend] exited code=${code} signal=${signal}`);
logMain(`[backend] exited code=${code} signal=${signal}`);
});
return backendProc;
@@ -124,12 +429,12 @@ async function waitForBackend({ timeoutMs }) {
function debugEnabled() {
// Enable debug helpers in dev by default; in packaged builds require explicit opt-in.
return !app.isPackaged || process.env.WECHAT_DESKTOP_DEBUG === "1";
if (!app.isPackaged) return true;
if (process.env.WECHAT_DESKTOP_DEBUG === "1") return true;
return process.argv.includes("--debug") || process.argv.includes("--devtools");
}
function registerDebugShortcuts() {
if (!debugEnabled()) return;
const toggleDevTools = () => {
const win = BrowserWindow.getFocusedWindow() || BrowserWindow.getAllWindows()[0];
if (!win) return;
@@ -186,10 +491,32 @@ function createMainWindow() {
preload: path.join(__dirname, "preload.cjs"),
contextIsolation: true,
nodeIntegration: false,
devTools: debugEnabled(),
// Allow DevTools to be opened in packaged builds (F12 / Ctrl+Shift+I).
// We still only auto-open it when debugEnabled() returns true.
devTools: true,
},
});
win.on("close", (event) => {
// In packaged builds, we default to "close -> minimize to tray" unless the user opts out.
if (!app.isPackaged) return;
if (isQuitting) return;
if (getCloseBehavior() !== "tray") return;
if (!tray) return;
try {
event.preventDefault();
win.setSkipTaskbar(true);
win.hide();
try {
tray.displayBalloon({
title: "WeChatDataAnalysis",
content: "已最小化到托盘,可从托盘图标再次打开。",
});
} catch {}
} catch {}
});
win.on("closed", () => {
stopBackend();
});
@@ -237,6 +564,53 @@ function registerWindowIpc() {
const win = getWin(event);
return !!win?.isMaximized();
});
ipcMain.handle("app:getAutoLaunch", () => {
try {
const settings = app.getLoginItemSettings();
return !!(settings?.openAtLogin || settings?.executableWillLaunchAtLogin);
} catch (err) {
logMain(`[main] getAutoLaunch failed: ${err?.message || err}`);
return false;
}
});
ipcMain.handle("app:setAutoLaunch", (_event, enabled) => {
const on = !!enabled;
try {
app.setLoginItemSettings({ openAtLogin: on });
} catch (err) {
logMain(`[main] setAutoLaunch(${on}) failed: ${err?.message || err}`);
return false;
}
try {
const settings = app.getLoginItemSettings();
return !!(settings?.openAtLogin || settings?.executableWillLaunchAtLogin);
} catch {
return on;
}
});
ipcMain.handle("app:getCloseBehavior", () => {
try {
return getCloseBehavior();
} catch (err) {
logMain(`[main] getCloseBehavior failed: ${err?.message || err}`);
return "tray";
}
});
ipcMain.handle("app:setCloseBehavior", (_event, behavior) => {
try {
const next = setCloseBehavior(behavior);
ensureTrayForCloseBehavior();
return next;
} catch (err) {
logMain(`[main] setCloseBehavior failed: ${err?.message || err}`);
return getCloseBehavior();
}
});
}
async function main() {
@@ -245,10 +619,19 @@ async function main() {
registerWindowIpc();
registerDebugShortcuts();
// Resolve/create the data dir early so we can log reliably and (optionally) place a link
// next to the installed exe for easier access.
resolveDataDir();
ensureOutputLink();
logMain(`[main] app.isPackaged=${app.isPackaged} argv=${JSON.stringify(process.argv)}`);
startBackend();
await waitForBackend({ timeoutMs: 30_000 });
const win = createMainWindow();
mainWindow = win;
ensureTrayForCloseBehavior();
const startUrl =
process.env.ELECTRON_START_URL ||
@@ -276,12 +659,25 @@ app.on("will-quit", () => {
});
app.on("before-quit", () => {
isQuitting = true;
destroyTray();
stopBackend();
});
main().catch((err) => {
// eslint-disable-next-line no-console
console.error(err);
logMain(`[main] fatal: ${err?.stack || String(err)}`);
stopBackend();
try {
const dir = getUserDataDir();
if (dir) {
dialog.showErrorBox(
"WeChatDataAnalysis 启动失败",
`启动失败:${err?.message || err}\n\n请查看日志目录:\n${dir}\n\n文件:desktop-main.log / backend-stdio.log / output\\\\logs\\\\...`
);
shell.openPath(dir);
}
} catch {}
app.quit();
});
+6 -1
View File
@@ -5,5 +5,10 @@ contextBridge.exposeInMainWorld("wechatDesktop", {
toggleMaximize: () => ipcRenderer.invoke("window:toggleMaximize"),
close: () => ipcRenderer.invoke("window:close"),
isMaximized: () => ipcRenderer.invoke("window:isMaximized"),
});
getAutoLaunch: () => ipcRenderer.invoke("app:getAutoLaunch"),
setAutoLaunch: (enabled) => ipcRenderer.invoke("app:setAutoLaunch", !!enabled),
getCloseBehavior: () => ipcRenderer.invoke("app:getCloseBehavior"),
setCloseBehavior: (behavior) => ipcRenderer.invoke("app:setCloseBehavior", String(behavior || "")),
});
+574 -41
View File
@@ -25,6 +25,23 @@
<circle v-if="!privacyMode" cx="12" cy="12" r="3" />
</svg>
</div>
<!-- 设置按钮仅桌面端 -->
<div
v-if="isDesktopEnv"
class="w-16 h-12 flex items-center justify-center cursor-pointer transition-colors text-gray-500 hover:text-gray-700"
@click="openDesktopSettings"
title="设置"
>
<svg class="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
</div>
</div>
@@ -272,7 +289,13 @@
<!-- 消息发送者头像 -->
<div class="w-[36px] h-[36px] rounded-md overflow-hidden bg-gray-300 flex-shrink-0" :class="[message.isSent ? 'ml-3' : 'mr-3', { 'privacy-blur': privacyMode }]">
<div v-if="message.avatar" class="w-full h-full">
<img :src="message.avatar" :alt="message.sender + '的头像'" class="w-full h-full object-cover">
<img
:src="message.avatar"
:alt="message.sender + '的头像'"
class="w-full h-full object-cover"
referrerpolicy="no-referrer"
@error="onMessageAvatarError($event, message)"
>
</div>
<div v-else class="w-full h-full flex items-center justify-center text-white text-xs font-bold"
:style="{ backgroundColor: message.avatarColor || (message.isSent ? '#4B5563' : '#6B7280') }">
@@ -302,7 +325,9 @@
:heading="message.title || message.content"
:abstract="message.content"
:preview="message.preview"
:fromAvatar="message.fromAvatar"
:from="message.from"
:isSent="message.isSent"
/>
<div v-else-if="message.renderType === 'file'"
class="wechat-redpacket-card wechat-special-card wechat-file-card msg-radius"
@@ -1543,6 +1568,104 @@
</div>
</div>
</div>
<!-- 桌面端设置弹窗 -->
<div
v-if="desktopSettingsOpen"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
@click.self="closeDesktopSettings"
>
<div class="w-full max-w-md bg-white rounded-lg shadow-lg">
<div class="px-5 py-4 border-b border-gray-200 flex items-center justify-between">
<div class="text-base font-medium text-gray-900">设置</div>
<button
type="button"
class="text-gray-500 hover:text-gray-700"
@click="closeDesktopSettings"
title="关闭"
>
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="px-5 py-4 space-y-4">
<div class="flex items-center justify-between gap-4">
<div class="min-w-0">
<div class="text-sm font-medium text-gray-900">开机自启动</div>
<div class="text-xs text-gray-500">系统登录后自动启动桌面端</div>
</div>
<input
type="checkbox"
class="h-4 w-4"
:checked="desktopAutoLaunch"
:disabled="desktopAutoLaunchLoading"
@change="onDesktopAutoLaunchToggle"
/>
</div>
<div v-if="desktopAutoLaunchError" class="text-xs text-red-600 whitespace-pre-wrap">
{{ desktopAutoLaunchError }}
</div>
<div class="flex items-center justify-between gap-4">
<div class="min-w-0">
<div class="text-sm font-medium text-gray-900">关闭窗口行为</div>
<div class="text-xs text-gray-500">点击关闭按钮时默认最小化到托盘</div>
</div>
<select
class="text-sm px-2 py-1 rounded-md border border-gray-200"
:disabled="desktopCloseBehaviorLoading"
:value="desktopCloseBehavior"
@change="onDesktopCloseBehaviorChange"
>
<option value="tray">最小化到托盘</option>
<option value="exit">直接退出</option>
</select>
</div>
<div v-if="desktopCloseBehaviorError" class="text-xs text-red-600 whitespace-pre-wrap">
{{ desktopCloseBehaviorError }}
</div>
<div class="flex items-center justify-between gap-4">
<div class="min-w-0">
<div class="text-sm font-medium text-gray-900">启动后自动开启实时获取</div>
<div class="text-xs text-gray-500">进入聊天页后自动打开实时开关默认关闭</div>
</div>
<input
type="checkbox"
class="h-4 w-4"
:checked="desktopAutoRealtime"
@change="onDesktopAutoRealtimeToggle"
/>
</div>
<div class="flex items-center justify-between gap-4">
<div class="min-w-0">
<div class="text-sm font-medium text-gray-900">有数据时默认进入聊天页</div>
<div class="text-xs text-gray-500">有已解密账号时打开应用默认跳转到 /chat默认关闭</div>
</div>
<input
type="checkbox"
class="h-4 w-4"
:checked="desktopDefaultToChatWhenData"
@change="onDesktopDefaultToChatToggle"
/>
</div>
</div>
<div class="px-5 py-4 border-t border-gray-200 flex items-center justify-end gap-2">
<button
type="button"
class="text-sm px-3 py-2 rounded-md bg-white border border-gray-200 hover:bg-gray-50"
@click="closeDesktopSettings"
>
关闭
</button>
</div>
</div>
</div>
</div>
</template>
@@ -1607,6 +1730,145 @@ const selectedContact = ref(null)
// 隐私模式
const privacyMode = ref(false)
// 桌面端设置(仅 Electron 环境可见)
const isDesktopEnv = ref(false)
const desktopSettingsOpen = ref(false)
const DESKTOP_SETTING_AUTO_REALTIME_KEY = 'desktop.settings.autoRealtime'
const DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY = 'desktop.settings.defaultToChatWhenData'
const desktopAutoRealtime = ref(false)
const desktopDefaultToChatWhenData = ref(false)
const desktopAutoLaunch = ref(false)
const desktopAutoLaunchLoading = ref(false)
const desktopAutoLaunchError = ref('')
const desktopCloseBehavior = ref('tray') // tray | exit
const desktopCloseBehaviorLoading = ref(false)
const desktopCloseBehaviorError = ref('')
const readLocalBool = (key) => {
if (!process.client) return false
try {
return localStorage.getItem(key) === 'true'
} catch {
return false
}
}
const writeLocalBool = (key, value) => {
if (!process.client) return
try {
localStorage.setItem(key, value ? 'true' : 'false')
} catch {}
}
// 尽量早读本地设置,避免首次加载联系人时拿不到 autoRealtime 选项
if (process.client) {
desktopAutoRealtime.value = readLocalBool(DESKTOP_SETTING_AUTO_REALTIME_KEY)
desktopDefaultToChatWhenData.value = readLocalBool(DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY)
}
const refreshDesktopAutoLaunch = async () => {
if (!process.client || typeof window === 'undefined') return
if (!window.wechatDesktop?.getAutoLaunch) return
desktopAutoLaunchLoading.value = true
desktopAutoLaunchError.value = ''
try {
desktopAutoLaunch.value = !!(await window.wechatDesktop.getAutoLaunch())
} catch (e) {
desktopAutoLaunchError.value = e?.message || '读取开机自启动状态失败'
} finally {
desktopAutoLaunchLoading.value = false
}
}
const setDesktopAutoLaunch = async (enabled) => {
if (!process.client || typeof window === 'undefined') return
if (!window.wechatDesktop?.setAutoLaunch) return
desktopAutoLaunchLoading.value = true
desktopAutoLaunchError.value = ''
try {
desktopAutoLaunch.value = !!(await window.wechatDesktop.setAutoLaunch(!!enabled))
} catch (e) {
desktopAutoLaunchError.value = e?.message || '设置开机自启动失败'
await refreshDesktopAutoLaunch()
} finally {
desktopAutoLaunchLoading.value = false
}
}
const refreshDesktopCloseBehavior = async () => {
if (!process.client || typeof window === 'undefined') return
if (!window.wechatDesktop?.getCloseBehavior) return
desktopCloseBehaviorLoading.value = true
desktopCloseBehaviorError.value = ''
try {
const v = await window.wechatDesktop.getCloseBehavior()
desktopCloseBehavior.value = (String(v || '').toLowerCase() === 'exit') ? 'exit' : 'tray'
} catch (e) {
desktopCloseBehaviorError.value = e?.message || '读取关闭窗口行为失败'
} finally {
desktopCloseBehaviorLoading.value = false
}
}
const setDesktopCloseBehavior = async (behavior) => {
if (!process.client || typeof window === 'undefined') return
if (!window.wechatDesktop?.setCloseBehavior) return
const desired = (String(behavior || '').toLowerCase() === 'exit') ? 'exit' : 'tray'
desktopCloseBehaviorLoading.value = true
desktopCloseBehaviorError.value = ''
try {
const v = await window.wechatDesktop.setCloseBehavior(desired)
desktopCloseBehavior.value = (String(v || '').toLowerCase() === 'exit') ? 'exit' : 'tray'
} catch (e) {
desktopCloseBehaviorError.value = e?.message || '设置关闭窗口行为失败'
await refreshDesktopCloseBehavior()
} finally {
desktopCloseBehaviorLoading.value = false
}
}
const openDesktopSettings = async () => {
desktopSettingsOpen.value = true
await refreshDesktopAutoLaunch()
await refreshDesktopCloseBehavior()
}
const closeDesktopSettings = () => {
desktopSettingsOpen.value = false
}
const onDesktopAutoLaunchToggle = async (ev) => {
const checked = !!ev?.target?.checked
await setDesktopAutoLaunch(checked)
}
const onDesktopCloseBehaviorChange = async (ev) => {
const v = String(ev?.target?.value || '').trim()
await setDesktopCloseBehavior(v)
}
const onDesktopAutoRealtimeToggle = async (ev) => {
const checked = !!ev?.target?.checked
desktopAutoRealtime.value = checked
writeLocalBool(DESKTOP_SETTING_AUTO_REALTIME_KEY, checked)
if (checked) {
// 开启后尝试立即启用实时模式(不可用则静默忽略)
try {
await tryEnableRealtimeAuto()
} catch {}
}
}
const onDesktopDefaultToChatToggle = (ev) => {
const checked = !!ev?.target?.checked
desktopDefaultToChatWhenData.value = checked
writeLocalBool(DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY, checked)
}
// 联系人数据
const contacts = ref([])
@@ -1632,6 +1894,7 @@ let realtimeSessionsRefreshQueued = false
let realtimeFullSyncFuture = null
let realtimeFullSyncQueued = false
let realtimeFullSyncPriority = ''
let realtimeChangeDebounceTimer = null
const allMessages = ref({})
@@ -3406,6 +3669,9 @@ const applyRouteSelection = async () => {
// 默认选择第一个联系人
onMounted(() => {
if (process.client && typeof window !== 'undefined') {
isDesktopEnv.value = !!window.wechatDesktop
}
loadContacts()
loadSearchHistory()
})
@@ -3436,6 +3702,8 @@ const loadContacts = async () => {
} finally {
isLoadingContacts.value = false
}
await tryEnableRealtimeAuto()
}
const loadSessionsForSelectedAccount = async () => {
@@ -3653,6 +3921,36 @@ const normalizeMessage = (msg) => {
)
}
// WeChat public account thumbnails (mmbiz.qpic.cn, wx.qlogo.cn...) are hotlink-protected:
// the browser will get a placeholder image ("此图片来自微信公众号平台").
// Proxy them via backend with a mp.weixin.qq.com Referer to fetch the real image.
const normalizedThumbUrl = (() => {
// Backend may provide either `thumbUrl` (appmsg) or `preview` (some exports). Use the first usable one.
const candidates = [msg.thumbUrl, msg.preview]
for (const cand of candidates) {
if (isUsableMediaUrl(cand)) return normalizeMaybeUrl(cand)
}
return ''
})()
const normalizedLinkPreviewUrl = (() => {
const u = normalizedThumbUrl
if (!u) return ''
if (/^\/api\/chat\/media\//i.test(u) || /^blob:/i.test(u) || /^data:/i.test(u)) return u
if (!/^https?:\/\//i.test(u)) return u
try {
const host = new URL(u).hostname.toLowerCase()
if (host.endsWith('.qpic.cn') || host.endsWith('.qlogo.cn')) {
return `${mediaBase}/api/chat/media/proxy_image?url=${encodeURIComponent(u)}`
}
} catch {}
return u
})()
const fromUsername = String(msg.fromUsername || '').trim()
const fromAvatar = fromUsername
? `${mediaBase}/api/chat/avatar?account=${encodeURIComponent(selectedAccount.value || '')}&username=${encodeURIComponent(fromUsername)}`
: ''
const localEmojiUrl = msg.emojiMd5 ? `${mediaBase}/api/chat/media/emoji?account=${encodeURIComponent(selectedAccount.value || '')}&md5=${encodeURIComponent(msg.emojiMd5)}&username=${encodeURIComponent(selectedContact.value?.username || '')}` : ''
const localImageUrl = (() => {
if (!msg.imageMd5 && !msg.imageFileId) return ''
@@ -3792,14 +4090,23 @@ const normalizeMessage = (msg) => {
transferReceived: msg.paySubType === '3' || msg.transferStatus === '已收款',
voiceUrl: normalizedVoiceUrl || '',
voiceDuration: msg.voiceLength || msg.voiceDuration || '',
preview: msg.thumbUrl || '',
from: '',
preview: normalizedLinkPreviewUrl || '',
from: String(msg.from || '').trim(),
fromUsername,
fromAvatar,
isGroup: !!selectedContact.value?.isGroup,
avatar: msg.senderAvatar || fallbackAvatar || null,
// Backends may use either `senderAvatar` (our API) or `avatar` (exported JSON).
avatar: msg.senderAvatar || msg.avatar || fallbackAvatar || null,
avatarColor: null
}
}
const onMessageAvatarError = (e, message) => {
// Make sure we fall back to the initial avatar if the URL 404s/blocks.
try { e?.target && (e.target.style.display = 'none') } catch {}
try { if (message) message.avatar = null } catch {}
}
const shouldShowEmojiDownload = (message) => {
if (!message?.emojiMd5) return false
const u = String(message?.emojiRemoteUrl || '').trim()
@@ -4338,9 +4645,9 @@ const loadMessages = async ({ username, reset }) => {
if (messageTypeFilter.value && messageTypeFilter.value !== 'all') {
params.render_types = messageTypeFilter.value
}
if (reset) {
await queueRealtimeFullSync(username)
if (realtimeEnabled.value) {
// In realtime mode, read directly from WCDB to avoid blocking on background sync.
params.source = 'realtime'
}
const resp = await api.listChatMessages(params)
@@ -4441,6 +4748,12 @@ const stopRealtimeStream = () => {
} catch {}
realtimeEventSource = null
}
if (realtimeChangeDebounceTimer) {
try {
clearTimeout(realtimeChangeDebounceTimer)
} catch {}
realtimeChangeDebounceTimer = null
}
}
const refreshRealtimeIncremental = async () => {
@@ -4468,8 +4781,8 @@ const refreshRealtimeIncremental = async () => {
if (messageTypeFilter.value && messageTypeFilter.value !== 'all') {
params.render_types = messageTypeFilter.value
}
params.source = 'realtime'
await queueRealtimeFullSync(username)
const resp = await api.listChatMessages(params)
if (selectedContact.value?.username !== username) return
@@ -4514,6 +4827,19 @@ const queueRealtimeRefresh = () => {
})
}
const queueRealtimeChange = () => {
if (!process.client || typeof window === 'undefined') return
if (!realtimeEnabled.value) return
if (realtimeChangeDebounceTimer) return
// Debounce noisy db_storage change events to avoid hammering the backend.
realtimeChangeDebounceTimer = setTimeout(() => {
realtimeChangeDebounceTimer = null
queueRealtimeRefresh()
queueRealtimeSessionsRefresh()
}, 500)
}
const startRealtimeStream = () => {
stopRealtimeStream()
if (!process.client || typeof window === 'undefined') return
@@ -4534,9 +4860,7 @@ const startRealtimeStream = () => {
try {
const data = JSON.parse(String(ev.data || '{}'))
if (String(data?.type || '') === 'change') {
queueRealtimeFullSync(selectedContact.value?.username || '')
queueRealtimeRefresh()
queueRealtimeSessionsRefresh()
queueRealtimeChange()
}
} catch {}
}
@@ -4546,15 +4870,18 @@ const startRealtimeStream = () => {
}
}
const toggleRealtime = async () => {
const toggleRealtime = async (opts = {}) => {
const silent = !!opts?.silent
if (!process.client || typeof window === 'undefined') return
if (!selectedAccount.value) return
if (!realtimeEnabled.value) {
await fetchRealtimeStatus()
if (!realtimeAvailable.value) {
window.alert(realtimeStatusError.value || '实时模式不可用:缺少密钥或 db_storage 路径。')
return
if (!silent) {
window.alert(realtimeStatusError.value || '实时模式不可用:缺少密钥或 db_storage 路径。')
}
return false
}
realtimeEnabled.value = true
startRealtimeStream()
@@ -4562,7 +4889,7 @@ const toggleRealtime = async () => {
if (selectedContact.value?.username) {
await refreshSelectedMessages()
}
return
return true
}
realtimeEnabled.value = false
@@ -4571,6 +4898,19 @@ const toggleRealtime = async () => {
if (selectedContact.value?.username) {
await refreshSelectedMessages()
}
return true
}
const tryEnableRealtimeAuto = async () => {
if (!process.client || typeof window === 'undefined') return
if (!isDesktopEnv.value) return
if (!desktopAutoRealtime.value) return
if (realtimeEnabled.value) return
if (!selectedAccount.value) return
try {
await toggleRealtime({ silent: true })
} catch {}
}
watch(selectedAccount, async () => {
@@ -4714,28 +5054,89 @@ const LinkCard = defineComponent({
heading: { type: String, default: '' },
abstract: { type: String, default: '' },
preview: { type: String, default: '' },
from: { type: String, default: '' }
fromAvatar: { type: String, default: '' },
from: { type: String, default: '' },
isSent: { type: Boolean, default: false }
},
setup(props) {
return () => h(
'a',
{
href: props.href,
target: '_blank',
rel: 'noreferrer',
class: 'block max-w-sm w-full bg-white msg-radius border border-neutral-200 overflow-hidden hover:bg-gray-50 transition-colors'
},
[
props.preview ? h('div', { class: 'w-full bg-black/5' }, [
h('img', { src: props.preview, alt: props.heading || '链接预览', class: 'w-full max-h-40 object-cover' })
]) : null,
h('div', { class: 'px-3 py-2' }, [
h('div', { class: 'text-sm font-medium text-gray-900 line-clamp-2' }, props.heading || props.href),
props.abstract ? h('div', { class: 'text-xs text-gray-600 mt-1 line-clamp-2' }, props.abstract) : null,
props.from ? h('div', { class: 'text-[10px] text-gray-400 mt-1 truncate' }, props.from) : null
])
].filter(Boolean)
)
const getFromText = () => {
const raw = String(props.from || '').trim()
if (raw) return raw
// Fallback: when the appmsg XML doesn't provide sourcedisplayname/appname,
// show the host so the footer row still matches WeChat's fixed card layout.
try {
const host = new URL(String(props.href || '')).hostname
return String(host || '').trim()
} catch {
return ''
}
}
return () => {
const fromText = getFromText()
// WeChat link cards show a small avatar next to the source text. We don't
// always have a real image URL, so fall back to the first glyph.
const fromAvatarText = (() => {
const t = String(fromText || '').trim()
return t ? (Array.from(t)[0] || '') : ''
})()
const fromAvatarUrl = String(props.fromAvatar || '').trim()
return h(
'a',
{
href: props.href,
target: '_blank',
rel: 'noreferrer',
class: [
'wechat-link-card',
'wechat-special-card',
'msg-radius',
props.isSent ? 'wechat-special-sent-side' : ''
].filter(Boolean).join(' '),
// Inline size is intentional: LinkCard is a local component rendered via `h()` and
// does not inherit the SFC scoped CSS attribute, so relying on scoped CSS for exact
// sizing is fragile. Keep width in sync with the WeChat desktop card size.
style: {
width: '210px',
minWidth: '210px',
maxWidth: '210px',
display: 'flex',
flexDirection: 'column',
boxSizing: 'border-box',
flex: '0 0 auto',
background: '#fff',
border: 'none',
boxShadow: 'none',
textDecoration: 'none',
outline: 'none'
}
},
[
h('div', { class: 'wechat-link-content' }, [
h('div', { class: 'wechat-link-info' }, [
h('div', { class: 'wechat-link-title' }, props.heading || props.href),
props.abstract ? h('div', { class: 'wechat-link-desc' }, props.abstract) : null
].filter(Boolean)),
props.preview ? h('div', { class: 'wechat-link-thumb' }, [
h('img', { src: props.preview, alt: props.heading || '链接预览', class: 'wechat-link-thumb-img', referrerpolicy: 'no-referrer' })
]) : null
].filter(Boolean)),
h('div', { class: 'wechat-link-from' }, [
h('div', { class: 'wechat-link-from-avatar', 'aria-hidden': 'true' }, [
fromAvatarText || '\u200B',
fromAvatarUrl ? h('img', {
src: fromAvatarUrl,
alt: '',
class: 'wechat-link-from-avatar-img',
referrerpolicy: 'no-referrer',
onError: (e) => { try { e?.target && (e.target.style.display = 'none') } catch {} }
}) : null
].filter(Boolean)),
h('div', { class: 'wechat-link-from-name' }, fromText || '\u200B')
])
].filter(Boolean)
)
}
}
})
@@ -5049,24 +5450,24 @@ const LinkCard = defineComponent({
}
/* 统一特殊消息尾巴(红包 / 文件等) */
.wechat-special-card {
:deep(.wechat-special-card) {
position: relative;
overflow: visible;
}
.wechat-special-card::after {
:deep(.wechat-special-card)::after {
content: '';
position: absolute;
top: 16px;
top: 12px;
left: -4px;
width: 10px;
height: 10px;
width: 12px;
height: 12px;
background-color: inherit;
transform: rotate(45deg);
border-radius: 2px;
}
.wechat-special-sent-side::after {
:deep(.wechat-special-sent-side)::after {
left: auto;
right: -4px;
}
@@ -5418,6 +5819,138 @@ const LinkCard = defineComponent({
margin-right: 4px;
}
/* 链接消息样式 - 微信风格 */
:deep(.wechat-link-card) {
width: 210px;
min-width: 210px;
max-width: 210px;
background: #fff;
display: flex;
flex-direction: column;
box-sizing: border-box;
border: none;
box-shadow: none;
outline: none;
cursor: pointer;
text-decoration: none;
transition: background-color 0.15s ease;
}
:deep(.wechat-link-card:hover) {
background: #f5f5f5;
}
:deep(.wechat-link-content) {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 10px;
box-sizing: border-box;
/* Keep a small breathing room above the footer divider. */
padding: 8px 10px 6px;
flex: 1 1 auto;
}
:deep(.wechat-link-info) {
display: flex;
flex-direction: column;
overflow: hidden;
flex: 1 1 auto;
min-width: 0;
}
:deep(.wechat-link-title) {
font-size: 14px;
color: #1a1a1a;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
line-height: 1.4;
word-break: break-word;
}
:deep(.wechat-link-desc) {
font-size: 12px;
color: #8c8c8c;
margin-top: 4px;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
line-height: 1.4;
word-break: break-word;
}
:deep(.wechat-link-thumb) {
width: 42px;
height: 42px;
flex-shrink: 0;
border-radius: 0;
overflow: hidden;
background: #f2f2f2;
/* Center the thumbnail in the content area (WeChat desktop style). */
align-self: center;
}
:deep(.wechat-link-thumb-img) {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
:deep(.wechat-link-from) {
height: 30px;
display: flex;
align-items: center;
gap: 5px;
padding: 0 10px;
position: relative;
flex-shrink: 0;
}
:deep(.wechat-link-from)::before {
content: '';
position: absolute;
top: 0;
left: 11px;
right: 11px;
height: 1.5px;
background: #e8e8e8;
}
:deep(.wechat-link-from-avatar) {
width: 16px;
height: 16px;
border-radius: 50%;
background: #111;
color: #fff;
font-size: 11px;
line-height: 16px;
text-align: center;
flex-shrink: 0;
position: relative;
overflow: hidden;
}
:deep(.wechat-link-from-avatar-img) {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
:deep(.wechat-link-from-name) {
font-size: 12px;
color: #b2b2b2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 隐私模式模糊效果 */
.privacy-blur {
filter: blur(9px);
+26 -1
View File
@@ -55,6 +55,31 @@
</template>
<script setup>
import { onMounted } from 'vue'
import { useApi } from '~/composables/useApi'
const DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY = 'desktop.settings.defaultToChatWhenData'
onMounted(async () => {
if (!process.client || typeof window === 'undefined') return
if (!window.wechatDesktop) return
let enabled = false
try {
enabled = localStorage.getItem(DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY) === 'true'
} catch {}
if (!enabled) return
try {
const api = useApi()
const resp = await api.listChatAccounts()
const accounts = resp?.accounts || []
if (accounts.length) {
await navigateTo('/chat', { replace: true })
}
} catch {}
})
// 开始检测并跳转到结果页面
const startDetection = async () => {
// 直接跳转到检测结果页面,让该页面处理检测
@@ -100,4 +125,4 @@ const startDetection = async () => {
linear-gradient(90deg, rgba(7, 193, 96, 0.1) 1px, transparent 1px);
background-size: 50px 50px;
}
</style>
</style>
@@ -894,6 +894,7 @@ def _parse_message_for_export(
content_text = raw_text
title = ""
url = ""
from_name = ""
record_item = ""
image_md5 = ""
image_file_id = ""
@@ -934,6 +935,7 @@ def _parse_message_for_export(
content_text = str(parsed.get("content") or "")
title = str(parsed.get("title") or "")
url = str(parsed.get("url") or "")
from_name = str(parsed.get("from") or "")
record_item = str(parsed.get("recordItem") or "")
quote_title = str(parsed.get("quoteTitle") or "")
quote_content = str(parsed.get("quoteContent") or "")
@@ -1162,6 +1164,7 @@ def _parse_message_for_export(
"content": content_text,
"title": title,
"url": url,
"from": from_name,
"recordItem": record_item,
"thumbUrl": thumb_url,
"imageMd5": image_md5,
+70 -2
View File
@@ -773,7 +773,21 @@ def _parse_app_message(text: str) -> dict[str, Any]:
app_type = 0
title = _extract_xml_tag_text(text, "title")
des = _extract_xml_tag_text(text, "des")
url = _extract_xml_tag_text(text, "url")
url = _normalize_xml_url(_extract_xml_tag_text(text, "url"))
# Some appmsg payloads (notably mp.weixin.qq.com link shares) include a "source" block:
# <sourceusername>gh_xxx</sourceusername>
# <sourcedisplayname>公众号名</sourcedisplayname>
# We'll surface that as `from` so the frontend can render the publisher line like WeChat.
source_display_name = (
_extract_xml_tag_text(text, "sourcedisplayname")
or _extract_xml_tag_text(text, "sourceDisplayName")
or _extract_xml_tag_text(text, "appname")
)
source_username = (
_extract_xml_tag_text(text, "sourceusername")
or _extract_xml_tag_text(text, "sourceUsername")
)
lower = text.lower()
@@ -794,13 +808,15 @@ def _parse_app_message(text: str) -> dict[str, Any]:
}
if app_type in (5, 68) and url:
thumb_url = _extract_xml_tag_text(text, "thumburl")
thumb_url = _normalize_xml_url(_extract_xml_tag_text(text, "thumburl"))
return {
"renderType": "link",
"content": des or title or "[链接]",
"title": title or des or "",
"url": url,
"thumbUrl": thumb_url or "",
"from": str(source_display_name or "").strip(),
"fromUsername": str(source_username or "").strip(),
}
if app_type in (6, 74):
@@ -1322,6 +1338,58 @@ def _load_contact_rows(contact_db_path: Path, usernames: list[str]) -> dict[str,
conn.close()
def _load_usernames_by_display_names(contact_db_path: Path, names: list[str]) -> dict[str, str]:
"""Best-effort mapping from display name -> username using contact.db.
Some appmsg/link payloads only provide `sourcedisplayname` (surfaced as `from`) but not
`sourceusername` (`fromUsername`). We use this mapping to recover `fromUsername` so the
frontend can render the publisher avatar via `/api/chat/avatar`.
"""
uniq = list(dict.fromkeys([str(n or "").strip() for n in names if str(n or "").strip()]))
if not uniq:
return {}
placeholders = ",".join(["?"] * len(uniq))
hits: dict[str, set[str]] = {}
conn = sqlite3.connect(str(contact_db_path))
conn.row_factory = sqlite3.Row
try:
def query_table(table: str) -> None:
for col in ("remark", "nick_name", "alias"):
sql = f"""
SELECT username, {col} AS display_name
FROM {table}
WHERE {col} IN ({placeholders})
"""
try:
rows = conn.execute(sql, uniq).fetchall()
except Exception:
rows = []
for r in rows:
try:
dn = str(r["display_name"] or "").strip()
u = str(r["username"] or "").strip()
except Exception:
continue
if not dn or not u:
continue
hits.setdefault(dn, set()).add(u)
query_table("contact")
query_table("stranger")
# Only return unambiguous mappings (display name -> exactly 1 username).
out: dict[str, str] = {}
for dn, users in hits.items():
if len(users) == 1:
out[dn] = next(iter(users))
return out
finally:
conn.close()
def _make_search_tokens(q: str) -> list[str]:
tokens = [t for t in re.split(r"\s+", str(q or "").strip()) if t]
if len(tokens) > 8:
+15 -8
View File
@@ -3,6 +3,7 @@
"""
import logging
import os
import sys
from datetime import datetime
from pathlib import Path
@@ -58,6 +59,11 @@ class WeChatLogger:
def setup_logging(self, log_level: str = "INFO"):
"""设置日志配置"""
# Allow overriding via env var for easier debugging (e.g. WECHAT_TOOL_LOG_LEVEL=DEBUG)
env_level = str(os.environ.get("WECHAT_TOOL_LOG_LEVEL", "") or "").strip()
if env_level:
log_level = env_level
# 创建日志目录
now = datetime.now()
log_dir = Path("output/logs") / str(now.year) / f"{now.month:02d}" / f"{now.day:02d}"
@@ -88,46 +94,47 @@ class WeChatLogger:
# 文件处理器
file_handler = logging.FileHandler(self.log_file, encoding='utf-8')
file_handler.setFormatter(file_formatter)
file_handler.setLevel(getattr(logging, log_level.upper()))
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(getattr(logging, log_level.upper()))
console_handler.setLevel(level)
# 配置根日志器
root_logger.setLevel(getattr(logging, log_level.upper()))
root_logger.setLevel(level)
root_logger.addHandler(file_handler)
root_logger.addHandler(console_handler)
# 只为uvicorn日志器添加文件处理器,保持其原有的控制台处理器(带颜色)
uvicorn_logger = logging.getLogger("uvicorn")
uvicorn_logger.addHandler(file_handler)
uvicorn_logger.setLevel(getattr(logging, log_level.upper()))
uvicorn_logger.setLevel(level)
# 只为uvicorn.access日志器添加文件处理器
uvicorn_access_logger = logging.getLogger("uvicorn.access")
uvicorn_access_logger.addHandler(file_handler)
uvicorn_access_logger.setLevel(getattr(logging, log_level.upper()))
uvicorn_access_logger.setLevel(level)
# 只为uvicorn.error日志器添加文件处理器
uvicorn_error_logger = logging.getLogger("uvicorn.error")
uvicorn_error_logger.addHandler(file_handler)
uvicorn_error_logger.setLevel(getattr(logging, log_level.upper()))
uvicorn_error_logger.setLevel(level)
# 配置FastAPI日志器
fastapi_logger = logging.getLogger("fastapi")
fastapi_logger.handlers = []
fastapi_logger.addHandler(file_handler)
fastapi_logger.addHandler(console_handler)
fastapi_logger.setLevel(getattr(logging, log_level.upper()))
fastapi_logger.setLevel(level)
# 记录初始化信息
logger = logging.getLogger(__name__)
logger.info("=" * 60)
logger.info("微信解密工具日志系统初始化完成")
logger.info(f"日志文件: {self.log_file}")
logger.info(f"日志级别: {log_level}")
logger.info(f"日志级别: {logging.getLevelName(level)}")
logger.info("=" * 60)
return self.log_file
+373 -24
View File
@@ -39,6 +39,7 @@ from ..chat_helpers import (
_make_snippet,
_match_tokens,
_load_contact_rows,
_load_usernames_by_display_names,
_load_latest_message_previews,
_lookup_resource_md5,
_normalize_xml_url,
@@ -212,6 +213,13 @@ async def stream_chat_realtime_events(
if not db_storage_dir.exists() or not db_storage_dir.is_dir():
raise HTTPException(status_code=400, detail="db_storage directory not found for this account.")
logger.info(
"[realtime] SSE stream open account=%s interval_ms=%s db_storage=%s",
account_dir.name,
int(interval_ms),
str(db_storage_dir),
)
async def gen():
last_mtime_ns = 0
last_heartbeat = 0.0
@@ -225,27 +233,40 @@ async def stream_chat_realtime_events(
}
yield f"data: {json.dumps(initial, ensure_ascii=False)}\n\n"
while True:
if await request.is_disconnected():
break
try:
while True:
if await request.is_disconnected():
break
mtime_ns = _scan_db_storage_mtime_ns(db_storage_dir)
if mtime_ns and mtime_ns != last_mtime_ns:
last_mtime_ns = mtime_ns
payload = {
"type": "change",
"account": account_dir.name,
"mtimeNs": int(mtime_ns),
"ts": int(time.time() * 1000),
}
yield f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
# Avoid blocking the event loop on a potentially large directory walk.
scan_t0 = time.perf_counter()
try:
mtime_ns = await asyncio.to_thread(_scan_db_storage_mtime_ns, db_storage_dir)
except Exception:
mtime_ns = 0
scan_ms = (time.perf_counter() - scan_t0) * 1000.0
if scan_ms > 1000:
logger.warning("[realtime] SSE scan slow account=%s ms=%.1f", account_dir.name, scan_ms)
now = time.time()
if now - last_heartbeat > 15:
last_heartbeat = now
yield ": ping\n\n"
if mtime_ns and mtime_ns != last_mtime_ns:
last_mtime_ns = mtime_ns
payload = {
"type": "change",
"account": account_dir.name,
"mtimeNs": int(mtime_ns),
"ts": int(time.time() * 1000),
}
logger.info("[realtime] SSE change account=%s mtime_ns=%s", account_dir.name, int(mtime_ns))
yield f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
await asyncio.sleep(interval_ms / 1000.0)
now = time.time()
if now - last_heartbeat > 15:
last_heartbeat = now
yield ": ping\n\n"
await asyncio.sleep(interval_ms / 1000.0)
finally:
logger.info("[realtime] SSE stream closed account=%s", account_dir.name)
headers = {"Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no"}
return StreamingResponse(gen(), media_type="text/event-stream", headers=headers)
@@ -336,7 +357,7 @@ def _ensure_session_last_message_table(conn: sqlite3.Connection) -> None:
@router.post("/api/chat/realtime/sync", summary="实时消息同步到解密库(按会话增量)")
async def sync_chat_realtime_messages(
def sync_chat_realtime_messages(
request: Request,
username: str,
account: Optional[str] = None,
@@ -356,11 +377,23 @@ async def sync_chat_realtime_messages(
max_scan = 5000
account_dir = _resolve_account_dir(account)
trace_id = f"rt-sync-{int(time.time() * 1000)}-{threading.get_ident()}"
logger.info(
"[%s] realtime sync start account=%s username=%s max_scan=%s",
trace_id,
account_dir.name,
username,
int(max_scan),
)
# Lock per (account, username) to avoid concurrent writes to the same sqlite tables.
logger.info("[%s] acquiring per-session lock account=%s username=%s", trace_id, account_dir.name, username)
with _realtime_sync_lock(account_dir.name, username):
logger.info("[%s] per-session lock acquired account=%s username=%s", trace_id, account_dir.name, username)
try:
logger.info("[%s] ensure wcdb connected account=%s", trace_id, account_dir.name)
rt_conn = WCDB_REALTIME.ensure_connected(account_dir)
logger.info("[%s] wcdb connected account=%s handle=%s", trace_id, account_dir.name, int(rt_conn.handle))
except WCDBRealtimeError as e:
raise HTTPException(status_code=400, detail=str(e))
@@ -368,6 +401,14 @@ async def sync_chat_realtime_messages(
if not resolved:
raise HTTPException(status_code=404, detail="Conversation table not found in decrypted databases.")
msg_db_path, table_name = resolved
logger.info(
"[%s] resolved decrypted table account=%s username=%s db=%s table=%s",
trace_id,
account_dir.name,
username,
str(msg_db_path),
table_name,
)
msg_conn = sqlite3.connect(str(msg_db_path))
msg_conn.row_factory = sqlite3.Row
@@ -456,8 +497,34 @@ async def sync_chat_realtime_messages(
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
@@ -525,9 +592,27 @@ async def sync_chat_realtime_messages(
# 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)]
insert_t0 = time.perf_counter()
msg_conn.executemany(insert_sql, values)
msg_conn.commit()
insert_ms = (time.perf_counter() - insert_t0) * 1000.0
inserted = len(new_rows)
logger.info(
"[%s] sqlite insert done account=%s username=%s inserted=%s ms=%.1f",
trace_id,
account_dir.name,
username,
int(inserted),
insert_ms,
)
if insert_ms > 1000:
logger.warning(
"[%s] sqlite insert slow account=%s username=%s ms=%.1f",
trace_id,
account_dir.name,
username,
insert_ms,
)
if ("packed_info_data" in insert_cols) and backfill_rows:
update_values = []
@@ -538,12 +623,30 @@ async def sync_chat_realtime_messages(
update_values.append((pdata, int(r.get("local_id") or 0)))
if update_values:
before_changes = msg_conn.total_changes
update_t0 = time.perf_counter()
msg_conn.executemany(
f"UPDATE {quoted_table} SET packed_info_data = ? WHERE local_id = ? AND (packed_info_data IS NULL OR length(packed_info_data) = 0)",
update_values,
)
msg_conn.commit()
update_ms = (time.perf_counter() - update_t0) * 1000.0
backfilled = int(msg_conn.total_changes - before_changes)
logger.info(
"[%s] sqlite backfill done account=%s username=%s rows=%s ms=%.1f",
trace_id,
account_dir.name,
username,
int(backfilled),
update_ms,
)
if update_ms > 1000:
logger.warning(
"[%s] sqlite backfill slow account=%s username=%s ms=%.1f",
trace_id,
account_dir.name,
username,
update_ms,
)
# Update session.db so left sidebar ordering/time can follow new messages.
newest = new_rows[0] if new_rows else None
@@ -635,6 +738,16 @@ async def sync_chat_realtime_messages(
finally:
sconn.close()
logger.info(
"[%s] realtime sync done account=%s username=%s scanned=%s inserted=%s backfilled=%s maxLocalIdBefore=%s",
trace_id,
account_dir.name,
username,
int(scanned),
int(inserted),
int(backfilled),
int(max_local_id),
)
return {
"status": "success",
"account": account_dir.name,
@@ -749,8 +862,31 @@ def _sync_chat_realtime_messages_for_table(
while scanned < int(max_scan):
take = min(batch_size, int(max_scan) - scanned)
logger.info(
"[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.info(
"[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
@@ -815,9 +951,25 @@ def _sync_chat_realtime_messages_for_table(
continue
values = [tuple(r.get(c) for c in insert_cols) for r in reversed(new_rows)]
insert_t0 = time.perf_counter()
msg_conn.executemany(insert_sql, values)
msg_conn.commit()
insert_ms = (time.perf_counter() - insert_t0) * 1000.0
inserted = len(new_rows)
logger.info(
"[realtime] sqlite insert done account=%s username=%s inserted=%s ms=%.1f",
account_dir.name,
username,
int(inserted),
insert_ms,
)
if insert_ms > 1000:
logger.warning(
"[realtime] sqlite insert slow account=%s username=%s ms=%.1f",
account_dir.name,
username,
insert_ms,
)
if ("packed_info_data" in insert_cols) and backfill_rows:
update_values = []
@@ -828,12 +980,28 @@ def _sync_chat_realtime_messages_for_table(
update_values.append((pdata, int(r.get("local_id") or 0)))
if update_values:
before_changes = msg_conn.total_changes
update_t0 = time.perf_counter()
msg_conn.executemany(
f"UPDATE {quoted_table} SET packed_info_data = ? WHERE local_id = ? AND (packed_info_data IS NULL OR length(packed_info_data) = 0)",
update_values,
)
msg_conn.commit()
update_ms = (time.perf_counter() - update_t0) * 1000.0
backfilled = int(msg_conn.total_changes - before_changes)
logger.info(
"[realtime] sqlite backfill done account=%s username=%s rows=%s ms=%.1f",
account_dir.name,
username,
int(backfilled),
update_ms,
)
if update_ms > 1000:
logger.warning(
"[realtime] sqlite backfill slow account=%s username=%s ms=%.1f",
account_dir.name,
username,
update_ms,
)
newest = new_rows[0] if new_rows else None
preview = ""
@@ -937,7 +1105,7 @@ def _sync_chat_realtime_messages_for_table(
@router.post("/api/chat/realtime/sync_all", summary="实时消息同步到解密库(全会话增量)")
async def sync_chat_realtime_messages_all(
def sync_chat_realtime_messages_all(
request: Request,
account: Optional[str] = None,
max_scan: int = 200,
@@ -952,6 +1120,16 @@ async def sync_chat_realtime_messages_all(
说明:这是增量同步,不会每次全表扫描;priority_username 会优先同步并可设置更大的 priority_max_scan。
"""
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",
trace_id,
account_dir.name,
int(max_scan),
str(priority_username or "").strip(),
bool(include_hidden),
bool(include_official),
)
if max_scan < 20:
max_scan = 20
@@ -965,15 +1143,29 @@ async def sync_chat_realtime_messages_all(
priority = str(priority_username or "").strip()
started = time.time()
logger.info("[%s] acquiring global sync lock account=%s", trace_id, account_dir.name)
with _realtime_sync_all_lock(account_dir.name):
logger.info("[%s] global sync lock acquired account=%s", trace_id, account_dir.name)
try:
logger.info("[%s] ensure wcdb connected account=%s", trace_id, account_dir.name)
rt_conn = WCDB_REALTIME.ensure_connected(account_dir)
logger.info("[%s] wcdb connected account=%s handle=%s", trace_id, account_dir.name, int(rt_conn.handle))
except WCDBRealtimeError as e:
raise HTTPException(status_code=400, detail=str(e))
try:
logger.info("[%s] wcdb_get_sessions account=%s", trace_id, account_dir.name)
wcdb_t0 = time.perf_counter()
with rt_conn.lock:
raw_sessions = _wcdb_get_sessions(rt_conn.handle)
wcdb_ms = (time.perf_counter() - wcdb_t0) * 1000.0
logger.info(
"[%s] wcdb_get_sessions done account=%s sessions=%s ms=%.1f",
trace_id,
account_dir.name,
len(raw_sessions or []),
wcdb_ms,
)
except Exception:
raw_sessions = []
@@ -1017,6 +1209,13 @@ async def sync_chat_realtime_messages_all(
sessions = _dedupe(sessions)
sessions.sort(key=lambda x: int(x[0] or 0), reverse=True)
all_usernames = [u for _, u in sessions if u]
logger.info(
"[%s] sessions prepared account=%s raw=%s filtered=%s",
trace_id,
account_dir.name,
len(raw_sessions or []),
len(all_usernames),
)
# Skip sessions whose decrypted session.db already has a newer/equal sort_timestamp.
decrypted_ts_by_user: dict[str, int] = {}
@@ -1079,10 +1278,25 @@ async def sync_chat_realtime_messages_all(
continue
sync_usernames.append(u)
logger.info(
"[%s] sessions need_sync account=%s need_sync=%s skipped_up_to_date=%s",
trace_id,
account_dir.name,
len(sync_usernames),
int(skipped_up_to_date),
)
if priority and priority in sync_usernames:
sync_usernames = [priority] + [u for u in sync_usernames if u != priority]
table_map = _resolve_decrypted_message_tables(account_dir, sync_usernames)
logger.info(
"[%s] resolved decrypted tables account=%s resolved=%s need_sync=%s",
trace_id,
account_dir.name,
len(table_map),
len(sync_usernames),
)
scanned_total = 0
inserted_total = 0
@@ -1115,17 +1329,50 @@ async def sync_chat_realtime_messages_all(
inserted_total += ins
if ins:
updated_sessions += 1
logger.info(
"[%s] synced session account=%s username=%s inserted=%s scanned=%s",
trace_id,
account_dir.name,
uname,
ins,
int(result.get("scanned") or 0),
)
except HTTPException as e:
errors.append(f"{uname}: {str(e.detail or '')}".strip())
logger.warning(
"[%s] sync session failed account=%s username=%s err=%s",
trace_id,
account_dir.name,
uname,
str(e.detail or "").strip(),
)
continue
except Exception as e:
errors.append(f"{uname}: {str(e)}".strip())
logger.exception(
"[%s] sync session crashed account=%s username=%s",
trace_id,
account_dir.name,
uname,
)
continue
elapsed_ms = int((time.time() - started) * 1000)
if len(errors) > 20:
errors = errors[:20] + [f"... and {len(errors) - 20} more"]
logger.info(
"[%s] realtime sync_all done account=%s sessions_total=%s need_sync=%s synced=%s updated=%s inserted_total=%s elapsed_ms=%s errors=%s",
trace_id,
account_dir.name,
len(all_usernames),
len(sync_usernames),
int(synced),
int(updated_sessions),
int(inserted_total),
int(elapsed_ms),
len(errors),
)
return {
"status": "success",
"account": account_dir.name,
@@ -1519,6 +1766,8 @@ def _append_full_messages_from_rows(
content_text = raw_text
title = ""
url = ""
from_name = ""
from_username = ""
record_item = ""
image_md5 = ""
emoji_md5 = ""
@@ -1561,6 +1810,8 @@ def _append_full_messages_from_rows(
content_text = str(parsed.get("content") or "")
title = str(parsed.get("title") or "")
url = str(parsed.get("url") or "")
from_name = str(parsed.get("from") or "")
from_username = str(parsed.get("fromUsername") or "")
record_item = str(parsed.get("recordItem") or "")
quote_title = str(parsed.get("quoteTitle") or "")
quote_content = str(parsed.get("quoteContent") or "")
@@ -1781,6 +2032,7 @@ def _append_full_messages_from_rows(
amount = str(parsed.get("amount") or amount)
cover_url = str(parsed.get("coverUrl") or cover_url)
thumb_url = str(parsed.get("thumbUrl") or thumb_url)
from_name = str(parsed.get("from") or from_name)
file_size = str(parsed.get("size") or file_size)
pay_sub_type = str(parsed.get("paySubType") or pay_sub_type)
file_md5 = str(parsed.get("fileMd5") or file_md5)
@@ -1828,6 +2080,8 @@ def _append_full_messages_from_rows(
"content": content_text,
"title": title,
"url": url,
"from": from_name,
"fromUsername": from_username,
"recordItem": record_item,
"imageMd5": image_md5,
"imageFileId": image_file_id,
@@ -1949,13 +2203,42 @@ def _postprocess_full_messages(
is_sent = m.get("isSent", False)
m["transferStatus"] = "已收款" if is_sent else "已被接收"
# Some appmsg payloads provide only `from` (sourcedisplayname) but not `fromUsername` (sourceusername).
# Recover `fromUsername` via contact.db so the frontend can render the publisher avatar.
missing_from_names = [
str(m.get("from") or "").strip()
for m in merged
if str(m.get("renderType") or "").strip() == "link"
and str(m.get("from") or "").strip()
and not str(m.get("fromUsername") or "").strip()
]
if missing_from_names:
name_to_username = _load_usernames_by_display_names(contact_db_path, missing_from_names)
if name_to_username:
for m in merged:
if str(m.get("fromUsername") or "").strip():
continue
if str(m.get("renderType") or "").strip() != "link":
continue
fn = str(m.get("from") or "").strip()
if fn and fn in name_to_username:
m["fromUsername"] = name_to_username[fn]
from_usernames = [str(m.get("fromUsername") or "").strip() for m in merged]
uniq_senders = list(
dict.fromkeys([u for u in (sender_usernames + list(pat_usernames) + quote_usernames) if u])
dict.fromkeys([u for u in (sender_usernames + list(pat_usernames) + quote_usernames + from_usernames) if u])
)
sender_contact_rows = _load_contact_rows(contact_db_path, uniq_senders)
local_sender_avatars = _query_head_image_usernames(head_image_db_path, uniq_senders)
for m in merged:
# If appmsg doesn't provide sourcedisplayname, try mapping sourceusername to display name.
if (not str(m.get("from") or "").strip()) and str(m.get("fromUsername") or "").strip():
fu = str(m.get("fromUsername") or "").strip()
frow = sender_contact_rows.get(fu)
if frow is not None:
m["from"] = _pick_display_name(frow, fu)
su = str(m.get("senderUsername") or "")
if not su:
continue
@@ -2097,7 +2380,7 @@ async def list_chat_accounts():
@router.get("/api/chat/sessions", summary="获取会话列表(聊天左侧列表)")
async def list_chat_sessions(
def list_chat_sessions(
request: Request,
account: Optional[str] = None,
limit: int = 400,
@@ -2120,10 +2403,32 @@ async def list_chat_sessions(
rows: list[Any]
if source_norm == "realtime":
trace_id = f"rt-sessions-{int(time.time() * 1000)}-{threading.get_ident()}"
logger.info(
"[%s] list_sessions realtime start account=%s limit=%s include_hidden=%s include_official=%s preview=%s",
trace_id,
account_dir.name,
int(limit),
bool(include_hidden),
bool(include_official),
str(preview or ""),
)
try:
logger.info("[%s] ensure wcdb connected account=%s", trace_id, account_dir.name)
conn = WCDB_REALTIME.ensure_connected(account_dir)
logger.info("[%s] wcdb connected account=%s handle=%s", trace_id, account_dir.name, int(conn.handle))
logger.info("[%s] wcdb_get_sessions account=%s", trace_id, account_dir.name)
wcdb_t0 = time.perf_counter()
with conn.lock:
raw = _wcdb_get_sessions(conn.handle)
wcdb_ms = (time.perf_counter() - wcdb_t0) * 1000.0
logger.info(
"[%s] wcdb_get_sessions done account=%s sessions=%s ms=%.1f",
trace_id,
account_dir.name,
len(raw or []),
wcdb_ms,
)
except WCDBRealtimeError as e:
raise HTTPException(status_code=400, detail=str(e))
@@ -2156,6 +2461,7 @@ async def list_chat_sessions(
norm.sort(key=lambda r: _ts(r.get("sort_timestamp")), reverse=True)
rows = norm
logger.info("[%s] list_sessions realtime normalized account=%s rows=%s", trace_id, account_dir.name, len(rows))
else:
session_db_path = account_dir / "session.db"
sconn = sqlite3.connect(str(session_db_path))
@@ -2479,6 +2785,8 @@ def _collect_chat_messages(
content_text = raw_text
title = ""
url = ""
from_name = ""
from_username = ""
record_item = ""
image_md5 = ""
emoji_md5 = ""
@@ -2523,6 +2831,8 @@ def _collect_chat_messages(
content_text = str(parsed.get("content") or "")
title = str(parsed.get("title") or "")
url = str(parsed.get("url") or "")
from_name = str(parsed.get("from") or "")
from_username = str(parsed.get("fromUsername") or "")
record_item = str(parsed.get("recordItem") or "")
quote_title = str(parsed.get("quoteTitle") or "")
quote_content = str(parsed.get("quoteContent") or "")
@@ -2725,6 +3035,7 @@ def _collect_chat_messages(
content_text = str(parsed.get("content") or content_text)
title = str(parsed.get("title") or title)
url = str(parsed.get("url") or url)
from_name = str(parsed.get("from") or from_name)
record_item = str(parsed.get("recordItem") or record_item)
quote_title = str(parsed.get("quoteTitle") or quote_title)
quote_content = str(parsed.get("quoteContent") or quote_content)
@@ -2785,6 +3096,8 @@ def _collect_chat_messages(
"content": content_text,
"title": title,
"url": url,
"from": from_name,
"fromUsername": from_username,
"recordItem": record_item,
"imageMd5": image_md5,
"imageFileId": image_file_id,
@@ -2829,7 +3142,7 @@ def _collect_chat_messages(
@router.get("/api/chat/messages", summary="获取会话消息列表")
async def list_chat_messages(
def list_chat_messages(
request: Request,
username: str,
account: Optional[str] = None,
@@ -3124,6 +3437,8 @@ async def list_chat_messages(
content_text = raw_text
title = ""
url = ""
from_name = ""
from_username = ""
record_item = ""
image_md5 = ""
emoji_md5 = ""
@@ -3168,6 +3483,8 @@ async def list_chat_messages(
content_text = str(parsed.get("content") or "")
title = str(parsed.get("title") or "")
url = str(parsed.get("url") or "")
from_name = str(parsed.get("from") or "")
from_username = str(parsed.get("fromUsername") or "")
record_item = str(parsed.get("recordItem") or "")
quote_title = str(parsed.get("quoteTitle") or "")
quote_content = str(parsed.get("quoteContent") or "")
@@ -3366,6 +3683,7 @@ async def list_chat_messages(
content_text = str(parsed.get("content") or content_text)
title = str(parsed.get("title") or title)
url = str(parsed.get("url") or url)
from_name = str(parsed.get("from") or from_name)
record_item = str(parsed.get("recordItem") or record_item)
quote_title = str(parsed.get("quoteTitle") or quote_title)
quote_content = str(parsed.get("quoteContent") or quote_content)
@@ -3419,6 +3737,8 @@ async def list_chat_messages(
"content": content_text,
"title": title,
"url": url,
"from": from_name,
"fromUsername": from_username,
"recordItem": record_item,
"imageMd5": image_md5,
"imageFileId": image_file_id,
@@ -3546,15 +3866,44 @@ async def list_chat_messages(
is_sent = m.get("isSent", False)
m["transferStatus"] = "已收款" if is_sent else "已被接收"
# Some appmsg payloads provide only `from` (sourcedisplayname) but not `fromUsername` (sourceusername).
# Recover `fromUsername` via contact.db so the frontend can render the publisher avatar.
missing_from_names = [
str(m.get("from") or "").strip()
for m in merged
if str(m.get("renderType") or "").strip() == "link"
and str(m.get("from") or "").strip()
and not str(m.get("fromUsername") or "").strip()
]
if missing_from_names:
name_to_username = _load_usernames_by_display_names(contact_db_path, missing_from_names)
if name_to_username:
for m in merged:
if str(m.get("fromUsername") or "").strip():
continue
if str(m.get("renderType") or "").strip() != "link":
continue
fn = str(m.get("from") or "").strip()
if fn and fn in name_to_username:
m["fromUsername"] = name_to_username[fn]
from_usernames = [str(m.get("fromUsername") or "").strip() for m in merged]
uniq_senders = list(
dict.fromkeys(
[u for u in (sender_usernames + list(pat_usernames) + quote_usernames) if u]
[u for u in (sender_usernames + list(pat_usernames) + quote_usernames + from_usernames) if u]
)
)
sender_contact_rows = _load_contact_rows(contact_db_path, uniq_senders)
local_sender_avatars = _query_head_image_usernames(head_image_db_path, uniq_senders)
for m in merged:
# If appmsg doesn't provide sourcedisplayname, try mapping sourceusername to display name.
if (not str(m.get("from") or "").strip()) and str(m.get("fromUsername") or "").strip():
fu = str(m.get("fromUsername") or "").strip()
frow = sender_contact_rows.get(fu)
if frow is not None:
m["from"] = _pick_display_name(frow, fu)
su = str(m.get("senderUsername") or "")
if not su:
continue
@@ -408,6 +408,91 @@ def _detect_media_type_and_ext(data: bytes) -> tuple[bytes, str, str]:
return payload, media_type, ext
def _is_allowed_proxy_image_host(host: str) -> bool:
"""Allowlist hosts for proxying images to avoid turning this into a general SSRF gadget."""
h = str(host or "").strip().lower()
if not h:
return False
# WeChat public account/article thumbnails and avatars commonly live on these CDNs.
return h.endswith(".qpic.cn") or h.endswith(".qlogo.cn")
@router.get("/api/chat/media/proxy_image", summary="代理获取远程图片(解决微信公众号图片防盗链)")
async def proxy_image(url: str):
u = html.unescape(str(url or "")).strip()
if not u:
raise HTTPException(status_code=400, detail="Missing url.")
if not _is_safe_http_url(u):
raise HTTPException(status_code=400, detail="Invalid url (only public http/https allowed).")
try:
p = urlparse(u)
except Exception:
raise HTTPException(status_code=400, detail="Invalid url.")
host = (p.hostname or "").strip().lower()
if not _is_allowed_proxy_image_host(host):
raise HTTPException(status_code=400, detail="Unsupported url host for proxy_image.")
def _download_bytes() -> tuple[bytes, str]:
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36",
"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
# qpic/qlogo often require a mp.weixin.qq.com referer (anti-hotlink)
"Referer": "https://mp.weixin.qq.com/",
"Origin": "https://mp.weixin.qq.com",
}
r = requests.get(u, headers=headers, timeout=20, stream=True)
try:
r.raise_for_status()
content_type = str(r.headers.get("Content-Type") or "").strip()
max_bytes = 10 * 1024 * 1024
chunks: list[bytes] = []
total = 0
for ch in r.iter_content(chunk_size=64 * 1024):
if not ch:
continue
chunks.append(ch)
total += len(ch)
if total > max_bytes:
raise HTTPException(status_code=400, detail="Proxy image too large (>10MB).")
return b"".join(chunks), content_type
finally:
try:
r.close()
except Exception:
pass
try:
data, ct = await asyncio.to_thread(_download_bytes)
except HTTPException:
raise
except Exception as e:
logger.warning(f"proxy_image failed: url={u} err={e}")
raise HTTPException(status_code=502, detail=f"Proxy image failed: {e}")
if not data:
raise HTTPException(status_code=502, detail="Proxy returned empty body.")
payload, media_type, _ext = _detect_media_type_and_ext(data)
# Prefer upstream Content-Type when it looks like an image (sniffing may fail for some formats).
if media_type == "application/octet-stream" and ct:
try:
mt = ct.split(";")[0].strip()
if mt.startswith("image/"):
media_type = mt
except Exception:
pass
if not str(media_type or "").startswith("image/"):
raise HTTPException(status_code=502, detail="Proxy did not return an image.")
resp = Response(content=payload, media_type=media_type)
resp.headers["Cache-Control"] = "public, max-age=86400"
return resp
@router.post("/api/chat/media/emoji/download", summary="下载表情消息资源到本地 resource")
async def download_chat_emoji(req: EmojiDownloadRequest):
md5 = str(req.md5 or "").strip().lower()