237 lines
8.7 KiB
Go
237 lines
8.7 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"
|
||
)
|
||
|
||
// StreetSnapService 街拍业务接口。
|
||
type StreetSnapService interface {
|
||
List(ctx context.Context, q dto.StreetSnapQuery) ([]dto.PublicStreetSnap, int64, 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
|
||
media repository.MediaRepository
|
||
img *imgurl.Composer
|
||
deleter storage.Deleter
|
||
cleaner MediaCleanupEnqueuer // 删除图集时异步清理七牛孤儿图的入队入口
|
||
}
|
||
|
||
// NewStreetSnapService 创建街拍服务。
|
||
// 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 街拍不存在。
|
||
var ErrStreetSnapNotFound = NewError(http.StatusNotFound, "street snap not found")
|
||
|
||
// List 查询街拍列表,并按需附带每条的前 N 张图。
|
||
func (s *streetSnapService) List(ctx context.Context, q dto.StreetSnapQuery) ([]dto.PublicStreetSnap, int64, error) {
|
||
q.Normalize()
|
||
|
||
rows, total, err := s.repo.List(ctx, q)
|
||
if err != nil {
|
||
return nil, 0, internalErr(err.Error())
|
||
}
|
||
|
||
list := make([]dto.PublicStreetSnap, 0, len(rows))
|
||
ids := make([]uint32, 0, len(rows))
|
||
for _, r := range rows {
|
||
ids = append(ids, r.ID)
|
||
list = append(list, dto.PublicStreetSnap{
|
||
UID: hashid.EncodeTyped(hashid.TypeSnap, r.ID),
|
||
Title: r.Title,
|
||
Cover: s.img.Compose(r.Cover, false), // 列表封面统一普通图
|
||
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
|
||
}
|
||
|
||
// attachImages 用单次 IN 查询为列表批量补齐图片,避免前端逐条请求详情。
|
||
func (s *streetSnapService) attachImages(ctx context.Context, list []dto.PublicStreetSnap, ids []uint32, limit int) error {
|
||
grouped, err := s.repo.ImagesBySnapIDs(ctx, ids)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
for i := range list {
|
||
raw, ok := grouped[ids[i]]
|
||
if !ok {
|
||
continue
|
||
}
|
||
// 列表缩略图统一普通图;再剔除与卡片主图(cover)同 URL 的首图,避免重复展示。
|
||
imgs := s.toPublicSnapImages(raw, false)
|
||
imgs = excludeCoverSnap(imgs, list[i].Cover)
|
||
if len(imgs) > limit {
|
||
imgs = imgs[:limit]
|
||
}
|
||
list[i].Images = imgs
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// excludeCoverSnap 同 excludeCover,作用于已拼装 URL 的街拍图片列表。
|
||
func excludeCoverSnap(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 查询街拍详情,含完整图片集。
|
||
// 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) {
|
||
return nil, ErrStreetSnapNotFound
|
||
}
|
||
return nil, internalErr(err.Error())
|
||
}
|
||
|
||
imgs, err := s.repo.ListImages(ctx, id)
|
||
if err != nil {
|
||
return nil, internalErr(err.Error())
|
||
}
|
||
|
||
return &dto.PublicStreetSnapDetail{
|
||
UID: hashid.EncodeTyped(hashid.TypeSnap, snap.ID),
|
||
Title: snap.Title,
|
||
Cover: s.img.Compose(snap.Cover, hd),
|
||
Images: s.toPublicSnapImages(imgs, hd),
|
||
}, nil
|
||
}
|
||
|
||
// IDs 返回全部街拍的编码 uid(供 SSG 枚举详情页路径使用,对外暴露的即编码串)。
|
||
// Popular 返回热度前 N 个街拍(按图片数降序),供 SSG 首页热门街拍展示。
|
||
func (s *streetSnapService) Popular(ctx context.Context, limit int) ([]dto.PublicStreetSnap, error) {
|
||
rows, err := s.repo.Popular(ctx, limit)
|
||
if err != nil {
|
||
return nil, internalErr(err.Error())
|
||
}
|
||
list := make([]dto.PublicStreetSnap, 0, len(rows))
|
||
for _, r := range rows {
|
||
list = append(list, dto.PublicStreetSnap{
|
||
UID: hashid.EncodeTyped(hashid.TypeSnap, r.ID),
|
||
Title: r.Title,
|
||
Cover: s.img.Compose(r.Cover, false), // 热门街拍封面统一普通图
|
||
ImageCount: r.ImageCount,
|
||
})
|
||
}
|
||
return list, nil
|
||
}
|
||
|
||
// toPublicSnapImages 把图片实体映射为对外结构(url + 名称 + 图片自身的编码 id)。
|
||
// ID 用 TypeSnapImage 前缀编码,与街拍 uid(s 前缀)区分开,供单图收藏定位。
|
||
// hd=true 时返回高清原图(VIP),false 时拼普通样式(免费/未登录)。
|
||
func (s *streetSnapService) toPublicSnapImages(imgs []model.StreetSnapImage, hd bool) []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.EncodeTyped(hashid.TypeSnapImage, im.ID),
|
||
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
|
||
}
|