Files
backend_v2/internal/service/street_snap_service.go
toom1996 42f6316125 update
2026-09-25 11:31:52 +08:00

422 lines
16 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"
"sort"
"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)
// AdminList 后台街拍列表:读基表,包含 pending / rejected(否则审核员看不到待审内容)。
AdminList(ctx context.Context, q dto.StreetSnapQuery) ([]dto.PublicStreetSnap, int64, error)
Detail(ctx context.Context, id string, locale string) (*dto.PublicStreetSnapDetail, error)
// DetailAdmin 后台详情:读基表,能渲染 pending / rejected(供后台详情页复用)。
DetailAdmin(ctx context.Context, id string, locale string) (*dto.PublicStreetSnapDetail, error)
// Popular 返回热度前 N 个街拍(按图片数降序),供 SSG 首页热门街拍展示。
Popular(ctx context.Context, limit int, locale string) ([]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 彻底下架街拍:级联软删图片 + 下架 + 异步入队清理S4孤儿图。
PurgeGallery(ctx context.Context, id uint32) error
}
type streetSnapService struct {
repo repository.StreetSnapRepository
media repository.MediaRepository
img *imgurl.Composer
deleter storage.Deleter
cleaner MediaCleanupEnqueuer // 删除图集时异步清理S4孤儿图的入队入口
}
// 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())
}
ids := make([]uint32, 0, len(rows))
for _, r := range rows {
ids = append(ids, r.ID)
}
list := s.toPublicSnaps(rows, q.Locale)
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 后台街拍列表:读基表,包含 pending / rejected。
//
// 与 List 用同一个映射(toPublicSnaps),但列表不附图——后台列表只展示卡片字段,
// 与走秀后台 AdminList 的口径一致。
func (s *streetSnapService) AdminList(ctx context.Context, q dto.StreetSnapQuery) ([]dto.PublicStreetSnap, int64, error) {
q.Normalize()
rows, total, err := s.repo.ListAdmin(ctx, q)
if err != nil {
return nil, 0, internalErr(err.Error())
}
return s.toPublicSnaps(rows, q.Locale), total, nil
}
// toPublicSnaps 把街拍实体映射为对外列表结构(卡片字段:编码 id / 标题 / 缩略封面 / 图片数)。
// 公开 List 与后台 AdminList 共用,避免两处重复维护映射(封面统一走缩略图样式,小尺寸省流量)。
func (s *streetSnapService) toPublicSnaps(rows []model.StreetSnap, locale string) []dto.PublicStreetSnap {
list := make([]dto.PublicStreetSnap, 0, len(rows))
for _, r := range rows {
list = append(list, dto.PublicStreetSnap{
UID: hashid.EncodeWithType(r.ID, hashid.TypeSnap),
Title: pickLocale(r.Title, r.TitleCn, locale),
Cover: s.img.ComposeThumb(r.Cover),
ImageCount: r.ImageCount,
Status: r.Status, // 仅后台模板渲染状态列用(json:"-" 不进公开响应)
})
}
return list
}
// 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 公开详情:读公开视图,pending / rejected 视同不存在(ErrStreetSnapNotFound)。
// 图片统一走展示样式(VIP 与免费同质量)。
func (s *streetSnapService) Detail(ctx context.Context, id string, locale string) (*dto.PublicStreetSnapDetail, error) {
snap, err := s.repo.FindByID(ctx, id)
if err != nil {
return nil, s.detailErr(err)
}
imgs, err := s.repo.ListImages(ctx, id)
if err != nil {
return nil, internalErr(err.Error())
}
return s.buildSnapDetail(snap, imgs, locale), nil
}
// DetailAdmin 后台详情:读基表,能渲染 pending / rejected。
//
// 后台列表(AdminList)故意把待审行链到详情页,且详情页是通往编辑页的唯一入口;
// 若后台详情也走公开视图,待审项会 404、审核无从下手。
func (s *streetSnapService) DetailAdmin(ctx context.Context, id string, locale string) (*dto.PublicStreetSnapDetail, error) {
snap, err := s.repo.FindByIDBase(ctx, id)
if err != nil {
return nil, s.detailErr(err)
}
imgs, err := s.repo.ListImagesBase(ctx, id)
if err != nil {
return nil, internalErr(err.Error())
}
return s.buildSnapDetail(snap, imgs, locale), nil
}
// detailErr 统一详情取数失败的错误映射(公开 Detail 与 DetailAdmin 共用)。
func (s *streetSnapService) detailErr(err error) error {
if errors.Is(err, repository.ErrNotFound) {
return ErrStreetSnapNotFound
}
return internalErr(err.Error())
}
// buildSnapDetail 把街拍行与图片集映射为详情 DTO(公开/后台共用,差异只在数据来源)。
func (s *streetSnapService) buildSnapDetail(snap *model.StreetSnap, imgs []model.StreetSnapImage, locale string) *dto.PublicStreetSnapDetail {
return &dto.PublicStreetSnapDetail{
UID: hashid.EncodeWithType(snap.ID, hashid.TypeSnap),
Title: pickLocale(snap.Title, snap.TitleCn, locale),
Cover: s.img.Compose(snap.Cover),
Images: s.buildSnapImages(imgs),
}
}
// buildSnapImages 把街拍图片组装成「主图 + 其副图」的嵌套结构(与走秀详情同形):
// Images 只含主图(is_detail=0),每张主图的副图挂在其 Detail 子数组下。
// 父引用指向「副图」时沿链向上归并到该副图所在组的主图;父行缺失 / 成环则把该图按主图渲染(不丢图)。
// 主图 / 副图的归并完全靠 parent_image_id 表达(街拍不落库组序号)。
func (s *streetSnapService) buildSnapImages(imgs []model.StreetSnapImage) []dto.PublicArticleImage {
if len(imgs) == 0 {
return nil
}
byID := make(map[uint32]*model.StreetSnapImage, len(imgs))
for i := range imgs {
byID[imgs[i].ID] = &imgs[i]
}
// resolveRoot 返回 img 所属主图的行 id;自身是主图则返回自身;父行缺失 / 成环则按主图渲染(返回自身)。
resolveRoot := func(start uint32) uint32 {
cur := start
seen := map[uint32]bool{}
for {
n, ok := byID[cur]
if !ok {
return start // 父行不存在 → 按主图渲染(返回自身)
}
if n.IsDetail == 0 {
return n.ID
}
if seen[cur] {
return start // 环 → 容错,自成主图
}
seen[cur] = true
cur = n.ParentImageID
}
}
// 第一遍:按 sort_order 建立主图(根),并记录「主图行 id → 输出下标」,副图排在主图之前也能正确归位。
out := make([]dto.PublicArticleImage, 0, len(imgs))
index := make(map[uint32]int, len(imgs))
for _, im := range imgs {
if resolveRoot(im.ID) != im.ID {
continue
}
pub := s.toPublicSnapImage(im)
index[im.ID] = len(out)
out = append(out, pub)
}
// 第二遍:把副图挂到所属主图的 Detail 下(副图沿用所属主图的 id 作为分组键)。
for _, im := range imgs {
root := resolveRoot(im.ID)
if root == im.ID {
continue
}
gi, ok := index[root]
if !ok {
continue // 理论不发生(孤儿已按主图渲染)
}
pub := s.toPublicSnapImage(im)
pub.IsDetail = 1
out[gi].Detail = append(out[gi].Detail, pub)
}
return out
}
// Popular 返回热度前 N 个街拍(按图片数降序),供 SSG 首页热门街拍展示。
func (s *streetSnapService) Popular(ctx context.Context, limit int, locale string) ([]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.EncodeWithType(r.ID, hashid.TypeSnap),
Title: pickLocale(r.Title, r.TitleCn, locale),
Cover: s.img.Compose(r.Cover), // 首页热门街拍卡片为大图展示,用高清样式(high)
ImageCount: r.ImageCount,
})
}
return list, nil
}
// toPublicSnapImages 把图片实体映射为对外结构(url + 名称 + 图片自身的编码 id)。
// ID 用 EncodeWithType(TypeSnapImage) 编码(类型进密码、无前缀),与街拍图集 uid 区分开,供单图收藏定位。
// 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, s.toPublicSnapImage(im))
}
return out
}
// toPublicSnapImage 把单张图片实体映射为对外结构(url + 名称 + 图片自身的编码 id)。
func (s *streetSnapService) toPublicSnapImage(im model.StreetSnapImage) dto.PublicArticleImage {
return dto.PublicArticleImage{
ID: hashid.EncodeWithType(im.ID, hashid.TypeSnapImage),
Image: s.img.Compose(im.Image),
Thumb: s.img.ComposeThumb(im.Image),
Name: im.Name,
}
}
// 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.EncodeWithType(im.ID, hashid.TypeSnapImage),
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) {
snap, imgs, err := s.repo.GetForEdit(ctx, id)
if err != nil {
return nil, nil, err
}
// 编辑页按「主图 → 紧邻其副图」重排:副图的 sort_order 通常排在全部主图之后,
// 若原样平铺,副图会被甩到几百张图的最末尾,看起来像「副图没显示」。
// 重排后主图与其副图相邻,分组一眼可见;孤儿副图(父不在本记录)追加末尾,不丢图。
return snap, orderStreetImagesForEdit(imgs), nil
}
// orderStreetImagesForEdit 把街拍图片重排为「每张主图后紧跟其副图」的扁平顺序。
// 同一主图下的副图按 sort_order 升序;父引用指向副图(理论上不发生)的也兜住不丢。
func orderStreetImagesForEdit(imgs []model.StreetSnapImage) []model.StreetSnapImage {
byID := make(map[uint32]*model.StreetSnapImage, len(imgs))
detailsByParent := make(map[uint32][]model.StreetSnapImage)
var mains []model.StreetSnapImage
for i := range imgs {
im := &imgs[i]
byID[im.ID] = im
if im.IsDetail == 0 {
mains = append(mains, *im)
} else {
detailsByParent[im.ParentImageID] = append(detailsByParent[im.ParentImageID], *im)
}
}
out := make([]model.StreetSnapImage, 0, len(imgs))
emitted := make(map[uint32]bool, len(imgs))
for _, m := range mains {
out = append(out, m)
emitted[m.ID] = true
ds := detailsByParent[m.ID]
sort.Slice(ds, func(i, j int) bool { return ds[i].SortOrder < ds[j].SortOrder })
for _, d := range ds {
out = append(out, d)
emitted[d.ID] = true
}
}
// 未被上述主图认领的副图(父缺失 / 父本身是副图):追加末尾,保证不丢图。
for _, im := range imgs {
if im.IsDetail == 1 && !emitted[im.ID] {
out = append(out, im)
}
}
return out
}
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(删除前清理S4孤儿用),再软删 DB 行,最后按引用计数决定是否真删S4文件。
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 {
// 下架街拍:软删图片 + 置下架 + 异步入队清理S4孤儿图(见 PurgeGallery)。
return s.PurgeGallery(ctx, id)
}
// 上架(deleted=0)仅恢复图集行,不动图片/不清理(图片若已删不可恢复,属预期)。
return s.repo.SetSnapDeleted(ctx, id, 0)
}
// PurgeGallery 下架街拍并异步清理S4孤儿图:逻辑同 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 入队清理S4失败(文件将暂留): %v", id, err)
}
}
return nil
}