update
This commit is contained in:
@ -194,25 +194,22 @@ func (s *articleService) detailErr(err error) error {
|
||||
|
||||
// buildDetail 把查询行与图片集映射为详情 DTO(公开/后台共用,差异只在数据来源)。
|
||||
func (s *articleService) buildDetail(row *model.RunwayRow, imgs []model.BrandRunwayImage, locale string) *dto.PublicArticleDetail {
|
||||
// 主图(is_detail=0)进 Images,默认展示;细节图(is_detail=1)按 look_index
|
||||
// 直接挂到所属主图的 Detail 子数组下 —— 「look → 细节」的层级与前端 UI 一致,
|
||||
// 前端读 img.detail 即可,无需再单独维护一份「按 look_index 分组」的映射表。
|
||||
// 主图(is_detail=0)进 Images,默认展示;细节图(is_detail=1)按 parent_image_id
|
||||
// 直接挂到所属主图的 Detail 子数组下 —— 「主图 → 细节」的层级与前端 UI 一致,前端读 img.detail 即可。
|
||||
mains := make([]model.BrandRunwayImage, 0, len(imgs))
|
||||
byLook := make(map[uint32][]model.BrandRunwayImage)
|
||||
detailsByParent := make(map[uint32][]model.BrandRunwayImage)
|
||||
for _, im := range imgs {
|
||||
if im.IsDetail == 0 {
|
||||
if im.IsDetail == 0 || im.ParentImageID == 0 {
|
||||
mains = append(mains, im)
|
||||
} else {
|
||||
byLook[im.LookIndex] = append(byLook[im.LookIndex], im)
|
||||
detailsByParent[im.ParentImageID] = append(detailsByParent[im.ParentImageID], im)
|
||||
}
|
||||
}
|
||||
pubMains := s.toPublicImages(mains)
|
||||
for i := range pubMains {
|
||||
// 挂载后即从 byLook 摘除:主图 look_index 若有重复,只归属第一张,避免同一批
|
||||
// 细节图被复制进多个主图(正常数据不会重复,此处仅作防御)。
|
||||
if kids, ok := byLook[pubMains[i].LookIndex]; ok && len(kids) > 0 {
|
||||
// 主图 id 对应的细节图子集挂到该主图下(正常数据每个主图一份,无需防御性摘除)。
|
||||
if kids, ok := detailsByParent[mains[i].ID]; ok && len(kids) > 0 {
|
||||
pubMains[i].Detail = s.toPublicImages(kids)
|
||||
delete(byLook, pubMains[i].LookIndex)
|
||||
}
|
||||
}
|
||||
|
||||
@ -231,7 +228,45 @@ func (s *articleService) buildDetail(row *model.RunwayRow, imgs []model.BrandRun
|
||||
// ── 后台编辑支持 ──────────────────────────────────────────────────────────────
|
||||
|
||||
func (s *articleService) GetForEdit(ctx context.Context, id uint32) (*model.BrandRunway, []model.BrandRunwayImage, error) {
|
||||
return s.articles.GetForEdit(ctx, id)
|
||||
rw, imgs, err := s.articles.GetForEdit(ctx, id)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
// 与街拍编辑页同构:把每张主图(is_detail=0)紧跟其细节图(同 parent_image_id、is_detail=1)重排,
|
||||
// 避免细节图被甩到几百张图的最末尾、看起来像「没显示」。孤儿细节图(无主图)追加末尾。
|
||||
return rw, orderRunwayImagesForEdit(imgs), nil
|
||||
}
|
||||
|
||||
// orderRunwayImagesForEdit 走秀图片按 parent_image_id 重排为「主图后紧跟其细节图」的扁平顺序。
|
||||
// 同一主图内的细节图按 sort_order 升序;孤儿细节图(parent 缺失)追加末尾,不丢图。
|
||||
func orderRunwayImagesForEdit(imgs []model.BrandRunwayImage) []model.BrandRunwayImage {
|
||||
mains := make([]model.BrandRunwayImage, 0, len(imgs))
|
||||
mainSeen := make(map[uint32]bool, len(imgs))
|
||||
for _, im := range imgs {
|
||||
isMain := im.IsDetail == 0 || im.ParentImageID == 0
|
||||
if isMain && !mainSeen[im.ID] {
|
||||
mainSeen[im.ID] = true
|
||||
mains = append(mains, im)
|
||||
}
|
||||
}
|
||||
detailsByParent := make(map[uint32][]model.BrandRunwayImage, len(imgs))
|
||||
for _, im := range imgs {
|
||||
if im.IsDetail == 1 && im.ParentImageID != 0 && !mainSeen[im.ID] {
|
||||
detailsByParent[im.ParentImageID] = append(detailsByParent[im.ParentImageID], im)
|
||||
}
|
||||
}
|
||||
out := make([]model.BrandRunwayImage, 0, len(imgs))
|
||||
for _, main := range mains {
|
||||
out = append(out, main)
|
||||
out = append(out, detailsByParent[main.ID]...)
|
||||
}
|
||||
// 孤儿细节图(parent 缺失):追加末尾,保证不丢图。
|
||||
for _, im := range imgs {
|
||||
if im.IsDetail == 1 && im.ParentImageID == 0 && !mainSeen[im.ID] {
|
||||
out = append(out, im)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *articleService) UpdateRunway(ctx context.Context, id uint32, fields map[string]any) error {
|
||||
@ -306,7 +341,6 @@ func (s *articleService) toPublicImages(imgs []model.BrandRunwayImage) []dto.Pub
|
||||
Thumb: s.img.ComposeThumb(im.Image),
|
||||
Name: im.Name,
|
||||
IsDetail: im.IsDetail,
|
||||
LookIndex: im.LookIndex,
|
||||
})
|
||||
}
|
||||
return out
|
||||
@ -325,7 +359,6 @@ func (s *articleService) toThumbImages(imgs []model.BrandRunwayImage) []dto.Publ
|
||||
Image: s.img.ComposeThumb(im.Image),
|
||||
Name: im.Name,
|
||||
IsDetail: im.IsDetail,
|
||||
LookIndex: im.LookIndex,
|
||||
})
|
||||
}
|
||||
return out
|
||||
|
||||
@ -37,6 +37,11 @@ func (c *captureIngestRepo) FindNearDuplicateImage(ctx context.Context, tables [
|
||||
|
||||
func (c *captureIngestRepo) MarkDone(ctx context.Context, id uint32) error { return nil }
|
||||
|
||||
// RepairRunwayDetailParents 在捕获型仓储里无需真正落库回填,直接返回成功(被测路径只关心落库记录)。
|
||||
func (c *captureIngestRepo) RepairRunwayDetailParents(ctx context.Context, runwayID uint32) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// fakeBrandRepo 品牌校验恒通过。
|
||||
type fakeBrandRepo struct{ repository.BrandRepository }
|
||||
|
||||
|
||||
@ -286,7 +286,6 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob,
|
||||
Image: fi.url,
|
||||
Name: fmt.Sprintf("Look %d", i+1),
|
||||
SortOrder: uint32(i + 1),
|
||||
LookIndex: uint32(i + 1),
|
||||
IsDetail: 0,
|
||||
Phash: sqlNull(fi.phash),
|
||||
IsDuplicate: fi.isDup,
|
||||
@ -333,13 +332,21 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob,
|
||||
s.failOrRetry(ctx, job.ID, "save runway: "+saveErr.Error())
|
||||
return
|
||||
}
|
||||
// 入库后按 sort_order 回填细节图的 parent_image_id,使新走秀自动成组(与街拍一致)。
|
||||
runwayID := rw.ID
|
||||
if runwayID == 0 {
|
||||
runwayID = reuseID
|
||||
}
|
||||
if rerr := s.repo.RepairRunwayDetailParents(ctx, runwayID); rerr != nil {
|
||||
log.Printf("[ingest] job=%d runway 回填 parent 失败: %v", job.ID, rerr)
|
||||
}
|
||||
log.Printf("[ingest] job=%d runway 写正式表 耗时=%v 图片行=%d 复用驳回=%v",
|
||||
job.ID, time.Since(writeStart).Round(time.Millisecond), len(images), action == ingestReuseRejected)
|
||||
_ = s.repo.MarkDone(ctx, job.ID)
|
||||
}
|
||||
|
||||
// fetchLookImages 按 Looks 结构下载主图+细节图并上传到存储,返回可直接落正式图片表的
|
||||
// BrandRunwayImage 行(带 look_index / is_detail 分组)。任意一张下载/上传失败即把
|
||||
// BrandRunwayImage 行(带 parent_image_id / is_detail 分组)。任意一张下载/上传失败即把
|
||||
// failed 置 true,调用方据此把整条任务判失败并回滚本批已上传的 key,符合「单图失败=整任务失败」策略。
|
||||
// cover 取首个成功下载的主图;image_count 由调用方按返回行中 is_detail == 0 的数量计
|
||||
//(countMainImages),而不是 len(Looks):跳过空主图 / sha1 重复后实际行数可能更少。
|
||||
@ -408,7 +415,6 @@ func (s *IngestService) fetchLookImages(ctx context.Context, looks []dto.RunwayL
|
||||
Image: r.url,
|
||||
Name: name,
|
||||
SortOrder: uint32(order),
|
||||
LookIndex: uint32(task.lookIdx),
|
||||
IsDetail: isDetail,
|
||||
Phash: sqlNull(r.phashBits),
|
||||
IsDuplicate: isDup,
|
||||
@ -435,9 +441,11 @@ func countMainImages(images []model.BrandRunwayImage) uint16 {
|
||||
// processStreet 街拍入库:实体键分三支(新建 / 放弃 / 复用驳回)→ 下载图 → 直写 street_snaps(无品牌)。
|
||||
func (s *IngestService) processStreet(ctx context.Context, job model.IngestJob, p dto.RunwayIngest) {
|
||||
// 1) 实体键查重并决定分支(单表模型,同 runway)。
|
||||
// 实体键含 title:与下方 snap.Title 用的是同一个 p.TitleEn,保证查重口径与落库内容一致,
|
||||
// 实体键含 title:与下方 snap.Title 用的是同一个归一化后的 title,保证查重口径与落库内容一致,
|
||||
// 同城同年的不同专题(Day 2 / Day 3)因此不会被误判为重复。
|
||||
reuseID, existStatus, exist, err := s.repo.StreetSnapEntityState(ctx, p.City, p.Year, p.TitleEn)
|
||||
// 标题先经 NormalizeStreetTitle 归一化(空白折叠),消除「纯空白微调」造成的重复实体(设计规格 §12.4)。
|
||||
title := repository.NormalizeStreetTitle(p.TitleEn)
|
||||
reuseID, existStatus, exist, err := s.repo.StreetSnapEntityState(ctx, p.City, p.Year, title)
|
||||
if err != nil {
|
||||
// 同 runway:读失败不得当「未命中」,否则会给同一实体留两行。fail-closed + 退避重试。
|
||||
s.failOrRetry(ctx, job.ID, "entity lookup: "+err.Error())
|
||||
@ -465,7 +473,7 @@ func (s *IngestService) processStreet(ctx context.Context, job model.IngestJob,
|
||||
writeStart := time.Now()
|
||||
snap := &model.StreetSnap{
|
||||
JobID: job.ID,
|
||||
Title: p.TitleEn, // 街拍单标题,爬虫优先填 title_en
|
||||
Title: title, // 街拍单标题,爬虫优先填 title_en(已归一化,与查重口径一致)
|
||||
Year: p.Year,
|
||||
City: p.City,
|
||||
Cover: cover,
|
||||
@ -695,7 +703,7 @@ func concurrentFetch(ctx context.Context, n int, fn func(context.Context, int) d
|
||||
}
|
||||
|
||||
// lookImageTask 是 fetchLookImages 摊平后的一张待下载任务:记录它属于哪个 look、
|
||||
// 是主图还是第几张细节图,供并发下载完成后重建 Name / LookIndex / IsDetail。
|
||||
// 是主图还是第几张细节图,供并发下载完成后重建 Name / 主副图归属 / IsDetail。
|
||||
type lookImageTask struct {
|
||||
url string
|
||||
lookIdx int // 1-based look 序号
|
||||
|
||||
@ -28,8 +28,8 @@ type RecordCard struct {
|
||||
|
||||
// RecordImageRef 记录图片引用(统一结构)。
|
||||
//
|
||||
// IsDetail / ParentImageID 只有街拍模块会填(走秀的主/细节图走 LookIndex 那套,语义不同,不复用);
|
||||
// DetailCount 让模板不用自己数副图。
|
||||
// 街拍 / 走秀共用同一套主副图模型:is_detail=0 为主图(parent_image_id=0),
|
||||
// =1 为副图(parent_image_id 指向同记录内主图的行 id)。DetailCount 让模板不用自己数副图。
|
||||
type RecordImageRef struct {
|
||||
ID uint32 `json:"id"`
|
||||
Image string `json:"image"`
|
||||
@ -70,8 +70,9 @@ type RecordDetailView struct {
|
||||
Images []RecordImageRef `json:"images"`
|
||||
// Fields 是审核前可微调的字段列表(按模块声明顺序渲染),替代模板里的 IsStreet 分支。
|
||||
Fields []EditField `json:"fields"`
|
||||
// Groups 街拍详情的「主图 → 其副图」分组视图(按 sort_order 顺序)。走秀模块留空。
|
||||
// 与 Images 同源同序:Images 保持扁平(既有渲染 / runway 仍用),Groups 供按组折叠。
|
||||
// Groups 详情的「主图 → 其副图」分组视图(按 sort_order 顺序)。
|
||||
// 街拍与走秀均按 parent_image_id 分组(见各模块 RecordDetail),结构同构。
|
||||
// 与 Images 同源同序:Images 保持扁平(既有渲染),Groups 供按组折叠。
|
||||
Groups []ImageGroup `json:"groups,omitempty"`
|
||||
}
|
||||
|
||||
@ -286,20 +287,28 @@ func (s *reviewService) DeleteImage(ctx context.Context, kind string, recordID,
|
||||
}
|
||||
|
||||
func (s *reviewService) AttachImages(ctx context.Context, kind string, recordID, mainImgID uint32, imgIDs []uint32) error {
|
||||
if kind != dto.IngestKindStreet {
|
||||
return ErrInvalidImageGroup
|
||||
}
|
||||
if len(imgIDs) == 0 {
|
||||
return ErrInvalidImageGroup
|
||||
}
|
||||
return translateGroupErr(s.repo.AttachStreetSnapImages(ctx, recordID, mainImgID, imgIDs))
|
||||
switch kind {
|
||||
case dto.IngestKindStreet:
|
||||
return translateGroupErr(s.repo.AttachStreetSnapImages(ctx, recordID, mainImgID, imgIDs))
|
||||
case dto.IngestKindRunway:
|
||||
return translateGroupErr(s.repo.AttachRunwayImages(ctx, recordID, mainImgID, imgIDs))
|
||||
default:
|
||||
return ErrInvalidImageGroup
|
||||
}
|
||||
}
|
||||
|
||||
func (s *reviewService) DetachImage(ctx context.Context, kind string, recordID, imgID uint32) error {
|
||||
if kind != dto.IngestKindStreet {
|
||||
switch kind {
|
||||
case dto.IngestKindStreet:
|
||||
return translateGroupErr(s.repo.DetachStreetSnapImage(ctx, recordID, imgID))
|
||||
case dto.IngestKindRunway:
|
||||
return translateGroupErr(s.repo.DetachRunwayImage(ctx, recordID, imgID))
|
||||
default:
|
||||
return ErrInvalidImageGroup
|
||||
}
|
||||
return translateGroupErr(s.repo.DetachStreetSnapImage(ctx, recordID, imgID))
|
||||
}
|
||||
|
||||
// translateGroupErr 把仓储层的哨兵错误翻成给用户看的中文文案;其余错误原样透传。
|
||||
@ -367,12 +376,58 @@ func (m *runwayModule) RecordDetail(ctx context.Context, id uint32) (*RecordDeta
|
||||
{Name: "season_code", Label: "季节码", Value: d.SeasonCode, Type: "text"},
|
||||
},
|
||||
}
|
||||
// 按 parent_image_id 成组(与街拍同构):is_detail=0 为主图;is_detail=1 且 parent_image_id>0
|
||||
// 的是其副图;is_detail=1 且 parent_image_id=0 的孤儿细节图按主图渲染(自成一组,不丢图)。
|
||||
// 这样走秀详情页(消费 Groups)与街拍同构,编辑页(消费 Images)也能按主图→细节图顺序渲染。
|
||||
mains := make([]model.BrandRunwayImage, 0, len(imgs))
|
||||
mainSeen := make(map[uint32]bool, len(imgs))
|
||||
for _, im := range imgs {
|
||||
v.Images = append(v.Images, RecordImageRef{ID: im.ID, Image: m.img.Compose(im.Image), Name: im.Name, SortOrder: im.SortOrder})
|
||||
isMain := im.IsDetail == 0 || im.ParentImageID == 0
|
||||
if isMain && !mainSeen[im.ID] {
|
||||
mainSeen[im.ID] = true
|
||||
mains = append(mains, im)
|
||||
}
|
||||
}
|
||||
detailsByParent := make(map[uint32][]model.BrandRunwayImage, len(imgs))
|
||||
for _, im := range imgs {
|
||||
if im.IsDetail == 1 && im.ParentImageID != 0 && !mainSeen[im.ID] {
|
||||
detailsByParent[im.ParentImageID] = append(detailsByParent[im.ParentImageID], im)
|
||||
}
|
||||
}
|
||||
detailCount := make(map[uint32]int, len(detailsByParent))
|
||||
for p, ds := range detailsByParent {
|
||||
detailCount[p] = len(ds)
|
||||
}
|
||||
for _, main := range mains {
|
||||
ref := RecordImageRef{
|
||||
ID: main.ID,
|
||||
Image: m.img.Compose(main.Image),
|
||||
Name: main.Name,
|
||||
SortOrder: main.SortOrder,
|
||||
IsDetail: 0,
|
||||
ParentImageID: 0,
|
||||
DetailCount: detailCount[main.ID],
|
||||
}
|
||||
v.Images = append(v.Images, ref)
|
||||
grp := ImageGroup{Image: ref}
|
||||
for _, d := range detailsByParent[main.ID] {
|
||||
dref := RecordImageRef{
|
||||
ID: d.ID,
|
||||
Image: m.img.Compose(d.Image),
|
||||
Name: d.Name,
|
||||
SortOrder: d.SortOrder,
|
||||
IsDetail: 1,
|
||||
ParentImageID: d.ParentImageID,
|
||||
}
|
||||
grp.Details = append(grp.Details, dref)
|
||||
v.Images = append(v.Images, dref)
|
||||
}
|
||||
v.Groups = append(v.Groups, grp)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
|
||||
func (m *runwayModule) UpdateRecord(ctx context.Context, id uint32, fields map[string]any) error {
|
||||
return m.repo.UpdateRecord(ctx, id, fields)
|
||||
}
|
||||
|
||||
@ -14,9 +14,10 @@ import (
|
||||
// fakeGroupRepo 只实现分组相关的仓储方法,其余由嵌入的接口兜底(测试中不会调到未实现的方法)。
|
||||
type fakeGroupRepo struct {
|
||||
repository.ReviewRepository
|
||||
images []model.StreetSnapImage
|
||||
detail *model.StreetSnap
|
||||
attachFn func(snapID, mainID uint32, imgIDs []uint32) error
|
||||
images []model.StreetSnapImage
|
||||
detail *model.StreetSnap
|
||||
attachFn func(snapID, mainID uint32, imgIDs []uint32) error
|
||||
attachRunwayFn func(runwayID, mainID uint32, imgIDs []uint32) error
|
||||
}
|
||||
|
||||
func (f *fakeGroupRepo) StreetRecordDetail(_ context.Context, _ uint32) (*model.StreetSnap, error) {
|
||||
@ -41,6 +42,17 @@ func (f *fakeGroupRepo) DetachStreetSnapImage(_ context.Context, _ uint32, _ uin
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeGroupRepo) AttachRunwayImages(_ context.Context, runwayID, mainID uint32, imgIDs []uint32) error {
|
||||
if f.attachRunwayFn != nil {
|
||||
return f.attachRunwayFn(runwayID, mainID, imgIDs)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeGroupRepo) DetachRunwayImage(_ context.Context, _ uint32, _ uint32) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
var _ repository.ReviewRepository = (*fakeGroupRepo)(nil)
|
||||
|
||||
func newGroupService(f *fakeGroupRepo) ReviewService {
|
||||
@ -102,12 +114,32 @@ func TestStreetGroupAttachDelegates(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestStreetGroupNonStreetRejected 走秀模块不允许主副图分组操作。
|
||||
// TestStreetGroupNonStreetRejected 既非 street 也非 runway 的实体类型不允许主副图分组操作。
|
||||
func TestStreetGroupNonStreetRejected(t *testing.T) {
|
||||
repo := &fakeGroupRepo{}
|
||||
svc := newGroupService(repo)
|
||||
if err := svc.AttachImages(context.Background(), dto.IngestKindRunway, 7, 1, []uint32{2}); !errors.Is(err, ErrInvalidImageGroup) {
|
||||
t.Fatalf("走秀并入应返回 ErrInvalidImageGroup,实际 %v", err)
|
||||
if err := svc.AttachImages(context.Background(), "bogus", 7, 1, []uint32{2}); !errors.Is(err, ErrInvalidImageGroup) {
|
||||
t.Fatalf("未知实体并入应返回 ErrInvalidImageGroup,实际 %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunwayGroupAttachDelegates 走秀并入必须透传参数到 runway 仓储。
|
||||
func TestRunwayGroupAttachDelegates(t *testing.T) {
|
||||
var got struct {
|
||||
runwayID uint32
|
||||
mainID uint32
|
||||
imgIDs []uint32
|
||||
}
|
||||
repo := &fakeGroupRepo{attachRunwayFn: func(runwayID, mainID uint32, imgIDs []uint32) error {
|
||||
got.runwayID, got.mainID, got.imgIDs = runwayID, mainID, imgIDs
|
||||
return nil
|
||||
}}
|
||||
svc := newGroupService(repo)
|
||||
if err := svc.AttachImages(context.Background(), dto.IngestKindRunway, 9, 4, []uint32{5, 6}); err != nil {
|
||||
t.Fatalf("Runway AttachImages 失败: %v", err)
|
||||
}
|
||||
if got.runwayID != 9 || got.mainID != 4 || len(got.imgIDs) != 2 {
|
||||
t.Fatalf("参数透传错误: runwayID=%d mainID=%d imgIDs=%v", got.runwayID, got.mainID, got.imgIDs)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -12,7 +12,7 @@ import (
|
||||
//
|
||||
// 5 张图:A 主,B 副(挂 A);C 主;D 副(挂 C);E 副(挂 B → 应上浮归并到 A)
|
||||
//
|
||||
// 期望 images 只有 2 张主图:A(Detail=[B,E], LookIndex=1)、C(Detail=[D], LookIndex=2)。
|
||||
// 期望 images 只有 2 张主图:A(Detail=[B,E])、C(Detail=[D])。
|
||||
func TestBuildSnapImages(t *testing.T) {
|
||||
s := &streetSnapService{img: imgurl.New("", "", "")}
|
||||
imgs := []model.StreetSnapImage{
|
||||
@ -29,21 +29,21 @@ func TestBuildSnapImages(t *testing.T) {
|
||||
}
|
||||
|
||||
a := out[0]
|
||||
if a.Name != "A" || a.IsDetail != 0 || a.LookIndex != 1 {
|
||||
t.Fatalf("第 1 张应为 A 主图 LookIndex=1,实际 name=%s is_detail=%d look=%d", a.Name, a.IsDetail, a.LookIndex)
|
||||
if a.Name != "A" || a.IsDetail != 0 {
|
||||
t.Fatalf("第 1 张应为 A 主图,实际 name=%s is_detail=%d", a.Name, a.IsDetail)
|
||||
}
|
||||
if len(a.Detail) != 2 || a.Detail[0].Name != "B" || a.Detail[1].Name != "E" {
|
||||
t.Fatalf("A 的副图应依序为 [B,E],实际 %d 张", len(a.Detail))
|
||||
}
|
||||
for _, d := range a.Detail {
|
||||
if d.IsDetail != 1 || d.LookIndex != 1 {
|
||||
t.Fatalf("副图 %s 的 is_detail/look_index 应为 1/1,实际 %d/%d", d.Name, d.IsDetail, d.LookIndex)
|
||||
if d.IsDetail != 1 {
|
||||
t.Fatalf("副图 %s 的 is_detail 应为 1,实际 %d", d.Name, d.IsDetail)
|
||||
}
|
||||
}
|
||||
|
||||
c := out[1]
|
||||
if c.Name != "C" || c.LookIndex != 2 {
|
||||
t.Fatalf("第 2 张应为 C 主图 LookIndex=2,实际 name=%s look=%d", c.Name, c.LookIndex)
|
||||
if c.Name != "C" {
|
||||
t.Fatalf("第 2 张应为 C 主图,实际 name=%s", c.Name)
|
||||
}
|
||||
if len(c.Detail) != 1 || c.Detail[0].Name != "D" {
|
||||
t.Fatalf("C 的副图应为 [D],实际 %d 张", len(c.Detail))
|
||||
|
||||
96
internal/service/street_snap_i18n_test.go
Normal file
96
internal/service/street_snap_i18n_test.go
Normal file
@ -0,0 +1,96 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"fashionapi/internal/dto"
|
||||
"fashionapi/internal/model"
|
||||
"fashionapi/internal/pkg/imgurl"
|
||||
"fashionapi/internal/repository"
|
||||
)
|
||||
|
||||
// fakeSnapRepo 只实现测试路径用到的仓储方法;其余由嵌入的接口兜底(测试中不会调到未实现的方法)。
|
||||
type fakeSnapRepo struct {
|
||||
repository.StreetSnapRepository
|
||||
findByID *model.StreetSnap
|
||||
listImages []model.StreetSnapImage
|
||||
rows []model.StreetSnap
|
||||
}
|
||||
|
||||
func (f *fakeSnapRepo) FindByID(_ context.Context, _ string) (*model.StreetSnap, error) {
|
||||
return f.findByID, nil
|
||||
}
|
||||
|
||||
func (f *fakeSnapRepo) ListImages(_ context.Context, _ string) ([]model.StreetSnapImage, error) {
|
||||
return f.listImages, nil
|
||||
}
|
||||
|
||||
func (f *fakeSnapRepo) List(_ context.Context, q dto.StreetSnapQuery) ([]model.StreetSnap, int64, error) {
|
||||
return f.rows, int64(len(f.rows)), nil
|
||||
}
|
||||
|
||||
var _ repository.StreetSnapRepository = (*fakeSnapRepo)(nil)
|
||||
|
||||
func newSnapService(f *fakeSnapRepo) StreetSnapService {
|
||||
return NewStreetSnapService(f, nil, imgurl.New("", "", ""), nil, nil)
|
||||
}
|
||||
|
||||
// TestStreetSnapDetailLocalizesTitle 详情按 locale 返回中/英标题。
|
||||
func TestStreetSnapDetailLocalizesTitle(t *testing.T) {
|
||||
repo := &fakeSnapRepo{
|
||||
findByID: &model.StreetSnap{ID: 1, Title: "London Street Style", TitleCn: "伦敦街拍", Cover: "c.jpg"},
|
||||
listImages: []model.StreetSnapImage{{ID: 1, Image: "a.jpg"}},
|
||||
}
|
||||
svc := newSnapService(repo)
|
||||
|
||||
cn, err := svc.Detail(context.Background(), "1", "cn")
|
||||
if err != nil {
|
||||
t.Fatalf("Detail cn err: %v", err)
|
||||
}
|
||||
if cn.Title != "伦敦街拍" {
|
||||
t.Errorf("cn 标题应为中文,实际 %q", cn.Title)
|
||||
}
|
||||
|
||||
en, err := svc.Detail(context.Background(), "1", "en")
|
||||
if err != nil {
|
||||
t.Fatalf("Detail en err: %v", err)
|
||||
}
|
||||
if en.Title != "London Street Style" {
|
||||
t.Errorf("en 标题应为英文,实际 %q", en.Title)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStreetSnapDetailFallsBackToEn 中文为空时回落英文。
|
||||
func TestStreetSnapDetailFallsBackToEn(t *testing.T) {
|
||||
repo := &fakeSnapRepo{findByID: &model.StreetSnap{ID: 1, Title: "Only English", TitleCn: ""}}
|
||||
svc := newSnapService(repo)
|
||||
|
||||
cn, err := svc.Detail(context.Background(), "1", "cn")
|
||||
if err != nil {
|
||||
t.Fatalf("Detail cn err: %v", err)
|
||||
}
|
||||
if cn.Title != "Only English" {
|
||||
t.Errorf("中文为空应回落英文,实际 %q", cn.Title)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStreetSnapListLocalizesTitle 列表按 locale 取标题,中文为空回落英文。
|
||||
func TestStreetSnapListLocalizesTitle(t *testing.T) {
|
||||
repo := &fakeSnapRepo{rows: []model.StreetSnap{
|
||||
{ID: 1, Title: "EN A", TitleCn: "中 A"},
|
||||
{ID: 2, Title: "EN B", TitleCn: ""},
|
||||
}}
|
||||
svc := newSnapService(repo)
|
||||
|
||||
got, _, err := svc.List(context.Background(), dto.StreetSnapQuery{Locale: "cn", Images: 0})
|
||||
if err != nil {
|
||||
t.Fatalf("List err: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("期望 2 条,实际 %d", len(got))
|
||||
}
|
||||
if got[0].Title != "中 A" || got[1].Title != "EN B" {
|
||||
t.Errorf("列表本地化错误: %#v", got)
|
||||
}
|
||||
}
|
||||
@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"sort"
|
||||
|
||||
"fashionapi/internal/dto"
|
||||
"fashionapi/internal/model"
|
||||
@ -19,11 +20,11 @@ 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)
|
||||
Detail(ctx context.Context, id string, locale string) (*dto.PublicStreetSnapDetail, error)
|
||||
// DetailAdmin 后台详情:读基表,能渲染 pending / rejected(供后台详情页复用)。
|
||||
DetailAdmin(ctx context.Context, id string) (*dto.PublicStreetSnapDetail, error)
|
||||
DetailAdmin(ctx context.Context, id string, locale string) (*dto.PublicStreetSnapDetail, error)
|
||||
// Popular 返回热度前 N 个街拍(按图片数降序),供 SSG 首页热门街拍展示。
|
||||
Popular(ctx context.Context, limit int) ([]dto.PublicStreetSnap, error)
|
||||
Popular(ctx context.Context, limit int, locale string) ([]dto.PublicStreetSnap, error)
|
||||
// GetForEdit 取原始街拍记录与图片,供后台编辑表单预填。
|
||||
GetForEdit(ctx context.Context, id uint32) (*model.StreetSnap, []model.StreetSnapImage, error)
|
||||
// UpdateSnap 编辑后更新字段(字段白名单在仓储层强制)。
|
||||
@ -70,7 +71,7 @@ func (s *streetSnapService) List(ctx context.Context, q dto.StreetSnapQuery) ([]
|
||||
for _, r := range rows {
|
||||
ids = append(ids, r.ID)
|
||||
}
|
||||
list := s.toPublicSnaps(rows)
|
||||
list := s.toPublicSnaps(rows, q.Locale)
|
||||
|
||||
if q.Images > 0 && len(list) > 0 {
|
||||
if err := s.attachImages(ctx, list, ids, q.Images); err != nil {
|
||||
@ -91,17 +92,17 @@ func (s *streetSnapService) AdminList(ctx context.Context, q dto.StreetSnapQuery
|
||||
if err != nil {
|
||||
return nil, 0, internalErr(err.Error())
|
||||
}
|
||||
return s.toPublicSnaps(rows), total, nil
|
||||
return s.toPublicSnaps(rows, q.Locale), total, nil
|
||||
}
|
||||
|
||||
// toPublicSnaps 把街拍实体映射为对外列表结构(卡片字段:编码 id / 标题 / 缩略封面 / 图片数)。
|
||||
// 公开 List 与后台 AdminList 共用,避免两处重复维护映射(封面统一走缩略图样式,小尺寸省流量)。
|
||||
func (s *streetSnapService) toPublicSnaps(rows []model.StreetSnap) []dto.PublicStreetSnap {
|
||||
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: r.Title,
|
||||
Title: pickLocale(r.Title, r.TitleCn, locale),
|
||||
Cover: s.img.ComposeThumb(r.Cover),
|
||||
ImageCount: r.ImageCount,
|
||||
Status: r.Status, // 仅后台模板渲染状态列用(json:"-" 不进公开响应)
|
||||
@ -149,7 +150,7 @@ func excludeCoverSnap(imgs []dto.PublicArticleImage, cover string) []dto.PublicA
|
||||
|
||||
// Detail 公开详情:读公开视图,pending / rejected 视同不存在(ErrStreetSnapNotFound)。
|
||||
// 图片统一走展示样式(VIP 与免费同质量)。
|
||||
func (s *streetSnapService) Detail(ctx context.Context, id string) (*dto.PublicStreetSnapDetail, error) {
|
||||
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)
|
||||
@ -158,14 +159,14 @@ func (s *streetSnapService) Detail(ctx context.Context, id string) (*dto.PublicS
|
||||
if err != nil {
|
||||
return nil, internalErr(err.Error())
|
||||
}
|
||||
return s.buildSnapDetail(snap, imgs), nil
|
||||
return s.buildSnapDetail(snap, imgs, locale), nil
|
||||
}
|
||||
|
||||
// DetailAdmin 后台详情:读基表,能渲染 pending / rejected。
|
||||
//
|
||||
// 后台列表(AdminList)故意把待审行链到详情页,且详情页是通往编辑页的唯一入口;
|
||||
// 若后台详情也走公开视图,待审项会 404、审核无从下手。
|
||||
func (s *streetSnapService) DetailAdmin(ctx context.Context, id string) (*dto.PublicStreetSnapDetail, error) {
|
||||
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)
|
||||
@ -174,7 +175,7 @@ func (s *streetSnapService) DetailAdmin(ctx context.Context, id string) (*dto.Pu
|
||||
if err != nil {
|
||||
return nil, internalErr(err.Error())
|
||||
}
|
||||
return s.buildSnapDetail(snap, imgs), nil
|
||||
return s.buildSnapDetail(snap, imgs, locale), nil
|
||||
}
|
||||
|
||||
// detailErr 统一详情取数失败的错误映射(公开 Detail 与 DetailAdmin 共用)。
|
||||
@ -186,10 +187,10 @@ func (s *streetSnapService) detailErr(err error) error {
|
||||
}
|
||||
|
||||
// buildSnapDetail 把街拍行与图片集映射为详情 DTO(公开/后台共用,差异只在数据来源)。
|
||||
func (s *streetSnapService) buildSnapDetail(snap *model.StreetSnap, imgs []model.StreetSnapImage) *dto.PublicStreetSnapDetail {
|
||||
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: snap.Title,
|
||||
Title: pickLocale(snap.Title, snap.TitleCn, locale),
|
||||
Cover: s.img.Compose(snap.Cover),
|
||||
Images: s.buildSnapImages(imgs),
|
||||
}
|
||||
@ -198,7 +199,7 @@ func (s *streetSnapService) buildSnapDetail(snap *model.StreetSnap, imgs []model
|
||||
// buildSnapImages 把街拍图片组装成「主图 + 其副图」的嵌套结构(与走秀详情同形):
|
||||
// Images 只含主图(is_detail=0),每张主图的副图挂在其 Detail 子数组下。
|
||||
// 父引用指向「副图」时沿链向上归并到该副图所在组的主图;父行缺失 / 成环则把该图按主图渲染(不丢图)。
|
||||
// LookIndex 是合成的 1-based 主图序号(街拍不落库组序号)。
|
||||
// 主图 / 副图的归并完全靠 parent_image_id 表达(街拍不落库组序号)。
|
||||
func (s *streetSnapService) buildSnapImages(imgs []model.StreetSnapImage) []dto.PublicArticleImage {
|
||||
if len(imgs) == 0 {
|
||||
return nil
|
||||
@ -237,12 +238,12 @@ func (s *streetSnapService) buildSnapImages(imgs []model.StreetSnapImage) []dto.
|
||||
continue
|
||||
}
|
||||
pub := s.toPublicSnapImage(im)
|
||||
pub.LookIndex = uint32(len(out) + 1) // 合成的 1-based 主图序号
|
||||
|
||||
index[im.ID] = len(out)
|
||||
out = append(out, pub)
|
||||
}
|
||||
|
||||
// 第二遍:把副图挂到所属主图的 Detail 下(LookIndex 取所属主图的值)。
|
||||
// 第二遍:把副图挂到所属主图的 Detail 下(副图沿用所属主图的 id 作为分组键)。
|
||||
for _, im := range imgs {
|
||||
root := resolveRoot(im.ID)
|
||||
if root == im.ID {
|
||||
@ -254,14 +255,14 @@ func (s *streetSnapService) buildSnapImages(imgs []model.StreetSnapImage) []dto.
|
||||
}
|
||||
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) {
|
||||
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())
|
||||
@ -270,7 +271,7 @@ func (s *streetSnapService) Popular(ctx context.Context, limit int) ([]dto.Publi
|
||||
for _, r := range rows {
|
||||
list = append(list, dto.PublicStreetSnap{
|
||||
UID: hashid.EncodeWithType(r.ID, hashid.TypeSnap),
|
||||
Title: r.Title,
|
||||
Title: pickLocale(r.Title, r.TitleCn, locale),
|
||||
Cover: s.img.Compose(r.Cover), // 首页热门街拍卡片为大图展示,用高清样式(high)
|
||||
ImageCount: r.ImageCount,
|
||||
})
|
||||
@ -323,7 +324,50 @@ func (s *streetSnapService) toThumbSnapImages(imgs []model.StreetSnapImage) []dt
|
||||
// ── 后台编辑支持 ──────────────────────────────────────────────────────────────
|
||||
|
||||
func (s *streetSnapService) GetForEdit(ctx context.Context, id uint32) (*model.StreetSnap, []model.StreetSnapImage, error) {
|
||||
return s.repo.GetForEdit(ctx, id)
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user