Files
backend_v2/internal/service/street_snap_service.go
toom1996 be4db0bf96 fix(publish): 最终审查修复波——迁移索引口径 / 入库计数 / 仓储证据
C1(关键):2026-09-22-03 的街拍实体键索引改用最终表达式
(city, year, COALESCE(title, '')),与体检及 StreetSnapEntityState 口径一致。
此前索引按 (city, year) 比体检更窄,同城同年多专题的合法数据会通过体检、
再由索引抛原生 23505;04 退化为幂等兜底并在注释说明真正常态由 03 承载。
新增 db/migrations/README.md,写清迁移链执行顺序与各步前提/不可逆点。

I1:processRunway 的 image_count 改为按实际落库主图行数(countMainImages),
不再用 len(p.Looks)(空主图 / sha1 重复会被跳过,导致计数偏大且不再自愈)。
补 service 单测与「入库后 image_count == 存活主图行数」的 DB 断言。

I2:三个迁移文件不再把已删除的一次性搬迁脚本写成硬前置,改为写明取回方式
(git show c4bafc5:scripts/migrate_single_table/main.go,并须在旧代码树上运行)。

I3:新增 SetRecordStatus / SetStreetRecordStatus 仓储集成测试:
pending 经仓储通过后在公开视图可见、驳回后不可见、不存在的 id 返回 ErrNotFound。

Minor:修正锁不住口径的走秀 image_count 测试(改为删主图、留细节图);
修正去重注释与事实不符(含 FindNearDuplicateImage 注释);
ListRecords / ListStreetRecords 改用 Scope 杜绝 Count 后复用 *gorm.DB;
dbtool 视图改 CREATE OR REPLACE 并重跑 dump(仍 4 视图、无草稿表);
删除挂在 Popular 上的「IDs 返回…」注释;README 改为单表 + status 现状;
SetRecordStatus 注释写明有意不校验前置状态;规格补两条已知不一致。
2026-09-23 16:03:22 +08:00

378 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"
)
// 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) (*dto.PublicStreetSnapDetail, error)
// DetailAdmin 后台详情:读基表,能渲染 pending / rejected(供后台详情页复用)。
DetailAdmin(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 彻底下架街拍:级联软删图片 + 下架 + 异步入队清理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)
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), total, nil
}
// toPublicSnaps 把街拍实体映射为对外列表结构(卡片字段:编码 id / 标题 / 缩略封面 / 图片数)。
// 公开 List 与后台 AdminList 共用,避免两处重复维护映射(封面统一走缩略图样式,小尺寸省流量)。
func (s *streetSnapService) toPublicSnaps(rows []model.StreetSnap) []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: r.Title,
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) (*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), nil
}
// DetailAdmin 后台详情:读基表,能渲染 pending / rejected。
//
// 后台列表(AdminList)故意把待审行链到详情页,且详情页是通往编辑页的唯一入口;
// 若后台详情也走公开视图,待审项会 404、审核无从下手。
func (s *streetSnapService) DetailAdmin(ctx context.Context, id 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), 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) *dto.PublicStreetSnapDetail {
return &dto.PublicStreetSnapDetail{
UID: hashid.EncodeWithType(snap.ID, hashid.TypeSnap),
Title: snap.Title,
Cover: s.img.Compose(snap.Cover),
Images: s.buildSnapImages(imgs),
}
}
// buildSnapImages 把街拍图片组装成「主图 + 其副图」的嵌套结构(与走秀详情同形):
// Images 只含主图(is_detail=0),每张主图的副图挂在其 Detail 子数组下。
// 父引用指向「副图」时沿链向上归并到该副图所在组的主图;父行缺失 / 成环则把该图按主图渲染(不丢图)。
// LookIndex 是合成的 1-based 主图序号(街拍不落库组序号)。
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)
pub.LookIndex = uint32(len(out) + 1) // 合成的 1-based 主图序号
index[im.ID] = len(out)
out = append(out, pub)
}
// 第二遍:把副图挂到所属主图的 Detail 下(LookIndex 取所属主图的值)。
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
pub.LookIndex = out[gi].LookIndex
out[gi].Detail = append(out[gi].Detail, pub)
}
return out
}
// 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.EncodeWithType(r.ID, hashid.TypeSnap),
Title: r.Title,
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) {
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(删除前清理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
}