333 lines
14 KiB
Go
333 lines
14 KiB
Go
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 是「清理S4孤儿图」任务的入队入口;由 IngestService 实现,
|
||
// 图集服务在删除图集时调用它,把同步的S4删除改为异步队列,避免阻塞删除请求。
|
||
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)
|
||
// DetailAdmin 后台详情:读基表,能渲染 pending / rejected(供后台详情页复用)。
|
||
DetailAdmin(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 彻底下架图集:级联软删图片 + 下架 + 异步入队清理S4孤儿图。
|
||
PurgeGallery(ctx context.Context, id uint32) error
|
||
}
|
||
|
||
type articleService struct {
|
||
articles repository.ArticleRepository
|
||
media repository.MediaRepository
|
||
img *imgurl.Composer
|
||
deleter storage.Deleter
|
||
cleaner MediaCleanupEnqueuer // 删除图集时异步清理S4孤儿图的入队入口
|
||
}
|
||
|
||
// 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,
|
||
Status: r.Status,
|
||
})
|
||
}
|
||
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 公开详情:读公开视图,pending / rejected 视同不存在(ErrArticleNotFound)。
|
||
// 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 {
|
||
return nil, s.detailErr(err)
|
||
}
|
||
imgs, err := s.articles.ListImages(ctx, id)
|
||
if err != nil {
|
||
return nil, internalErr(err.Error())
|
||
}
|
||
return s.buildDetail(row, imgs, locale), nil
|
||
}
|
||
|
||
// DetailAdmin 后台详情:读基表,能渲染 pending / rejected。
|
||
//
|
||
// 后台列表(AdminList)故意把待审行链到详情页,且详情页是通往编辑页的唯一入口;
|
||
// 若后台详情也走公开视图,待审项会 404、审核无从下手。
|
||
func (s *articleService) DetailAdmin(ctx context.Context, id string, locale string) (*dto.PublicArticleDetail, error) {
|
||
row, err := s.articles.FindByIDBase(ctx, id)
|
||
if err != nil {
|
||
return nil, s.detailErr(err)
|
||
}
|
||
imgs, err := s.articles.ListImagesBase(ctx, id)
|
||
if err != nil {
|
||
return nil, internalErr(err.Error())
|
||
}
|
||
return s.buildDetail(row, imgs, locale), nil
|
||
}
|
||
|
||
// detailErr 统一详情取数失败的错误映射(公开 Detail 与 DetailAdmin 共用)。
|
||
func (s *articleService) detailErr(err error) error {
|
||
if errors.Is(err, repository.ErrNotFound) {
|
||
return ErrArticleNotFound
|
||
}
|
||
return NewError(http.StatusInternalServerError, err.Error())
|
||
}
|
||
|
||
// buildDetail 把查询行与图片集映射为详情 DTO(公开/后台共用,差异只在数据来源)。
|
||
func (s *articleService) buildDetail(row *model.RunwayRow, imgs []model.BrandRunwayImage, locale string) *dto.PublicArticleDetail {
|
||
// 主图(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,
|
||
}
|
||
}
|
||
|
||
// ── 后台编辑支持 ──────────────────────────────────────────────────────────────
|
||
|
||
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(删除前清理S4孤儿用),再软删 DB 行,最后按引用计数决定是否真删S4文件。
|
||
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 {
|
||
// 下架图集:软删图片 + 置下架 + 异步入队清理S4孤儿图(见 PurgeGallery)。
|
||
return s.PurgeGallery(ctx, id)
|
||
}
|
||
// 上架(deleted=0)仅恢复图集行,不动图片/不清理(图片若已删不可恢复,属预期)。
|
||
return s.articles.SetRunwayDeleted(ctx, id, 0)
|
||
}
|
||
|
||
// PurgeGallery 下架图集并异步清理S4孤儿图:
|
||
// 1. 级联软删其全部图片(使其退出引用计数),拿到这些图片的 key;
|
||
// 2. 把图集自身置为下架(对外不可见);
|
||
// 3. 把这批 key 入队,由 worker 按引用计数判定是否为真孤儿再删S4文件(异步、不阻塞本请求)。
|
||
//
|
||
// 仍被其他图集引用的 key 不会被误删(内容寻址共享 key 安全);入队失败仅告警,
|
||
// 图集已下架、图片已软删,S4文件留待后续手动清理或周期性兜底扫描。
|
||
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 入队清理S4失败(文件将暂留): %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
|
||
}
|