Files
backend_v2/internal/service/street_snap_service.go
toom1996 dbee704c95 update
2026-09-13 00:52:35 +08:00

256 lines
9.5 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"
)
// StreetSnapService 街拍业务接口。
type StreetSnapService interface {
List(ctx context.Context, q dto.StreetSnapQuery) ([]dto.PublicStreetSnap, int64, error)
Detail(ctx context.Context, id string) (*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 负责把库里存储的 S4 key 拼装为可访问 URL(统一走展示样式,VIP 与免费同质量);
// 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.ComposeThumb(r.Cover), // 列表封面走缩略图样式(小尺寸省流量)
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.toThumbSnapImages(raw)
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 查询街拍详情,含完整图片集。图片统一走展示样式(VIP 与免费同质量)。
func (s *streetSnapService) Detail(ctx context.Context, id string) (*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),
Images: s.toPublicSnapImages(imgs),
}, 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), // 首页热门街拍卡片为大图展示,用高清样式(high)
ImageCount: r.ImageCount,
})
}
return list, nil
}
// toPublicSnapImages 把图片实体映射为对外结构(url + 名称 + 图片自身的编码 id)。
// ID 用 TypeSnapImage 前缀编码,与街拍 uid(s 前缀)区分开,供单图收藏定位。
// Image 走展示样式(high);额外给出 Thumb(缩略图样式),供大图灯箱左栏缩略图条等
// 小尺寸场景使用(列表用的 toThumbSnapImages 不填 Thumb,其 Image 本身就是缩略图)。
func (s *streetSnapService) toPublicSnapImages(imgs []model.StreetSnapImage) []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),
Thumb: s.img.ComposeThumb(im.Image),
Name: im.Name,
})
}
return out
}
// toThumbSnapImages 与 toPublicSnapImages 同构,但图片地址走「列表缩略图」样式(ComposeThumb),
// 仅供列表卡片 / 图条等小尺寸场景使用;详情页与大图灯箱仍用 toPublicSnapImages(展示样式)。
func (s *streetSnapService) toThumbSnapImages(imgs []model.StreetSnapImage) []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.ComposeThumb(im.Image),
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
}