Files
backend_v2/internal/service/article_service.go

333 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}