feat(admin): account deletion wipes all remote/local data (blobs+sqlite+changes), invites mark holder_gone; UI: invite \u5df2\u9500\u6237 badge + filter

This commit is contained in:
root
2026-10-17 17:04:37 +08:00
parent 3d2d064920
commit 48aa5dc07f
3 changed files with 56 additions and 4 deletions
+2
View File
@@ -195,6 +195,8 @@ async function adminGet<T>(path: string, baseUrl?: string): Promise<T> {
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 {
+2
View File
@@ -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;
}
+52 -4
View File
@@ -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') {