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