This commit is contained in:
toom1996
2026-09-07 00:01:48 +08:00
parent b049104a0b
commit 74ba700598
31 changed files with 1113 additions and 606 deletions

View File

@ -3,27 +3,57 @@ 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)
Detail(ctx context.Context, id string, locale string, hd bool) (*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 创建文章服务。
func NewArticleService(articles repository.ArticleRepository) ArticleService {
return &articleService{articles: articles}
// img 负责把库里存储的七牛 key 拼装为可访问 URL(按 tier 决定普通/高清);
// 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 张图。
@ -43,7 +73,7 @@ func (s *articleService) List(ctx context.Context, q dto.ArticleQuery) ([]dto.Pu
UID: hashid.EncodeTyped(hashid.TypeRunway, r.ID),
BrandUID: hashid.Encode(r.BrandID),
Title: pickLocale(r.TitleEn, r.TitleCn, q.Locale),
Cover: r.Cover,
Cover: s.img.Compose(r.Cover, false), // 列表封面统一普通图
BrandName: pickLocale(r.BrandNameEn, r.BrandNameCn, "en"), // 品牌名强制优先英文,保持英文观感
ImageCount: r.ImageCount,
})
@ -57,6 +87,32 @@ func (s *articleService) List(ctx context.Context, q dto.ArticleQuery) ([]dto.Pu
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.EncodeTyped(hashid.TypeRunway, r.ID),
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,
SourceURL: r.SourceURL,
})
}
return list, total, nil
}
// attachImages 用单次 IN 查询为列表批量补齐图片,避免前端逐篇请求详情。
// ids 是与之对应的数字主键(对外已编码为 UID,此处用数字主键查库)。
func (s *articleService) attachImages(ctx context.Context, list []dto.PublicArticle, ids []uint32, limit int) error {
@ -65,23 +121,24 @@ func (s *articleService) attachImages(ctx context.Context, list []dto.PublicArti
return err
}
for i := range list {
imgs, ok := grouped[ids[i]]
raw, ok := grouped[ids[i]]
if !ok {
continue
}
// 缩略图条带剔除主图(cover),避免与卡片主图重复。cover 恒等于图片列表首张。
// 列表缩略图统一普通图;再剔除与卡片主图(cover)同 URL 的首图,避免重复展示。
imgs := s.toPublicImages(raw, false)
imgs = excludeCover(imgs, list[i].Cover)
if len(imgs) > limit {
imgs = imgs[:limit]
}
list[i].Images = toPublicImages(imgs)
list[i].Images = imgs
}
return nil
}
// excludeCover 从图片列表中去掉与主图(cover)同 URL 的元素,供列表缩略图条带使用。
// excludeCover 从已拼装 URL 的列表里去掉与主图(cover)同 URL 的元素,供列表缩略图条带使用。
// 详情接口(完整图集)不走此函数,封面作为其中一张 look 需保留。
func excludeCover(imgs []model.BrandRunwayImage, cover string) []model.BrandRunwayImage {
func excludeCover(imgs []dto.PublicArticleImage, cover string) []dto.PublicArticleImage {
if cover == "" {
return imgs
}
@ -94,8 +151,9 @@ func excludeCover(imgs []model.BrandRunwayImage, cover string) []model.BrandRunw
return kept
}
// Detail 查询文章详情,含完整图片集。locale 决定标题/描述/品牌名的展示语言。
func (s *articleService) Detail(ctx context.Context, id string, locale string) (*dto.PublicArticleDetail, error) {
// Detail 查询文章详情,含完整图片集。locale 决定标题/描述/品牌名的展示语言;
// hd=true 时返回高清原图(VIP),false 时所有图拼普通样式(免费/未登录)。
func (s *articleService) Detail(ctx context.Context, id string, locale string, hd bool) (*dto.PublicArticleDetail, error) {
row, err := s.articles.FindByID(ctx, id)
if err != nil {
if errors.Is(err, repository.ErrNotFound) {
@ -114,17 +172,80 @@ func (s *articleService) Detail(ctx context.Context, id string, locale string) (
BrandUID: hashid.Encode(row.BrandID),
Title: pickLocale(row.TitleEn, row.TitleCn, locale),
Description: pickLocale(row.DescriptionEn, row.DescriptionCn, locale),
Cover: row.Cover,
Cover: s.img.Compose(row.Cover, hd),
// 品牌名强制优先英文("en"),不随 locale 切换,保持英文观感。
BrandName: pickLocale(row.BrandNameEn, row.BrandNameCn, "en"),
Images: toPublicImages(imgs),
SourceURL: row.SourceURL,
Images: s.toPublicImages(imgs, hd),
}, 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
}
// IDs 返回全部文章的编码 uid(供 SSG 枚举详情页路径使用,对外暴露的即编码串)。
// toPublicImages 把图片实体映射为对外结构(url + 名称 + 图片自身的编码 id)。
// ID 用 TypeRunwayImage 前缀编码,与文章 uid(r 前缀)区分开,供单图收藏定位。
func toPublicImages(imgs []model.BrandRunwayImage) []dto.PublicArticleImage {
// hd=true 时返回高清原图(VIP),false 时拼普通样式(免费/未登录)。
func (s *articleService) toPublicImages(imgs []model.BrandRunwayImage, hd bool) []dto.PublicArticleImage {
if len(imgs) == 0 {
return nil
}
@ -132,7 +253,7 @@ func toPublicImages(imgs []model.BrandRunwayImage) []dto.PublicArticleImage {
for _, im := range imgs {
out = append(out, dto.PublicArticleImage{
ID: hashid.EncodeTyped(hashid.TypeRunwayImage, im.ID),
Image: im.Image,
Image: s.img.Compose(im.Image, hd),
Name: im.Name,
})
}