update
This commit is contained in:
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"fashionapi/internal/dto"
|
||||
@ -31,6 +32,10 @@ type AuthService interface {
|
||||
Logout(ctx context.Context, refreshToken string) error
|
||||
// RevokeAllByRefresh 根据 refresh token 找到所属用户,吊销其全部 refresh(踢下线)。
|
||||
RevokeAllByRefresh(ctx context.Context, refreshToken string) (int64, error)
|
||||
// ListUsers 列出全量用户(后台管理提级 VIP 用)。
|
||||
ListUsers(ctx context.Context) ([]dto.UserPayload, error)
|
||||
// SetTier 设置用户等级(free/vip),供后台把某账号提级为 VIP。
|
||||
SetTier(ctx context.Context, id uint32, tier string) error
|
||||
}
|
||||
|
||||
type authService struct {
|
||||
@ -86,7 +91,7 @@ func (s *authService) Login(ctx context.Context, account, password string) (stri
|
||||
if _, err := s.refresh.RevokeAllByUser(ctx, user.ID); err != nil {
|
||||
return "", "", 0, nil, internalErr("revoke stale sessions: " + err.Error())
|
||||
}
|
||||
access, err := s.jwt.Generate(user.ID, user.Username, user.Email)
|
||||
access, err := s.jwt.Generate(user.ID, user.Username, user.Email, user.Tier)
|
||||
if err != nil {
|
||||
return "", "", 0, nil, err
|
||||
}
|
||||
@ -111,7 +116,7 @@ func (s *authService) Refresh(ctx context.Context, refreshToken string) (string,
|
||||
if err != nil {
|
||||
return "", 0, unauthorized("invalid refresh token")
|
||||
}
|
||||
access, err := s.jwt.Generate(user.ID, user.Username, user.Email)
|
||||
access, err := s.jwt.Generate(user.ID, user.Username, user.Email, user.Tier)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
@ -198,5 +203,29 @@ func hashToken(raw string) string {
|
||||
}
|
||||
|
||||
func toUserPayload(u model.User) dto.UserPayload {
|
||||
return dto.UserPayload{ID: u.ID, Username: u.Username, Email: u.Email}
|
||||
return dto.UserPayload{ID: u.ID, Username: u.Username, Email: u.Email, Tier: u.Tier}
|
||||
}
|
||||
|
||||
// ListUsers 列出全量用户(后台管理提级 VIP 用)。
|
||||
func (s *authService) ListUsers(ctx context.Context) ([]dto.UserPayload, error) {
|
||||
users, err := s.users.List(ctx)
|
||||
if err != nil {
|
||||
return nil, internalErr(err.Error())
|
||||
}
|
||||
out := make([]dto.UserPayload, 0, len(users))
|
||||
for _, u := range users {
|
||||
out = append(out, toUserPayload(u))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SetTier 设置用户等级(free/vip),供后台把某账号提级为 VIP。
|
||||
func (s *authService) SetTier(ctx context.Context, id uint32, tier string) error {
|
||||
if tier != "free" && tier != "vip" {
|
||||
return errors.New("tier must be free or vip")
|
||||
}
|
||||
if err := s.users.SetTier(ctx, id, tier); err != nil {
|
||||
return internalErr(err.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -2,7 +2,10 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"fashionapi/internal/dto"
|
||||
"fashionapi/internal/model"
|
||||
@ -17,8 +20,20 @@ type BrandService interface {
|
||||
// 供 SSG 构建期(RunwayLooks 侧栏 / 品牌弹窗 HOT 标签)一次性取前 N 个热门品牌;
|
||||
// 复用 List 的 featured 收窄逻辑(FeaturedIDs("images", limit)),与首页精选口径一致。
|
||||
Hot(ctx context.Context, limit int, locale string) ([]dto.PublicBrand, error)
|
||||
// Get 按编码 id 取单个品牌(后台编辑用)。不存在返回 ErrBrandNotFound。
|
||||
Get(ctx context.Context, uid string) (*model.Brand, error)
|
||||
// Create 新建品牌,返回编码后的 id。
|
||||
Create(ctx context.Context, nameEn, nameCn string) (string, error)
|
||||
// Update 按编码 id 更新品牌名称。
|
||||
Update(ctx context.Context, uid, nameEn, nameCn string) error
|
||||
// CrawlTasks 返回爬虫抓取所需的品牌列表(编码 brand_uid + 英文品牌名)。
|
||||
// brandID>0 时仅返回该品牌(对应 --brand 单品牌调试)。
|
||||
CrawlTasks(ctx context.Context, brandID uint32) ([]dto.CrawlBrand, error)
|
||||
}
|
||||
|
||||
// ErrBrandNotFound 品牌不存在。
|
||||
var ErrBrandNotFound = NewError(http.StatusNotFound, "brand not found")
|
||||
|
||||
type brandService struct {
|
||||
brands repository.BrandRepository
|
||||
}
|
||||
@ -96,3 +111,63 @@ func (s *brandService) toPublicBrands(items []model.Brand, locale string) []dto.
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
// Get 按编码 id 取单个品牌(后台编辑用)。
|
||||
func (s *brandService) Get(ctx context.Context, uid string) (*model.Brand, error) {
|
||||
id, err := hashid.Decode(uid)
|
||||
if err != nil {
|
||||
return nil, ErrBrandNotFound
|
||||
}
|
||||
b, err := s.brands.FindByID(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrNotFound) {
|
||||
return nil, ErrBrandNotFound
|
||||
}
|
||||
return nil, internalErr(err.Error())
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// Create 新建品牌,返回编码后的 id。
|
||||
func (s *brandService) Create(ctx context.Context, nameEn, nameCn string) (string, error) {
|
||||
now := uint32(time.Now().Unix())
|
||||
id, err := s.brands.Create(ctx, &model.Brand{
|
||||
NameEn: nameEn,
|
||||
NameCn: nameCn,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
return "", internalErr(err.Error())
|
||||
}
|
||||
return hashid.Encode(id), nil
|
||||
}
|
||||
|
||||
// Update 按编码 id 更新品牌名称。
|
||||
func (s *brandService) Update(ctx context.Context, uid, nameEn, nameCn string) error {
|
||||
id, err := hashid.Decode(uid)
|
||||
if err != nil {
|
||||
return ErrBrandNotFound
|
||||
}
|
||||
if err := s.brands.Update(ctx, id, nameEn, nameCn); err != nil {
|
||||
return internalErr(err.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CrawlTasks 返回爬虫取任务所需的品牌列表:编码 brand_uid + 英文品牌名。
|
||||
// 爬虫直接拿 brand_uid 上送 ingest,无需自己编码;name 用于拼 vogue 抓取 URL。
|
||||
func (s *brandService) CrawlTasks(ctx context.Context, brandID uint32) ([]dto.CrawlBrand, error) {
|
||||
brands, err := s.brands.CrawlBrands(ctx, brandID)
|
||||
if err != nil {
|
||||
return nil, internalErr(err.Error())
|
||||
}
|
||||
out := make([]dto.CrawlBrand, 0, len(brands))
|
||||
for _, b := range brands {
|
||||
out = append(out, dto.CrawlBrand{
|
||||
BrandUID: hashid.Encode(b.ID),
|
||||
Name: b.NameEn,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ import (
|
||||
|
||||
"fashionapi/internal/dto"
|
||||
"fashionapi/internal/pkg/hashid"
|
||||
"fashionapi/internal/pkg/imgurl"
|
||||
"fashionapi/internal/repository"
|
||||
)
|
||||
|
||||
@ -27,11 +28,13 @@ type IndexRunwayData struct {
|
||||
|
||||
type indexService struct {
|
||||
brands repository.BrandRepository
|
||||
img *imgurl.Composer
|
||||
}
|
||||
|
||||
// NewIndexService 创建首页数据服务。
|
||||
func NewIndexService(brands repository.BrandRepository) IndexService {
|
||||
return &indexService{brands: brands}
|
||||
// img 负责把库里存储的七牛 key 拼装为可访问 URL(首页统一普通图)。
|
||||
func NewIndexService(brands repository.BrandRepository, img *imgurl.Composer) IndexService {
|
||||
return &indexService{brands: brands, img: img}
|
||||
}
|
||||
|
||||
// Runway 首页 runway 区块数据。
|
||||
@ -52,7 +55,7 @@ func (s *indexService) Runway(ctx context.Context, locale string) (IndexRunwayDa
|
||||
ArticleUID: hashid.Encode(b.ArticleID),
|
||||
// 品牌名强制优先英文("en"),不随 locale 切换,保持英文观感。
|
||||
Brand: pickLocale(b.BrandEn, b.BrandCn, "en"),
|
||||
Cover: b.Cover,
|
||||
Cover: s.img.Compose(b.Cover, false), // 首页封面统一普通图
|
||||
Title: pickLocale(b.TitleEn, b.TitleCn, locale),
|
||||
})
|
||||
}
|
||||
|
||||
@ -3,29 +3,51 @@ 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)
|
||||
Detail(ctx context.Context, id string, hd bool) (*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 彻底下架街拍:级联软删图片 + 下架 + 异步入队清理七牛孤儿图。
|
||||
PurgeGallery(ctx context.Context, id uint32) error
|
||||
}
|
||||
|
||||
type streetSnapService struct {
|
||||
repo repository.StreetSnapRepository
|
||||
repo repository.StreetSnapRepository
|
||||
media repository.MediaRepository
|
||||
img *imgurl.Composer
|
||||
deleter storage.Deleter
|
||||
cleaner MediaCleanupEnqueuer // 删除图集时异步清理七牛孤儿图的入队入口
|
||||
}
|
||||
|
||||
// NewStreetSnapService 创建街拍服务。
|
||||
func NewStreetSnapService(repo repository.StreetSnapRepository) StreetSnapService {
|
||||
return &streetSnapService{repo: repo}
|
||||
// img 负责把库里存储的七牛 key 拼装为可访问 URL(按 tier 决定普通/高清);
|
||||
// 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 街拍不存在。
|
||||
@ -47,7 +69,7 @@ func (s *streetSnapService) List(ctx context.Context, q dto.StreetSnapQuery) ([]
|
||||
list = append(list, dto.PublicStreetSnap{
|
||||
UID: hashid.EncodeTyped(hashid.TypeSnap, r.ID),
|
||||
Title: r.Title,
|
||||
Cover: r.Cover,
|
||||
Cover: s.img.Compose(r.Cover, false), // 列表封面统一普通图
|
||||
ImageCount: r.ImageCount,
|
||||
})
|
||||
}
|
||||
@ -67,22 +89,23 @@ func (s *streetSnapService) attachImages(ctx context.Context, list []dto.PublicS
|
||||
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.toPublicSnapImages(raw, false)
|
||||
imgs = excludeCoverSnap(imgs, list[i].Cover)
|
||||
if len(imgs) > limit {
|
||||
imgs = imgs[:limit]
|
||||
}
|
||||
list[i].Images = toPublicSnapImages(imgs)
|
||||
list[i].Images = imgs
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// excludeCoverSnap 同 excludeCover,作用于街拍图片列表。
|
||||
func excludeCoverSnap(imgs []model.StreetSnapImage, cover string) []model.StreetSnapImage {
|
||||
// excludeCoverSnap 同 excludeCover,作用于已拼装 URL 的街拍图片列表。
|
||||
func excludeCoverSnap(imgs []dto.PublicArticleImage, cover string) []dto.PublicArticleImage {
|
||||
if cover == "" {
|
||||
return imgs
|
||||
}
|
||||
@ -96,7 +119,8 @@ func excludeCoverSnap(imgs []model.StreetSnapImage, cover string) []model.Street
|
||||
}
|
||||
|
||||
// Detail 查询街拍详情,含完整图片集。
|
||||
func (s *streetSnapService) Detail(ctx context.Context, id string) (*dto.PublicStreetSnapDetail, error) {
|
||||
// hd=true 时返回高清原图(VIP),false 时所有图拼普通样式(免费/未登录)。
|
||||
func (s *streetSnapService) Detail(ctx context.Context, id string, hd bool) (*dto.PublicStreetSnapDetail, error) {
|
||||
snap, err := s.repo.FindByID(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrNotFound) {
|
||||
@ -113,8 +137,8 @@ func (s *streetSnapService) Detail(ctx context.Context, id string) (*dto.PublicS
|
||||
return &dto.PublicStreetSnapDetail{
|
||||
UID: hashid.EncodeTyped(hashid.TypeSnap, snap.ID),
|
||||
Title: snap.Title,
|
||||
Cover: snap.Cover,
|
||||
Images: toPublicSnapImages(imgs),
|
||||
Cover: s.img.Compose(snap.Cover, hd),
|
||||
Images: s.toPublicSnapImages(imgs, hd),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@ -130,7 +154,7 @@ func (s *streetSnapService) Popular(ctx context.Context, limit int) ([]dto.Publi
|
||||
list = append(list, dto.PublicStreetSnap{
|
||||
UID: hashid.EncodeTyped(hashid.TypeSnap, r.ID),
|
||||
Title: r.Title,
|
||||
Cover: r.Cover,
|
||||
Cover: s.img.Compose(r.Cover, false), // 热门街拍封面统一普通图
|
||||
ImageCount: r.ImageCount,
|
||||
})
|
||||
}
|
||||
@ -139,7 +163,8 @@ func (s *streetSnapService) Popular(ctx context.Context, limit int) ([]dto.Publi
|
||||
|
||||
// toPublicSnapImages 把图片实体映射为对外结构(url + 名称 + 图片自身的编码 id)。
|
||||
// ID 用 TypeSnapImage 前缀编码,与街拍 uid(s 前缀)区分开,供单图收藏定位。
|
||||
func toPublicSnapImages(imgs []model.StreetSnapImage) []dto.PublicArticleImage {
|
||||
// hd=true 时返回高清原图(VIP),false 时拼普通样式(免费/未登录)。
|
||||
func (s *streetSnapService) toPublicSnapImages(imgs []model.StreetSnapImage, hd bool) []dto.PublicArticleImage {
|
||||
if len(imgs) == 0 {
|
||||
return nil
|
||||
}
|
||||
@ -147,9 +172,65 @@ func toPublicSnapImages(imgs []model.StreetSnapImage) []dto.PublicArticleImage {
|
||||
for _, im := range imgs {
|
||||
out = append(out, dto.PublicArticleImage{
|
||||
ID: hashid.EncodeTyped(hashid.TypeSnapImage, im.ID),
|
||||
Image: im.Image,
|
||||
Image: s.img.Compose(im.Image, hd),
|
||||
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(删除前清理七牛孤儿用),再软删 DB 行,最后按引用计数决定是否真删七牛文件。
|
||||
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 {
|
||||
// 下架街拍:软删图片 + 置下架 + 异步入队清理七牛孤儿图(见 PurgeGallery)。
|
||||
return s.PurgeGallery(ctx, id)
|
||||
}
|
||||
// 上架(deleted=0)仅恢复图集行,不动图片/不清理(图片若已删不可恢复,属预期)。
|
||||
return s.repo.SetSnapDeleted(ctx, id, 0)
|
||||
}
|
||||
|
||||
// PurgeGallery 下架街拍并异步清理七牛孤儿图:逻辑同 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 入队清理七牛失败(文件将暂留): %v", id, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user