package service import ( "context" "errors" "log" "net/http" "fashionapi/internal/dto" "fashionapi/internal/model" "fashionapi/internal/pkg/hashid" "fashionapi/internal/pkg/imgurl" "fashionapi/internal/pkg/storage" "fashionapi/internal/repository" ) // StreetSnapService 街拍业务接口。 type StreetSnapService interface { List(ctx context.Context, q dto.StreetSnapQuery) ([]dto.PublicStreetSnap, int64, error) Detail(ctx context.Context, id string) (*dto.PublicStreetSnapDetail, error) // Popular 返回热度前 N 个街拍(按图片数降序),供 SSG 首页热门街拍展示。 Popular(ctx context.Context, limit int) ([]dto.PublicStreetSnap, error) // GetForEdit 取原始街拍记录与图片,供后台编辑表单预填。 GetForEdit(ctx context.Context, id uint32) (*model.StreetSnap, []model.StreetSnapImage, error) // UpdateSnap 编辑后更新字段(字段白名单在仓储层强制)。 UpdateSnap(ctx context.Context, id uint32, fields map[string]any) error // DeleteImage 软删单张街拍图片。 DeleteImage(ctx context.Context, imageID uint32) error // SetCover 更新街拍封面。 SetCover(ctx context.Context, snapID uint32, image string) error // SetDeleted 上下架:deleted=1 下架,0 上架。 SetDeleted(ctx context.Context, id uint32, deleted uint8) error // PurgeGallery 彻底下架街拍:级联软删图片 + 下架 + 异步入队清理S4孤儿图。 PurgeGallery(ctx context.Context, id uint32) error } type streetSnapService struct { repo repository.StreetSnapRepository media repository.MediaRepository img *imgurl.Composer deleter storage.Deleter cleaner MediaCleanupEnqueuer // 删除图集时异步清理S4孤儿图的入队入口 } // NewStreetSnapService 创建街拍服务。 // img 负责把库里存储的 S4 key 拼装为可访问 URL(统一走展示样式,VIP 与免费同质量); // media + deleter 用于删除图集时清理孤儿文件(引用计数归零才真删); // cleaner 把「删存储」动作异步入队(由入库 worker 处理),避免阻塞删除请求。 func NewStreetSnapService(repo repository.StreetSnapRepository, media repository.MediaRepository, img *imgurl.Composer, deleter storage.Deleter, cleaner MediaCleanupEnqueuer) StreetSnapService { return &streetSnapService{repo: repo, media: media, img: img, deleter: deleter, cleaner: cleaner} } // ErrStreetSnapNotFound 街拍不存在。 var ErrStreetSnapNotFound = NewError(http.StatusNotFound, "street snap not found") // List 查询街拍列表,并按需附带每条的前 N 张图。 func (s *streetSnapService) List(ctx context.Context, q dto.StreetSnapQuery) ([]dto.PublicStreetSnap, int64, error) { q.Normalize() rows, total, err := s.repo.List(ctx, q) if err != nil { return nil, 0, internalErr(err.Error()) } list := make([]dto.PublicStreetSnap, 0, len(rows)) ids := make([]uint32, 0, len(rows)) for _, r := range rows { ids = append(ids, r.ID) list = append(list, dto.PublicStreetSnap{ UID: hashid.EncodeWithType(r.ID, hashid.TypeSnap), Title: r.Title, Cover: s.img.ComposeThumb(r.Cover), // 列表封面走缩略图样式(小尺寸省流量) ImageCount: r.ImageCount, }) } if q.Images > 0 && len(list) > 0 { if err := s.attachImages(ctx, list, ids, q.Images); err != nil { return nil, 0, internalErr(err.Error()) } } return list, total, nil } // attachImages 用单次 IN 查询为列表批量补齐图片,避免前端逐条请求详情。 func (s *streetSnapService) attachImages(ctx context.Context, list []dto.PublicStreetSnap, ids []uint32, limit int) error { grouped, err := s.repo.ImagesBySnapIDs(ctx, ids) if err != nil { return err } for i := range list { raw, ok := grouped[ids[i]] if !ok { continue } // 列表缩略图走缩略图样式(与卡片封面同样式,故去重比较仍成立); // 再剔除与卡片主图(cover)同 URL 的首图,避免重复展示。 imgs := s.toThumbSnapImages(raw) imgs = excludeCoverSnap(imgs, list[i].Cover) if len(imgs) > limit { imgs = imgs[:limit] } list[i].Images = imgs } return nil } // excludeCoverSnap 同 excludeCover,作用于已拼装 URL 的街拍图片列表。 func excludeCoverSnap(imgs []dto.PublicArticleImage, cover string) []dto.PublicArticleImage { if cover == "" { return imgs } kept := imgs[:0] for _, im := range imgs { if im.Image != cover { kept = append(kept, im) } } return kept } // Detail 查询街拍详情,含完整图片集。图片统一走展示样式(VIP 与免费同质量)。 func (s *streetSnapService) Detail(ctx context.Context, id string) (*dto.PublicStreetSnapDetail, error) { snap, err := s.repo.FindByID(ctx, id) if err != nil { if errors.Is(err, repository.ErrNotFound) { return nil, ErrStreetSnapNotFound } return nil, internalErr(err.Error()) } imgs, err := s.repo.ListImages(ctx, id) if err != nil { return nil, internalErr(err.Error()) } return &dto.PublicStreetSnapDetail{ UID: hashid.EncodeWithType(snap.ID, hashid.TypeSnap), Title: snap.Title, Cover: s.img.Compose(snap.Cover), Images: s.buildSnapImages(imgs), }, nil } // buildSnapImages 把街拍图片组装成「主图 + 其副图」的嵌套结构(与走秀详情同形): // Images 只含主图(is_detail=0),每张主图的副图挂在其 Detail 子数组下。 // 父引用指向「副图」时沿链向上归并到该副图所在组的主图;父行缺失 / 成环则把该图按主图渲染(不丢图)。 // LookIndex 是合成的 1-based 主图序号(街拍不落库组序号)。 func (s *streetSnapService) buildSnapImages(imgs []model.StreetSnapImage) []dto.PublicArticleImage { if len(imgs) == 0 { return nil } byID := make(map[uint32]*model.StreetSnapImage, len(imgs)) for i := range imgs { byID[imgs[i].ID] = &imgs[i] } // resolveRoot 返回 img 所属主图的行 id;自身是主图则返回自身;父行缺失 / 成环则按主图渲染(返回自身)。 resolveRoot := func(start uint32) uint32 { cur := start seen := map[uint32]bool{} for { n, ok := byID[cur] if !ok { return start // 父行不存在 → 按主图渲染(返回自身) } if n.IsDetail == 0 { return n.ID } if seen[cur] { return start // 环 → 容错,自成主图 } seen[cur] = true cur = n.ParentImageID } } // 第一遍:按 sort_order 建立主图(根),并记录「主图行 id → 输出下标」,副图排在主图之前也能正确归位。 out := make([]dto.PublicArticleImage, 0, len(imgs)) index := make(map[uint32]int, len(imgs)) for _, im := range imgs { if resolveRoot(im.ID) != im.ID { continue } pub := s.toPublicSnapImage(im) pub.LookIndex = uint32(len(out) + 1) // 合成的 1-based 主图序号 index[im.ID] = len(out) out = append(out, pub) } // 第二遍:把副图挂到所属主图的 Detail 下(LookIndex 取所属主图的值)。 for _, im := range imgs { root := resolveRoot(im.ID) if root == im.ID { continue } gi, ok := index[root] if !ok { continue // 理论不发生(孤儿已按主图渲染) } pub := s.toPublicSnapImage(im) pub.IsDetail = 1 pub.LookIndex = out[gi].LookIndex out[gi].Detail = append(out[gi].Detail, pub) } return out } // IDs 返回全部街拍的编码 uid(供 SSG 枚举详情页路径使用,对外暴露的即编码串)。 // Popular 返回热度前 N 个街拍(按图片数降序),供 SSG 首页热门街拍展示。 func (s *streetSnapService) Popular(ctx context.Context, limit int) ([]dto.PublicStreetSnap, error) { rows, err := s.repo.Popular(ctx, limit) if err != nil { return nil, internalErr(err.Error()) } list := make([]dto.PublicStreetSnap, 0, len(rows)) for _, r := range rows { list = append(list, dto.PublicStreetSnap{ UID: hashid.EncodeWithType(r.ID, hashid.TypeSnap), Title: r.Title, Cover: s.img.Compose(r.Cover), // 首页热门街拍卡片为大图展示,用高清样式(high) ImageCount: r.ImageCount, }) } return list, nil } // toPublicSnapImages 把图片实体映射为对外结构(url + 名称 + 图片自身的编码 id)。 // ID 用 EncodeWithType(TypeSnapImage) 编码(类型进密码、无前缀),与街拍图集 uid 区分开,供单图收藏定位。 // Image 走展示样式(high);额外给出 Thumb(缩略图样式),供大图灯箱左栏缩略图条等 // 小尺寸场景使用(列表用的 toThumbSnapImages 不填 Thumb,其 Image 本身就是缩略图)。 func (s *streetSnapService) toPublicSnapImages(imgs []model.StreetSnapImage) []dto.PublicArticleImage { if len(imgs) == 0 { return nil } out := make([]dto.PublicArticleImage, 0, len(imgs)) for _, im := range imgs { out = append(out, s.toPublicSnapImage(im)) } return out } // toPublicSnapImage 把单张图片实体映射为对外结构(url + 名称 + 图片自身的编码 id)。 func (s *streetSnapService) toPublicSnapImage(im model.StreetSnapImage) dto.PublicArticleImage { return dto.PublicArticleImage{ ID: hashid.EncodeWithType(im.ID, hashid.TypeSnapImage), Image: s.img.Compose(im.Image), Thumb: s.img.ComposeThumb(im.Image), Name: im.Name, } } // toThumbSnapImages 与 toPublicSnapImages 同构,但图片地址走「列表缩略图」样式(ComposeThumb), // 仅供列表卡片 / 图条等小尺寸场景使用;详情页与大图灯箱仍用 toPublicSnapImages(展示样式)。 func (s *streetSnapService) toThumbSnapImages(imgs []model.StreetSnapImage) []dto.PublicArticleImage { if len(imgs) == 0 { return nil } out := make([]dto.PublicArticleImage, 0, len(imgs)) for _, im := range imgs { out = append(out, dto.PublicArticleImage{ ID: hashid.EncodeWithType(im.ID, hashid.TypeSnapImage), Image: s.img.ComposeThumb(im.Image), Name: im.Name, }) } return out } // ── 后台编辑支持 ────────────────────────────────────────────────────────────── func (s *streetSnapService) GetForEdit(ctx context.Context, id uint32) (*model.StreetSnap, []model.StreetSnapImage, error) { return s.repo.GetForEdit(ctx, id) } func (s *streetSnapService) UpdateSnap(ctx context.Context, id uint32, fields map[string]any) error { return s.repo.UpdateSnap(ctx, id, fields) } func (s *streetSnapService) DeleteImage(ctx context.Context, imageID uint32) error { // 先取 key(删除前清理S4孤儿用),再软删 DB 行,最后按引用计数决定是否真删S4文件。 key, err := s.repo.GetSnapImageKey(ctx, imageID) if err != nil { return err } if err := s.repo.DeleteSnapImage(ctx, imageID); err != nil { return err } if key != "" { purgeOrphanImages(ctx, s.media, s.deleter, []string{key}) } return nil } func (s *streetSnapService) SetCover(ctx context.Context, snapID uint32, image string) error { return s.repo.SetSnapCover(ctx, snapID, image) } func (s *streetSnapService) SetDeleted(ctx context.Context, id uint32, deleted uint8) error { if deleted == 1 { // 下架街拍:软删图片 + 置下架 + 异步入队清理S4孤儿图(见 PurgeGallery)。 return s.PurgeGallery(ctx, id) } // 上架(deleted=0)仅恢复图集行,不动图片/不清理(图片若已删不可恢复,属预期)。 return s.repo.SetSnapDeleted(ctx, id, 0) } // PurgeGallery 下架街拍并异步清理S4孤儿图:逻辑同 articleService.PurgeGallery, // 仅仓储调用换成街拍的 SoftDeleteSnapImages / SetSnapDeleted。 func (s *streetSnapService) PurgeGallery(ctx context.Context, id uint32) error { keys, err := s.repo.SoftDeleteSnapImages(ctx, id) if err != nil { return err } if err := s.repo.SetSnapDeleted(ctx, id, 1); err != nil { return err } if len(keys) > 0 && s.cleaner != nil { if err := s.cleaner.EnqueueMediaCleanup(ctx, keys); err != nil { log.Printf("[warn] 下架街拍 %d 入队清理S4失败(文件将暂留): %v", id, err) } } return nil }