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

@@ -18,9 +18,9 @@ public sealed partial class ChannelService(
private IFileGrants Files => fileGrants ?? NoFileGrants.Instance;
/// <summary>帧内容在文件域里的帧,BlobKey 以它开头;主后端只有头部、长度和摘要</summary>
public const string FilesPrefix = "files:";
/// <summary>这个条目是文件域的对象(不含目录控制帧)</summary>
/// <summary>这个条目是文件域的对象(不含目录控制帧)。所在频道没有分配文件域(文件服务没开、小文件)时,文件和聊天一样存在主后端</summary>
private static bool IsFileObject(IResourceTx tx, Id16 channelId, Envelope env) =>
!env.IsControl && tx.GetWorkspaceCrypto(channelId).Objects.Exists(o => o.ItemId == env.ItemId.ToBase64Url() && o.Domain == CryptoDomain.Files);
!env.IsControl && FileDomainOf(tx, channelId, env.ItemId) is not null;
private static bool IsRemote(string? blobKey) => blobKey is not null && blobKey.StartsWith(FilesPrefix, StringComparison.Ordinal);
@@ -86,6 +86,7 @@ public sealed class ChannelCryptoTests
using var env = new TestEnv(); var ownerEndpoint = TestEnv.Endpoint(); var memberEndpoint = TestEnv.Endpoint();
var space = env.Create(ownerEndpoint, ChannelMode.Raw); var owner = space.Owner(ownerEndpoint);
var channelId = env.Service.GetWorkspace(space.Id, owner).Channels.Single().Id;
env.Service.SetWorkspaceChannelFileDomain(space.Id, owner, channelId, "domain-1");
var oldFile = TestEnv.NewId();
env.Service.DeclareObject(space.Id, owner, new ObjectEnvelope { ItemId = oldFile.ToBase64Url(), ChannelId = channelId, Domain = CryptoDomain.Files, Epoch = 0, KeyEnvelope = "" });
await Register(env.Service, space.Id, owner, TestEnv.Frame(oldFile, 0, FrameFlags.Final, 16));
@@ -167,7 +167,16 @@ public class ChannelServiceTests : IDisposable
var item = TestEnv.NewId();
Svc.DeclareObject(space.Id, owner, new ObjectEnvelope { ItemId = item.ToBase64Url(), ChannelId = channelId, Domain = CryptoDomain.Files, Epoch = 0, KeyEnvelope = "" });
// 文件域的对象不能把字节直接交给主后端
// 频道没有文件域(文件服务没开、小文件):文件和聊天一样存在主后端,不能整个登记
var hosted = TestEnv.NewId();
Svc.DeclareObject(space.Id, owner, new ObjectEnvelope { ItemId = hosted.ToBase64Url(), ChannelId = channelId, Domain = CryptoDomain.Files, Epoch = 0, KeyEnvelope = "" });
Assert.True((await Svc.AppendFrameAsync(space.Id, owner, TestEnv.Frame(hosted, 0, FrameFlags.Gated | FrameFlags.Final, 60), 3)).Created);
var hostedFrame = TestEnv.Frame(hosted, 0, FrameFlags.Gated | FrameFlags.Final, 60);
Assert.Equal(ErrorCode.BadRequest, Assert.Throws<WebDropException>(() => Svc.RegisterFileItem(space.Id, owner, hostedFrame.AsMemory(0, Envelope.HeaderSize),
[new ChannelService.FrameEntry(60, System.Security.Cryptography.SHA256.HashData(hostedFrame))], 3)).Code);
// 分配了文件域的频道:文件域的对象不能把字节直接交给主后端
Svc.SetWorkspaceChannelFileDomain(space.Id, owner, channelId, "domain-1");
byte[] first = TestEnv.Frame(item, 0, FrameFlags.Gated, 120);
await AssertCode(ErrorCode.UseFileApi, () => Svc.AppendFrameAsync(space.Id, owner, first, null));