Skip to content

Commit 22c12da

Browse files
committed
feat(account-data): 新增账号信息查询与项目数据删除能力
新增 /api/chat/account_info 与 DELETE /api/chat/account 删除账号时清理编辑记录、账号密钥缓存与导出/数据库目录 桌面端新增 app:getAccountInfo、app:deleteAccountData IPC 能力 前端 API 层新增 getChatAccountInfo、deleteChatAccount
1 parent b82d46b commit 22c12da

6 files changed

Lines changed: 317 additions & 0 deletions

File tree

desktop/src/main.cjs

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,161 @@ function getUserDataDir() {
215215
return resolveDataDir();
216216
}
217217

218+
function sanitizeAccountName(account) {
219+
const name = String(account || "").trim();
220+
if (!name) throw new Error("缺少账号参数");
221+
if (name === "." || name === "..") throw new Error("账号参数非法");
222+
if (name.includes("/") || name.includes("\\")) throw new Error("账号参数非法");
223+
return name;
224+
}
225+
226+
function listDecryptedAccountsOnDisk(databasesDir) {
227+
try {
228+
if (!fs.existsSync(databasesDir)) return [];
229+
} catch {
230+
return [];
231+
}
232+
233+
let entries = [];
234+
try {
235+
entries = fs.readdirSync(databasesDir, { withFileTypes: true });
236+
} catch {
237+
return [];
238+
}
239+
240+
const accounts = [];
241+
for (const entry of entries) {
242+
try {
243+
if (!entry || !entry.isDirectory()) continue;
244+
const accountDir = path.join(databasesDir, entry.name);
245+
const hasSession = fs.existsSync(path.join(accountDir, "session.db"));
246+
const hasContact = fs.existsSync(path.join(accountDir, "contact.db"));
247+
if (hasSession && hasContact) accounts.push(String(entry.name || ""));
248+
} catch {}
249+
}
250+
accounts.sort((a, b) => a.localeCompare(b));
251+
return accounts;
252+
}
253+
254+
function resolveAccountDirInOutput(account) {
255+
const dataDir = resolveDataDir();
256+
if (!dataDir) throw new Error("无法定位数据目录");
257+
258+
const outputDir = path.join(dataDir, "output");
259+
const databasesDir = path.join(outputDir, "databases");
260+
const accountName = sanitizeAccountName(account);
261+
262+
const base = path.resolve(databasesDir);
263+
const accountDir = path.resolve(path.join(databasesDir, accountName));
264+
if (accountDir !== base && !accountDir.startsWith(base + path.sep)) {
265+
throw new Error("账号路径非法");
266+
}
267+
268+
return {
269+
dataDir,
270+
outputDir,
271+
databasesDir,
272+
accountName,
273+
accountDir,
274+
};
275+
}
276+
277+
function getAccountInfoFromDisk(account) {
278+
const { accountName, accountDir } = resolveAccountDirInOutput(account);
279+
if (!fs.existsSync(accountDir) || !fs.statSync(accountDir).isDirectory()) {
280+
throw new Error("账号数据不存在");
281+
}
282+
283+
let entries = [];
284+
try {
285+
entries = fs.readdirSync(accountDir, { withFileTypes: true });
286+
} catch {}
287+
const dbFiles = entries
288+
.filter((e) => !!e && e.isFile() && String(e.name || "").toLowerCase().endsWith(".db"))
289+
.map((e) => String(e.name || ""))
290+
.sort((a, b) => a.localeCompare(b));
291+
292+
let sessionUpdatedAt = 0;
293+
try {
294+
const st = fs.statSync(path.join(accountDir, "session.db"));
295+
sessionUpdatedAt = Math.floor(Number(st?.mtimeMs || 0) / 1000);
296+
} catch {}
297+
298+
return {
299+
status: "success",
300+
account: accountName,
301+
path: accountDir,
302+
database_count: dbFiles.length,
303+
databases: dbFiles,
304+
session_updated_at: sessionUpdatedAt,
305+
};
306+
}
307+
308+
function removeAccountFromKeyStore(dataDir, accountName) {
309+
const keyStorePath = path.join(dataDir, "output", "account_keys.json");
310+
try {
311+
if (!fs.existsSync(keyStorePath)) return false;
312+
const raw = fs.readFileSync(keyStorePath, { encoding: "utf8" });
313+
const parsed = JSON.parse(raw || "{}");
314+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return false;
315+
if (!Object.prototype.hasOwnProperty.call(parsed, accountName)) return false;
316+
delete parsed[accountName];
317+
fs.writeFileSync(keyStorePath, JSON.stringify(parsed, null, 2), { encoding: "utf8" });
318+
return true;
319+
} catch {
320+
return false;
321+
}
322+
}
323+
324+
async function deleteAccountDataFromDisk(account) {
325+
const { dataDir, outputDir, databasesDir, accountName, accountDir } = resolveAccountDirInOutput(account);
326+
if (!fs.existsSync(accountDir) || !fs.statSync(accountDir).isDirectory()) {
327+
throw new Error("账号数据不存在");
328+
}
329+
330+
const wasBackendRunning = !!backendProc;
331+
let restartError = null;
332+
let result = null;
333+
334+
if (wasBackendRunning) {
335+
await stopBackendAndWait({ timeoutMs: 10_000 });
336+
}
337+
338+
try {
339+
const exportsDir = path.join(outputDir, "exports", accountName);
340+
try {
341+
fs.rmSync(exportsDir, { recursive: true, force: true });
342+
} catch {}
343+
344+
fs.rmSync(accountDir, { recursive: true, force: true });
345+
const removedKeyCache = removeAccountFromKeyStore(dataDir, accountName);
346+
const accounts = listDecryptedAccountsOnDisk(databasesDir);
347+
result = {
348+
status: "success",
349+
deleted_account: accountName,
350+
accounts,
351+
default_account: accounts.length ? accounts[0] : null,
352+
removed_key_cache: removedKeyCache,
353+
};
354+
} finally {
355+
if (wasBackendRunning) {
356+
try {
357+
startBackend();
358+
await waitForBackend({ timeoutMs: 30_000 });
359+
} catch (err) {
360+
restartError = err;
361+
logMain(`[main] failed to restart backend after deleteAccountData: ${err?.message || err}`);
362+
}
363+
}
364+
}
365+
366+
if (restartError) {
367+
throw new Error(`删除完成,但后端重启失败:${restartError?.message || restartError}`);
368+
}
369+
if (!result) throw new Error("删除账号数据失败");
370+
return result;
371+
}
372+
218373
function getExeDir() {
219374
try {
220375
return path.dirname(process.execPath);
@@ -1384,6 +1539,22 @@ function registerWindowIpc() {
13841539
}
13851540
});
13861541

