feat: Lsky/COS gateway - attachments upload direct to object storage (remote_url/remote_thumb), preview loads from COS, graceful local fallback

This commit is contained in:
root
2026-10-17 01:16:59 +08:00
parent a94c1f6e25
commit 2bba3be08e
23 changed files with 174 additions and 51 deletions
+63
View File
@@ -2,6 +2,23 @@
import http from 'node:http';
import fs from 'node:fs';
import zlib from 'node:zlib';
// 图床网关:Lsky Pro(已对接 COS),附件直传对象存储,应用服务器不做搬运工
const LSKY_API = (process.env.LSKY_API || '').replace(/\/$/, '');
const LSKY_TOKEN = process.env.LSKY_TOKEN || '';
async function lskyUpload(buf, fileName, mime) {
const fd = new FormData();
fd.append('file', new Blob([buf], { type: mime || 'application/octet-stream' }), fileName);
const r = await fetch(LSKY_API + '/upload', {
method: 'POST',
headers: { authorization: 'Bearer ' + LSKY_TOKEN, accept: 'application/json' },
body: fd,
signal: AbortSignal.timeout(60_000),
});
const j = await r.json().catch(() => null);
if (!j || j.status !== true || !j.data?.url) throw new Error('LSKY ' + r.status + ': ' + (j?.message || 'no url'));
return j.data;
}
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { getDb } from './db.mjs';
@@ -166,6 +183,29 @@ const server = http.createServer(async (req, res) => {
if (!r.changes) return send(404, false, '邀请码不存在');
return send(200, true, { deleted: r.changes });
}
if (req.method === 'POST' && route === '/admin/attachments/sync-lsky') {
// 存量迁移:本用户所有未上图的附件推送到 Lsky(逐个,失败跳过)
if (!LSKY_API || !LSKY_TOKEN) return send(400, false, '未配置 LSKY_API/LSKY_TOKEN');
const cmds = commandsFor(auth.user_id);
const rows = cmds.getDb().prepare("SELECT id, file_name, mime FROM attachment WHERE owner_type='footprint' AND deleted_at IS NULL AND (remote_url IS NULL OR remote_url='')").all();
let done = 0, failed = 0;
for (const row of rows) {
try {
const orig = cmds.handleCommand('attachment_read', { id: row.id });
const up = await lskyUpload(Buffer.from(orig.data_b64, 'base64'), row.file_name, row.mime);
let thumbUrl = null;
if (row.mime?.startsWith('image/')) {
const td = cmds.handleCommand('attachment_thumb', { id: row.id });
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 });
done++;
} catch (e) { failed++; console.log('[lsky-migrate]', row.id, e.message); }
}
return send(200, true, { total: rows.length, done, failed });
}
if (req.method === 'POST' && route === '/admin/demo-seed') {
const demoRow = db.prepare('SELECT id FROM users WHERE username=?').get('demo');
if (!demoRow) return send(404, false, 'demo 账号不存在');
@@ -224,6 +264,29 @@ const server = http.createServer(async (req, res) => {
});
}
// 附件直传图床:attachment_add 落本地后同步推 Lsky(COS),失败自动回退本地
if (req.method === 'POST' && route === '/api/attachment_add') {
const body = await readJson(req);
const data = commandsFor(auth.user_id).handleCommand('attachment_add', body);
if (LSKY_API && LSKY_TOKEN) {
try {
const orig = Buffer.from(body.data_b64 || '', 'base64');
const up = await lskyUpload(orig, body.file_name || ('file-' + data.item.id), body.mime);
let thumbUrl = 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;
}
commandsFor(auth.user_id).handleCommand('attachment_set_remote', { id: data.item.id, remote_url: up.url, remote_thumb: thumbUrl });
data.item.remote_url = up.url;
data.item.remote_thumb = thumbUrl;
} catch (e) {
console.log('[lsky] 直传失败,保留本地存储:', e.message);
}
}
return send(200, true, data);
}
// Web 应用命令层:/api/<command>(与桌面端同一契约,按用户隔离的档案库)
if (req.method === 'POST' && route.startsWith('/api/')) {
const cmd = route.slice(5);