feat(files): store files on the host when a channel has no file domain

Chat stays usable when the file service is off or down. Files up to 256 MB are hosted like chat; downloads try the host first and fall back to file-service tickets.

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:24:41 +08:00
1 parent 7e6ef5e587
commit 4b4ab4ee4d
8 files changed
+47 -16

No files matched your search

+22 -5
View File
@@ -66,21 +66,34 @@ export async function createFileDomain(resource: SavedResource, session: Channel
* 保证这个频道有文件域:没有就创建一个并记到频道上(创建频道时没带的、旧频道)。
* 只有所有者和管理员能分配;其他人遇到没有文件域的频道只能等管理员分配。
*/
export async function ensureThreadFileDomain(resource: SavedResource, session: ChannelSession, threadId: string): Promise<void> {
if (resource.kind !== 'channel') return;
export async function ensureThreadFileDomain(resource: SavedResource, session: ChannelSession, threadId: string, size: number): Promise<boolean> {
if (resource.kind !== 'channel') return false;
const thread = (await cachedWorkspace(resource.id)).structure.channels.find((channel) => channel.id === threadId);
if (!thread) throw new Error('频道不存在');
if (thread.fileDomain) return;
if (!await filesEnabled()) throw new Error('服务端没有开启文件服务,不能上传文件');
if (thread.fileDomain) return true;
// 文件服务没开启或没部署好:小文件照样存在主后端(和聊天一样),大文件要等文件服务
const hosted = (reason: string): false => {
if (size > HOSTED_FILE_MAX_BYTES) throw new Error(`${reason},暂时只能上传不超过 ${HOSTED_FILE_MAX_BYTES / 1024 / 1024} MB 的文件`);
return false;
};
if (!await filesEnabled()) return hosted('服务端没有开启文件服务');
let domain: string;
try { domain = await createFileDomain(resource, session); }
catch (error) {
if (hasCode(error, ErrorCode.Forbidden)) throw new Error('这个频道还没有文件空间,请让管理员先为它分配');
if (hasCode(error, ErrorCode.Forbidden)) {
if (size <= HOSTED_FILE_MAX_BYTES) return false;
throw new Error('这个频道还没有文件空间,请让管理员先为它分配');
}
if (error instanceof Error && error.message === FILE_SERVICE_DOWN) return hosted('文件服务暂时不可用');
throw error;
}
await changeWorkspace(resource.id, `channels/${threadId}/file-domain`, 'PUT', { fileDomain: domain });
return true;
}
/** 没有文件域时,文件存在主后端能收的上限 */
export const HOSTED_FILE_MAX_BYTES = 256 * 1024 * 1024;
/** 频道文件域的容量和已用字节 */
export async function fileDomainUsage(resourceId: string, threadId: string): Promise<FileDomainUsage | undefined> {
const { session, resource } = await getSession(resourceId);
@@ -141,6 +154,10 @@ export async function deleteFileBytes(grant: FileGrantBody, itemId: string): Pro
*/
export async function openItemFrames(resource: SavedResource, session: ChannelSession, itemId: string, from: number, signal?: AbortSignal): Promise<Response> {
if (resource.kind === 'channel') {
// 先问主后端:没有文件域的频道里,文件和聊天一样存在这里;存在文件服务的文件它会回 use_file_api,再去要票据
try {
return await apiFetch(`/channels/${resource.id}/items/${itemId}/frames?from=${from}`, { headers: session.authHeaders(tokenRole(session)), ...(signal ? { signal } : {}) });
} catch (error) { if (!hasCode(error, ErrorCode.UseFileApi)) throw error; }
const grant = await requestGrant(resource, session, { op: 'get', item: itemId, from });
return externalFetch(`${base(grant)}/file/v1/domains/${grant.domain}/items/${itemHex(itemId)}/frames?from=${from}`, { 'X-WD-Ticket': grant.ticket }, signal);
}