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" ) // MediaCleanupEnqueuer 是「清理七牛孤儿图」任务的入队入口;由 IngestService 实现, // 图集服务在删除图集时调用它,把同步的七牛删除改为异步队列,避免阻塞删除请求。 type MediaCleanupEnqueuer interface { EnqueueMediaCleanup(ctx context.Context, keys []string) error } // ArticleService 文章业务接口。 type ArticleService interface { List(ctx context.Context, q dto.ArticleQuery) ([]dto.PublicArticle, int64, error) Detail(ctx context.Context, id string, locale string) (*dto.PublicArticleDetail, error) // AdminList 后台管理列表:返回含季节 / 年份 / 系列等管理字段的走秀项。 AdminList(ctx context.Context, q dto.ArticleQuery) ([]dto.AdminRunway, int64, error) // GetForEdit 取原始走秀记录与图片,供后台编辑表单预填。 GetForEdit(ctx context.Context, id uint32) (*model.BrandRunway, []model.BrandRunwayImage, error) // UpdateRunway 审核/编辑后更新字段(字段白名单在仓储层强制)。 UpdateRunway(ctx context.Context, id uint32, fields map[string]any) error // DeleteImage 软删单张走秀图片。 DeleteImage(ctx context.Context, imageID uint32) error // SetCover 更新走秀封面(URL 由调用方经校验后传入)。 SetCover(ctx context.Context, runwayID uint32, image string) error // SetDeleted 上下架:deleted=1 下架(对外不可见),0 上架。 SetDeleted(ctx context.Context, id uint32, deleted uint8) error // PurgeGallery 彻底下架图集:级联软删图片 + 下架 + 异步入队清理七牛孤儿图。 PurgeGallery(ctx context.Context, id uint32) error } type articleService struct { articles repository.ArticleRepository media repository.MediaRepository img *imgurl.Composer deleter storage.Deleter cleaner MediaCleanupEnqueuer // 删除图集时异步清理七牛孤儿图的入队入口 } // NewArticleService 创建文章服务。 // img 负责把库里存储的 S4 key 拼装为可访问 URL(统一走展示样式,VIP 与免费同质量); // media + deleter 用于删除图集时清理孤儿文件(引用计数归零才真删); // cleaner 把「删存储」动作异步入队(由入库 worker 处理),避免阻塞删除请求。 func NewArticleService(articles repository.ArticleRepository, media repository.MediaRepository, img *imgurl.Composer, deleter storage.Deleter, cleaner MediaCleanupEnqueuer) ArticleService { return &articleService{articles: articles, media: media, img: img, deleter: deleter, cleaner: cleaner} } // List 查询文章列表,并按需附带每篇的前 N 张图。 func (s *articleService) List(ctx context.Context, q dto.ArticleQuery) ([]dto.PublicArticle, int64, error) { q.Normalize() rows, total, err := s.articles.List(ctx, q) if err != nil { return nil, 0, internalErr(err.Error()) } list := make([]dto.PublicArticle, 0, len(rows)) ids := make([]uint32, 0, len(rows)) for _, r := range rows { ids = append(ids, r.ID) list = append(list, dto.PublicArticle{ UID: hashid.EncodeWithType(r.ID, hashid.TypeRunway), BrandUID: hashid.Encode(r.BrandID), Title: pickLocale(r.TitleEn, r.TitleCn, q.Locale), Cover: s.img.ComposeThumb(r.Cover), // 列表封面走缩略图样式(小尺寸省流量) BrandName: pickLocale(r.BrandNameEn, r.BrandNameCn, "en"), // 品牌名强制优先英文,保持英文观感 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 } // AdminList 后台管理列表:复用同一套筛选与排序,但返回含季节 / 年份 / 系列 / // 管理字段(对外 PublicArticle 刻意不暴露这些)。不附带缩略图,列表更轻。 func (s *articleService) AdminList(ctx context.Context, q dto.ArticleQuery) ([]dto.AdminRunway, int64, error) { q.Normalize() rows, total, err := s.articles.ListAdmin(ctx, q) if err != nil { return nil, 0, internalErr(err.Error()) } list := make([]dto.AdminRunway, 0, len(rows)) for _, r := range rows { list = append(list, dto.AdminRunway{ UID: hashid.EncodeWithType(r.ID, hashid.TypeRunway), BrandUID: hashid.Encode(r.BrandID), BrandName: pickLocale(r.BrandNameEn, r.BrandNameCn, "en"), Title: pickLocale(r.TitleEn, r.TitleCn, q.Locale), Year: r.Year, Season: r.Season, CollectionType: r.CollectionType, SeasonCode: r.SeasonCode, ImageCount: r.ImageCount, }) } return list, total, nil } // attachImages 用单次 IN 查询为列表批量补齐图片,避免前端逐篇请求详情。 // ids 是与之对应的数字主键(对外已编码为 UID,此处用数字主键查库)。 func (s *articleService) attachImages(ctx context.Context, list []dto.PublicArticle, ids []uint32, limit int) error { grouped, err := s.articles.ImagesByRunwayIDs(ctx, ids) if err != nil { return err } for i := range list { raw, ok := grouped[ids[i]] if !ok { continue } // 列表缩略图走缩略图样式(与卡片封面同样式,故去重比较仍成立); // 再剔除与卡片主图(cover)同 URL 的首图,避免重复展示。 imgs := s.toThumbImages(raw) imgs = excludeCover(imgs, list[i].Cover) if len(imgs) > limit { imgs = imgs[:limit] } list[i].Images = imgs } return nil } // excludeCover 从已拼装 URL 的列表里去掉与主图(cover)同 URL 的元素,供列表缩略图条带使用。 // 详情接口(完整图集)不走此函数,封面作为其中一张 look 需保留。 func excludeCover(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 查询文章详情,含完整图片集。locale 决定标题/描述/品牌名的展示语言; // 图片统一走展示样式(VIP 与免费同质量)。 func (s *articleService) Detail(ctx context.Context, id string, locale string) (*dto.PublicArticleDetail, error) { row, err := s.articles.FindByID(ctx, id) if err != nil { if errors.Is(err, repository.ErrNotFound) { return nil, ErrArticleNotFound } return nil, NewError(http.StatusInternalServerError, err.Error()) } imgs, err := s.articles.ListImages(ctx, id) if err != nil { return nil, internalErr(err.Error()) } // 主图(is_detail=0)进 Images,默认展示;细节图(is_detail=1)按 look_index // 直接挂到所属主图的 Detail 子数组下 —— 「look → 细节」的层级与前端 UI 一致, // 前端读 img.detail 即可,无需再单独维护一份「按 look_index 分组」的映射表。 mains := make([]model.BrandRunwayImage, 0, len(imgs)) byLook := make(map[uint32][]model.BrandRunwayImage) for _, im := range imgs { if im.IsDetail == 0 { mains = append(mains, im) } else { byLook[im.LookIndex] = append(byLook[im.LookIndex], im) } } pubMains := s.toPublicImages(mains) for i := range pubMains { // 挂载后即从 byLook 摘除:主图 look_index 若有重复,只归属第一张,避免同一批 // 细节图被复制进多个主图(正常数据不会重复,此处仅作防御)。 if kids, ok := byLook[pubMains[i].LookIndex]; ok && len(kids) > 0 { pubMains[i].Detail = s.toPublicImages(kids) delete(byLook, pubMains[i].LookIndex) } } return &dto.PublicArticleDetail{ UID: hashid.EncodeWithType(row.ID, hashid.TypeRunway), BrandUID: hashid.Encode(row.BrandID), Title: pickLocale(row.TitleEn, row.TitleCn, locale), Description: pickLocale(row.DescriptionEn, row.DescriptionCn, locale), Cover: s.img.Compose(row.Cover), // 品牌名强制优先英文("en"),不随 locale 切换,保持英文观感。 BrandName: pickLocale(row.BrandNameEn, row.BrandNameCn, "en"), Images: pubMains, }, nil } // ── 后台编辑支持 ────────────────────────────────────────────────────────────── func (s *articleService) GetForEdit(ctx context.Context, id uint32) (*model.BrandRunway, []model.BrandRunwayImage, error) { return s.articles.GetForEdit(ctx, id) } func (s *articleService) UpdateRunway(ctx context.Context, id uint32, fields map[string]any) error { return s.articles.UpdateRunway(ctx, id, fields) } func (s *articleService) DeleteImage(ctx context.Context, imageID uint32) error { // 先取 key(删除前清理七牛孤儿用),再软删 DB 行,最后按引用计数决定是否真删七牛文件。 key, err := s.articles.GetRunwayImageKey(ctx, imageID) if err != nil { return err } if err := s.articles.DeleteRunwayImage(ctx, imageID); err != nil { return err } if key != "" { purgeOrphanImages(ctx, s.media, s.deleter, []string{key}) } return nil } func (s *articleService) SetCover(ctx context.Context, runwayID uint32, image string) error { return s.articles.SetRunwayCover(ctx, runwayID, image) } func (s *articleService) SetDeleted(ctx context.Context, id uint32, deleted uint8) error { if deleted == 1 { // 下架图集:软删图片 + 置下架 + 异步入队清理七牛孤儿图(见 PurgeGallery)。 return s.PurgeGallery(ctx, id) } // 上架(deleted=0)仅恢复图集行,不动图片/不清理(图片若已删不可恢复,属预期)。 return s.articles.SetRunwayDeleted(ctx, id, 0) } // PurgeGallery 下架图集并异步清理七牛孤儿图: // 1. 级联软删其全部图片(使其退出引用计数),拿到这些图片的 key; // 2. 把图集自身置为下架(对外不可见); // 3. 把这批 key 入队,由 worker 按引用计数判定是否为真孤儿再删七牛文件(异步、不阻塞本请求)。 // // 仍被其他图集引用的 key 不会被误删(内容寻址共享 key 安全);入队失败仅告警, // 图集已下架、图片已软删,七牛文件留待后续手动清理或周期性兜底扫描。 func (s *articleService) PurgeGallery(ctx context.Context, id uint32) error { keys, err := s.articles.SoftDeleteRunwayImages(ctx, id) if err != nil { return err } if err := s.articles.SetRunwayDeleted(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 入队清理七牛失败(文件将暂留): %v", id, err) } } return nil } // toPublicImages 把图片实体映射为对外结构(url + 名称 + 图片自身的编码 id)。 // ID 用 TypeRunwayImage 前缀编码,与文章 uid(r 前缀)区分开,供单图收藏定位。 // Image 走展示样式(high);额外给出 Thumb(缩略图样式),供大图灯箱左栏缩略图条与右下角 // 细节缩略图等小尺寸场景使用——那些位置只显示几十到一百多像素,用大图纯属浪费带宽。 // 注意:列表用的 toThumbImages 不填 Thumb(列表的 Image 本身就是缩略图)。 func (s *articleService) toPublicImages(imgs []model.BrandRunwayImage) []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.TypeRunwayImage), Image: s.img.Compose(im.Image), Thumb: s.img.ComposeThumb(im.Image), Name: im.Name, IsDetail: im.IsDetail, LookIndex: im.LookIndex, }) } return out } // toThumbImages 与 toPublicImages 同构,但图片地址走「列表缩略图」样式(ComposeThumb), // 仅供列表卡片 / 图条等小尺寸场景使用;详情页与大图灯箱仍用 toPublicImages(展示样式)。 func (s *articleService) toThumbImages(imgs []model.BrandRunwayImage) []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.TypeRunwayImage), Image: s.img.ComposeThumb(im.Image), Name: im.Name, IsDetail: im.IsDetail, LookIndex: im.LookIndex, }) } return out }