From 48aa5dc07f777f2636ddff1e36d474f70d86a0e8 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 17 Oct 2026 17:04:37 +0800 Subject: [PATCH] feat(admin): account deletion wipes all remote/local data (blobs+sqlite+changes), invites mark holder_gone; UI: invite \u5df2\u9500\u6237 badge + filter --- app/src/lib/api.ts | 2 ++ sync-server/src/db.mjs | 2 ++ sync-server/src/index.mjs | 56 ++++++++++++++++++++++++++++++++++++--- 3 files changed, 56 insertions(+), 4 deletions(-) diff --git a/app/src/lib/api.ts b/app/src/lib/api.ts index ec715ba..117d65b 100644 --- a/app/src/lib/api.ts +++ b/app/src/lib/api.ts @@ -195,6 +195,8 @@ async function adminGet(path: string, baseUrl?: string): Promise { export interface InviteRow { id: number; code: string; remark: string | null; created_at: string; used_by: string | null; used_at: string | null; valid: boolean; + /** 已使用邀请码对应的账号已被注销(状态固定展示为「已销户」) */ + holder_gone?: boolean; used_by_name?: string | null; created_by_name?: string | null; } export interface AdminUserRow { diff --git a/sync-server/src/db.mjs b/sync-server/src/db.mjs index c250f9e..8599687 100644 --- a/sync-server/src/db.mjs +++ b/sync-server/src/db.mjs @@ -112,6 +112,8 @@ CREATE TABLE IF NOT EXISTS attachments ( );`); const hasRemark = db.prepare("SELECT COUNT(*) AS c FROM pragma_table_info('invite_codes') WHERE name='remark'").get().c; if (Number(hasRemark) === 0) db.exec("ALTER TABLE invite_codes ADD COLUMN remark TEXT;"); + const hasHolderGone = db.prepare("SELECT COUNT(*) AS c FROM pragma_table_info('invite_codes') WHERE name='holder_gone'").get().c; + if (Number(hasHolderGone) === 0) db.exec("ALTER TABLE invite_codes ADD COLUMN holder_gone INTEGER NOT NULL DEFAULT 0;"); return db; } diff --git a/sync-server/src/index.mjs b/sync-server/src/index.mjs index 16f9eb7..b261b39 100644 --- a/sync-server/src/index.mjs +++ b/sync-server/src/index.mjs @@ -28,12 +28,52 @@ async function lskyUpload(buf, fileName, mime) { return { url, thumb_url: sanitizeImageUrl(j?.data?.thumbnail_url) || undefined, key: j.data.key }; } +function remoteAttsOf(uid) { + try { return commandsFor(uid).getDb().prepare('SELECT remote_key, remote_thumb_key, remote_url, remote_thumb FROM attachment').all(); } + catch { return []; } +} +// blob 目录与 attachments.mjs/db.mjs 同源(DATA_DIR env 或 cwd/data);删除账号时整目录清空 +function userBlobDir(uid) { + return path.join(process.env.DATA_DIR || path.join(process.cwd(), 'data'), 'blobs', String(uid)); +} +// 递归删除目录并统计实际移除的文件个数 +function rmDirCounted(dir) { + let n = 0; + let entries; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return 0; } + for (const e of entries) { + const fp = path.join(dir, e.name); + if (e.isDirectory()) { n += rmDirCounted(fp); try { fs.rmdirSync(fp); } catch {} } + else { try { fs.unlinkSync(fp); n++; } catch {} } + } + try { fs.rmdirSync(dir); } catch {} + return n; +} +// 用户档案库附件清单(rel_path 相对 USERS_DIR;删除账号时物理清文件) +function localAttachmentFiles(uid) { + const out = new Set(); + try { + for (const a of commandsFor(uid).getDb().prepare('SELECT rel_path, thumb_rel_path FROM attachment').all()) { + for (const rel of [a.rel_path, a.thumb_rel_path]) if (rel) out.add(String(rel)); + } + } catch {} + return out; +} +function rmLocalAttachmentFiles(uid) { + let n = 0; + for (const rel of localAttachmentFiles(uid)) { + try { fs.rmSync(path.join(USERS_DIR, rel), { force: true }); n++; } catch {} + try { fs.rmdirSync(path.dirname(path.join(USERS_DIR, rel))); } catch {} + } + return n; +} async function deleteUserCascade(db, target) { // 图床远端:附件 remote keys + 用户资产(头像等) let remote = 0; + let localFiles = 0; if (LSKY_API && LSKY_TOKEN) { try { - const atts = commandsFor(target.id).getDb().prepare('SELECT remote_key, remote_thumb_key FROM attachment WHERE deleted_at IS NULL').all(); + const atts = remoteAttsOf(target.id); for (const a of atts) { for (const k of [a.remote_key, a.remote_thumb_key]) { if (k) { try { await lskyDelete(k); remote++; } catch {} } @@ -46,22 +86,30 @@ async function deleteUserCascade(db, target) { } catch {} } try { db.prepare('DELETE FROM user_assets WHERE user_id=?').run(target.id); } catch {} + // 本地落盘数据:档案库 blob 目录、档案库附件文件、sqlite 三件套 —— 全部清空不占空间 + try { localFiles += rmLocalAttachmentFiles(target.id); } catch {} try { commandsFor(target.id).close(); } catch {} userCommands.delete(target.id); if (demoUserId === target.id) demoUserId = undefined; for (const f of [target.id + '.db', target.id + '.db-wal', target.id + '.db-shm']) { try { fs.rmSync(path.join(USERS_DIR, f), { force: true }); } catch {} } - try { fs.rmSync(path.join(USERS_DIR, String(target.id)), { recursive: true, force: true }); } catch {} + try { localFiles += rmDirCounted(path.join(USERS_DIR, String(target.id))); } catch {} + try { localFiles += rmDirCounted(userBlobDir(target.id)); } catch {} + console.log('[footprints-sync] 用户 ' + target.id + ' 数据已销毁: 图床' + remote + ' 个副本, 本地 ' + localFiles + ' 个文件'); db.exec('BEGIN'); try { db.prepare('DELETE FROM tokens WHERE user_id=?').run(target.id); db.prepare('DELETE FROM current WHERE user_id=?').run(target.id); db.prepare('DELETE FROM attachments WHERE user_id=?').run(target.id); + db.prepare('DELETE FROM changes WHERE user_id=?').run(target.id); db.prepare('DELETE FROM devices WHERE user_id=?').run(target.id); db.prepare('DELETE FROM users WHERE id=?').run(target.id); + // 其 used 邀请码标记为「已销户」 + db.prepare('UPDATE invite_codes SET holder_gone=1 WHERE used_by=?').run(target.id); db.exec('COMMIT'); } catch (e) { db.exec('ROLLBACK'); throw e; } + try { db.exec('VACUUM'); } catch {} // 回收空间 return remote; } async function lskyDelete(key) { @@ -242,13 +290,13 @@ const server = http.createServer(async (req, res) => { if (req.method === 'GET' && route === '/admin/invites') { const invites = db.prepare(` - SELECT i.id, i.code, i.remark, i.created_at, i.used_by, i.used_at, i.valid, + SELECT i.id, i.code, i.remark, i.created_at, i.used_by, i.used_at, i.valid, i.holder_gone, u.username AS used_by_name, cu.username AS created_by_name FROM invite_codes i LEFT JOIN users u ON u.id = i.used_by LEFT JOIN users cu ON cu.id = i.created_by ORDER BY i.id DESC`).all() - .map(i => ({ ...i, valid: !!i.valid })); + .map(i => ({ ...i, valid: !!i.valid, holder_gone: !!i.holder_gone })); return send(200, true, { total: invites.length, invites }); } if (req.method === 'POST' && route === '/admin/invites/generate') {