feat(admin): demo account rebuild button + user asset registry (photo keys cascade-deleted with user)

This commit is contained in:
root
2026-10-17 13:59:18 +08:00
parent 6a97590f90
commit b48c36fdb3
18 changed files with 156 additions and 88 deletions
+51 -29
View File
@@ -28,6 +28,42 @@ async function lskyUpload(buf, fileName, mime) {
return { url, thumb_url: sanitizeImageUrl(j?.data?.thumbnail_url) || undefined, key: j.data.key };
}
async function deleteUserCascade(db, target) {
// 图床远端:附件 remote keys + 用户资产(头像等)
let remote = 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();
for (const a of atts) {
for (const k of [a.remote_key, a.remote_thumb_key]) {
if (k) { try { await lskyDelete(k); remote++; } catch {} }
}
}
} catch {}
try {
const assets = db.prepare('SELECT key FROM user_assets WHERE user_id=?').all(target.id);
for (const a of assets) { try { await lskyDelete(a.key); remote++; } catch {} }
} catch {}
}
try { db.prepare('DELETE FROM user_assets WHERE user_id=?').run(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 {}
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 devices WHERE user_id=?').run(target.id);
db.prepare('DELETE FROM users WHERE id=?').run(target.id);
db.exec('COMMIT');
} catch (e) { db.exec('ROLLBACK'); throw e; }
return remote;
}
async function lskyDelete(key) {
const r = await fetch(LSKY_API + '/images/' + encodeURIComponent(key), {
method: 'DELETE', headers: { authorization: 'Bearer ' + LSKY_TOKEN, accept: 'application/json' },
@@ -159,7 +195,7 @@ const server = http.createServer(async (req, res) => {
}
// ---- 管理(仅 is_admin 登录会话) ----
if (route === '/admin/users' || route === '/admin/users/delete' || route === '/admin/invites' || route === '/admin/demo-seed' || route === '/admin/attachments/sync-lsky' || route.startsWith('/admin/invites/')) {
if (route === '/admin/users' || route === '/admin/users/delete' || route === '/admin/demo-rebuild' || route === '/admin/invites' || route === '/admin/demo-seed' || route === '/admin/attachments/sync-lsky' || route.startsWith('/admin/invites/')) {
let auth;
try { auth = authUser(req); }
catch (e) { return send(e.code || 401, false, e.message); }
@@ -187,36 +223,20 @@ const server = http.createServer(async (req, res) => {
if (target.is_admin && db.prepare('SELECT COUNT(*) AS c FROM users WHERE is_admin=1').get().c <= 1) {
return send(400, false, '不能删除最后一位管理员');
}
// 图床远端文件(尽力而为,不阻塞删除)
let remote = 0;
try {
const atts = commandsFor(target.id).getDb().prepare('SELECT remote_key, remote_thumb_key FROM attachment WHERE deleted_at IS NULL').all();
for (const a of atts) {
for (const k of [a.remote_key, a.remote_thumb_key]) {
if (k && LSKY_API && LSKY_TOKEN) { try { await lskyDelete(k); remote++; } catch {} }
}
}
} 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 {}
// 认证库级联清理
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 devices WHERE user_id=?').run(target.id);
db.prepare('DELETE FROM users WHERE id=?').run(target.id);
db.exec('COMMIT');
} catch (e) { db.exec('ROLLBACK'); throw e; }
const remote = await deleteUserCascade(db, target);
console.log('[footprints-sync] 用户已删除:', target.username, '(图床文件 ' + remote + ' 个)');
return send(200, true, { ok: true, username: target.username, remote_deleted: remote });
// 重建演示账号:级联删除现有 demo → 重新注册 → 重新播种
if (req.method === 'POST' && route === '/admin/demo-rebuild') {
const existing = db.prepare('SELECT id, username FROM users WHERE username=?').get('demo');
if (existing && existing.id !== auth.user_id) await deleteUserCascade(db, existing);
const { register } = await import('./auth.mjs');
const uid = register({ username: 'demo', password: 'demo1234', device_name: 'seed-demo', seed: true }).user_id;
const seeded = commandsFor(uid).handleCommand('demo_seed', {});
console.log('[footprints-sync] demo 账号已重建:', JSON.stringify(seeded));
return send(200, true, { ok: true, seeded });
}
}
if (req.method === 'GET' && route === '/admin/invites') {
@@ -369,6 +389,8 @@ const server = http.createServer(async (req, res) => {
const buf = Buffer.from(body.data_b64 || '', 'base64');
if (!buf.length) return send(400, false, '空文件');
const up = await lskyUpload(buf, body.file_name || 'profile-photo.png', body.mime || 'image/png');
try { getDb().prepare('INSERT OR REPLACE INTO user_assets (user_id, kind, key, url) VALUES (?,?,?,?)')
.run(auth.user_id, 'photo', up.key || sanitizeImageUrl(up.url), sanitizeImageUrl(up.url)); } catch {}
return send(200, true, { url: sanitizeImageUrl(up.url) });
} catch (e) {
return send(500, false, '证件照上传失败: ' + e.message);