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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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') {
|
||||
|
||||
Reference in New Issue
Block a user