perf(host): store avatars as content-addressed files and serve them from disk
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/push/web-build Pipeline failed
ci/woodpecker/push/dev-image-api unknown status

Space info now carries avatar_version instead of the inlined thumbnail; the worker fetches the thumbnail once per version. Legacy base64 rows are migrated to files on startup, and the daily backup uploads avatar files.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> [CI]
This commit is contained in:
justmeandClaude Sonnet 5.5 committed 2026-10-19 14:16:50 +08:00
1 parent 8af4f284a0
commit 7274390aa1
21 files changed
+339 -48

No files matched your search

+2 -2
View File
@@ -1,5 +1,5 @@
// Generated from backend DTOs; do not edit.
// backend-contract-sha256: 340876061d7b324e4fd16972e276ffb0f238eeac66b214a043b3b4b98d560fcf
// backend-contract-sha256: e0ae14a47dfbb3c22017cdd7b5e12465b3e273842db2a35f36b7335c20b51cac
export interface AccountChannelBody {
channel_id: string;
@@ -75,7 +75,7 @@ export interface ChannelHistoryRequest {
filesHistory: boolean;
}
export interface ChannelInfo {
avatar?: string | null;
avatar_version?: string | null;
can_create_channel: boolean;
can_delete_any: boolean;
can_delete_own: boolean;
+2 -2
View File
@@ -2,7 +2,7 @@ import type { SavedResource } from './database';
import type { ChannelInfo, Share, ShareSettings } from '@/domain/types';
import type { ChannelInfo as ChannelWire, ShareInfo as ShareWire, ShareItemInfo as ShareWireItem } from '@/api/generated';
export type { ChannelWire, ShareWire, ShareWireItem };
export function toChannelInfo(raw: ChannelWire, saved: SavedResource): ChannelInfo {
export function toChannelInfo(raw: ChannelWire, saved: SavedResource, avatar?: string): ChannelInfo {
const joinPolicy = raw.settings.join_policy;
if (joinPolicy !== 'open' && joinPolicy !== 'invite') throw new Error('空间加入规则无效');
return {
@@ -10,7 +10,7 @@ export function toChannelInfo(raw: ChannelWire, saved: SavedResource): ChannelIn
creatorAccount: raw.creator_account ?? undefined,
joinPolicy, passwordRequired: raw.password_required,
name: raw.settings.name ?? undefined,
avatar: raw.avatar ?? undefined,
avatar,
lastSeq: raw.last_seq,
mode: raw.mode,
isOwner: raw.is_owner,
+2 -1
View File
@@ -11,6 +11,7 @@ import { previewKindOf } from '@/lib/files/fileKind';
import { blobs, readers } from '../runtime/sessions';
import { showRejected, publishWorkspace } from '../runtime/presentation';
import { type ChannelWire, toChannelInfo } from '../runtime/wire';
import { channelInfo } from './spaceAvatars';
import { apiFetch, readJson } from '../runtime/http';
import { readResourceRecord } from './workspaceRecords';
import { clearFileCatalog, fileCatalogs, visibleFiles } from './fileCatalog';
@@ -139,7 +140,7 @@ export async function channelMetadata(resource: SavedResource, session: ChannelS
const info = await readJson<ChannelWire>(`/channels/${resource.id}`, session.authHeaders(tokenRole(session)));
session.verifyChannelInfo(info);
Object.assign(resource, await cacheResource(resource, channelCache(info)));
publishWorkspace(resource.id, { t: 'channel', info: toChannelInfo(info, resource) });
publishWorkspace(resource.id, { t: 'channel', info: await channelInfo(info, resource) });
await loadWorkspace(resource.id);
}
+7 -6
View File
@@ -14,6 +14,7 @@ import type { ChannelSettingsPatch as SettingsRequest, JoinResult, MemberBody }
import { freshlyJoined, getSession, sessions } from '../runtime/sessions';
import { makeLink } from './links';
import { toChannelInfo, type ChannelWire } from '../runtime/wire';
import { channelInfo } from './spaceAvatars';
import { stopChannel } from './channelTransport';
import { publishWorkspace } from '../runtime/presentation';
import { requireAccount, accountPublicKey } from '../account/accountSession';
@@ -62,7 +63,7 @@ export async function createChannel(options: CreateOptions, captchaToken?: strin
attachDataKeys(session, resource);
const workspace = await import('./workspaceStructure').then(module => module.loadWorkspace(session.id));
if (workspace.structure.defaultChannelId) await initializeChannelKeys(resource, session, workspace.structure.defaultChannelId);
return toChannelInfo(info, resource);
return await channelInfo(info, resource);
} catch (error) {
session.dispose();
throw error;
@@ -94,7 +95,7 @@ export async function joinChannel(input: { link: ParsedChannelLink; password?: s
await saveResource(resource);
sessions.set(id, session);
freshlyJoined.set(id, Date.now());
return toChannelInfo(info, resource);
return await channelInfo(info, resource);
} catch (error) {
session.dispose();
throw error;
@@ -106,7 +107,7 @@ export async function previewChannel(link: ParsedChannelLink): Promise<ChannelIn
try {
const info = await readJson<ChannelWire>(`/channels/${link.id}`, session.authHeaders('read'));
session.verifyChannelInfo(info);
return toChannelInfo(info, { id: link.id, kind: 'channel', mode: session.mode, role: session.role,
return await channelInfo(info, { id: link.id, kind: 'channel', mode: session.mode, role: session.role,
endpointSecret: session.endpointSecret, link: session.link(self.location.origin), createdAt: info.created_at,
expiresAt: info.expires_at, maxMembers: info.settings.max_members });
} finally { session.dispose(); }
@@ -132,13 +133,13 @@ export async function listChannels(): Promise<ChannelInfo[]> {
const { session } = await getSession(resource.id);
const raw = catalog?.get(resource.id) ?? await readJson<ChannelWire>(`/channels/${resource.id}`, session.authHeaders(tokenRole(session)));
session.verifyChannelInfo(raw);
const info = toChannelInfo(raw, resource);
const info = await channelInfo(raw, resource);
await cacheResource(resource, channelCache(raw));
return { ...info, dataSource: 'server' };
} catch (error) {
if (hasCode(error, ErrorCode.NotFound, ErrorCode.Gone)) return null;
if (!isOfflineFailure(error)) throw error;
if (resource.channelMetadata) return { ...toChannelInfo(resource.channelMetadata, resource), dataSource: 'cache' };
if (resource.channelMetadata) return { ...await channelInfo(resource.channelMetadata, resource), dataSource: 'cache' };
return {
id: resource.id, name: resource.name, mode: resource.mode, isOwner: false, canCreateChannel: false, canManageFolders: false, canInvite: false, canEditSettings: false, canManageRoles: false, canRemoveMembers: false, canDeleteAny: false, canDeleteOwn: false, maxMembers: resource.maxMembers, maxDownloads: resource.maxDownloads ?? undefined,
creatorAccount: resource.creatorAccount,
@@ -169,7 +170,7 @@ export async function updateChannelSettings(resourceId: string, patch: ChannelSe
}).then((response) => response.json() as Promise<ChannelWire>);
session.verifyChannelInfo(info);
const updated = await cacheResource(resource, channelCache(info));
publishWorkspace(resourceId, { t: 'channel', info: toChannelInfo(info, updated) });
publishWorkspace(resourceId, { t: 'channel', info: await channelInfo(info, updated) });
}
export async function forgetChannel(id: string): Promise<void> {
+41
View File
@@ -0,0 +1,41 @@
import type { ChannelInfo } from '@/domain/types';
import type { SavedResource } from '../runtime/database';
import { toChannelInfo, type ChannelWire } from '../runtime/wire';
import { apiFetch } from '../runtime/http';
import { getSession, tokenRole } from '../runtime/sessions';
/**
* 空间头像不随空间信息下发(动图缩略图有几百 KB,每次刷新都带上会让服务端和浏览器反复分配大块内存)。
* 信息里只有头像版本,版本变了才取一次缩略图;同一个版本只在内存里留一份。
*/
interface Entry { version: string; value?: Promise<string | undefined> }
const cache = new Map<string, Entry>();
function toDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result));
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(blob);
});
}
export function spaceAvatar(spaceId: string, version: string | null | undefined): Promise<string | undefined> {
if (!version) { cache.delete(spaceId); return Promise.resolve(undefined); }
const entry = cache.get(spaceId);
if (entry && entry.version === version && entry.value) return entry.value;
const value = (async () => {
try {
const { session } = await getSession(spaceId);
const response = await apiFetch(`/channels/${spaceId}/avatar`, { headers: session.authHeaders(tokenRole(session)), cache: 'no-cache' });
return await toDataUrl(await response.blob());
} catch { cache.delete(spaceId); return undefined; }
})();
cache.set(spaceId, { version, value });
return value;
}
/** 带上头像的空间信息(头像按版本取,见上) */
export async function channelInfo(raw: ChannelWire, saved: SavedResource): Promise<ChannelInfo> {
return toChannelInfo(raw, saved, await spaceAvatar(raw.id, raw.avatar_version));
}