fix: report attachment cache failures and retries

This commit is contained in:
2026-07-19 05:05:25 +08:00
parent fadbe39b66
commit c6f8e9fff9
2 changed files with 9 additions and 7 deletions
+1
View File
@@ -8,6 +8,7 @@
- 貼文頁、首頁卡片、RSS 與 Atom 優先顯示 Memos 原始發布時間,不再顯示同一次匯入的 Hub 寫入時間。 - 貼文頁、首頁卡片、RSS 與 Atom 優先顯示 Memos 原始發布時間,不再顯示同一次匯入的 Hub 寫入時間。
- 多使用者 Memos 來源只同步 API Key 所屬帳號建立的公開貼文;舊來源會在下一次同步自動補回遠端帳號身分並重新篩選鏡像。 - 多使用者 Memos 來源只同步 API Key 所屬帳號建立的公開貼文;舊來源會在下一次同步自動補回遠端帳號身分並重新篩選鏡像。
- 遠端附件快取失敗會在來源控制台顯示可重試提示,並保留原始連結作為回退。
### Changed ### Changed
+8 -7
View File
@@ -16,11 +16,11 @@ function remoteAttachmentUrl(attachment: any, baseUrl: string) {
} }
async function cacheAttachments(source: Source, attachments: any[]) { async function cacheAttachments(source: Source, attachments: any[]) {
if (source.attachment_storage_mode === "remote") return attachments; if (source.attachment_storage_mode === "remote") return { items: attachments, errors: [] as string[] };
const directory = join(process.cwd(), "public", "uploads", "cache", `source-${source.id}`); await mkdir(directory, { recursive: true }); const directory = join(process.cwd(), "public", "uploads", "cache", `source-${source.id}`); await mkdir(directory, { recursive: true });
let used = 0; let used = 0;
for (const row of db.prepare("SELECT attachments_json FROM posts WHERE source_id=?").all(source.id) as { attachments_json: string }[]) { try { used += (JSON.parse(row.attachments_json) as any[]).filter((item) => String(item.url || "").startsWith(`/uploads/cache/source-${source.id}/`)).reduce((sum, item) => sum + Number(item.size || 0), 0); } catch {} } for (const row of db.prepare("SELECT attachments_json FROM posts WHERE source_id=?").all(source.id) as { attachments_json: string }[]) { try { used += (JSON.parse(row.attachments_json) as any[]).filter((item) => String(item.url || "").startsWith(`/uploads/cache/source-${source.id}/`)).reduce((sum, item) => sum + Number(item.size || 0), 0); } catch {} }
const result: any[] = []; const result: any[] = [], errors: string[] = [];
for (const attachment of attachments) { for (const attachment of attachments) {
const url = remoteAttachmentUrl(attachment, source.base_url); const type = attachment.type || ""; const url = remoteAttachmentUrl(attachment, source.base_url); const type = attachment.type || "";
if (!url || (source.attachment_storage_mode === "images" && !type.startsWith("image/"))) { result.push(attachment); continue; } if (!url || (source.attachment_storage_mode === "images" && !type.startsWith("image/"))) { result.push(attachment); continue; }
@@ -31,9 +31,9 @@ async function cacheAttachments(source: Source, attachments: any[]) {
const filename = attachment.filename || attachment.name || "attachment"; const key = createHash("sha256").update(url).digest("hex").slice(0, 24) + extname(filename); const filename = attachment.filename || attachment.name || "attachment"; const key = createHash("sha256").update(url).digest("hex").slice(0, 24) + extname(filename);
await writeFile(join(directory, key), body); used += body.length; await writeFile(join(directory, key), body); used += body.length;
result.push({ ...attachment, originalUrl: url, url: `/uploads/cache/source-${source.id}/${key}`, type: type || response.headers.get("content-type") || "application/octet-stream", size: body.length }); result.push({ ...attachment, originalUrl: url, url: `/uploads/cache/source-${source.id}/${key}`, type: type || response.headers.get("content-type") || "application/octet-stream", size: body.length });
} catch (error) { recordError("attachment-cache", error, { sourceId: source.id, url }); result.push(attachment); } } catch (error) { const message = error instanceof Error ? error.message : "快取附件失敗"; errors.push(message); recordError("attachment-cache", error, { sourceId: source.id, url }); result.push(attachment); }
} }
return result; return { items: result, errors };
} }
async function cleanupCache(source: Source) { async function cleanupCache(source: Source) {
@@ -43,22 +43,23 @@ async function cleanupCache(source: Source) {
} }
async function upsertRemote(source: Source, memo: any) { async function upsertRemote(source: Source, memo: any) {
const tags = JSON.stringify(memo.tags || []), attachments = JSON.stringify(await cacheAttachments(source, memo.attachments || memo.resources || [])); const cached = await cacheAttachments(source, memo.attachments || memo.resources || []); const tags = JSON.stringify(memo.tags || []), attachments = JSON.stringify(cached.items);
db.prepare(`INSERT INTO posts(source_id,author_id,remote_memo_name,content,visibility,tags_json,attachments_json,origin,remote_created_at,remote_updated_at,sync_status,hidden,remote_url) VALUES(?,?,?,?,?,?,?,?,?,?, 'synced',0,?) ON CONFLICT(source_id,remote_memo_name) DO UPDATE SET content=excluded.content,visibility=excluded.visibility,tags_json=excluded.tags_json,attachments_json=excluded.attachments_json,remote_updated_at=excluded.remote_updated_at,remote_url=excluded.remote_url,hidden=0,updated_at=CURRENT_TIMESTAMP`).run(source.id, source.user_id, memo.name, memo.content, memo.visibility, tags, attachments, "memos", memo.createTime || null, memo.updateTime || null, memoUrl(source.base_url, memo.name)); db.prepare(`INSERT INTO posts(source_id,author_id,remote_memo_name,content,visibility,tags_json,attachments_json,origin,remote_created_at,remote_updated_at,sync_status,hidden,remote_url) VALUES(?,?,?,?,?,?,?,?,?,?, 'synced',0,?) ON CONFLICT(source_id,remote_memo_name) DO UPDATE SET content=excluded.content,visibility=excluded.visibility,tags_json=excluded.tags_json,attachments_json=excluded.attachments_json,remote_updated_at=excluded.remote_updated_at,remote_url=excluded.remote_url,hidden=0,updated_at=CURRENT_TIMESTAMP`).run(source.id, source.user_id, memo.name, memo.content, memo.visibility, tags, attachments, "memos", memo.createTime || null, memo.updateTime || null, memoUrl(source.base_url, memo.name));
return cached.errors;
} }
async function pull(source: Source) { async function pull(source: Source) {
const token = decrypt(source.token_encrypted); const identity = await getMemosIdentity(source.base_url, token); const creator = source.remote_user || identity.name; const token = decrypt(source.token_encrypted); const identity = await getMemosIdentity(source.base_url, token); const creator = source.remote_user || identity.name;
const rules = { creator, tags: JSON.parse(source.sync_tags_json || "[]") as string[], from: source.sync_from, to: source.sync_to, attachmentMode: source.sync_attachment_mode }; const rules = { creator, tags: JSON.parse(source.sync_tags_json || "[]") as string[], from: source.sync_from, to: source.sync_to, attachmentMode: source.sync_attachment_mode };
const memos = await listMemos(source.base_url, token, rules); const memos = await listMemos(source.base_url, token, rules);
for (const memo of memos) await upsertRemote(source, memo); const cacheErrors: string[] = []; for (const memo of memos) cacheErrors.push(...await upsertRemote(source, memo));
const names = memos.map((memo) => memo.name); const names = memos.map((memo) => memo.name);
if (names.length) { const placeholders = names.map(() => "?").join(","); db.prepare(`UPDATE posts SET hidden=1,updated_at=CURRENT_TIMESTAMP WHERE source_id=? AND remote_memo_name IS NOT NULL AND remote_memo_name NOT IN (${placeholders})`).run(source.id, ...names); } if (names.length) { const placeholders = names.map(() => "?").join(","); db.prepare(`UPDATE posts SET hidden=1,updated_at=CURRENT_TIMESTAMP WHERE source_id=? AND remote_memo_name IS NOT NULL AND remote_memo_name NOT IN (${placeholders})`).run(source.id, ...names); }
else db.prepare("UPDATE posts SET hidden=1,updated_at=CURRENT_TIMESTAMP WHERE source_id=? AND remote_memo_name IS NOT NULL").run(source.id); else db.prepare("UPDATE posts SET hidden=1,updated_at=CURRENT_TIMESTAMP WHERE source_id=? AND remote_memo_name IS NOT NULL").run(source.id);
await cleanupCache(source); await cleanupCache(source);
const avatar = identity.avatarUrl || identity.avatar || null; const avatarUrl = avatar?.startsWith("/") ? `${source.base_url.replace(/\/$/, "")}${avatar}` : avatar; const avatar = identity.avatarUrl || identity.avatar || null; const avatarUrl = avatar?.startsWith("/") ? `${source.base_url.replace(/\/$/, "")}${avatar}` : avatar;
const name = identity.nickname || identity.username || identity.name; const name = identity.nickname || identity.username || identity.name;
db.prepare("UPDATE sources SET name=?,remote_user=?,sync_status='synced',last_synced_at=CURRENT_TIMESTAMP,last_error=NULL,last_connection_at=CURRENT_TIMESTAMP,last_connection_error=NULL,remote_display_name=?,remote_avatar_url=? WHERE id=?").run(name, creator, name, avatarUrl, source.id); db.prepare("UPDATE sources SET name=?,remote_user=?,sync_status='synced',last_synced_at=CURRENT_TIMESTAMP,last_error=NULL,last_connection_at=CURRENT_TIMESTAMP,last_connection_error=NULL,attachment_cache_error=?,remote_display_name=?,remote_avatar_url=? WHERE id=?").run(name, creator, cacheErrors.length ? `${cacheErrors.length} 個附件快取失敗;可按「立即同步」重試。` : null, name, avatarUrl, source.id);
} }
async function push(source: Source, payload: any) { async function push(source: Source, payload: any) {