256 lines
8 KiB
Go
256 lines
8 KiB
Go
package files
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
|
||
"telesrv/internal/domain"
|
||
"telesrv/internal/store"
|
||
)
|
||
|
||
// 上传分片上限:与 Telegram 客户端约定一致(单片 ≤512KB;分片总数有上限防止 OOM)。
|
||
const (
|
||
MaxUploadPartBytes = 524288 // 512KB
|
||
MaxUploadParts = 8000 // 512KB * 8000 ≈ 4GB 理论上限,足够主路径媒体
|
||
)
|
||
|
||
// blobMetaCacheCapacity 是 location_key→FileBlob 元数据 LRU 容量(每项约百字节,约 13MB)。
|
||
const blobMetaCacheCapacity = 1 << 16
|
||
|
||
// 小文件热缓存只覆盖 sticker/reaction/thumbnail 一类不可变小 blob;大媒体继续分段读。
|
||
const (
|
||
blobBytesCacheMaxEntryBytes = 256 << 10 // 256KB
|
||
blobBytesCacheMaxBytes = 64 << 20 // 64MB
|
||
)
|
||
|
||
// Service 实现 upload 分片累积、blob 落盘、getFile 下载,并把上传文件组装成 Photo / Document。
|
||
type Service struct {
|
||
media store.MediaStore
|
||
blobs BlobBackend
|
||
dc int
|
||
blobCache *blobMetaCache
|
||
byteCache *blobBytesCache
|
||
stickerSetCache *stickerSetFullCache
|
||
}
|
||
|
||
// NewService 创建 files 服务。dc 是本 server 的 DC id,写入新建 document/photo 的 dc_id。
|
||
func NewService(media store.MediaStore, blobs BlobBackend, dc int) *Service {
|
||
return &Service{
|
||
media: media,
|
||
blobs: blobs,
|
||
dc: dc,
|
||
blobCache: newBlobMetaCache(blobMetaCacheCapacity),
|
||
byteCache: newBlobBytesCache(blobBytesCacheMaxBytes),
|
||
stickerSetCache: newStickerSetFullCache(),
|
||
}
|
||
}
|
||
|
||
// SaveFilePart 累积一个 small file 分片。
|
||
func (s *Service) SaveFilePart(ctx context.Context, ownerUserID, fileID int64, part int, bytes []byte) (bool, error) {
|
||
if err := validatePart(part, len(bytes)); err != nil {
|
||
return false, err
|
||
}
|
||
if err := s.media.SaveFilePart(ctx, domain.UploadPart{
|
||
OwnerUserID: ownerUserID,
|
||
FileID: fileID,
|
||
Part: part,
|
||
Bytes: bytes,
|
||
}); err != nil {
|
||
return false, err
|
||
}
|
||
return true, nil
|
||
}
|
||
|
||
// SaveBigFilePart 累积一个 big file 分片(带已知总分片数)。
|
||
func (s *Service) SaveBigFilePart(ctx context.Context, ownerUserID, fileID int64, part, totalParts int, bytes []byte) (bool, error) {
|
||
if err := validatePart(part, len(bytes)); err != nil {
|
||
return false, err
|
||
}
|
||
if totalParts <= 0 || totalParts > MaxUploadParts {
|
||
return false, domain.ErrFilePartsInvalid
|
||
}
|
||
if err := s.media.SaveFilePart(ctx, domain.UploadPart{
|
||
OwnerUserID: ownerUserID,
|
||
FileID: fileID,
|
||
Part: part,
|
||
TotalParts: totalParts,
|
||
Big: true,
|
||
Bytes: bytes,
|
||
}); err != nil {
|
||
return false, err
|
||
}
|
||
return true, nil
|
||
}
|
||
|
||
// GetFile 按 location_key 取一段 blob 内容。found=false 表示该 location 无对应 blob。
|
||
// 元数据走进程内 LRU(消除每 chunk 一次 PG 查);小 blob 全量字节进 LRU,供 sticker /
|
||
// reaction / thumbnail 热路径直接内存切片;大 blob 仍按 offset/limit 段读。
|
||
func (s *Service) GetFile(ctx context.Context, req domain.FileDownloadRequest) (domain.FileChunk, bool, error) {
|
||
blob, ok := s.blobCache.get(req.LocationKey)
|
||
if !ok {
|
||
var (
|
||
found bool
|
||
err error
|
||
)
|
||
blob, found, err = s.media.GetFileBlob(ctx, req.LocationKey)
|
||
if err != nil {
|
||
return domain.FileChunk{}, false, err
|
||
}
|
||
if !found {
|
||
return domain.FileChunk{}, false, nil
|
||
}
|
||
s.blobCache.put(req.LocationKey, blob)
|
||
}
|
||
if blob.Size > 0 && blob.Size <= blobBytesCacheMaxEntryBytes {
|
||
if data, ok := s.byteCache.get(blob.ObjectKey); ok {
|
||
return domain.FileChunk{
|
||
Bytes: sliceBlobBytes(data, req.Offset, int64(req.Limit)),
|
||
MimeType: blob.MimeType,
|
||
Total: int64(len(data)),
|
||
}, true, nil
|
||
}
|
||
data, total, err := s.blobs.GetRange(ctx, blob.ObjectKey, 0, blobBytesCacheMaxEntryBytes+1)
|
||
if err != nil {
|
||
return domain.FileChunk{}, false, fmt.Errorf("read blob %q: %w", blob.LocationKey, err)
|
||
}
|
||
if total <= blobBytesCacheMaxEntryBytes && int64(len(data)) == total {
|
||
s.byteCache.put(blob.ObjectKey, data)
|
||
return domain.FileChunk{
|
||
Bytes: sliceBlobBytes(data, req.Offset, int64(req.Limit)),
|
||
MimeType: blob.MimeType,
|
||
Total: total,
|
||
}, true, nil
|
||
}
|
||
}
|
||
data, total, err := s.blobs.GetRange(ctx, blob.ObjectKey, req.Offset, int64(req.Limit))
|
||
if err != nil {
|
||
return domain.FileChunk{}, false, fmt.Errorf("read blob %q: %w", blob.LocationKey, err)
|
||
}
|
||
return domain.FileChunk{
|
||
Bytes: data,
|
||
MimeType: blob.MimeType,
|
||
Total: total,
|
||
}, true, nil
|
||
}
|
||
|
||
func sliceBlobBytes(data []byte, offset, limit int64) []byte {
|
||
total := int64(len(data))
|
||
if offset < 0 {
|
||
offset = 0
|
||
}
|
||
if offset >= total {
|
||
return []byte{}
|
||
}
|
||
end := total
|
||
if limit > 0 && offset+limit < end {
|
||
end = offset + limit
|
||
}
|
||
return append([]byte(nil), data[offset:end]...)
|
||
}
|
||
|
||
// ---- 资源读取(reaction / sticker / document)----
|
||
|
||
// ListAvailableReactions 返回可用 reaction 目录(带真实文档 id)。
|
||
func (s *Service) ListAvailableReactions(ctx context.Context) ([]domain.AvailableReaction, error) {
|
||
return s.media.ListAvailableReactions(ctx)
|
||
}
|
||
|
||
// GetDocuments 按 id 批量加载文档(自定义 emoji / 贴纸)。
|
||
func (s *Service) GetDocuments(ctx context.Context, ids []int64) ([]domain.Document, error) {
|
||
return s.media.GetDocuments(ctx, ids)
|
||
}
|
||
|
||
// ListStickerSets 列出某类贴纸集(用于 getAllStickers 等)。
|
||
func (s *Service) ListStickerSets(ctx context.Context, kind domain.StickerSetKind) ([]domain.StickerSet, error) {
|
||
return s.media.ListStickerSets(ctx, kind)
|
||
}
|
||
|
||
// ResolveStickerSet 按 ref 解析贴纸集,并按 DocumentIDs 顺序加载其文档。
|
||
func (s *Service) ResolveStickerSet(ctx context.Context, ref domain.StickerSetRef) (domain.StickerSet, []domain.Document, bool, error) {
|
||
if set, docs, ok := s.stickerSetCache.get(ref); ok {
|
||
return set, docs, true, nil
|
||
}
|
||
var (
|
||
set domain.StickerSet
|
||
found bool
|
||
err error
|
||
)
|
||
switch ref.Kind {
|
||
case domain.StickerSetRefByID:
|
||
set, found, err = s.media.GetStickerSetByID(ctx, ref.ID)
|
||
case domain.StickerSetRefByShortName:
|
||
set, found, err = s.media.GetStickerSetByShortName(ctx, ref.ShortName)
|
||
case domain.StickerSetRefBySystem:
|
||
set, found, err = s.media.GetStickerSetBySystemKey(ctx, ref.SystemKey)
|
||
default:
|
||
return domain.StickerSet{}, nil, false, nil
|
||
}
|
||
if err != nil || !found {
|
||
return domain.StickerSet{}, nil, found, err
|
||
}
|
||
docs, err := s.media.GetDocuments(ctx, set.DocumentIDs)
|
||
if err != nil {
|
||
return domain.StickerSet{}, nil, false, err
|
||
}
|
||
ordered := orderDocuments(docs, set.DocumentIDs)
|
||
s.stickerSetCache.put(set, ordered)
|
||
return set, ordered, true, nil
|
||
}
|
||
|
||
// orderDocuments 把无序的文档按 ids 顺序重排(GetDocuments 用 ANY 查询不保证顺序)。
|
||
func orderDocuments(docs []domain.Document, ids []int64) []domain.Document {
|
||
byID := make(map[int64]domain.Document, len(docs))
|
||
for _, d := range docs {
|
||
byID[d.ID] = d
|
||
}
|
||
out := make([]domain.Document, 0, len(ids))
|
||
for _, id := range ids {
|
||
if d, ok := byID[id]; ok {
|
||
out = append(out, d)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// assembleUpload 把已上传分片按 part 顺序拼成完整字节,并清理分片。
|
||
// expectedParts>0 时校验分片连续且齐全。
|
||
func (s *Service) assembleUpload(ctx context.Context, ownerUserID, fileID int64, expectedParts int) ([]byte, error) {
|
||
parts, err := s.media.LoadFileParts(ctx, ownerUserID, fileID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if len(parts) == 0 {
|
||
return nil, domain.ErrFilePartsInvalid
|
||
}
|
||
if expectedParts > 0 && len(parts) != expectedParts {
|
||
return nil, domain.ErrFilePartsInvalid
|
||
}
|
||
total := 0
|
||
for i, p := range parts {
|
||
if p.Part != i {
|
||
return nil, domain.ErrFilePartsInvalid // 缺片或乱序
|
||
}
|
||
total += len(p.Bytes)
|
||
}
|
||
buf := make([]byte, 0, total)
|
||
for _, p := range parts {
|
||
buf = append(buf, p.Bytes...)
|
||
}
|
||
if err := s.media.DeleteFileParts(ctx, ownerUserID, fileID); err != nil {
|
||
return nil, err
|
||
}
|
||
return buf, nil
|
||
}
|
||
|
||
func validatePart(part, size int) error {
|
||
if part < 0 || part >= MaxUploadParts {
|
||
return domain.ErrFilePartInvalid
|
||
}
|
||
if size == 0 {
|
||
return domain.ErrFilePartInvalid
|
||
}
|
||
if size > MaxUploadPartBytes {
|
||
return domain.ErrFilePartTooBig
|
||
}
|
||
return nil
|
||
}
|