feat(admin): demo account rebuild button + user asset registry (photo keys cascade-deleted with user)
This commit is contained in:
@@ -40,6 +40,16 @@ CREATE TABLE IF NOT EXISTS tokens (
|
||||
expires_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- 用户图床资产登记:头像等直传 COS 的文件 key 归账到用户,删除用户时级联清理
|
||||
CREATE TABLE IF NOT EXISTS user_assets (
|
||||
user_id TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
url TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
|
||||
PRIMARY KEY (user_id, kind, key)
|
||||
);
|
||||
|
||||
-- 每行最新状态(按 user + table + row_uuid 唯一,LWW 胜者)
|
||||
CREATE TABLE IF NOT EXISTS current (
|
||||
user_id TEXT NOT NULL,
|
||||
|
||||
+51
-29
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user