1542+
ipcMain.handle("app:getAccountInfo", async (_event, account) => {
1543+
try {
1544+
return getAccountInfoFromDisk(account);
1545+
} catch (e) {
1546+
throw new Error(e?.message || String(e));
1547+
}
1548+
});
1549+
1550+
ipcMain.handle("app:deleteAccountData", async (_event, account) => {
1551+
try {
1552+
return await deleteAccountDataFromDisk(account);
1553+
} catch (e) {
1554+
throw new Error(e?.message || String(e));
1555+
}
1556+
});
1557+
13871558
ipcMain.handle("app:checkForUpdates", async () => {
13881559
return await checkForUpdatesInternal();
13891560
});

desktop/src/preload.cjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ contextBridge.exposeInMainWorld("wechatDesktop", {
2222
// Data/output folder helpers
2323
getOutputDir: () => ipcRenderer.invoke("app:getOutputDir"),
2424
openOutputDir: () => ipcRenderer.invoke("app:openOutputDir"),
25+
getAccountInfo: (account) => ipcRenderer.invoke("app:getAccountInfo", String(account || "")),
26+
deleteAccountData: (account) => ipcRenderer.invoke("app:deleteAccountData", String(account || "")),
2527

2628
// Auto update
2729
getVersion: () => ipcRenderer.invoke("app:getVersion"),

frontend/composables/useApi.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,22 @@ export const useApi = () => {
6060
return await request('/chat/accounts')
6161
}
6262

63+
const getChatAccountInfo = async (params = {}) => {
64+
const query = new URLSearchParams()
65+
if (params && params.account) query.set('account', params.account)
66+
const url = '/chat/account_info' + (query.toString() ? `?${query.toString()}` : '')
67+
return await request(url)
68+
}
69+
70+
const deleteChatAccount = async (params = {}) => {
71+
const account = String(params?.account || '').trim()
72+
if (!account) throw new Error('Missing account')
73+
const query = new URLSearchParams()
74+
query.set('account', account)
75+
const url = '/chat/account' + (query.toString() ? `?${query.toString()}` : '')
76+
return await request(url, { method: 'DELETE' })
77+
}
78+
6379
const listChatSessions = async (params = {}) => {
6480
const query = new URLSearchParams()
6581
if (params && params.account) query.set('account', params.account)
@@ -540,6 +556,8 @@ export const useApi = () => {
540556
decryptDatabase,
541557
healthCheck,
542558
listChatAccounts,
559+
getChatAccountInfo,
560+
deleteChatAccount,
543561
listChatSessions,
544562
listChatMessages,
545563
getChatMessageRaw,

src/wechat_decrypt_tool/chat_edit_store.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -485,3 +485,30 @@ def update_message_edit_local_id(
485485
conn.close()
486486
except Exception:
487487
pass
488+
489+
490+
def delete_account_edits(account: str) -> int:
491+
a = str(account or "").strip()
492+
if not a:
493+
return 0
494+
495+
conn: Optional[sqlite3.Connection] = None
496+
try:
497+
conn = _connect()
498+
cur = conn.execute(
499+
"""
500+
DELETE FROM message_edits
501+
WHERE account = ?
502+
""",
503+
(a,),
504+
)
505+
conn.commit()
506+
return int(getattr(cur, "rowcount", 0) or 0)
507+
except Exception:
508+
return 0
509+
finally:
510+
try:
511+
if conn is not None:
512+
conn.close()
513+
except Exception:
514+
pass

src/wechat_decrypt_tool/key_store.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,3 +67,20 @@ def upsert_account_keys_in_store(
6767
pass
6868

6969
return item
70+
71+
72+
def remove_account_keys_from_store(account: str) -> bool:
73+
account = str(account or "").strip()
74+
if not account:
75+
return False
76+
77+
store = load_account_keys_store()
78+
if account not in store:
79+
return False
80+
81+
try:
82+
store.pop(account, None)
83+
_atomic_write_json(_KEY_STORE_PATH, store)
84+
return True
85+
except Exception:
86+
return False

src/wechat_decrypt_tool/routers/chat.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import sqlite3
44
import asyncio
55
import json
6+
import shutil
67
import time
78
import threading
89
from datetime import datetime, timedelta
@@ -67,6 +68,8 @@
6768
)
6869
from ..media_helpers import _resolve_account_db_storage_dir, _try_find_decrypted_resource
6970
from .. import chat_edit_store
71+
from ..app_paths import get_output_dir
72+
from ..key_store import remove_account_keys_from_store
7073
from ..path_fix import PathFixRoute
7174
from ..session_last_message import (
7275
build_session_last_message_table,
@@ -3496,6 +3499,85 @@ async def list_chat_accounts():
34963499
}
34973500

34983501

3502+
@router.get("/api/chat/account_info", summary="获取当前账号信息")
3503+
def get_chat_account_info(account: Optional[str] = None):
3504+
account_dir = _resolve_account_dir(account)
3505+
db_files = sorted([p.name for p in account_dir.glob("*.db") if p.is_file()])
3506+
3507+
session_db = account_dir / "session.db"
3508+
session_updated_at = 0
3509+
try:
3510+
session_updated_at = int(session_db.stat().st_mtime)
3511+
except Exception:
3512+
session_updated_at = 0
3513+
3514+
return {
3515+
"status": "success",
3516+
"account": account_dir.name,
3517+
"path": str(account_dir),
3518+
"database_count": len(db_files),
3519+
"databases": db_files,
3520+
"session_updated_at": session_updated_at,
3521+
}
3522+
3523+
3524+
@router.delete("/api/chat/account", summary="删除当前账号在本项目中的数据")
3525+
def delete_chat_account(account: str):
3526+
account_name = str(account or "").strip()
3527+
if not account_name:
3528+
raise HTTPException(status_code=400, detail="Missing account.")
3529+
3530+
account_dir = _resolve_account_dir(account_name)
3531+
3532+
# Best-effort: close realtime connections first, otherwise Windows may keep db files locked.
3533+
try:
3534+
WCDB_REALTIME.disconnect(account_name)
3535+
except Exception:
3536+
pass
3537+
3538+
with _REALTIME_SYNC_MU:
3539+
_REALTIME_SYNC_ALL_LOCKS.pop(account_name, None)
3540+
stale_lock_keys = [k for k in _REALTIME_SYNC_LOCKS.keys() if k and k[0] == account_name]
3541+
for k in stale_lock_keys:
3542+
_REALTIME_SYNC_LOCKS.pop(k, None)
3543+
3544+
removed_edit_count = 0
3545+
try:
3546+
removed_edit_count = int(chat_edit_store.delete_account_edits(account_name) or 0)
3547+
except Exception:
3548+
removed_edit_count = 0
3549+
3550+
removed_key_cache = False
3551+
try:
3552+
removed_key_cache = bool(remove_account_keys_from_store(account_name))
3553+
except Exception:
3554+
removed_key_cache = False
3555+
3556+
output_dir = get_output_dir()
3557+
exports_dir = output_dir / "exports" / account_name
3558+
if exports_dir.exists():
3559+
try:
3560+
shutil.rmtree(exports_dir)
3561+
except Exception:
3562+
# Ignore export cleanup failure; account dir removal is the core operation.
3563+
pass
3564+
3565+
try:
3566+
shutil.rmtree(account_dir)
3567+
except Exception as e:
3568+
raise HTTPException(status_code=500, detail=f"删除账号数据失败:{e}")
3569+
3570+
accounts = _list_decrypted_accounts()
3571+
return {
3572+
"status": "success",
3573+
"deleted_account": account_name,
3574+
"accounts": accounts,
3575+
"default_account": accounts[0] if accounts else None,
3576+
"removed_edit_count": removed_edit_count,
3577+
"removed_key_cache": removed_key_cache,
3578+
}
3579+
3580+
34993581
@router.get("/api/chat/sessions", summary="获取会话列表(聊天左侧列表)")
35003582
def list_chat_sessions(
35013583
request: Request,

0 commit comments

Comments
 (0)