From be73e595ca6ff60f698ce86a24747bdabf2add9a Mon Sep 17 00:00:00 2001 From: toom1996 <23cm.cn@gmail.com> Date: Mon, 21 Sep 2026 22:35:28 +0800 Subject: [PATCH] =?UTF-8?q?feat(street):=20=E5=85=AC=E5=BC=80=20API=20?= =?UTF-8?q?=E8=AF=A6=E6=83=85=E6=96=B0=E5=A2=9E=E4=B8=BB=E5=89=AF=E5=9B=BE?= =?UTF-8?q?=E5=88=86=E7=BB=84=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/dto/street_snap.go | 18 ++++-- internal/service/street_snap_group_test.go | 67 ++++++++++++++++++++++ internal/service/street_snap_service.go | 60 ++++++++++++++++++- 3 files changed, 139 insertions(+), 6 deletions(-) create mode 100644 internal/service/street_snap_group_test.go diff --git a/internal/dto/street_snap.go b/internal/dto/street_snap.go index 018d11c..b73bb88 100644 --- a/internal/dto/street_snap.go +++ b/internal/dto/street_snap.go @@ -60,11 +60,19 @@ type PublicStreetSnap struct { Images []PublicArticleImage `json:"images"` // 列表附带的每篇前 N 张缩略图 } -// PublicStreetSnapDetail 对外只读街拍详情:含完整图片集。 +// PublicStreetSnapDetail 对外只读街拍详情:含完整图片集与主副图分组。 type PublicStreetSnapDetail struct { - UID string `json:"id"` - Title string `json:"title"` - Cover string `json:"cover"` - Images []PublicArticleImage `json:"images"` + UID string `json:"id"` + Title string `json:"title"` + Cover string `json:"cover"` + Images []PublicArticleImage `json:"images"` // 扁平图片集(保持不变,向后兼容) + Groups []PublicStreetSnapGroup `json:"groups,omitempty"` // 按主副图分组(主图→其副图),与 Images 同源同序 Favorited bool `json:"favorited,omitempty"` // 已登录时该图集是否被当前用户收藏(gallery 级) } + +// PublicStreetSnapGroup 一张主图及其副图,供前端按组折叠展示。 +// Details 为空表示该主图没有副图(等价于 Images 里的一张独立图)。 +type PublicStreetSnapGroup struct { + Image PublicArticleImage `json:"image"` // 主图 + Details []PublicArticleImage `json:"details"` // 该主图名下的副图(可为空) +} diff --git a/internal/service/street_snap_group_test.go b/internal/service/street_snap_group_test.go new file mode 100644 index 0000000..6c63e29 --- /dev/null +++ b/internal/service/street_snap_group_test.go @@ -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)) + } +} diff --git a/internal/service/street_snap_service.go b/internal/service/street_snap_service.go index d7a0a90..6385959 100644 --- a/internal/service/street_snap_service.go +++ b/internal/service/street_snap_service.go @@ -134,14 +134,72 @@ func (s *streetSnapService) Detail(ctx context.Context, id string) (*dto.PublicS return nil, internalErr(err.Error()) } + flat, groups := s.groupSnapImages(imgs) return &dto.PublicStreetSnapDetail{ UID: hashid.EncodeWithType(snap.ID, hashid.TypeSnap), Title: snap.Title, Cover: s.img.Compose(snap.Cover), - Images: s.toPublicSnapImages(imgs), + Images: flat, + Groups: groups, }, 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 枚举详情页路径使用,对外暴露的即编码串)。 // Popular 返回热度前 N 个街拍(按图片数降序),供 SSG 首页热门街拍展示。 func (s *streetSnapService) Popular(ctx context.Context, limit int) ([]dto.PublicStreetSnap, error) {