feat(street): 公开 API 详情新增主副图分组字段

This commit is contained in:
toom1996
2026-09-21 22:35:28 +08:00
parent ed92b475d1
commit be73e595ca
3 changed files with 139 additions and 6 deletions

View File

@ -60,11 +60,19 @@ type PublicStreetSnap struct {
Images []PublicArticleImage `json:"images"` // 列表附带的每篇前 N 张缩略图 Images []PublicArticleImage `json:"images"` // 列表附带的每篇前 N 张缩略图
} }
// PublicStreetSnapDetail 对外只读街拍详情:含完整图片集。 // PublicStreetSnapDetail 对外只读街拍详情:含完整图片集与主副图分组。
type PublicStreetSnapDetail struct { type PublicStreetSnapDetail struct {
UID string `json:"id"` UID string `json:"id"`
Title string `json:"title"` Title string `json:"title"`
Cover string `json:"cover"` Cover string `json:"cover"`
Images []PublicArticleImage `json:"images"` Images []PublicArticleImage `json:"images"` // 扁平图片集(保持不变,向后兼容)
Groups []PublicStreetSnapGroup `json:"groups,omitempty"` // 按主副图分组(主图→其副图),与 Images 同源同序
Favorited bool `json:"favorited,omitempty"` // 已登录时该图集是否被当前用户收藏(gallery 级) Favorited bool `json:"favorited,omitempty"` // 已登录时该图集是否被当前用户收藏(gallery 级)
} }
// PublicStreetSnapGroup 一张主图及其副图,供前端按组折叠展示。
// Details 为空表示该主图没有副图(等价于 Images 里的一张独立图)。
type PublicStreetSnapGroup struct {
Image PublicArticleImage `json:"image"` // 主图
Details []PublicArticleImage `json:"details"` // 该主图名下的副图(可为空)
}

View File

@ -0,0 +1,67 @@
package service
import (
"testing"
"fashionapi/internal/model"
"fashionapi/internal/pkg/imgurl"
)
// TestGroupSnapImages 验证主副图分组与「副图的副图」上浮归并。
//
// 5 张图:A 主,B 副(挂 A);C 主;D 副(挂 C);E 副(挂 B → 应上浮归并到 A)
//
// 期望 2 组:第 0 组 = A + [B, E],第 1 组 = C + [D]。
func TestGroupSnapImages(t *testing.T) {
s := &streetSnapService{img: imgurl.New("", "", "")}
imgs := []model.StreetSnapImage{
{ID: 1, Image: "A", Name: "A", IsDetail: 0, ParentImageID: 0},
{ID: 2, Image: "B", Name: "B", IsDetail: 1, ParentImageID: 1},
{ID: 3, Image: "C", Name: "C", IsDetail: 0, ParentImageID: 0},
{ID: 4, Image: "D", Name: "D", IsDetail: 1, ParentImageID: 3},
{ID: 5, Image: "E", Name: "E", IsDetail: 1, ParentImageID: 2}, // 2 是副图 → 上浮到 A
}
flat, groups := s.groupSnapImages(imgs)
if len(flat) != 5 {
t.Fatalf("flat 应 5 张,实际 %d", len(flat))
}
if len(groups) != 2 {
t.Fatalf("groups 应为 2 组,实际 %d", len(groups))
}
g0 := groups[0]
if g0.Image.Name != "A" {
t.Fatalf("第 0 组主图应为 A,实际 %s", g0.Image.Name)
}
if len(g0.Details) != 2 {
t.Fatalf("第 0 组副图应为 2 张,实际 %d", len(g0.Details))
}
if g0.Details[0].Name != "B" || g0.Details[1].Name != "E" {
t.Fatalf("第 0 组副图顺序应为 [B, E],实际 %s, %s", g0.Details[0].Name, g0.Details[1].Name)
}
g1 := groups[1]
if g1.Image.Name != "C" {
t.Fatalf("第 1 组主图应为 C,实际 %s", g1.Image.Name)
}
if len(g1.Details) != 1 || g1.Details[0].Name != "D" {
t.Fatalf("第 1 组副图应为 [D],实际 %d 张", len(g1.Details))
}
}
// TestGroupSnapImagesOrphan 父引用指向失效行(不存在)时,该图按主图渲染,不丢图。
func TestGroupSnapImagesOrphan(t *testing.T) {
s := &streetSnapService{img: imgurl.New("", "", "")}
imgs := []model.StreetSnapImage{
{ID: 1, Image: "A", Name: "A", IsDetail: 1, ParentImageID: 99}, // 父 99 不存在
{ID: 2, Image: "B", Name: "B", IsDetail: 0, ParentImageID: 0},
}
flat, groups := s.groupSnapImages(imgs)
if len(flat) != 2 || len(groups) != 2 {
t.Fatalf("孤儿副图应自成主图:flat=%d groups=%d", len(flat), len(groups))
}
if groups[0].Image.Name != "A" || len(groups[0].Details) != 0 {
t.Fatalf("A 应作为主图且无副图,实际 %s details=%d", groups[0].Image.Name, len(groups[0].Details))
}
}

