package files import ( "bytes" "context" "crypto/rand" "encoding/binary" "fmt" "image" _ "image/jpeg" // 注册 jpeg DecodeConfig,用于读取上传头像/图片尺寸 _ "image/png" // 注册 png DecodeConfig "time" "telesrv/internal/domain" ) // 头像与图片消息共用的尺寸 type:'a' 小图(≤160),'c' 大图,'x' 通用下载尺寸。 // 同一份上传字节在多个 location_key 下建 blob(不做实际缩放,dev 主路径足够)。 // UploadProfilePhoto 把已上传文件组装成头像 Photo,落 blob/photos/profile_photos,并设为当前头像。 func (s *Service) UploadProfilePhoto(ctx context.Context, ownerType domain.PeerType, ownerID int64, file domain.UploadedFileRef, date int) (domain.Photo, error) { return s.UploadProfilePhotoKind(ctx, ownerType, ownerID, domain.ProfilePhotoKindProfile, file, date) } // UploadProfilePhotoKind stores a profile or fallback photo and makes it current for that kind. func (s *Service) UploadProfilePhotoKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, file domain.UploadedFileRef, date int) (domain.Photo, error) { data, err := s.assembleUpload(ctx, file.OwnerUserID, file.FileID, file.Parts) if err != nil { return domain.Photo{}, err } if len(data) == 0 { return domain.Photo{}, domain.ErrPhotoInvalid } if date == 0 { date = int(time.Now().Unix()) } photo, err := s.createPhoto(ctx, data, photoSizeSpecsForAvatar(data)) if err != nil { return domain.Photo{}, err } if err := s.media.AddProfilePhotoKind(ctx, ownerType, ownerID, kind, photo.ID, date); err != nil { return domain.Photo{}, err } return photo, nil } // CreatePhotoFromUpload 把已上传文件组装成 Photo(不绑定 profile_photos),用于频道头像 / 图片消息。 func (s *Service) CreatePhotoFromUpload(ctx context.Context, file domain.UploadedFileRef) (domain.Photo, error) { data, err := s.assembleUpload(ctx, file.OwnerUserID, file.FileID, file.Parts) if err != nil { return domain.Photo{}, err } if len(data) == 0 { return domain.Photo{}, domain.ErrPhotoInvalid } return s.createPhoto(ctx, data, photoSizeSpecsForMessage(data)) } // GetPhoto 按 id 返回已存储照片。 func (s *Service) GetPhoto(ctx context.Context, id int64) (domain.Photo, bool, error) { return s.media.GetPhoto(ctx, id) } // GetDocument 按 id 返回已存储文档(贴纸 / 文件)。 func (s *Service) GetDocument(ctx context.Context, id int64) (domain.Document, bool, error) { return s.media.GetDocument(ctx, id) } // CreateAvatarFromUpload 把已上传文件组装成头像 Photo('a'/'c' 尺寸,匹配 InputPeerPhotoFileLocation // big/small 与 channelFull 合成尺寸的下载路径),不绑定 profile_photos。用于频道 editPhoto。 func (s *Service) CreateAvatarFromUpload(ctx context.Context, file domain.UploadedFileRef) (domain.Photo, error) { data, err := s.assembleUpload(ctx, file.OwnerUserID, file.FileID, file.Parts) if err != nil { return domain.Photo{}, err } if len(data) == 0 { return domain.Photo{}, domain.ErrPhotoInvalid } return s.createPhoto(ctx, data, photoSizeSpecsForAvatar(data)) } // CreateDocumentFromUpload 把已上传文件组装成 Document(文件/视频/音频/gif/贴纸消息),落 blob + documents。 func (s *Service) CreateDocumentFromUpload(ctx context.Context, file domain.UploadedFileRef, spec domain.DocumentSpec) (domain.Document, error) { data, err := s.assembleUpload(ctx, file.OwnerUserID, file.FileID, file.Parts) if err != nil { return domain.Document{}, err } if len(data) == 0 { return domain.Document{}, domain.ErrDocumentInvalid } objectKey, err := s.blobs.Put(ctx, data) if err != nil { return domain.Document{}, err } docID := randomID() if err := s.media.PutFileBlob(ctx, domain.FileBlob{ LocationKey: fmt.Sprintf("doc:%d", docID), Backend: domain.MediaBackend(s.blobs.Name()), ObjectKey: objectKey, Size: int64(len(data)), MimeType: spec.MimeType, }); err != nil { return domain.Document{}, err } doc := domain.Document{ ID: docID, AccessHash: randomID(), FileReference: randomFileReference(), Date: int(time.Now().Unix()), MimeType: spec.MimeType, Size: int64(len(data)), DCID: s.dc, Attributes: spec.Attributes, } if spec.Thumb != nil { thumbData, err := s.assembleUpload(ctx, spec.Thumb.OwnerUserID, spec.Thumb.FileID, spec.Thumb.Parts) if err == nil && len(thumbData) > 0 { thumbKey, err := s.blobs.Put(ctx, thumbData) if err == nil { w, h := imageDimensions(thumbData, 0, 0) if err := s.media.PutFileBlob(ctx, domain.FileBlob{ LocationKey: fmt.Sprintf("doc:%d:m", docID), Backend: domain.MediaBackend(s.blobs.Name()), ObjectKey: thumbKey, Size: int64(len(thumbData)), MimeType: "image/jpeg", }); err == nil { doc.Thumbs = []domain.PhotoSize{{Kind: domain.PhotoSizeKindDefault, Type: "m", W: w, H: h, Size: len(thumbData)}} } } } } if err := s.media.PutDocument(ctx, doc); err != nil { return domain.Document{}, err } return doc, nil } // SetCurrentProfilePhoto 把已存在的 photo 设为当前头像(updateProfilePhoto 选历史头像)。 func (s *Service) SetCurrentProfilePhoto(ctx context.Context, ownerType domain.PeerType, ownerID, photoID int64, date int) (domain.Photo, bool, error) { return s.SetCurrentProfilePhotoKind(ctx, ownerType, ownerID, domain.ProfilePhotoKindProfile, photoID, date) } // SetCurrentProfilePhotoKind sets an existing photo as current for profile or fallback history. func (s *Service) SetCurrentProfilePhotoKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, photoID int64, date int) (domain.Photo, bool, error) { photo, ok, err := s.media.GetPhoto(ctx, photoID) if err != nil || !ok { return domain.Photo{}, ok, err } if date == 0 { date = int(time.Now().Unix()) } if err := s.media.AddProfilePhotoKind(ctx, ownerType, ownerID, kind, photoID, date); err != nil { return domain.Photo{}, false, err } return photo, true, nil } // CurrentProfilePhoto 返回某 owner 的当前头像 Photo。 func (s *Service) CurrentProfilePhoto(ctx context.Context, ownerType domain.PeerType, ownerID int64) (domain.Photo, bool, error) { return s.CurrentProfilePhotoKind(ctx, ownerType, ownerID, domain.ProfilePhotoKindProfile) } // CurrentProfilePhotoKind returns the current profile/fallback photo. func (s *Service) CurrentProfilePhotoKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind) (domain.Photo, bool, error) { id, ok, err := s.media.CurrentProfilePhotoKind(ctx, ownerType, ownerID, kind) if err != nil || !ok { return domain.Photo{}, ok, err } return s.media.GetPhoto(ctx, id) } // GetProfilePhotos 返回 owner 的头像历史(最新在前)。 func (s *Service) GetProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerID int64, offset, limit int, maxID int64) ([]domain.Photo, int, error) { return s.GetProfilePhotosKind(ctx, ownerType, ownerID, domain.ProfilePhotoKindProfile, offset, limit, maxID) } // GetProfilePhotosKind returns profile/fallback photo history. func (s *Service) GetProfilePhotosKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, offset, limit int, maxID int64) ([]domain.Photo, int, error) { ids, total, err := s.media.ListProfilePhotosKind(ctx, ownerType, ownerID, kind, offset, limit, maxID) if err != nil { return nil, 0, err } photos := make([]domain.Photo, 0, len(ids)) for _, id := range ids { if p, ok, err := s.media.GetPhoto(ctx, id); err != nil { return nil, 0, err } else if ok { photos = append(photos, p) } } return photos, total, nil } // DeleteProfilePhotos 停用指定头像,返回成功停用数量。 func (s *Service) DeleteProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerID int64, photoIDs []int64) (int, error) { return s.DeleteProfilePhotosKind(ctx, ownerType, ownerID, domain.ProfilePhotoKindProfile, photoIDs) } // DeleteProfilePhotosKind disables profile/fallback photos of the selected kind. func (s *Service) DeleteProfilePhotosKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, photoIDs []int64) (int, error) { deleted, err := s.media.DeleteProfilePhotosKind(ctx, ownerType, ownerID, kind, photoIDs) if err != nil { return 0, err } return len(deleted), nil } // createPhoto 把字节落 blob(每个尺寸一个 location_key,指向同一内容)并写 photos 表。 func (s *Service) createPhoto(ctx context.Context, data []byte, specs []photoSizeSpec) (domain.Photo, error) { objectKey, err := s.blobs.Put(ctx, data) if err != nil { return domain.Photo{}, err } photoID := randomID() sizes := make([]domain.PhotoSize, 0, len(specs)) for _, spec := range specs { if err := s.media.PutFileBlob(ctx, domain.FileBlob{ LocationKey: fmt.Sprintf("photo:%d:%s", photoID, spec.Type), Backend: domain.MediaBackend(s.blobs.Name()), ObjectKey: objectKey, Size: int64(len(data)), MimeType: "image/jpeg", }); err != nil { return domain.Photo{}, err } sizes = append(sizes, domain.PhotoSize{Kind: domain.PhotoSizeKindDefault, Type: spec.Type, W: spec.W, H: spec.H, Size: len(data)}) } photo := domain.Photo{ ID: photoID, AccessHash: randomID(), FileReference: randomFileReference(), Date: int(time.Now().Unix()), DCID: s.dc, Sizes: sizes, } if err := s.media.PutPhoto(ctx, photo); err != nil { return domain.Photo{}, err } return photo, nil } type photoSizeSpec struct { Type string W int H int } func photoSizeSpecsForAvatar(data []byte) []photoSizeSpec { w, h := imageDimensions(data, 640, 640) small := 160 if w < small { small = w } return []photoSizeSpec{ {Type: "a", W: small, H: small}, {Type: "c", W: w, H: h}, } } // photoSizeSpecsForMessage 给图片消息生成下载尺寸('m' 缩略 + 'x'/'y' 大图)。 func photoSizeSpecsForMessage(data []byte) []photoSizeSpec { w, h := imageDimensions(data, 1280, 1280) thumbW, thumbH := scaleDown(w, h, 320) return []photoSizeSpec{ {Type: "m", W: thumbW, H: thumbH}, {Type: "x", W: w, H: h}, } } func imageDimensions(data []byte, defW, defH int) (int, int) { cfg, _, err := image.DecodeConfig(bytes.NewReader(data)) if err != nil || cfg.Width <= 0 || cfg.Height <= 0 { return defW, defH } return cfg.Width, cfg.Height } func scaleDown(w, h, max int) (int, int) { if w <= max && h <= max { return w, h } if w >= h { return max, max * h / w } return max * w / h, max } func randomID() int64 { var b [8]byte _, _ = rand.Read(b[:]) v := int64(binary.BigEndian.Uint64(b[:]) >> 1) if v == 0 { v = 1 } return v } func randomFileReference() []byte { b := make([]byte, 16) _, _ = rand.Read(b) return b }