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
@@ -82,11 +82,11 @@ public static partial class Endpoints
|
||||
return Json(info, ApiJson.Default.ChannelInfo);
|
||||
});
|
||||
|
||||
// 空间头像原图(信息里只带缩略图):预览时才下载。带 ETag(内容哈希),没变时回 304
|
||||
// 空间头像(信息里只带版本):默认缩略图,size=full 原图。磁盘文件直接发送,带 ETag(内容哈希),没变时回 304
|
||||
api.MapGet("/channels/{id}/avatar", (string id, HttpContext ctx, ChannelService svc) =>
|
||||
{
|
||||
var data = svc.GetChannelAvatarFull(ParseId(id), RequestAuth.FromHeaders(ctx.Request, requireEndpoint: false));
|
||||
return AvatarResult(ctx, data, Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(data ?? ""))));
|
||||
bool thumb = ctx.Request.Query["size"] != "full";
|
||||
return AvatarResult(ctx, svc.GetChannelAvatarBlob(ParseId(id), RequestAuth.FromHeaders(ctx.Request, requireEndpoint: false), thumb));
|
||||
});
|
||||
|
||||
api.MapDelete("/channels/{id}", (string id, HttpContext ctx, ChannelService svc) =>
|
||||
@@ -486,16 +486,15 @@ public static partial class Endpoints
|
||||
catch (Exception error) when (error is JsonException or InvalidDataException) { throw new WebDropException(ErrorCode.BadRequest); }
|
||||
}
|
||||
|
||||
/// <summary>data URL 头像 → 图片字节(带 ETag,没变时 304)</summary>
|
||||
private static IResult AvatarResult(HttpContext ctx, string? data, string version)
|
||||
/// <summary>头像文件(带 ETag = 内容哈希,没变时 304)。直接按路径发送,不读进内存</summary>
|
||||
private static IResult AvatarResult(HttpContext ctx, AvatarBlob? blob)
|
||||
{
|
||||
if (data is null) return Results.NotFound();
|
||||
var etag = $"\"{version}\"";
|
||||
if (blob is null) return Results.NotFound();
|
||||
var etag = $"\"{blob.Hash}\"";
|
||||
ctx.Response.Headers.ETag = etag;
|
||||
ctx.Response.Headers.CacheControl = "private, no-cache";
|
||||
if (ctx.Request.Headers.IfNoneMatch == etag) return Results.StatusCode(StatusCodes.Status304NotModified);
|
||||
var mime = data[5..data.IndexOf(';')];
|
||||
return Results.Bytes(Convert.FromBase64String(data[(data.IndexOf(',') + 1)..]), mime);
|
||||
return Results.File(blob.Path, blob.Mime);
|
||||
}
|
||||
|
||||
private static async Task<T> ReadJson<T>(HttpRequest req, JsonTypeInfo<T> info, CancellationToken ct) where T : class
|
||||
@@ -515,7 +514,21 @@ public static partial class Endpoints
|
||||
{
|
||||
if (req.ContentLength is { } declared && declared > max) throw new WebDropException(ErrorCode.TooLarge);
|
||||
|
||||
using var ms = new MemoryStream(req.ContentLength is { } len ? (int)len : 1024);
|
||||
// 长度已知:直接读进刚好大小的数组,少一份拷贝(帧最大几 MB,多一份就是一次大对象分配)
|
||||
if (req.ContentLength is { } known)
|
||||
{
|
||||
var exact = new byte[(int)known];
|
||||
int filled = 0;
|
||||
while (filled < exact.Length)
|
||||
{
|
||||
int got = await req.Body.ReadAsync(exact.AsMemory(filled), ct);
|
||||
if (got == 0) throw new WebDropException(ErrorCode.BadRequest);
|
||||
filled += got;
|
||||
}
|
||||
return exact;
|
||||
}
|
||||
|
||||
using var ms = new MemoryStream(1024);
|
||||
byte[] buffer = ArrayPool<byte>.Shared.Rent(16 * 1024);
|
||||
try
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user