View File

@ -134,14 +134,72 @@ func (s *streetSnapService) Detail(ctx context.Context, id string) (*dto.PublicS
return nil, internalErr(err.Error()) return nil, internalErr(err.Error())
} }
flat, groups := s.groupSnapImages(imgs)
return &dto.PublicStreetSnapDetail{ return &dto.PublicStreetSnapDetail{
UID: hashid.EncodeWithType(snap.ID, hashid.TypeSnap), UID: hashid.EncodeWithType(snap.ID, hashid.TypeSnap),
Title: snap.Title, Title: snap.Title,
Cover: s.img.Compose(snap.Cover), Cover: s.img.Compose(snap.Cover),
Images: s.toPublicSnapImages(imgs), Images: flat,
Groups: groups,
}, nil }, nil
} }
// groupSnapImages 把街拍图片按主副图分组:主图各成一组,副图归入其(存活)主图组。
// 父引用指向「副图」时沿链向上归并到该副图所在组的主图;父行缺失(被软删)则把该图按主图渲染(不丢图)。
// 返回的 flat(扁平)与 groups 同源同序:groups 中每张主图对应的 Details 顺序,等于 flat 中该主图之后的副图出现顺序。
func (s *streetSnapService) groupSnapImages(imgs []model.StreetSnapImage) ([]dto.PublicArticleImage, []dto.PublicStreetSnapGroup) {
if len(imgs) == 0 {
return nil, nil
}
flat := s.toPublicSnapImages(imgs)
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
}
}
groups := make([]dto.PublicStreetSnapGroup, 0, len(imgs))
index := make(map[uint32]int, len(imgs))
for i, im := range imgs {
root := resolveRoot(im.ID)
isDetail := root != im.ID
ref := flat[i]
if !isDetail {
groups = append(groups, dto.PublicStreetSnapGroup{Image: ref})
index[im.ID] = len(groups) - 1
continue
}
if gi, ok := index[root]; ok {
groups[gi].Details = append(groups[gi].Details, ref)
} else {
// 兜底(理论不发生):主图未先出现,自成一组
groups = append(groups, dto.PublicStreetSnapGroup{Image: ref})
index[im.ID] = len(groups) - 1
}
}
return flat, groups
}
// IDs 返回全部街拍的编码 uid(供 SSG 枚举详情页路径使用,对外暴露的即编码串)。 // IDs 返回全部街拍的编码 uid(供 SSG 枚举详情页路径使用,对外暴露的即编码串)。
// Popular 返回热度前 N 个街拍(按图片数降序),供 SSG 首页热门街拍展示。 // Popular 返回热度前 N 个街拍(按图片数降序),供 SSG 首页热门街拍展示。
func (s *streetSnapService) Popular(ctx context.Context, limit int) ([]dto.PublicStreetSnap, error) { func (s *streetSnapService) Popular(ctx context.Context, limit int) ([]dto.PublicStreetSnap, error) {