From c39a4c80d528390ca820a560b6befa3c72178104 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 17 Oct 2026 10:10:19 +0800 Subject: [PATCH] feat: delete remote images from Lsky when record/attachment is deleted (keys stored at upload, legacy URLs resolved via pathname) --- app/schema.sql | 2 + app/server/db.mjs | 6 +-- sync-server/src/index.mjs | 80 ++++++++++++++++++++++++++++++++++++--- 3 files changed, 79 insertions(+), 9 deletions(-) diff --git a/app/schema.sql b/app/schema.sql index ea284a4..a602976 100644 --- a/app/schema.sql +++ b/app/schema.sql @@ -92,6 +92,8 @@ CREATE TABLE IF NOT EXISTS attachment ( thumb_rel_path TEXT, remote_url TEXT, remote_thumb TEXT, + remote_key TEXT, + remote_thumb_key TEXT, taken_at TEXT, deleted_at TEXT, origin TEXT, diff --git a/app/server/db.mjs b/app/server/db.mjs index 41ffae5..ab198dd 100644 --- a/app/server/db.mjs +++ b/app/server/db.mjs @@ -125,7 +125,7 @@ function preMigrateColumns(d) { } }; for (const t of SYNC_TABLES) addCols(t, SYNC_COLS); - addCols('attachment', [['sha256', 'TEXT'], ['thumb_rel_path', 'TEXT'], ['remote_url', 'TEXT'], ['remote_thumb', 'TEXT']]); + addCols('attachment', [['sha256', 'TEXT'], ['thumb_rel_path', 'TEXT'], ['remote_url', 'TEXT'], ['remote_thumb', 'TEXT'], ['remote_key', 'TEXT'], ['remote_thumb_key', 'TEXT']]); } export function migrateSync(d) { @@ -1084,8 +1084,8 @@ function listFootprints(d, query) { if (!row) throw new Error('附件不存在'); withTx(d, () => { const h = hlcNext(d); - d.prepare('UPDATE attachment SET remote_url=COALESCE(?, remote_url), remote_thumb=COALESCE(?, remote_thumb), hlc=? WHERE id=?') - .run(payload.remote_url ?? null, payload.remote_thumb ?? null, h, row.id); + d.prepare('UPDATE attachment SET remote_url=COALESCE(?, remote_url), remote_thumb=COALESCE(?, remote_thumb), remote_key=COALESCE(?, remote_key), remote_thumb_key=COALESCE(?, remote_thumb_key), hlc=? WHERE id=?') + .run(payload.remote_url ?? null, payload.remote_thumb ?? null, payload.remote_key ?? null, payload.remote_thumb_key ?? null, h, row.id); touchOplog(d, 'attachment', row.uuid, 'upsert', h); }); return { ok: true }; diff --git a/sync-server/src/index.mjs b/sync-server/src/index.mjs index 3b2709d..7adc6cc 100644 --- a/sync-server/src/index.mjs +++ b/sync-server/src/index.mjs @@ -27,6 +27,53 @@ async function lskyUpload(buf, fileName, mime) { if (!j || j.status !== true || !url) throw new Error('LSKY ' + r.status + ': ' + (j?.message || 'no url')); return { url, thumb_url: sanitizeImageUrl(j?.data?.thumbnail_url) || undefined, key: j.data.key }; } + +async function lskyDelete(key) { + const r = await fetch(LSKY_API + '/images/' + encodeURIComponent(key), { + method: 'DELETE', headers: { authorization: 'Bearer ' + LSKY_TOKEN, accept: 'application/json' }, + signal: AbortSignal.timeout(30_000), + }); + const j = await r.json().catch(() => null); + if (!j || j.status !== true) throw new Error('LSKY delete ' + r.status + ': ' + (j?.message || '')); + return true; +} + +// 历史 URL 反查 key:按 pathname 在图床相册里匹配(最多翻 5 页) +async function lskyFindKeyByUrl(url) { + try { + const pathname = new URL(url).pathname.replace(/^\/i\//, ''); + for (let page = 1; page <= 5; page++) { + const r = await fetch(LSKY_API + '/images?per_page=100&page=' + page, { + headers: { authorization: 'Bearer ' + LSKY_TOKEN, accept: 'application/json' }, + signal: AbortSignal.timeout(30_000), + }); + const j = await r.json().catch(() => null); + const items = j?.data?.data || []; + const hit = items.find(x => x.pathname === pathname); + if (hit) return hit.key; + if (page >= (j?.data?.last_page || 1)) break; + } + } catch (e) { console.log('[lsky] 反查 key 失败:', e.message); } + return null; +} + +// 删除联动:记录/附件删除后清理图床(后台异步) +function lskyCleanupRemote(items) { + if (!LSKY_API || !LSKY_TOKEN) return; + (async () => { + for (const it of items) { + try { + let k = it.remote_key; + if (!k && it.remote_url) k = await lskyFindKeyByUrl(it.remote_url); + if (k) await lskyDelete(k); + let tk = it.remote_thumb_key; + if (!tk && it.remote_thumb) tk = await lskyFindKeyByUrl(it.remote_thumb); + if (tk) await lskyDelete(tk); + if (k || tk) console.log('[lsky] 图床已清理:', it.file_name || it.id); + } catch (e) { console.log('[lsky] 图床清理失败:', it.file_name || it.id, e.message); } + } + })(); +} import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { getDb } from './db.mjs'; @@ -207,7 +254,7 @@ const server = http.createServer(async (req, res) => { const tUp = await lskyUpload(Buffer.from(td.data_b64, 'base64'), 'thumb-' + row.file_name + '.webp', td.mime || 'image/webp'); thumbUrl = tUp.thumb_url || tUp.url; } - cmds.handleCommand('attachment_set_remote', { id: row.id, remote_url: up.url, remote_thumb: thumbUrl }); + cmds.handleCommand('attachment_set_remote', { id: row.id, remote_url: up.url, remote_thumb: thumbUrl, remote_key: up.key, remote_thumb_key: thumbKey }); done++; } catch (e) { failed++; console.log('[lsky-migrate]', row.id, e.message); } } @@ -296,12 +343,13 @@ const server = http.createServer(async (req, res) => { (async () => { try { const up = await lskyUpload(buf, name, mime); - let thumbUrl = null; + let thumbUrl = null, thumbKey = null; if (thumbB64) { const tUp = await lskyUpload(Buffer.from(thumbB64, 'base64'), 'thumb-' + name + '.webp', 'image/webp'); thumbUrl = tUp.thumb_url || tUp.url; + thumbKey = tUp.key; } - cmds.handleCommand('attachment_set_remote', { id: itemId, remote_url: up.url, remote_thumb: thumbUrl }); + cmds.handleCommand('attachment_set_remote', { id: itemId, remote_url: up.url, remote_thumb: thumbUrl, remote_key: up.key, remote_thumb_key: thumbKey }); console.log('[lsky] 附件 #' + itemId + ' 已上图床'); } catch (e) { console.log('[lsky] 附件 #' + itemId + ' 后台上传失败(可用 sync-lsky 补传):', e.message); } })(); @@ -309,6 +357,26 @@ const server = http.createServer(async (req, res) => { return send(200, true, data); } + // 附件删除:本地清理 + 图床异步清理 + if (req.method === 'POST' && route === '/api/attachment_delete') { + const body = await readJson(req); + const cmds = commandsFor(auth.user_id); + const row = cmds.getDb().prepare('SELECT id, file_name, remote_url, remote_thumb, remote_key, remote_thumb_key FROM attachment WHERE id=?').get(Number(body.id)); + const data = cmds.handleCommand('attachment_delete', body); + if (row && (row.remote_key || row.remote_url || row.remote_thumb)) lskyCleanupRemote([row]); + return send(200, true, data); + } + + // 记录删除:级联清理附件 + 图床 + if (req.method === 'POST' && route === '/api/footprint_delete') { + const body = await readJson(req); + const cmds = commandsFor(auth.user_id); + const atts = cmds.getDb().prepare("SELECT id, file_name, remote_url, remote_thumb, remote_key, remote_thumb_key FROM attachment WHERE owner_type='footprint' AND owner_id=? AND deleted_at IS NULL AND (remote_url IS NOT NULL OR remote_key IS NOT NULL)").all(Number(body.id)); + const data = cmds.handleCommand('footprint_delete', body); + if (atts.length) lskyCleanupRemote(atts); + return send(200, true, data); + } + // 附件直传图床(兼容旧 base64 通道):同样异步 if (req.method === 'POST' && route === '/api/attachment_add') { const body = await readJson(req); @@ -319,13 +387,13 @@ const server = http.createServer(async (req, res) => { (async () => { try { const up = await lskyUpload(orig, body.file_name || ('file-' + itemId), body.mime); - let thumbUrl = null; + let thumbUrl = null, thumbKey = null; if (body.thumb_b64) { const tUp = await lskyUpload(Buffer.from(body.thumb_b64, 'base64'), 'thumb-' + (body.file_name || 'img') + '.webp', 'image/webp'); thumbUrl = tUp.thumb_url || tUp.url; + thumbKey = tUp.key; } - cmds = null; - commandsFor(auth.user_id).handleCommand('attachment_set_remote', { id: itemId, remote_url: up.url, remote_thumb: thumbUrl }); + commandsFor(auth.user_id).handleCommand('attachment_set_remote', { id: itemId, remote_url: up.url, remote_thumb: thumbUrl, remote_key: up.key, remote_thumb_key: thumbKey }); console.log('[lsky] 附件 #' + itemId + ' 已上图床'); } catch (e) { console.log('[lsky] 附件 #' + itemId + ' 后台上传失败:', e.message); } })();