perf(host): store avatars as content-addressed files and serve them from disk
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:
1 parent
8af4f284a0
commit
7274390aa1
21 files changed
+339
-48
No files matched your search
@@ -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,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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
Reference in new issue
Block a user