feat(street): 审核 service 暴露主副图分组操作与详情分组视图
This commit is contained in:
@ -2,6 +2,7 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
@ -26,11 +27,17 @@ type DraftCard struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// DraftImageRef 草稿图片引用(统一结构)。
|
// DraftImageRef 草稿图片引用(统一结构)。
|
||||||
|
//
|
||||||
|
// IsDetail / ParentImageID 只有街拍模块会填(走秀的主/细节图走 LookIndex 那套,语义不同,不复用);
|
||||||
|
// DetailCount 让模板不用自己数副图。
|
||||||
type DraftImageRef struct {
|
type DraftImageRef struct {
|
||||||
ID uint32 `json:"id"`
|
ID uint32 `json:"id"`
|
||||||
Image string `json:"image"`
|
Image string `json:"image"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
SortOrder uint32 `json:"sort_order"`
|
SortOrder uint32 `json:"sort_order"`
|
||||||
|
IsDetail uint8 `json:"is_detail"` // 1=副图(挂在 ParentImageID 指向的主图下);0=主图
|
||||||
|
ParentImageID uint32 `json:"parent_image_id"` // 副图所属主图的行 id;主图为 0
|
||||||
|
DetailCount int `json:"detail_count"` // 仅主图有意义:名下(存活)副图数量
|
||||||
}
|
}
|
||||||
|
|
||||||
// EditField 审核详情页里可编辑字段的通用描述;详情页按此循环渲染输入框,
|
// EditField 审核详情页里可编辑字段的通用描述;详情页按此循环渲染输入框,
|
||||||
@ -63,8 +70,24 @@ type DraftDetailView struct {
|
|||||||
Images []DraftImageRef `json:"images"`
|
Images []DraftImageRef `json:"images"`
|
||||||
// Fields 是审核前可微调的字段列表(按模块声明顺序渲染),替代模板里的 IsStreet 分支。
|
// Fields 是审核前可微调的字段列表(按模块声明顺序渲染),替代模板里的 IsStreet 分支。
|
||||||
Fields []EditField `json:"fields"`
|
Fields []EditField `json:"fields"`
|
||||||
|
// MainID 审核页「当前主图」的行 id,来自 URL 查询参数 ?main=;0 表示未选定。
|
||||||
|
// 只有街拍模块会用到(走秀无主副图概念)。
|
||||||
|
MainID uint32 `json:"main_id"`
|
||||||
|
// Groups 街拍详情的「主图 → 其副图」分组视图(按 sort_order 顺序)。走秀模块留空。
|
||||||
|
// 与 Images 同源同序:Images 保持扁平(既有渲染 / runway 仍用),Groups 供按组折叠。
|
||||||
|
Groups []DraftImageGroup `json:"groups,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DraftImageGroup 一张主图及其副图,供审核页按组折叠渲染。
|
||||||
|
type DraftImageGroup struct {
|
||||||
|
Image DraftImageRef `json:"image"` // 主图
|
||||||
|
Details []DraftImageRef `json:"details"` // 该主图名下的副图(可为空)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrInvalidImageGroup 主副图分组操作不合法:主图不存在 / 不属于本草稿 / 自身不是主图 / 空集合。
|
||||||
|
// 这里是给用户看的文案,handler 直接渲染 err.Error()。
|
||||||
|
var ErrInvalidImageGroup = errors.New("主图无效:它必须属于本草稿、未被删除,且自身是主图")
|
||||||
|
|
||||||
// KindTab 审核列表页顶部「类型」标签;Pending 为该模块待审数(驱动大审核列表概览)。
|
// KindTab 审核列表页顶部「类型」标签;Pending 为该模块待审数(驱动大审核列表概览)。
|
||||||
type KindTab struct {
|
type KindTab struct {
|
||||||
Kind string `json:"kind"` // 空串表示「全部」
|
Kind string `json:"kind"` // 空串表示「全部」
|
||||||
@ -102,6 +125,12 @@ type ReviewService interface {
|
|||||||
Reject(ctx context.Context, kind string, id uint32, reviewer, reason string) error
|
Reject(ctx context.Context, kind string, id uint32, reviewer, reason string) error
|
||||||
// DeleteDraftImage 软删审核草稿里的单张图片(审核阶段剔除坏图,不删S4文件)。
|
// DeleteDraftImage 软删审核草稿里的单张图片(审核阶段剔除坏图,不删S4文件)。
|
||||||
DeleteDraftImage(ctx context.Context, kind string, draftID, imgID uint32) error
|
DeleteDraftImage(ctx context.Context, kind string, draftID, imgID uint32) error
|
||||||
|
// AttachDraftImages 把若干张草稿图并入指定主图(街拍专用;走秀模块返回 ErrInvalidImageGroup)。
|
||||||
|
AttachDraftImages(ctx context.Context, kind string, draftID, mainImgID uint32, imgIDs []uint32) error
|
||||||
|
// AttachPrevDraftImage 把某张草稿图并入「同草稿内上一行所属组的主图」(街拍专用)。
|
||||||
|
AttachPrevDraftImage(ctx context.Context, kind string, draftID, imgID uint32) error
|
||||||
|
// DetachDraftImage 把副图拆出恢复为主图(街拍专用)。
|
||||||
|
DetachDraftImage(ctx context.Context, kind string, draftID, imgID uint32) error
|
||||||
// KindTabs 返回类型标签(含「全部」与每个模块的待审数),驱动大审核列表概览。
|
// KindTabs 返回类型标签(含「全部」与每个模块的待审数),驱动大审核列表概览。
|
||||||
KindTabs(ctx context.Context) []KindTab
|
KindTabs(ctx context.Context) []KindTab
|
||||||
}
|
}
|
||||||
@ -260,6 +289,41 @@ func (s *reviewService) DeleteDraftImage(ctx context.Context, kind string, draft
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *reviewService) AttachDraftImages(ctx context.Context, kind string, draftID, mainImgID uint32, imgIDs []uint32) error {
|
||||||
|
if kind != dto.IngestKindStreet {
|
||||||
|
return ErrInvalidImageGroup
|
||||||
|
}
|
||||||
|
if len(imgIDs) == 0 {
|
||||||
|
return ErrInvalidImageGroup
|
||||||
|
}
|
||||||
|
return translateGroupErr(s.repo.AttachStreetDraftImages(ctx, draftID, mainImgID, imgIDs))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *reviewService) AttachPrevDraftImage(ctx context.Context, kind string, draftID, imgID uint32) error {
|
||||||
|
if kind != dto.IngestKindStreet {
|
||||||
|
return ErrInvalidImageGroup
|
||||||
|
}
|
||||||
|
return translateGroupErr(s.repo.AttachPrevStreetDraftImage(ctx, draftID, imgID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *reviewService) DetachDraftImage(ctx context.Context, kind string, draftID, imgID uint32) error {
|
||||||
|
if kind != dto.IngestKindStreet {
|
||||||
|
return ErrInvalidImageGroup
|
||||||
|
}
|
||||||
|
return translateGroupErr(s.repo.DetachStreetDraftImage(ctx, draftID, imgID))
|
||||||
|
}
|
||||||
|
|
||||||
|
// translateGroupErr 把仓储层的哨兵错误翻成给用户看的中文文案;其余错误原样透传。
|
||||||
|
func translateGroupErr(err error) error {
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if errors.Is(err, repository.ErrInvalidImageGroup) {
|
||||||
|
return ErrInvalidImageGroup
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// ── runway 模块 ────────────────────────────────────────────────────────────────
|
// ── runway 模块 ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type runwayModule struct {
|
type runwayModule struct {
|
||||||
@ -385,9 +449,73 @@ func (m *streetModule) DraftDetail(ctx context.Context, id uint32) (*DraftDetail
|
|||||||
{Name: "city", Label: "城市", Value: d.City, Type: "text"},
|
{Name: "city", Label: "城市", Value: d.City, Type: "text"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 按 sort_order 顺序成组:主图各成一组;副图归入其(存活)主图组,父引用失效则自成一主图组。
|
||||||
|
// 父引用指向「副图」时沿链向上归并到该副图所在组的主图(保证与公开 API 同源同序)。
|
||||||
|
byID := make(map[uint32]model.StreetSnapDraftImage, len(imgs))
|
||||||
for _, im := range imgs {
|
for _, im := range imgs {
|
||||||
v.Images = append(v.Images, DraftImageRef{ID: im.ID, Image: m.img.Compose(im.Image), Name: im.Name, SortOrder: im.SortOrder})
|
byID[im.ID] = im
|
||||||
}
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
detailCount := make(map[uint32]int, len(imgs))
|
||||||
|
for _, im := range imgs {
|
||||||
|
if im.IsDetail == 1 {
|
||||||
|
if root := resolveRoot(im.ID); root != im.ID {
|
||||||
|
detailCount[root]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
groups := make([]DraftImageGroup, 0, len(imgs))
|
||||||
|
groupIndex := make(map[uint32]int, len(imgs))
|
||||||
|
for _, im := range imgs {
|
||||||
|
root := resolveRoot(im.ID)
|
||||||
|
isDetail := root != im.ID
|
||||||
|
isDetailVal := uint8(0)
|
||||||
|
if isDetail {
|
||||||
|
isDetailVal = 1
|
||||||
|
}
|
||||||
|
ref := DraftImageRef{
|
||||||
|
ID: im.ID,
|
||||||
|
Image: m.img.Compose(im.Image),
|
||||||
|
Name: im.Name,
|
||||||
|
SortOrder: im.SortOrder,
|
||||||
|
IsDetail: isDetailVal,
|
||||||
|
ParentImageID: im.ParentImageID,
|
||||||
|
DetailCount: detailCount[im.ID],
|
||||||
|
}
|
||||||
|
v.Images = append(v.Images, ref)
|
||||||
|
if !isDetail {
|
||||||
|
groups = append(groups, DraftImageGroup{Image: ref})
|
||||||
|
groupIndex[im.ID] = len(groups) - 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if gi, ok := groupIndex[root]; ok {
|
||||||
|
groups[gi].Details = append(groups[gi].Details, ref)
|
||||||
|
} else { // 兜底(理论不发生):主图未先出现,自成一组
|
||||||
|
groups = append(groups, DraftImageGroup{Image: ref})
|
||||||
|
groupIndex[im.ID] = len(groups) - 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
v.Groups = groups
|
||||||
return v, nil
|
return v, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
134
internal/service/review_street_group_test.go
Normal file
134
internal/service/review_street_group_test.go
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"fashionapi/internal/dto"
|
||||||
|
"fashionapi/internal/model"
|
||||||
|
"fashionapi/internal/pkg/imgurl"
|
||||||
|
"fashionapi/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeGroupRepo 只实现分组相关的仓储方法,其余由嵌入的接口兜底(测试中不会调到未实现的方法)。
|
||||||
|
type fakeGroupRepo struct {
|
||||||
|
repository.ReviewRepository
|
||||||
|
images []model.StreetSnapDraftImage
|
||||||
|
detail *model.StreetSnapDraft
|
||||||
|
attachFn func(draftID, mainID uint32, imgIDs []uint32) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeGroupRepo) StreetDraftDetail(_ context.Context, _ uint32) (*model.StreetSnapDraft, error) {
|
||||||
|
return f.detail, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeGroupRepo) StreetDraftImages(_ context.Context, _ uint32) ([]model.StreetSnapDraftImage, error) {
|
||||||
|
return f.images, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeGroupRepo) AttachStreetDraftImages(_ context.Context, draftID, mainID uint32, imgIDs []uint32) error {
|
||||||
|
if f.attachFn != nil {
|
||||||
|
return f.attachFn(draftID, mainID, imgIDs)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeGroupRepo) AttachPrevStreetDraftImage(_ context.Context, _ uint32, _ uint32) error {
|
||||||
|
if f.attachFn != nil {
|
||||||
|
return f.attachFn(0, 0, nil)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeGroupRepo) DetachStreetDraftImage(_ context.Context, _ uint32, _ uint32) error {
|
||||||
|
if f.attachFn != nil {
|
||||||
|
return f.attachFn(0, 0, nil)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ repository.ReviewRepository = (*fakeGroupRepo)(nil)
|
||||||
|
|
||||||
|
func newGroupService(f *fakeGroupRepo) ReviewService {
|
||||||
|
return NewReviewService(f, nil, imgurl.New("", "", ""), nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStreetGroupDetailExposesGrouping 详情必须输出可折叠分组:主图各成一组,副图归入主图。
|
||||||
|
func TestStreetGroupDetailExposesGrouping(t *testing.T) {
|
||||||
|
repo := &fakeGroupRepo{
|
||||||
|
detail: &model.StreetSnapDraft{ID: 7, Title: "T", Year: 2026, City: "Beijing", Status: model.DraftStatusPending},
|
||||||
|
images: []model.StreetSnapDraftImage{
|
||||||
|
{ID: 1, Image: "main.jpg", Name: "main", SortOrder: 1, IsDetail: 0, ParentImageID: 0},
|
||||||
|
{ID: 2, Image: "det.jpg", Name: "det", SortOrder: 2, IsDetail: 1, ParentImageID: 1},
|
||||||
|
{ID: 3, Image: "solo.jpg", Name: "solo", SortOrder: 3, IsDetail: 0, ParentImageID: 0},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
svc := newGroupService(repo)
|
||||||
|
v, err := svc.DraftDetail(context.Background(), dto.IngestKindStreet, 7)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DraftDetail 失败: %v", err)
|
||||||
|
}
|
||||||
|
if len(v.Images) != 3 {
|
||||||
|
t.Fatalf("Images 应保留扁平 3 张,实际 %d", len(v.Images))
|
||||||
|
}
|
||||||
|
if len(v.Groups) != 2 {
|
||||||
|
t.Fatalf("Groups 应为 2 组,实际 %d", len(v.Groups))
|
||||||
|
}
|
||||||
|
if v.Groups[0].Image.ID != 1 || len(v.Groups[0].Details) != 1 {
|
||||||
|
t.Fatalf("第 0 组应为 1 张主图 + 1 副图,实际 main=%d details=%d", v.Groups[0].Image.ID, len(v.Groups[0].Details))
|
||||||
|
}
|
||||||
|
if v.Groups[0].Image.DetailCount != 1 {
|
||||||
|
t.Fatalf("主图 DetailCount 应为 1,实际 %d", v.Groups[0].Image.DetailCount)
|
||||||
|
}
|
||||||
|
if v.Groups[0].Details[0].ID != 2 {
|
||||||
|
t.Fatalf("副图应为 id=2,实际 %d", v.Groups[0].Details[0].ID)
|
||||||
|
}
|
||||||
|
if v.Groups[1].Image.ID != 3 {
|
||||||
|
t.Fatalf("第 1 组主图应为 id=3,实际 %d", v.Groups[1].Image.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStreetGroupAttachDelegates 并入必须透传参数到仓储。
|
||||||
|
func TestStreetGroupAttachDelegates(t *testing.T) {
|
||||||
|
var got struct {
|
||||||
|
draftID uint32
|
||||||
|
mainID uint32
|
||||||
|
imgIDs []uint32
|
||||||
|
}
|
||||||
|
repo := &fakeGroupRepo{attachFn: func(draftID, mainID uint32, imgIDs []uint32) error {
|
||||||
|
got.draftID, got.mainID, got.imgIDs = draftID, mainID, imgIDs
|
||||||
|
return nil
|
||||||
|
}}
|
||||||
|
svc := newGroupService(repo)
|
||||||
|
if err := svc.AttachDraftImages(context.Background(), dto.IngestKindStreet, 7, 1, []uint32{2, 3}); err != nil {
|
||||||
|
t.Fatalf("AttachDraftImages 失败: %v", err)
|
||||||
|
}
|
||||||
|
if got.draftID != 7 || got.mainID != 1 || len(got.imgIDs) != 2 {
|
||||||
|
t.Fatalf("参数透传错误: draftID=%d mainID=%d imgIDs=%v", got.draftID, got.mainID, got.imgIDs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStreetGroupNonStreetRejected 走秀模块不允许主副图分组操作。
|
||||||
|
func TestStreetGroupNonStreetRejected(t *testing.T) {
|
||||||
|
repo := &fakeGroupRepo{}
|
||||||
|
svc := newGroupService(repo)
|
||||||
|
if err := svc.AttachDraftImages(context.Background(), dto.IngestKindRunway, 7, 1, []uint32{2}); !errors.Is(err, ErrInvalidImageGroup) {
|
||||||
|
t.Fatalf("走秀并入应返回 ErrInvalidImageGroup,实际 %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStreetGroupPreventCrossDraft 仓储拦截跨草稿主图后,service 翻成中文用户错误。
|
||||||
|
func TestStreetGroupPreventCrossDraft(t *testing.T) {
|
||||||
|
repo := &fakeGroupRepo{attachFn: func(_, _ uint32, _ []uint32) error {
|
||||||
|
return repository.ErrInvalidImageGroup
|
||||||
|
}}
|
||||||
|
svc := newGroupService(repo)
|
||||||
|
err := svc.AttachDraftImages(context.Background(), dto.IngestKindStreet, 7, 1, []uint32{2, 3})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("跨草稿应报错")
|
||||||
|
}
|
||||||
|
if err.Error() != "主图无效:它必须属于本草稿、未被删除,且自身是主图" {
|
||||||
|
t.Fatalf("错误文案不符: %q", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user