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

+23 -10
View File
@@ -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
{