Compare commits

...

5 Commits

15 changed files with 1585 additions and 181 deletions
+231
View File
@@ -7,6 +7,7 @@ const {
globalShortcut,
dialog,
shell,
session,
} = require("electron");
let autoUpdater = null;
let autoUpdaterLoadError = null;
@@ -215,6 +216,161 @@ function getUserDataDir() {
return resolveDataDir();
}
function sanitizeAccountName(account) {
const name = String(account || "").trim();
if (!name) throw new Error("缺少账号参数");
if (name === "." || name === "..") throw new Error("账号参数非法");
if (name.includes("/") || name.includes("\\")) throw new Error("账号参数非法");
return name;
}
function listDecryptedAccountsOnDisk(databasesDir) {
try {
if (!fs.existsSync(databasesDir)) return [];
} catch {
return [];
}
let entries = [];
try {
entries = fs.readdirSync(databasesDir, { withFileTypes: true });
} catch {
return [];
}
const accounts = [];
for (const entry of entries) {
try {
if (!entry || !entry.isDirectory()) continue;
const accountDir = path.join(databasesDir, entry.name);
const hasSession = fs.existsSync(path.join(accountDir, "session.db"));
const hasContact = fs.existsSync(path.join(accountDir, "contact.db"));
if (hasSession && hasContact) accounts.push(String(entry.name || ""));
} catch {}
}
accounts.sort((a, b) => a.localeCompare(b));
return accounts;
}
function resolveAccountDirInOutput(account) {
const dataDir = resolveDataDir();
if (!dataDir) throw new Error("无法定位数据目录");
const outputDir = path.join(dataDir, "output");
const databasesDir = path.join(outputDir, "databases");
const accountName = sanitizeAccountName(account);
const base = path.resolve(databasesDir);
const accountDir = path.resolve(path.join(databasesDir, accountName));
if (accountDir !== base && !accountDir.startsWith(base + path.sep)) {
throw new Error("账号路径非法");
}
return {
dataDir,
outputDir,
databasesDir,
accountName,
accountDir,
};
}
function getAccountInfoFromDisk(account) {
const { accountName, accountDir } = resolveAccountDirInOutput(account);
if (!fs.existsSync(accountDir) || !fs.statSync(accountDir).isDirectory()) {
throw new Error("账号数据不存在");
}
let entries = [];
try {
entries = fs.readdirSync(accountDir, { withFileTypes: true });
} catch {}
const dbFiles = entries
.filter((e) => !!e && e.isFile() && String(e.name || "").toLowerCase().endsWith(".db"))
.map((e) => String(e.name || ""))
.sort((a, b) => a.localeCompare(b));
let sessionUpdatedAt = 0;
try {
const st = fs.statSync(path.join(accountDir, "session.db"));
sessionUpdatedAt = Math.floor(Number(st?.mtimeMs || 0) / 1000);
} catch {}
return {
status: "success",
account: accountName,
path: accountDir,
database_count: dbFiles.length,
databases: dbFiles,
session_updated_at: sessionUpdatedAt,
};
}
function removeAccountFromKeyStore(dataDir, accountName) {
const keyStorePath = path.join(dataDir, "output", "account_keys.json");
try {
if (!fs.existsSync(keyStorePath)) return false;
const raw = fs.readFileSync(keyStorePath, { encoding: "utf8" });
const parsed = JSON.parse(raw || "{}");
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return false;
if (!Object.prototype.hasOwnProperty.call(parsed, accountName)) return false;
delete parsed[accountName];
fs.writeFileSync(keyStorePath, JSON.stringify(parsed, null, 2), { encoding: "utf8" });
return true;
} catch {
return false;
}
}
async function deleteAccountDataFromDisk(account) {
const { dataDir, outputDir, databasesDir, accountName, accountDir } = resolveAccountDirInOutput(account);
if (!fs.existsSync(accountDir) || !fs.statSync(accountDir).isDirectory()) {
throw new Error("账号数据不存在");
}
const wasBackendRunning = !!backendProc;
let restartError = null;
let result = null;
if (wasBackendRunning) {
await stopBackendAndWait({ timeoutMs: 10_000 });
}
try {
const exportsDir = path.join(outputDir, "exports", accountName);
try {
fs.rmSync(exportsDir, { recursive: true, force: true });
} catch {}
fs.rmSync(accountDir, { recursive: true, force: true });
const removedKeyCache = removeAccountFromKeyStore(dataDir, accountName);
const accounts = listDecryptedAccountsOnDisk(databasesDir);
result = {
status: "success",
deleted_account: accountName,
accounts,
default_account: accounts.length ? accounts[0] : null,
removed_key_cache: removedKeyCache,
};
} finally {
if (wasBackendRunning) {
try {
startBackend();
await waitForBackend({ timeoutMs: 30_000 });
} catch (err) {
restartError = err;
logMain(`[main] failed to restart backend after deleteAccountData: ${err?.message || err}`);
}
}
}
if (restartError) {
throw new Error(`删除完成,但后端重启失败:${restartError?.message || restartError}`);
}
if (!result) throw new Error("删除账号数据失败");
return result;
}
function getExeDir() {
try {
return path.dirname(process.execPath);
@@ -310,6 +466,34 @@ function getDesktopSettingsPath() {
return path.join(dir, "desktop-settings.json");
}
function getPackagedUiDir() {
if (!app.isPackaged) return null;
try {
return path.join(process.resourcesPath, "ui");
} catch {
return null;
}
}
function readPackagedUiBuildId() {
const uiDir = getPackagedUiDir();
if (!uiDir) return "";
try {
const indexPath = path.join(uiDir, "index.html");
if (!fs.existsSync(indexPath)) return "";
const html = fs.readFileSync(indexPath, { encoding: "utf8" });
const match =
html.match(/buildId:"([^"]+)"/) ||
html.match(/\/_payload\.json\?([^"'&<>\s]+)/) ||
html.match(/data-src="\/_payload\.json\?([^"]+)"/);
return String(match?.[1] || "").trim();
} catch (err) {
logMain(`[main] failed to read packaged UI build id: ${err?.message || err}`);
return "";
}
}
function loadDesktopSettings() {
if (desktopSettings) return desktopSettings;
@@ -321,6 +505,9 @@ function loadDesktopSettings() {
ignoredUpdateVersion: "",
// Backend (FastAPI) listens on this port. Used in packaged builds.
backendPort: DEFAULT_BACKEND_PORT,
// Tracks the packaged UI build so we can invalidate Chromium's HTTP cache
// after upgrades without wiping user data/localStorage.
lastSeenUiBuildId: "",
};
const p = getDesktopSettingsPath();
@@ -384,6 +571,33 @@ function setIgnoredUpdateVersion(version) {
return desktopSettings.ignoredUpdateVersion;
}
async function refreshRendererCacheForPackagedUi() {
if (!app.isPackaged) return;
const nextBuildId = readPackagedUiBuildId();
if (!nextBuildId) return;
const prevBuildId = String(loadDesktopSettings()?.lastSeenUiBuildId || "").trim();
if (prevBuildId === nextBuildId) return;
try {
const ses = session?.defaultSession;
if (ses) {
await ses.clearCache();
try {
await ses.clearStorageData({ storages: ["serviceworkers"] });
} catch {}
}
logMain(`[main] cleared renderer cache for UI build change: ${prevBuildId || "(none)"} -> ${nextBuildId}`);
} catch (err) {
logMain(`[main] failed to clear renderer cache for UI build change: ${err?.message || err}`);
}
loadDesktopSettings();
desktopSettings.lastSeenUiBuildId = nextBuildId;
persistDesktopSettings();
}
function parseEnvBool(value) {
if (value == null) return null;
const v = String(value).trim().toLowerCase();
@@ -1384,6 +1598,22 @@ function registerWindowIpc() {
}
});
ipcMain.handle("app:getAccountInfo", async (_event, account) => {
try {
return getAccountInfoFromDisk(account);
} catch (e) {
throw new Error(e?.message || String(e));
}
});
ipcMain.handle("app:deleteAccountData", async (_event, account) => {
try {
return await deleteAccountDataFromDisk(account);
} catch (e) {
throw new Error(e?.message || String(e));
}
});
ipcMain.handle("app:checkForUpdates", async () => {
return await checkForUpdatesInternal();
});
@@ -1443,6 +1673,7 @@ function registerWindowIpc() {
async function main() {
await app.whenReady();
await refreshRendererCacheForPackagedUi();
Menu.setApplicationMenu(null);
registerWindowIpc();
registerDebugShortcuts();
+2
View File
@@ -22,6 +22,8 @@ contextBridge.exposeInMainWorld("wechatDesktop", {
// Data/output folder helpers
getOutputDir: () => ipcRenderer.invoke("app:getOutputDir"),
openOutputDir: () => ipcRenderer.invoke("app:openOutputDir"),
getAccountInfo: (account) => ipcRenderer.invoke("app:getAccountInfo", String(account || "")),
deleteAccountData: (account) => ipcRenderer.invoke("app:deleteAccountData", String(account || "")),
// Auto update
getVersion: () => ipcRenderer.invoke("app:getVersion"),
+252 -158
View File
@@ -63,186 +63,222 @@
</div>
<section ref="desktopSectionRef">
<div class="mb-3 text-[12px] font-bold text-[#999] tracking-widest">桌面行为</div>
<div class="space-y-3">
<div class="flex items-center justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="text-[13px] font-medium text-[#222]">开机自启动</div>
<div class="mt-0.5 text-[11px] text-[#909090]">系统登录后自动启动桌面端应用</div>
</div>
<button
type="button"
role="switch"
:aria-checked="desktopAutoLaunch"
class="settings-switch shrink-0"
:class="switchTrackClass(desktopAutoLaunch, !isDesktopEnv || desktopAutoLaunchLoading)"
:disabled="!isDesktopEnv || desktopAutoLaunchLoading"
@click="toggleDesktopAutoLaunch"
>
<span class="settings-switch-thumb" :class="desktopAutoLaunch ? 'translate-x-[20px]' : 'translate-x-0'" />
</button>
</div>
<div v-if="desktopAutoLaunchError" class="text-xs text-red-600 whitespace-pre-wrap -mt-2">
{{ desktopAutoLaunchError }}
</div>
<div class="flex items-center justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="text-[13px] font-medium text-[#222]">关闭窗口行为</div>
<div class="mt-0.5 text-[11px] text-[#909090]">点击关闭按钮时默认最小化到托盘</div>
</div>
<select
class="shrink-0 rounded-[6px] border border-[#e2e2e2] bg-white px-2 py-1 text-[12px] text-[#333] outline-none transition focus:border-[#07b75b] focus:ring-1 focus:ring-[#07b75b]/30"
:disabled="!isDesktopEnv || 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 -mt-2">
{{ desktopCloseBehaviorError }}
</div>
<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]">后端端口</div>
<div class="mt-0.5 text-[11px] text-[#909090]">桌面端重启内置后端并刷新网页端尝试切换端口</div>
</div>
<div class="flex shrink-0 items-center gap-1.5">
<input
v-model="desktopBackendPortInput"
type="number"
min="1"
max="65535"
class="w-16 rounded-[6px] border border-[#e2e2e2] bg-white px-2 py-1 text-center text-[12px] tabular-nums text-[#333] outline-none transition focus:border-[#07b75b] focus:ring-1 focus:ring-[#07b75b]/30"
:disabled="desktopBackendPortLoading || desktopBackendPortApplying"
@keyup.enter="onDesktopBackendPortApply"
/>
<div class="mb-2.5 text-[12px] font-bold text-[#999] tracking-widest">桌面行为</div>
<div class="overflow-hidden rounded-[10px] border border-[#e7e7e7] bg-white divide-y divide-[#ececec]">
<div class="px-3.5 py-3">
<div class="flex items-center justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="text-[13px] font-medium text-[#222]">开机自启动</div>
<div class="mt-0.5 text-[11px] text-[#909090]">系统登录后自动启动桌面端应用</div>
</div>
<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="desktopBackendPortLoading || desktopBackendPortApplying"
@click="onDesktopBackendPortApply"
role="switch"
:aria-checked="desktopAutoLaunch"
class="settings-switch shrink-0"
:class="switchTrackClass(desktopAutoLaunch, !isDesktopEnv || desktopAutoLaunchLoading)"
:disabled="!isDesktopEnv || desktopAutoLaunchLoading"
@click="toggleDesktopAutoLaunch"
>
{{ desktopBackendPortApplying ? '...' : '应用' }}
</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="desktopBackendPortLoading || desktopBackendPortApplying"
@click="onDesktopBackendPortReset"
>
恢复默认
<span class="settings-switch-thumb" :class="desktopAutoLaunch ? 'translate-x-[20px]' : 'translate-x-0'" />
</button>
</div>
</div>
<div v-if="desktopBackendPortError" class="text-xs text-red-600 whitespace-pre-wrap -mt-1.5">
{{ desktopBackendPortError }}
<div v-if="desktopAutoLaunchError" class="mt-1.5 text-[11px] text-red-600 whitespace-pre-wrap">
{{ desktopAutoLaunchError }}
</div>
</div>
<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="px-3.5 py-3">
<div class="flex items-center justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="text-[13px] font-medium text-[#222]">关闭窗口行为</div>
<div class="mt-0.5 text-[11px] text-[#909090]">点击关闭按钮时默认最小化到托盘</div>
</div>
<select
class="shrink-0 rounded-[6px] border border-[#e2e2e2] bg-white px-2 py-1 text-[12px] text-[#333] outline-none transition focus:border-[#07b75b] focus:ring-1 focus:ring-[#07b75b]/30"
:disabled="!isDesktopEnv || desktopCloseBehaviorLoading"
:value="desktopCloseBehavior"
@change="onDesktopCloseBehaviorChange"
>
<option value="tray">最小化到托盘</option>
<option value="exit">直接退出</option>
</select>
</div>
<div v-if="desktopCloseBehaviorError" class="mt-1.5 text-[11px] text-red-600 whitespace-pre-wrap">
{{ desktopCloseBehaviorError }}
</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="text-xs text-red-600 whitespace-pre-wrap -mt-1.5">
{{ desktopOutputDirError }}
<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]">后端端口</div>
<div class="mt-0.5 text-[11px] text-[#909090]">桌面端重启内置后端并刷新网页端尝试切换端口</div>
</div>
<div class="flex shrink-0 items-center gap-1.5">
<input
v-model="desktopBackendPortInput"
type="number"
min="1"
max="65535"
class="w-16 rounded-[6px] border border-[#e2e2e2] bg-white px-2 py-1 text-center text-[12px] tabular-nums text-[#333] outline-none transition focus:border-[#07b75b] focus:ring-1 focus:ring-[#07b75b]/30"
:disabled="desktopBackendPortLoading || desktopBackendPortApplying"
@keyup.enter="onDesktopBackendPortApply"
/>
<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="desktopBackendPortLoading || desktopBackendPortApplying"
@click="onDesktopBackendPortApply"
>
{{ desktopBackendPortApplying ? '...' : '应用' }}
</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="desktopBackendPortLoading || desktopBackendPortApplying"
@click="onDesktopBackendPortReset"
>
恢复默认
</button>
</div>
</div>
<div v-if="desktopBackendPortError" class="mt-1.5 text-[11px] text-red-600 whitespace-pre-wrap">
{{ desktopBackendPortError }}
</div>
</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>
<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 }}
</div>
</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]">日志文件</div>
<div class="mt-0.5 text-[11px] text-[#909090] break-words">{{ desktopLogFileText }}</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="desktopLogFileLoading || desktopLogFileOpening"
@click="onOpenBackendLogFile"
>
{{ desktopLogFileOpening ? '打开中...' : '打开日志' }}
</button>
</div>
<div v-if="desktopLogFileError" class="mt-1.5 text-[11px] text-red-600 whitespace-pre-wrap">
{{ desktopLogFileError }}
</div>
</div>
</div>
</section>
<section ref="startupSectionRef">
<div class="mb-3 text-[12px] font-bold text-[#999] tracking-widest">启动偏好</div>
<div class="space-y-3">
<div class="flex items-center justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="text-[13px] font-medium text-[#222]">启动后自动开启实时获取</div>
<div class="mt-0.5 text-[11px] text-[#909090]">进入聊天页后自动打开实时开关</div>
<div class="mb-2.5 text-[12px] font-bold text-[#999] tracking-widest">启动偏好</div>
<div class="overflow-hidden rounded-[10px] border border-[#e7e7e7] bg-white divide-y divide-[#ececec]">
<div class="px-3.5 py-3">
<div class="flex items-center justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="text-[13px] font-medium text-[#222]">启动后自动开启实时获取</div>
<div class="mt-0.5 text-[11px] text-[#909090]">进入聊天页后自动打开实时开关</div>
</div>
<button
type="button"
role="switch"
:aria-checked="desktopAutoRealtime"
class="settings-switch shrink-0"
:class="switchTrackClass(desktopAutoRealtime)"
@click="toggleDesktopAutoRealtime"
>
<span class="settings-switch-thumb" :class="desktopAutoRealtime ? 'translate-x-[20px]' : 'translate-x-0'" />
</button>
</div>
<button
type="button"
role="switch"
:aria-checked="desktopAutoRealtime"
class="settings-switch shrink-0"
:class="switchTrackClass(desktopAutoRealtime)"
@click="toggleDesktopAutoRealtime"
>
<span class="settings-switch-thumb" :class="desktopAutoRealtime ? 'translate-x-[20px]' : 'translate-x-0'" />
</button>
</div>
<div class="flex items-center justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="text-[13px] font-medium text-[#222]">有数据时默认进入聊天页</div>
<div class="mt-0.5 text-[11px] text-[#909090]">已解密账号时打开应用跳转到 /chat</div>
<div class="px-3.5 py-3">
<div class="flex items-center justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="text-[13px] font-medium text-[#222]">数据时默认进入聊天页</div>
<div class="mt-0.5 text-[11px] text-[#909090]">有已解密账号时打开应用跳转到 /chat</div>
</div>
<button
type="button"
role="switch"
:aria-checked="desktopDefaultToChatWhenData"
class="settings-switch shrink-0"
:class="switchTrackClass(desktopDefaultToChatWhenData)"
@click="toggleDesktopDefaultToChat"
>
<span class="settings-switch-thumb" :class="desktopDefaultToChatWhenData ? 'translate-x-[20px]' : 'translate-x-0'" />
</button>
</div>
<button
type="button"
role="switch"
:aria-checked="desktopDefaultToChatWhenData"
class="settings-switch shrink-0"
:class="switchTrackClass(desktopDefaultToChatWhenData)"
@click="toggleDesktopDefaultToChat"
>
<span class="settings-switch-thumb" :class="desktopDefaultToChatWhenData ? 'translate-x-[20px]' : 'translate-x-0'" />
</button>
</div>
</div>
</section>
<section ref="updatesSectionRef">
<div class="mb-3 text-[12px] font-bold text-[#999] tracking-widest">更新</div>
<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]">当前版本</div>
<div class="mt-0.5 text-[11px] text-[#909090]">{{ desktopVersionText }}</div>
<div class="mb-2.5 text-[12px] font-bold text-[#999] tracking-widest">更新</div>
<div class="overflow-hidden rounded-[10px] border border-[#e7e7e7] bg-white divide-y divide-[#ececec]">
<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]">当前版本</div>
<div class="mt-0.5 text-[11px] text-[#909090]">{{ desktopVersionText }}</div>
</div>
<button
type="button"
class="shrink-0 rounded-[6px] border border-[#e2e2e2] bg-[#fafafa] px-2.5 py-1 text-[12px] text-[#222] transition hover:bg-[#f0f0f0] disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!isDesktopEnv || desktopUpdate.manualCheckLoading.value"
@click="onDesktopCheckUpdates"
>
{{ desktopUpdate.manualCheckLoading.value ? '检查中...' : '检查桌面版更新' }}
</button>
</div>
<div v-if="desktopUpdate.lastCheckMessage.value" class="mt-2 rounded-[6px] bg-[#f9f9f9] border border-[#eee] px-2.5 py-1.5 text-[11px] text-[#666] whitespace-pre-wrap break-words">
{{ desktopUpdate.lastCheckMessage.value }}
</div>
</div>
<button
type="button"
class="shrink-0 rounded-[6px] border border-[#e2e2e2] bg-[#fafafa] px-2.5 py-1 text-[12px] text-[#222] transition hover:bg-[#f0f0f0] disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!isDesktopEnv || desktopUpdate.manualCheckLoading.value"
@click="onDesktopCheckUpdates"
>
{{ desktopUpdate.manualCheckLoading.value ? '检查中...' : '检查桌面版更新' }}
</button>
</div>
<div v-if="desktopUpdate.lastCheckMessage.value" class="mt-2 rounded-[6px] bg-[#f9f9f9] border border-[#eee] px-2.5 py-1.5 text-[11px] text-[#666] whitespace-pre-wrap break-words">
{{ desktopUpdate.lastCheckMessage.value }}
</div>
</section>
<section ref="snsSectionRef">
<div class="mb-3 text-[12px] font-bold text-[#999] tracking-widest">朋友圈</div>
<div class="flex items-center justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="text-[13px] font-medium text-[#222]">朋友圈图片使用缓存</div>
<div class="mt-0.5 text-[11px] text-[#909090]">开启下载解密失败时回退本地缓存默认关闭始终重新下载</div>
<div class="mb-2.5 text-[12px] font-bold text-[#999] tracking-widest">朋友圈</div>
<div class="overflow-hidden rounded-[10px] border border-[#e7e7e7] bg-white divide-y divide-[#ececec]">
<div class="px-3.5 py-3">
<div class="flex items-center justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="text-[13px] font-medium text-[#222]">朋友圈图片使用缓存</div>
<div class="mt-0.5 text-[11px] text-[#909090]">开启下载解密失败时回退本地缓存默认关闭始终重新下载</div>
</div>
<button
type="button"
role="switch"
:aria-checked="snsUseCache"
class="settings-switch shrink-0"
:class="switchTrackClass(snsUseCache)"
@click="toggleSnsUseCache"
>
<span class="settings-switch-thumb" :class="snsUseCache ? 'translate-x-[20px]' : 'translate-x-0'" />
</button>
</div>
</div>
<button
type="button"
role="switch"
:aria-checked="snsUseCache"
class="settings-switch shrink-0"
:class="switchTrackClass(snsUseCache)"
@click="toggleSnsUseCache"
>
<span class="settings-switch-thumb" :class="snsUseCache ? 'translate-x-[20px]' : 'translate-x-0'" />
</button>
</div>
</section>
@@ -255,8 +291,9 @@
<script setup>
import { DESKTOP_SETTING_AUTO_REALTIME_KEY, DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY, SNS_SETTING_USE_CACHE_KEY, readLocalBoolSetting, writeLocalBoolSetting } from '~/utils/desktop-settings'
import { readApiBaseOverride, writeApiBaseOverride } from '~/utils/api-settings'
import { reportServerErrorFromError } from '~/utils/server-error-logging'
defineProps({
const props = defineProps({
open: {
type: Boolean,
default: false,
@@ -315,6 +352,15 @@ const desktopOutputDirText = computed(() => {
return v || '—'
})
const desktopLogFilePath = ref('')
const desktopLogFileLoading = ref(false)
const desktopLogFileOpening = ref(false)
const desktopLogFileError = ref('')
const desktopLogFileText = computed(() => {
const v = String(desktopLogFilePath.value || '').trim()
return v || '—'
})
const switchTrackClass = (enabled, disabled = false) => {
if (disabled) return enabled ? 'bg-[#07b75b] opacity-50 cursor-not-allowed' : 'bg-[#d0d0d0] opacity-50 cursor-not-allowed'
return enabled ? 'bg-[#07b75b] hover:brightness-95' : 'bg-[#d0d0d0] hover:brightness-95'
@@ -360,6 +406,24 @@ const onEscKeydown = (event) => {
handleClose()
}
const fetchAdminEndpoint = async (url, options = {}) => {
const apiBase = useApiBase()
try {
return await $fetch(url, {
baseURL: apiBase,
...options,
})
} catch (e) {
await reportServerErrorFromError(e, {
method: options?.method || 'GET',
requestUrl: url,
source: 'SettingsDialog',
apiBase,
})
throw e
}
}
const refreshDesktopAutoLaunch = async () => {
if (!process.client || typeof window === 'undefined') return
if (!window.wechatDesktop?.getAutoLaunch) return
@@ -436,8 +500,7 @@ const refreshDesktopBackendPort = async () => {
}
try {
const apiBase = useApiBase()
const resp = await $fetch('/admin/port', { baseURL: apiBase })
const resp = await fetchAdminEndpoint('/admin/port')
const n = Number(resp?.port)
const d = Number(resp?.default_port)
if (Number.isInteger(d) && d >= 1 && d <= 65535) desktopBackendPortDefault.value = d
@@ -494,6 +557,34 @@ const onDesktopOpenOutputDir = async () => {
}
}
const refreshBackendLogFileInfo = async () => {
if (!process.client || typeof window === 'undefined') return
desktopLogFileLoading.value = true
desktopLogFileError.value = ''
try {
const resp = await fetchAdminEndpoint('/admin/log-file')
desktopLogFilePath.value = String(resp?.path || '').trim()
} catch (e) {
desktopLogFileError.value = e?.message || '读取日志文件失败'
} finally {
desktopLogFileLoading.value = false
}
}
const onOpenBackendLogFile = async () => {
if (!process.client || typeof window === 'undefined') return
desktopLogFileOpening.value = true
desktopLogFileError.value = ''
try {
const resp = await fetchAdminEndpoint('/admin/log-file/open', { method: 'POST' })
if (resp?.path) desktopLogFilePath.value = String(resp.path || '').trim()
} catch (e) {
desktopLogFileError.value = e?.message || '打开日志文件失败'
} finally {
desktopLogFileOpening.value = false
}
}
const applyDesktopBackendPort = async () => {
if (!process.client || typeof window === 'undefined') return
const raw = String(desktopBackendPortInput.value || '').trim()
@@ -510,10 +601,9 @@ const applyDesktopBackendPort = async () => {
return
}
const currentApiBase = useApiBase()
let currentBackendPort = null
try {
const info = await $fetch('/admin/port', { baseURL: currentApiBase })
const info = await fetchAdminEndpoint('/admin/port')
const p = Number(info?.port)
if (Number.isInteger(p) && p >= 1 && p <= 65535) currentBackendPort = p
} catch {}
@@ -524,8 +614,7 @@ const applyDesktopBackendPort = async () => {
})()
const isUiServedByBackend = !!(currentBackendPort && uiPort === currentBackendPort)
await $fetch('/admin/port', {
baseURL: currentApiBase,
await fetchAdminEndpoint('/admin/port', {
method: 'POST',
body: { port: n },
})
@@ -608,6 +697,11 @@ const onDesktopCheckUpdates = async () => {
await desktopUpdate.manualCheck()
}
watch(() => props.open, async (isOpen) => {
if (!isOpen) return
await refreshBackendLogFileInfo()
}, { immediate: true })
onMounted(async () => {
if (process.client && typeof window !== 'undefined') {
const isElectron = /electron/i.test(String(navigator.userAgent || ''))
+300 -17
View File
@@ -6,7 +6,12 @@
<div class="flex-1 flex flex-col justify-start pt-0 gap-0">
<!-- Avatar -->
<div class="w-full h-[60px] flex items-center justify-center">
<div class="w-[40px] h-[40px] rounded-md overflow-hidden bg-gray-300 flex-shrink-0">
<button
type="button"
class="group relative w-[40px] h-[40px] rounded-md overflow-hidden bg-gray-300 flex-shrink-0 ring-1 ring-transparent transition hover:ring-[#07b75b]/40"
title="账号信息"
@click="openAccountDialog"
>
<img v-if="selfAvatarUrl" :src="selfAvatarUrl" alt="avatar" class="w-full h-full object-cover" />
<div
v-else
@@ -15,7 +20,7 @@
>
</div>
</div>
</button>
</div>
<!-- Chat -->
@@ -164,22 +169,116 @@
</div>
</div>
<!-- Settings -->
<div
class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
@click="goSettings"
title="设置"
>
<div class="w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent group-hover:bg-[#E1E1E1]">
<svg class="w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]" :class="settingsDialogOpen ? 'text-[#07b75b]' : 'text-[#5d5d5d]'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<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 class="mt-auto">
<!-- Guide -->
<div
class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
title="引导页"
@click="goGuide"
>
<div class="w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent group-hover:bg-[#E1E1E1]">
<svg class="w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)] text-[#5d5d5d]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M3 10.5L12 3l9 7.5" />
<path d="M5 9.5V20h14V9.5" />
<path d="M10 20v-6h4v6" />
</svg>
</div>
</div>
<!-- Settings -->
<div
class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
@click="goSettings"
title="设置"
>
<div class="w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent group-hover:bg-[#E1E1E1]">
<svg class="w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]" :class="settingsDialogOpen ? 'text-[#07b75b]' : 'text-[#5d5d5d]'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<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>
</div>
</div>
<div
v-if="accountDialogOpen"
class="fixed inset-0 z-[130] flex items-center justify-center bg-black/35 px-4"
@click.self="closeAccountDialog"
>
<div class="w-full max-w-[440px] overflow-hidden rounded-[12px] border border-[#e7e7e7] bg-white shadow-2xl">
<div class="flex items-center justify-between border-b border-[#efefef] px-4 py-3">
<div class="text-[14px] font-semibold text-[#222]">当前账号信息</div>
<button
type="button"
class="flex h-7 w-7 items-center justify-center rounded-md text-[#888] transition hover:bg-[#f2f2f2] hover:text-[#222]"
title="关闭"
:disabled="accountDeleteLoading"
@click="closeAccountDialog"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
</div>
<div class="space-y-3 px-4 py-4">
<div v-if="accountInfoLoading" class="text-[12px] text-[#7a7a7a]">正在加载账号信息...</div>
<template v-else>
<div class="flex items-center gap-3">
<div class="w-[42px] h-[42px] rounded-md overflow-hidden bg-gray-300 flex-shrink-0">
<img v-if="selfAvatarUrl" :src="selfAvatarUrl" alt="avatar" class="w-full h-full object-cover" />
<div
v-else
class="w-full h-full flex items-center justify-center text-white text-xs font-bold"
:style="{ backgroundColor: '#4B5563' }"
>
</div>
</div>
<div class="min-w-0 flex-1">
<div class="truncate text-[14px] font-semibold text-[#222]">{{ selectedAccount || '未选择账号' }}</div>
<div class="mt-0.5 text-[11px] text-[#8a8a8a]">账号标识wxid</div>
</div>
</div>
<div class="rounded-[8px] border border-[#ededed] bg-[#fafafa] px-3 py-2 text-[12px] text-[#5f5f5f] space-y-1.5">
<div class="flex items-start justify-between gap-3">
<span class="text-[#8a8a8a] shrink-0">数据库数量</span>
<span class="font-medium text-[#333]">{{ accountInfo?.database_count ?? '—' }}</span>
</div>
<div class="flex items-start justify-between gap-3">
<span class="text-[#8a8a8a] shrink-0">数据目录</span>
<span class="break-all text-right text-[#444]">{{ accountInfo?.path || (selectedAccount ? `output/databases/${selectedAccount}` : '—') }}</span>
</div>
<div class="flex items-start justify-between gap-3">
<span class="text-[#8a8a8a] shrink-0">最近会话库更新时间</span>
<span class="text-[#444]">{{ sessionUpdatedAtText }}</span>
</div>
</div>
</template>
<div class="rounded-[8px] border border-amber-200 bg-amber-50 px-3 py-2 text-[12px] leading-relaxed text-amber-900">
仅删除本项目中的该账号解析数据/缓存/编辑记录不会删除微信客户端中的任何聊天内容或账号数据
</div>
<button
type="button"
class="w-full rounded-[8px] border border-red-200 bg-red-50 px-3 py-2 text-[12px] font-medium text-red-700 transition hover:bg-red-100 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!selectedAccount || accountDeleteLoading"
@click="deleteCurrentAccountData"
>
{{ accountDeleteLoading ? '删除中...' : '删除当前账号的项目数据' }}
</button>
<div class="text-[11px] text-[#8a8a8a]">删除成功后将自动返回引导页</div>
<div v-if="accountInfoError" class="text-[11px] text-red-600 whitespace-pre-wrap">{{ accountInfoError }}</div>
<div v-if="accountDeleteError" class="text-[11px] text-red-600 whitespace-pre-wrap">{{ accountDeleteError }}</div>
</div>
</div>
</div>
@@ -202,9 +301,130 @@ const { privacyMode } = storeToRefs(privacyStore)
const realtimeStore = useChatRealtimeStore()
const { enabled: realtimeEnabled, available: realtimeAvailable, checking: realtimeChecking, statusError: realtimeStatusError, toggling: realtimeToggling } = storeToRefs(realtimeStore)
const { open: settingsDialogOpen, openDialog: openSettingsDialog } = useSettingsDialog()
const { getChatAccountInfo, deleteChatAccount } = useApi()
const accountDialogOpen = ref(false)
const accountInfoLoading = ref(false)
const accountInfoError = ref('')
const accountInfo = ref(null)
const accountDeleteLoading = ref(false)
const accountDeleteError = ref('')
const accountInfoApiUnsupported = ref(false)
const deleteAccountApiUnsupported = ref(false)
const sessionUpdatedAtText = computed(() => {
const ts = Number(accountInfo.value?.session_updated_at || 0)
if (!Number.isFinite(ts) || ts <= 0) return '—'
try {
return new Date(ts * 1000).toLocaleString('zh-CN')
} catch {
return '—'
}
})
const isNotFoundError = (error) => {
const status = Number(
error?.statusCode
?? error?.status
?? error?.response?.status
?? error?.data?.statusCode
?? 0
)
return status === 404
}
const loadAccountInfoByDesktopBridge = async (account) => {
if (!process.client || typeof window === 'undefined') return null
if (!window.wechatDesktop?.getAccountInfo) return null
const res = await window.wechatDesktop.getAccountInfo(account)
return res && typeof res === 'object' ? res : null
}
const loadAccountInfo = async () => {
accountInfoLoading.value = true
accountInfoError.value = ''
const account = String(selectedAccount.value || '').trim()
if (!account) {
accountInfo.value = null
accountInfoLoading.value = false
return
}
try {
let lastError = null
if (!accountInfoApiUnsupported.value) {
try {
const res = await getChatAccountInfo({ account })
if (res?.status !== 'success') {
throw new Error(res?.message || '读取账号信息失败')
}
accountInfo.value = res
return
} catch (e) {
lastError = e
if (isNotFoundError(e)) {
accountInfoApiUnsupported.value = true
}
}
}
try {
const fallback = await loadAccountInfoByDesktopBridge(account)
if (fallback?.status === 'success') {
accountInfo.value = fallback
accountInfoError.value = ''
return
}
if (fallback && fallback?.status && fallback.status !== 'success') {
lastError = new Error(fallback?.message || '读取账号信息失败')
} else if (!lastError) {
lastError = new Error('读取账号信息失败')
}
} catch (fallbackErr) {
if (!lastError) {
lastError = fallbackErr
}
}
accountInfo.value = null
accountInfoError.value = lastError?.message || '读取账号信息失败'
} finally {
accountInfoLoading.value = false
}
}
const deleteAccountDataByDesktopBridge = async (account) => {
if (!process.client || typeof window === 'undefined') return null
if (!window.wechatDesktop?.deleteAccountData) return null
const res = await window.wechatDesktop.deleteAccountData(account)
return res && typeof res === 'object' ? res : { status: 'success' }
}
const openAccountDialog = async () => {
accountDialogOpen.value = true
accountDeleteError.value = ''
await loadAccountInfo()
}
const closeAccountDialog = () => {
if (accountDeleteLoading.value) return
accountDialogOpen.value = false
}
watch(selectedAccount, () => {
if (!accountDialogOpen.value) return
void loadAccountInfo()
})
onMounted(async () => {
await chatAccounts.ensureLoaded()
if (process.client && typeof window !== 'undefined') {
window.addEventListener('keydown', onWindowKeydown)
}
})
onBeforeUnmount(() => {
if (!process.client || typeof window === 'undefined') return
window.removeEventListener('keydown', onWindowKeydown)
})
const apiBase = useApiBase()
@@ -240,10 +460,73 @@ const goWrapped = async () => {
await navigateTo('/wrapped')
}
const goGuide = async () => {
await navigateTo('/')
}
const goSettings = () => {
openSettingsDialog()
}
const onWindowKeydown = (event) => {
if (event?.key !== 'Escape') return
if (!accountDialogOpen.value) return
event.preventDefault()
closeAccountDialog()
}
const deleteCurrentAccountData = async () => {
const account = String(selectedAccount.value || '').trim()
if (!account || accountDeleteLoading.value) return
if (process.client && typeof window !== 'undefined') {
const confirmed = window.confirm(
'将删除当前账号在本项目中的数据(解析缓存、编辑记录、导出缓存等),不会删除微信客户端内容。确认删除吗?'
)
if (!confirmed) return
}
accountDeleteLoading.value = true
accountDeleteError.value = ''
try {
let deleted = false
let lastError = null
if (!deleteAccountApiUnsupported.value) {
try {
const apiRes = await deleteChatAccount({ account })
if (apiRes?.status && apiRes.status !== 'success') {
throw new Error(apiRes?.message || '删除账号数据失败')
}
deleted = true
} catch (apiErr) {
lastError = apiErr
if (isNotFoundError(apiErr)) {
deleteAccountApiUnsupported.value = true
}
}
}
if (!deleted) {
const desktopRes = await deleteAccountDataByDesktopBridge(account)
if (!desktopRes) {
throw lastError || new Error('删除账号数据失败')
}
if (desktopRes?.status && desktopRes.status !== 'success') {
throw new Error(desktopRes?.message || '删除账号数据失败')
}
}
accountDialogOpen.value = false
await chatAccounts.ensureLoaded({ force: true })
await navigateTo('/')
} catch (e) {
accountDeleteError.value = e?.message || '删除账号数据失败'
} finally {
accountDeleteLoading.value = false
}
}
const realtimeBusy = computed(() => !!realtimeChecking.value || !!realtimeToggling.value)
const realtimeTitle = computed(() => {
+31 -2
View File
@@ -1,3 +1,5 @@
import { reportServerError } from '~/utils/server-error-logging'
// API请求组合式函数
export const useApi = () => {
const baseURL = useApiBase()
@@ -8,10 +10,19 @@ export const useApi = () => {
const response = await $fetch(url, {
baseURL,
...options,
onResponseError({ response }) {
async onResponseError({ response }) {
if (response.status === 400) {
throw new Error(response._data?.detail || '请求参数错误')
} else if (response.status === 500) {
} else if (response.status >= 500) {
await reportServerError({
status: response.status,
method: options?.method || 'GET',
requestUrl: url,
message: '服务器错误,请稍后重试',
backendDetail: response._data?.detail || '',
source: 'useApi',
apiBase: baseURL,
})
throw new Error('服务器错误,请稍后重试')
}
}
@@ -60,6 +71,22 @@ export const useApi = () => {
return await request('/chat/accounts')
}
const getChatAccountInfo = async (params = {}) => {
const query = new URLSearchParams()
if (params && params.account) query.set('account', params.account)
const url = '/chat/account_info' + (query.toString() ? `?${query.toString()}` : '')
return await request(url)
}
const deleteChatAccount = async (params = {}) => {
const account = String(params?.account || '').trim()
if (!account) throw new Error('Missing account')
const query = new URLSearchParams()
query.set('account', account)
const url = '/chat/account' + (query.toString() ? `?${query.toString()}` : '')
return await request(url, { method: 'DELETE' })
}
const listChatSessions = async (params = {}) => {
const query = new URLSearchParams()
if (params && params.account) query.set('account', params.account)
@@ -540,6 +567,8 @@ export const useApi = () => {
decryptDatabase,
healthCheck,
listChatAccounts,
getChatAccountInfo,
deleteChatAccount,
listChatSessions,
listChatMessages,
getChatMessageRaw,
+7
View File
@@ -2495,6 +2495,7 @@ definePageMeta({
import { useApi } from '~/composables/useApi'
import { parseTextWithEmoji } from '~/utils/wechat-emojis'
import { DESKTOP_SETTING_AUTO_REALTIME_KEY, readLocalBoolSetting } from '~/utils/desktop-settings'
import { reportServerErrorFromResponse } from '~/utils/server-error-logging'
import { heatColor } from '~/utils/wrapped/heatmap'
import { useChatAccountsStore } from '~/stores/chatAccounts'
import { useChatRealtimeStore } from '~/stores/chatRealtime'
@@ -3578,6 +3579,12 @@ const saveExportToSelectedFolder = async (options = {}) => {
try {
const resp = await fetch(getExportDownloadUrl(exportId))
if (!resp.ok) {
await reportServerErrorFromResponse(resp, {
method: 'GET',
requestUrl: getExportDownloadUrl(exportId),
message: `下载导出文件失败(${resp.status}`,
source: 'chat.exportDownload',
})
throw new Error(`下载导出文件失败(${resp.status}`)
}
const blob = await resp.blob()
+7
View File
@@ -707,6 +707,7 @@ import { useChatAccountsStore } from '~/stores/chatAccounts'
import { usePrivacyStore } from '~/stores/privacy'
import { parseTextWithEmoji } from '~/utils/wechat-emojis'
import { SNS_SETTING_USE_CACHE_KEY, readLocalBoolSetting } from '~/utils/desktop-settings'
import { reportServerErrorFromError } from '~/utils/server-error-logging'
useHead({ title: '朋友圈 - 微信数据分析助手' })
@@ -1131,6 +1132,12 @@ const loadSelfInfo = async () => {
selfInfo.value = resp
}
} catch (e) {
await reportServerErrorFromError(e, {
method: 'GET',
requestUrl: `${apiBase}/sns/self_info?account=${encodeURIComponent(selectedAccount.value)}`,
source: 'sns.loadSelfInfo',
apiBase,
})
console.error('获取个人信息失败', e)
}
}
+206
View File
@@ -0,0 +1,206 @@
import { useApiBase } from '~/composables/useApiBase'
const FRONTEND_SERVER_ERROR_ENDPOINT = '/admin/log-frontend-server-error'
const normalizeStatus = (value) => {
const n = Number(value)
if (!Number.isInteger(n)) return 0
return n
}
const stringifyDetail = (value) => {
if (value == null) return ''
if (typeof value === 'string') return value.trim()
try {
return JSON.stringify(value)
} catch {
return String(value).trim()
}
}
const currentOrigin = () => {
if (!process.client || typeof window === 'undefined') return ''
try {
return String(window.location?.origin || '').trim()
} catch {
return ''
}
}
const normalizeBasePath = (apiBase) => {
const raw = String(apiBase || '').trim()
if (!raw) return '/api'
if (/^https?:\/\//i.test(raw)) {
try {
const u = new URL(raw)
return u.pathname.replace(/\/+$/, '') || '/'
} catch {
return '/api'
}
}
return raw.replace(/\/+$/, '') || '/'
}
const normalizePathname = (value) => {
const raw = String(value || '').trim()
if (!raw) return ''
try {
return new URL(raw).pathname.replace(/\/+$/, '')
} catch {
return raw.split(/[?#]/, 1)[0].replace(/\/+$/, '')
}
}
export const isServerErrorStatus = (status) => normalizeStatus(status) >= 500
export const resolveRequestUrl = (requestUrl, apiBase = '') => {
const raw = String(requestUrl || '').trim()
if (!raw) return ''
if (/^https?:\/\//i.test(raw)) return raw
const origin = currentOrigin()
if (!origin) return raw
if (raw.startsWith('/')) {
const prefix = normalizeBasePath(apiBase)
const combined = raw === prefix || raw.startsWith(`${prefix}/`) ? raw : `${prefix}${raw}`
if (/^https?:\/\//i.test(String(apiBase || '').trim())) {
try {
const baseUrl = new URL(String(apiBase).trim())
return new URL(combined, `${baseUrl.origin}/`).toString()
} catch {
return new URL(combined, origin).toString()
}
}
return new URL(combined, origin).toString()
}
if (/^https?:\/\//i.test(String(apiBase || '').trim())) {
try {
const base = String(apiBase).trim()
return new URL(raw, base.endsWith('/') ? base : `${base}/`).toString()
} catch {
return new URL(raw, origin).toString()
}
}
return new URL(raw, origin).toString()
}
const isFrontendServerLogUrl = (requestUrl) => {
const path = normalizePathname(requestUrl)
return path.endsWith('/api/admin/log-frontend-server-error') || path.endsWith('/admin/log-frontend-server-error')
}
const extractBackendDetail = (data) => {
if (data == null) return ''
if (typeof data === 'string') return data.trim()
if (typeof data === 'object' && !Array.isArray(data) && Object.prototype.hasOwnProperty.call(data, 'detail')) {
return stringifyDetail(data.detail)
}
return stringifyDetail(data)
}
const resolveApiBase = (apiBase) => {
const raw = String(apiBase || '').trim()
if (raw) return raw
if (!process.client) return ''
try {
return String(useApiBase() || '').trim()
} catch {
return ''
}
}
export const extractServerErrorFromError = (error) => {
const response = error?.response
return {
status: normalizeStatus(error?.status ?? response?.status),
backendDetail: extractBackendDetail(response?._data ?? response?.data ?? error?.data),
message: String(error?.message || '').trim(),
requestUrl: String(response?.url || error?.request || '').trim(),
}
}
export const extractServerErrorDetailFromResponse = async (response) => {
if (!response || typeof response.clone !== 'function') return ''
try {
const clone = response.clone()
const contentType = String(clone.headers?.get?.('content-type') || '').toLowerCase()
if (contentType.includes('json')) {
try {
const payload = await clone.json()
return extractBackendDetail(payload)
} catch {}
}
const text = String(await clone.text()).trim()
if (!text) return ''
if (contentType.includes('json')) {
try {
return extractBackendDetail(JSON.parse(text))
} catch {}
}
return text
} catch {
return ''
}
}
export const reportServerError = async (context = {}) => {
if (!process.client || typeof window === 'undefined') return false
const status = normalizeStatus(context.status)
if (!isServerErrorStatus(status)) return false
const apiBase = resolveApiBase(context.apiBase)
const requestUrl = resolveRequestUrl(context.requestUrl, apiBase)
if (!requestUrl || isFrontendServerLogUrl(requestUrl)) return false
const endpointUrl = resolveRequestUrl(FRONTEND_SERVER_ERROR_ENDPOINT, apiBase)
if (!endpointUrl) return false
const payload = {
status,
method: String(context.method || 'GET').trim().toUpperCase() || 'GET',
request_url: requestUrl,
message: String(context.message || '').trim(),
backend_detail: String(context.backendDetail || '').trim(),
source: String(context.source || '').trim(),
page_url: String(window.location?.href || '').trim(),
}
try {
await fetch(endpointUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
keepalive: true,
})
return true
} catch {
return false
}
}
export const reportServerErrorFromError = async (error, context = {}) => {
const info = extractServerErrorFromError(error)
return await reportServerError({
...context,
status: context.status ?? info.status,
requestUrl: context.requestUrl || info.requestUrl,
message: context.message || info.message,
backendDetail: context.backendDetail || info.backendDetail,
})
}
export const reportServerErrorFromResponse = async (response, context = {}) => {
const status = normalizeStatus(context.status ?? response?.status)
if (!isServerErrorStatus(status)) return false
const backendDetail = context.backendDetail || (await extractServerErrorDetailFromResponse(response))
return await reportServerError({
...context,
status,
requestUrl: context.requestUrl || response?.url || '',
backendDetail,
})
}
+38 -4
View File
@@ -15,6 +15,7 @@ from .logging_config import setup_logging, get_logger
# 初始化日志系统
setup_logging()
logger = get_logger(__name__)
request_logger = get_logger("wechat_decrypt_tool.request")
from . import __version__ as APP_VERSION
from .path_fix import PathFixRoute
@@ -32,6 +33,7 @@ from .routers.sns import router as _sns_router
from .routers.sns_export import router as _sns_export_router
from .routers.wechat_detection import router as _wechat_detection_router
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
@@ -76,6 +78,11 @@ async def _add_sns_stage_timing_headers(request: Request, call_next):
return response
@app.middleware("http")
async def _log_server_errors(request: Request, call_next):
return await log_server_errors_middleware(request_logger, request, call_next)
app.include_router(_health_router)
app.include_router(_admin_router)
app.include_router(_wechat_detection_router)
@@ -99,9 +106,36 @@ class _SPAStaticFiles(StaticFiles):
self._fallback_200 = Path(str(self.directory)) / "200.html"
self._fallback_index = Path(str(self.directory)) / "index.html"
async def get_response(self, path: str, scope): # type: ignore[override]
@staticmethod
def _normalize_path(path: str) -> str:
return str(path or "").strip().lstrip("/")
@classmethod
def _is_shell_path(cls, path: str) -> bool:
normalized = cls._normalize_path(path)
return normalized in {"", "index.html", "200.html", "_payload.json"} or normalized.startswith(
"_payload.json/"
)
@classmethod
def _apply_cache_headers(cls, path: str, response):
normalized = cls._normalize_path(path)
try:
return await super().get_response(path, scope)
if cls._is_shell_path(normalized):
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate"
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
elif normalized.startswith("_nuxt/"):
response.headers.setdefault("Cache-Control", "public, max-age=31536000, immutable")
except Exception:
pass
return response
async def get_response(self, path: str, scope): # type: ignore[override]
normalized = self._normalize_path(path)
try:
response = await super().get_response(path, scope)
return self._apply_cache_headers(normalized, response)
except StarletteHTTPException as exc:
if exc.status_code != 404:
raise
@@ -112,8 +146,8 @@ class _SPAStaticFiles(StaticFiles):
raise
if self._fallback_200.exists():
return FileResponse(str(self._fallback_200))
return FileResponse(str(self._fallback_index))
return self._apply_cache_headers("200.html", FileResponse(str(self._fallback_200)))
return self._apply_cache_headers("index.html", FileResponse(str(self._fallback_index)))
def _maybe_mount_frontend() -> None:
@@ -485,3 +485,30 @@ def update_message_edit_local_id(
conn.close()
except Exception:
pass
def delete_account_edits(account: str) -> int:
a = str(account or "").strip()
if not a:
return 0
conn: Optional[sqlite3.Connection] = None
try:
conn = _connect()
cur = conn.execute(
"""
DELETE FROM message_edits
WHERE account = ?
""",
(a,),
)
conn.commit()
return int(getattr(cur, "rowcount", 0) or 0)
except Exception:
return 0
finally:
try:
if conn is not None:
conn.close()
except Exception:
pass
+17
View File
@@ -67,3 +67,20 @@ def upsert_account_keys_in_store(
pass
return item
def remove_account_keys_from_store(account: str) -> bool:
account = str(account or "").strip()
if not account:
return False
store = load_account_keys_store()
if account not in store:
return False
try:
store.pop(account, None)
_atomic_write_json(_KEY_STORE_PATH, store)
return True
except Exception:
return False
+131
View File
@@ -0,0 +1,131 @@
from __future__ import annotations
import json
from typing import Any
from starlette.requests import Request
from starlette.responses import Response
def _stringify_detail(detail: Any) -> str:
if detail is None:
return ""
if isinstance(detail, str):
return detail.strip()
try:
return json.dumps(detail, ensure_ascii=False)
except Exception:
return str(detail).strip()
def _extract_response_detail(response: Response) -> str:
body = getattr(response, "body", None)
if body is None:
return ""
try:
raw = body.tobytes() if isinstance(body, memoryview) else body
except Exception:
raw = body
if isinstance(raw, bytes):
text = raw.decode("utf-8", errors="ignore").strip()
else:
text = str(raw).strip()
if not text:
return ""
content_type = str(response.headers.get("content-type") or "").lower()
if "json" not in content_type:
return ""
try:
payload = json.loads(text)
except Exception:
return ""
if not isinstance(payload, dict):
return ""
return _stringify_detail(payload.get("detail"))
async def _buffer_response_body(response: Response) -> tuple[Response, bytes]:
body = getattr(response, "body", None)
if body is not None:
try:
raw = body.tobytes() if isinstance(body, memoryview) else body
except Exception:
raw = body
if isinstance(raw, bytes):
return response, raw
if isinstance(raw, str):
return response, raw.encode("utf-8")
return response, bytes(raw)
chunks: list[bytes] = []
body_iterator = getattr(response, "body_iterator", None)
if body_iterator is not None:
async for chunk in body_iterator:
if isinstance(chunk, memoryview):
chunks.append(chunk.tobytes())
elif isinstance(chunk, bytes):
chunks.append(chunk)
else:
chunks.append(str(chunk).encode("utf-8"))
body_bytes = b"".join(chunks)
rebuilt = Response(
content=body_bytes,
status_code=response.status_code,
headers=dict(response.headers),
media_type=response.media_type,
background=response.background,
)
return rebuilt, body_bytes
def _extract_response_detail_from_body(response: Response, body: bytes) -> str:
if not body:
return ""
try:
text = body.decode("utf-8", errors="ignore").strip()
except Exception:
return ""
if not text:
return ""
content_type = str(response.headers.get("content-type") or "").lower()
if "json" not in content_type:
return ""
try:
payload = json.loads(text)
except Exception:
return ""
if not isinstance(payload, dict):
return ""
return _stringify_detail(payload.get("detail"))
async def log_server_errors_middleware(logger, request: Request, call_next):
method = str(request.method or "").upper() or "GET"
path = str(request.url.path or "").strip() or "/"
try:
response = await call_next(request)
except Exception as exc:
logger.exception("[server-exception] method=%s path=%s error=%s", method, path, exc)
raise
status = int(getattr(response, "status_code", 0) or 0)
if status >= 500:
response, body = await _buffer_response_body(response)
detail = _extract_response_detail_from_body(response, body) or _extract_response_detail(response)
if detail:
logger.error("[server-5xx] status=%s method=%s path=%s detail=%s", status, method, path, detail)
else:
logger.error("[server-5xx] status=%s method=%s path=%s", status, method, path)
return response
+80
View File
@@ -13,11 +13,13 @@ import httpx
from fastapi import APIRouter, BackgroundTasks, HTTPException
from starlette.requests import Request
from ..logging_config import get_log_file_path, get_logger
from ..path_fix import PathFixRoute
from ..runtime_settings import read_effective_backend_port, write_backend_port_env_file, write_backend_port_setting
router = APIRouter(route_class=PathFixRoute)
logger = get_logger(__name__)
DEFAULT_BACKEND_PORT = 10392
_PORT_CHANGE_IN_PROGRESS = False
@@ -58,6 +60,36 @@ def _is_loopback_client(request: Request) -> bool:
return False
def _get_current_log_file_path() -> Path:
log_file = Path(get_log_file_path())
try:
log_file.parent.mkdir(parents=True, exist_ok=True)
except Exception:
pass
if not log_file.exists():
try:
log_file.touch(exist_ok=True)
except Exception:
pass
return log_file
def _open_path_with_default_app(path: Path) -> None:
target = str(path)
if os.name == "nt":
opener = getattr(os, "startfile", None)
if opener is None:
raise RuntimeError("当前系统不支持默认打开文件")
opener(target)
return
if sys.platform == "darwin":
subprocess.Popen(["open", target])
return
subprocess.Popen(["xdg-open", target])
def _is_port_available(port: int, host: str) -> bool:
try:
addr = (host, int(port))
@@ -126,6 +158,54 @@ async def _exit_process_after(delay_s: float) -> None:
os._exit(0) # noqa: S404
@router.get("/api/admin/log-file", summary="获取当前后端日志文件路径")
async def get_backend_log_file() -> dict:
log_file = _get_current_log_file_path()
return {"path": str(log_file), "exists": log_file.exists()}
@router.post("/api/admin/log-file/open", summary="打开当前后端日志文件(仅允许本机访问)")
async def open_backend_log_file(request: Request) -> dict:
if not _is_loopback_client(request):
raise HTTPException(status_code=403, detail="仅允许本机访问该接口")
log_file = _get_current_log_file_path()
try:
_open_path_with_default_app(log_file)
except Exception as e:
logger.error("open_backend_log_file failed path=%s err=%s", log_file, e)
raise HTTPException(status_code=500, detail=f"打开日志文件失败:{e}")
return {"success": True, "path": str(log_file)}
@router.post("/api/admin/log-frontend-server-error", summary="记录前端感知到的服务器错误")
async def log_frontend_server_error(payload: dict) -> dict:
data = payload if isinstance(payload, dict) else {}
try:
status = int(data.get("status"))
except Exception:
status = 0
method = str(data.get("method") or "").strip().upper() or "GET"
request_url = str(data.get("request_url") or "").strip()
message = str(data.get("message") or "").strip()
backend_detail = str(data.get("backend_detail") or "").strip()
source = str(data.get("source") or "").strip()
page_url = str(data.get("page_url") or "").strip()
logger.error(
"[frontend-server-error] status=%s method=%s request_url=%s message=%s backend_detail=%s source=%s page_url=%s",
status,
method,
request_url,
message,
backend_detail,
source,
page_url,
)
return {"success": True, "path": str(_get_current_log_file_path())}
@router.get("/api/admin/port", summary="获取后端端口(用于前端设置页)")
async def get_backend_port() -> dict:
port, source = read_effective_backend_port(default=DEFAULT_BACKEND_PORT)
+82
View File
@@ -3,6 +3,7 @@ import re
import sqlite3
import asyncio
import json
import shutil
import time
import threading
from datetime import datetime, timedelta
@@ -67,6 +68,8 @@ from ..chat_helpers import (
)
from ..media_helpers import _resolve_account_db_storage_dir, _try_find_decrypted_resource
from .. import chat_edit_store
from ..app_paths import get_output_dir
from ..key_store import remove_account_keys_from_store
from ..path_fix import PathFixRoute
from ..session_last_message import (
build_session_last_message_table,
@@ -3496,6 +3499,85 @@ async def list_chat_accounts():
}
@router.get("/api/chat/account_info", summary="获取当前账号信息")
def get_chat_account_info(account: Optional[str] = None):
account_dir = _resolve_account_dir(account)
db_files = sorted([p.name for p in account_dir.glob("*.db") if p.is_file()])
session_db = account_dir / "session.db"
session_updated_at = 0
try:
session_updated_at = int(session_db.stat().st_mtime)
except Exception:
session_updated_at = 0
return {
"status": "success",
"account": account_dir.name,
"path": str(account_dir),
"database_count": len(db_files),
"databases": db_files,
"session_updated_at": session_updated_at,
}
@router.delete("/api/chat/account", summary="删除当前账号在本项目中的数据")
def delete_chat_account(account: str):
account_name = str(account or "").strip()
if not account_name:
raise HTTPException(status_code=400, detail="Missing account.")
account_dir = _resolve_account_dir(account_name)
# Best-effort: close realtime connections first, otherwise Windows may keep db files locked.
try:
WCDB_REALTIME.disconnect(account_name)
except Exception:
pass
with _REALTIME_SYNC_MU:
_REALTIME_SYNC_ALL_LOCKS.pop(account_name, None)
stale_lock_keys = [k for k in _REALTIME_SYNC_LOCKS.keys() if k and k[0] == account_name]
for k in stale_lock_keys:
_REALTIME_SYNC_LOCKS.pop(k, None)
removed_edit_count = 0
try:
removed_edit_count = int(chat_edit_store.delete_account_edits(account_name) or 0)
except Exception:
removed_edit_count = 0
removed_key_cache = False
try:
removed_key_cache = bool(remove_account_keys_from_store(account_name))
except Exception:
removed_key_cache = False
output_dir = get_output_dir()
exports_dir = output_dir / "exports" / account_name
if exports_dir.exists():
try:
shutil.rmtree(exports_dir)
except Exception:
# Ignore export cleanup failure; account dir removal is the core operation.
pass
try:
shutil.rmtree(account_dir)
except Exception as e:
raise HTTPException(status_code=500, detail=f"删除账号数据失败:{e}")
accounts = _list_decrypted_accounts()
return {
"status": "success",
"deleted_account": account_name,
"accounts": accounts,
"default_account": accounts[0] if accounts else None,
"removed_edit_count": removed_edit_count,
"removed_key_cache": removed_key_cache,
}
@router.get("/api/chat/sessions", summary="获取会话列表(聊天左侧列表)")
def list_chat_sessions(
request: Request,
+174
View File
@@ -0,0 +1,174 @@
import importlib
import os
import sys
import unittest
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest.mock import patch
from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
def _close_logging_handlers() -> None:
import logging
for logger_name in ("", "uvicorn", "uvicorn.access", "uvicorn.error", "fastapi"):
lg = logging.getLogger(logger_name)
for h in lg.handlers[:]:
try:
h.close()
except Exception:
pass
try:
lg.removeHandler(h)
except Exception:
pass
class TestAdminServerErrorLogging(unittest.TestCase):
def setUp(self):
self._prev_data_dir = os.environ.get("WECHAT_TOOL_DATA_DIR")
self._td = TemporaryDirectory()
os.environ["WECHAT_TOOL_DATA_DIR"] = self._td.name
import wechat_decrypt_tool.app_paths as app_paths
import wechat_decrypt_tool.logging_config as logging_config
import wechat_decrypt_tool.request_logging as request_logging
import wechat_decrypt_tool.routers.admin as admin_router
importlib.reload(app_paths)
importlib.reload(logging_config)
importlib.reload(request_logging)
importlib.reload(admin_router)
self.logging_config = logging_config
self.request_logging = request_logging
self.admin_router = admin_router
self.log_file = self.logging_config.setup_logging()
def tearDown(self):
_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
self._td.cleanup()
def _read_log(self) -> str:
return self.log_file.read_text(encoding="utf-8")
def _make_admin_app(self) -> FastAPI:
app = FastAPI()
app.include_router(self.admin_router.router)
return app
def _make_logged_app(self) -> FastAPI:
app = FastAPI()
@app.middleware("http")
async def _log_server_errors(request, call_next):
return await self.request_logging.log_server_errors_middleware(
self.logging_config.get_logger("tests.server_error_logging"),
request,
call_next,
)
@app.get("/boom-http")
async def _boom_http():
raise HTTPException(status_code=500, detail="planned http failure")
@app.get("/boom-exception")
async def _boom_exception():
raise RuntimeError("planned unhandled failure")
return app
def test_get_log_file_returns_current_backend_log_path(self):
client = TestClient(self._make_admin_app(), client=("127.0.0.1", 52000))
resp = client.get("/api/admin/log-file")
self.assertEqual(resp.status_code, 200)
payload = resp.json()
self.assertEqual(Path(payload["path"]), self.log_file)
self.assertTrue(payload["exists"])
self.assertTrue(self.log_file.is_relative_to(Path(self._td.name) / "output" / "logs"))
def test_open_log_file_requires_loopback(self):
client = TestClient(self._make_admin_app(), client=("203.0.113.8", 52001))
resp = client.post("/api/admin/log-file/open")
self.assertEqual(resp.status_code, 403)
def test_open_log_file_uses_default_opener_for_loopback(self):
client = TestClient(self._make_admin_app(), client=("127.0.0.1", 52002))
with patch.object(self.admin_router, "_open_path_with_default_app") as mocked_open:
resp = client.post("/api/admin/log-file/open")
self.assertEqual(resp.status_code, 200)
mocked_open.assert_called_once_with(self.log_file)
self.assertEqual(resp.json()["path"], str(self.log_file))
def test_frontend_server_error_endpoint_writes_log(self):
client = TestClient(self._make_admin_app(), client=("127.0.0.1", 52003))
resp = client.post(
"/api/admin/log-frontend-server-error",
json={
"status": 503,
"method": "GET",
"request_url": "http://127.0.0.1:10392/api/chat/accounts",
"message": "fetch failed",
"backend_detail": "upstream timeout",
"source": "useApi",
"page_url": "http://127.0.0.1:10392/chat",
},
)
self.assertEqual(resp.status_code, 200)
text = self._read_log()
self.assertIn("[frontend-server-error]", text)
self.assertIn("status=503", text)
self.assertIn("source=useApi", text)
self.assertIn("upstream timeout", text)
def test_http_500_response_is_logged(self):
client = TestClient(self._make_logged_app(), client=("127.0.0.1", 52004))
resp = client.get("/boom-http")
self.assertEqual(resp.status_code, 500)
text = self._read_log()
self.assertIn("[server-5xx]", text)
self.assertIn("status=500", text)
self.assertIn("path=/boom-http", text)
self.assertIn("planned http failure", text)
def test_unhandled_exception_is_logged_with_traceback(self):
client = TestClient(
self._make_logged_app(),
client=("127.0.0.1", 52005),
raise_server_exceptions=False,
)
resp = client.get("/boom-exception")
self.assertEqual(resp.status_code, 500)
text = self._read_log()
self.assertIn("[server-exception]", text)
self.assertIn("path=/boom-exception", text)
self.assertIn("planned unhandled failure", text)
self.assertIn("Traceback", text)
if __name__ == "__main__":
unittest.main()