Files
backend_v2/docs/superpowers/plans/2026-09-21-street-main-detail.md
toom1996 d908036621 update
2026-09-22 11:12:23 +08:00

61 KiB
Raw Permalink Blame History

街拍主副图 实现计划

面向 AI 代理的工作者: 必需子技能:使用 subagent-driven-development(推荐)或 executing-plans 逐任务实现此计划。步骤使用复选框(- [ ])语法来跟踪进度。

目标: 给街拍图集加「主图 / 副图」分组——审核页人工指定,详情页按组折叠,公开 API 保守扩展。

架构: 纯人工分组,不引入算法。数据层在两张街拍图片表上加 is_detail + parent_image_id(列已由迁移脚本加好,本计划不改 DB);读侧把扁平图片列表「按主副关系重排」成组视图,父引用失效时容错按主图渲染。后台沿用现有「零 JS 同步表单 POST + 302」风格,当前主图由 URL 查询参数 ?main=<imgID> 承载。

技术栈: Go 1.2x + Gin + GORM + PostgreSQL;后台为服务端渲染(内联 html/template 字符串常量,无前端框架)。

规格: docs/superpowers/specs/2026-09-21-street-main-detail-design.md

全局约束

  • 零 JS:后台不得引入 <script> / fetch;交互一律同步表单 POST,成功返回 302。onclick="return confirm(...)" 仅作确认,允许保留。
  • 当前主图用 URL 查询参数承载:?main=<imgID>;页面顶部渲染常驻提示条,刷新/后退/书签均保持。
  • 不变量:is_detail = 1 的行,parent_image_id 必须指向同一 owner 内一张 is_detail = 0 的行。
  • 读侧容错:父行不存在或已被软删时,该图按主图渲染,不隐藏、不报错、不丢图。
  • 公开 API 保守扩展:PublicStreetSnapDetail.Images(扁平)保持原样不变,新增 Groups 字段;PublicStreetSnap(列表)不动。
  • image_count 语义不变:仍计全部图(不是只算主图)。
  • 组序号不落库:由读时按 sort_order 顺序推导(主图顺序即组序)。
  • 已被否决的做法(不要实现):算法自动分组;拖拽交互;修改 image_count 语义;给 runway 侧加同类字段。
  • DB 前置条件:street_snap_draft_images / street_snap_images 的 is_detail、parent_image_id 两列已由 db/migrations/2026-09-21-01-street-main-detail.sql 加好并应用,且已重导出进 db/backups/db_dump.sql。本计划任何一步都不应再写 DDL;若集成测试报 column "is_detail" does not exist,说明库未就绪,先重灌 dump(dbtool import -clean -in db/backups/db_dump.sql)。
  • 排序口径:街拍图片顺序一律由 ORDER BY sort_order ASC, id ASC 决定(sort_order 越小越靠前)。本功能不改变任何写入时的 sort_order 赋值逻辑。
  • 后台错误页:参数无效 / 操作失败一律 renderHTML(c, http.StatusXxx, tplError, gin.H{"Error": "..."}),不要 c.JSON。
  • HTML 表单不得嵌套:<form> 内不能包含另一个 <form>(浏览器只会认外层)。每张图的「并入 / 拆出 / 删除 / 并入上一张」按钮都是各自独立的 <form>,并列放在 .acts 容器里,绝不能用一个外层表单把它们包住。

文件结构

文件 职责 动作
internal/model/street_snap_draft.go 草稿图片结构 修改:StreetSnapDraftImage 加 IsDetail / ParentImageID
internal/model/street_snap.go 正式图片结构 修改:StreetSnapImage 加 IsDetail / ParentImageID
internal/repository/review_repository.go 草稿读写 + 晋升 修改:接口加 3 个分组方法 + SaveStreetSnapFromDraft 重建父引用
internal/service/review_service.go 审核业务逻辑 修改:DraftImageRef 加分组字段;新增 DraftImageGroup;DraftDetailView 加 MainID+Groups;接口加 3 个方法;实现;新增错误
internal/router/backstage.go 后台路由 修改:加 3 条路由
internal/handler/backstage_handler.go 后台页面渲染与表单 修改:加 3 个 handler;改 tplReviewDetail 模板
internal/handler/backstage_handler_test.go 模板解析冒烟 修改:既有解析测试自动覆盖新模板常量(确认仍通过)
internal/router/backstage_test.go 路由冒烟 修改:加 fakeReview;断言 3 条新路由的 302 与入参;断言分组渲染
internal/dto/street_snap.go 公开响应结构 修改:新增 PublicStreetSnapGroup + Groups 字段
internal/service/street_snap_service.go 公开详情组装 修改:Detail 增加 Groups 组装
internal/repository/street_main_detail_integration_test.go 仓储层集成测试 创建
internal/service/review_street_group_test.go service 层纯单测 创建
internal/service/street_snap_group_test.go 公开 API 分组纯单测 创建

为什么把「并入一个自带副图的主图」放在仓储层:它是数据不变量问题(不能出现「副图的副图」),必须和写入同一个事务;放在 service 层会拆成两次写、失去原子性。


实现裁决(规格未明确、由本计划定的三处)

  1. 并入一张自带副图的主图时:把它原有的副图一并改挂到新主图(而不是拒绝、也不是留成孤儿)。理由:保留不变量,且「把这一组并到那组」符合直觉。
  2. 「并入上一张」的目标:以「上一行所属组的主图」为目标;若上一行本身是主图,它就是目标。这样连续并入会链式归并到同一个主图。
  3. 拆出(detach)对主图调用:幂等成功、无副作用(不报错),避免用户重复点击时看到错误页。

关于「批量勾选并入」的交互取舍:规格 §3.2 提到「勾选多张 + 主图 → 合并」。但零 JS 下「一个外层表单包住所有图、里面再放各自的拆出/删除子表单」会产生表单嵌套(浏览器不支持)。本计划改用逐张「并入当前主图」按钮(每张图一个小表单,提交自身 img),既满足零 JS 又无嵌套;「批量」退化为多次点击,数据规模(街拍每篇 8–30 图)下完全可接受。若日后想要真批量,再开一个位于 grid 之外的独立表单。


任务 1:草稿图分组写入(模型字段 + 仓储三方法)

文件:

  • 修改:internal/model/street_snap_draft.go(StreetSnapDraftImage 结构体末尾)
  • 修改:internal/repository/review_repository.go:18-48(接口)、文件末尾(实现)
  • 创建:internal/repository/street_main_detail_integration_test.go

接口新增(review_repository.go 的 ReviewRepository 内,紧接 SoftDeleteStreetDraftImage 那两行之后):

	// AttachStreetDraftImages 把 imgIDs 变成 mainImgID 的副图(批量并入)。
	// mainImgID 必须属于同一草稿、未被软删、且自身是主图(is_detail=0),否则返回 ErrInvalidImageGroup。
	// 若某个被并入的图自身还带着副图,则它原有的副图一并改挂到 mainImgID ——
	// 避免出现「副图的副图」破坏不变量(见本计划「实现裁决」第 1 条)。
	// 幂等:已是 mainImgID 副图的行重复并入不报错。
	AttachStreetDraftImages(ctx context.Context, draftID, mainImgID uint32, imgIDs []uint32) error
	// AttachPrevStreetDraftImage 把 imgID 并入「同草稿内 sort_order 紧邻的前一行所属组的主图」。
	// imgID 是首行(没有上一行)时返回 ErrNotFound。
	AttachPrevStreetDraftImage(ctx context.Context, draftID, imgID uint32) error
	// DetachStreetDraftImage 把副图恢复为主图(is_detail=0, parent_image_id=0)。
	// 对主图调用是幂等成功(无副作用)。
	DetachStreetDraftImage(ctx context.Context, draftID, imgID uint32) error

新错误哨兵(review_repository.go 顶部 var 区,与既有 ErrNotFound 放一起):

// ErrInvalidImageGroup 主副图分组操作不合法:主图不存在 / 不属于该草稿 / 自身不是主图 / 空集合。
var ErrInvalidImageGroup = errors.New("invalid image group")

模型新增(internal/model/street_snap_draft.go,StreetSnapDraftImage 内 DupOf 之后):

	// 主副图分组(列由 db/migrations/2026-09-21-01-street-main-detail.sql 添加):
	// is_detail=0 为主图(parent_image_id=0);=1 为副图,parent_image_id 指向同草稿内主图的行 id。
	// 存行 id 而非序号:序号会因重排 / 插入 / 删除而失效。
	IsDetail      uint8  `gorm:"column:is_detail;not null;default:0" json:"is_detail"`
	ParentImageID uint32 `gorm:"column:parent_image_id;not null;default:0" json:"parent_image_id"`
  • 步骤 1:编写失败的集成测试

创建 internal/repository/street_main_detail_integration_test.go:

//go:build integration

// 集成测试:街拍草稿主副图分组需要真实 PostgreSQL。
//
// 运行方式(先起库并确保结构已就绪,见 README「数据库」一节):
//
//	docker compose -f scripts/pgvector/docker-compose.yml up -d
//	go test -tags integration ./internal/repository/ -run TestStreetMainDetail -v
//
// 默认 `go test ./...` 不会编译本文件(build tag 隔离)。
package repository

import (
	"context"
	"errors"
	"testing"

	"fashionapi/internal/model"
)

// seedStreetDraft 插入一条街拍草稿并返回其 id。
func seedStreetDraft(t *testing.T, repo ReviewRepository) uint32 {
	t.Helper()
	ctx := context.Background()
	db := repo.(*reviewRepository).db
	d := model.StreetSnapDraft{Title: "主副图测试", Year: 2026, City: "TestCity", Status: model.DraftStatusPending}
	if err := db.WithContext(ctx).Create(&d).Error; err != nil {
		t.Fatalf("插入草稿失败: %v", err)
	}
	t.Cleanup(func() {
		db.Exec("DELETE FROM street_snap_draft_images WHERE draft_id = ?", d.ID)
		db.Exec("DELETE FROM street_snap_drafts WHERE id = ?", d.ID)
	})
	return d.ID
}

// seedStreetDraftImage 插入一张草稿图并返回其行 id。
func seedStreetDraftImage(t *testing.T, repo ReviewRepository, draftID uint32, name string, order uint32) uint32 {
	t.Helper()
	img := model.StreetSnapDraftImage{DraftID: draftID, Image: name + ".jpg", Name: name, SortOrder: order}
	if err := repo.(*reviewRepository).db.WithContext(context.Background()).Create(&img).Error; err != nil {
		t.Fatalf("插入草稿图失败: %v", err)
	}
	return img.ID
}

// reloadStreetImages 取草稿的全部图(按 sort_order, id 升序),便于断言。
func reloadStreetImages(t *testing.T, repo ReviewRepository, draftID uint32) []model.StreetSnapDraftImage {
	t.Helper()
	imgs, err := repo.StreetDraftImages(context.Background(), draftID)
	if err != nil {
		t.Fatalf("读草稿图失败: %v", err)
	}
	return imgs
}

// TestStreetMainDetail 覆盖批量并入 / 并入上一张 / 拆出 / 跨草稿拦截四条主线。
func TestStreetMainDetail(t *testing.T) {
	repo := NewReviewRepository(testDB(t))
	ctx := context.Background()

	draftID := seedStreetDraft(t, repo)
	m1 := seedStreetDraftImage(t, repo, draftID, "a", 1)
	d2 := seedStreetDraftImage(t, repo, draftID, "b", 2)
	d3 := seedStreetDraftImage(t, repo, draftID, "c", 3)

	if err := repo.AttachStreetDraftImages(ctx, draftID, m1, []uint32{d2, d3}); err != nil {
		t.Fatalf("并入失败: %v", err)
	}
	for _, im := range reloadStreetImages(t, repo, draftID) {
		switch im.ID {
		case m1:
			if im.IsDetail != 0 || im.ParentImageID != 0 {
				t.Fatalf("主图应保持 is_detail=0/parent=0,实际 %d/%d", im.IsDetail, im.ParentImageID)
			}
		case d2, d3:
			if im.IsDetail != 1 || im.ParentImageID != m1 {
				t.Fatalf("副图应指向 %d,实际 is_detail=%d parent=%d", m1, im.IsDetail, im.ParentImageID)
			}
		}
	}

	// 幂等:重复并入同一批不报错,结果不变。
	if err := repo.AttachStreetDraftImages(ctx, draftID, m1, []uint32{d2, d3}); err != nil {
		t.Fatalf("重复并入应幂等,实际报错: %v", err)
	}

	// 传入主图自身:调用方过滤掉的语义由仓储兜住(id <> main 条件),不报错也不改动主图。
	if err := repo.AttachStreetDraftImages(ctx, draftID, m1, []uint32{m1}); err != nil {
		t.Fatalf("并入集合含主图自身应被忽略而非报错: %v", err)
	}
	for _, im := range reloadStreetImages(t, repo, draftID) {
		if im.ID == m1 && (im.IsDetail != 0 || im.ParentImageID != 0) {
			t.Fatalf("主图被自身并入后不应变化")
		}
	}

	// 拆出:把 d3 恢复为主图。
	if err := repo.DetachStreetDraftImage(ctx, draftID, d3); err != nil {
		t.Fatalf("拆出失败: %v", err)
	}
	for _, im := range reloadStreetImages(t, repo, draftID) {
		if im.ID == d3 && (im.IsDetail != 0 || im.ParentImageID != 0) {
			t.Fatalf("拆出后应为 is_detail=0/parent=0,实际 %d/%d", im.IsDetail, im.ParentImageID)
		}
	}
	if err := repo.DetachStreetDraftImage(ctx, draftID, m1); err != nil { // 对主图幂等成功
		t.Fatalf("对主图拆出应幂等成功: %v", err)
	}

	// 并入上一张:d3 的前一行是 d2(d2 已是 m1 的副图)→ 目标应折回 m1。
	if err := repo.AttachPrevStreetDraftImage(ctx, draftID, d3); err != nil {
		t.Fatalf("并入上一张失败: %v", err)
	}
	for _, im := range reloadStreetImages(t, repo, draftID) {
		if im.ID == d3 && (im.IsDetail != 1 || im.ParentImageID != m1) {
			t.Fatalf("并入上一张应折回主图 %d,实际 is_detail=%d parent=%d", m1, im.IsDetail, im.ParentImageID)
		}
	}

	// 首行「并入上一张」应返回 ErrNotFound。
	first := seedStreetDraftImage(t, repo, draftID, "z", 0)
	if err := repo.AttachPrevStreetDraftImage(ctx, draftID, first); !errors.Is(err, ErrNotFound) {
		t.Fatalf("首行并入上一张应返回 ErrNotFound,实际 %v", err)
	}

	// 跨草稿拦截:空集合 + 跨草稿主图。
	if err := repo.AttachStreetDraftImages(ctx, draftID, m1, nil); !errors.Is(err, ErrInvalidImageGroup) {
		t.Fatalf("空集合应返回 ErrInvalidImageGroup,实际 %v", err)
	}
	otherMain := seedStreetDraftImage(t, repo, seedStreetDraft(t, repo), "x", 1)
	if err := repo.AttachStreetDraftImages(ctx, draftID, otherMain, []uint32{d2}); !errors.Is(err, ErrInvalidImageGroup) {
		t.Fatalf("跨草稿主图应被拦截,实际 %v", err)
	}
}

// TestStreetMainDetailReparentGrandchildren 并入一张自带副图的主图时,其原副图应改挂到新主图。
func TestStreetMainDetailReparentGrandchildren(t *testing.T) {
	repo := NewReviewRepository(testDB(t))
	ctx := context.Background()

	draftID := seedStreetDraft(t, repo)
	m1 := seedStreetDraftImage(t, repo, draftID, "m1", 1)
	m2 := seedStreetDraftImage(t, repo, draftID, "m2", 2)
	child := seedStreetDraftImage(t, repo, draftID, "child", 3)

	if err := repo.AttachStreetDraftImages(ctx, draftID, m2, []uint32{child}); err != nil {
		t.Fatalf("预置副图失败: %v", err)
	}
	if err := repo.AttachStreetDraftImages(ctx, draftID, m1, []uint32{m2}); err != nil {
		t.Fatalf("并入整组失败: %v", err)
	}
	for _, im := range reloadStreetImages(t, repo, draftID) {
		if im.ID == m2 && (im.IsDetail != 1 || im.ParentImageID != m1) {
			t.Fatalf("m2 应成为 m1 的副图,实际 %d/%d", im.IsDetail, im.ParentImageID)
		}
		if im.ID == child && (im.IsDetail != 1 || im.ParentImageID != m1) {
			t.Fatalf("child 应改挂到 m1,实际 %d/%d", im.IsDetail, im.ParentImageID)
		}
	}
}
  • 步骤 2:运行测试验证失败

运行:go test -tags integration ./internal/repository/ -run TestStreetMainDetail -v 预期:编译失败,报 repo.AttachStreetDraftImages undefined(方法尚未实现)。

  • 步骤 3:加模型字段

按上文「模型新增」在 internal/model/street_snap_draft.go 的 StreetSnapDraftImage 中追加两个字段(注释一并照抄)。

  • 步骤 4:加接口方法与错误哨兵

按上文在 internal/repository/review_repository.go 中:

  1. 在 ReviewRepository 接口的 SoftDeleteStreetDraftImage 声明之后插入三个方法声明;
  2. 在 ErrNotFound 所在 var 块追加 ErrInvalidImageGroup。
  • 步骤 5:实现三个方法

在 internal/repository/review_repository.go 末尾追加:

// ── 街拍草稿图片:主副图分组 ──────────────────────────────────────────────

// AttachStreetDraftImages 把 imgIDs 变成 mainImgID 的副图(批量并入)。
//
// 事务内三步:1) 校验主图存在、未软删、本身是主图;2) 先「改挂孙辈」——被并入图原有的副图
// 改挂到新主图(避免副图的副图);3) 再把被并入图自身标为副图。第 2 步必须在第 3 步之前。
func (r *reviewRepository) AttachStreetDraftImages(ctx context.Context, draftID, mainImgID uint32, imgIDs []uint32) error {
	ids := make([]uint32, 0, len(imgIDs))
	for _, id := range imgIDs {
		if id != 0 && id != mainImgID {
			ids = append(ids, id)
		}
	}
	if mainImgID == 0 || len(ids) == 0 {
		return ErrInvalidImageGroup
	}

	return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
		var main model.StreetSnapDraftImage
		if err := tx.Where("id = ? AND draft_id = ? AND is_deleted = 0 AND is_detail = 0", mainImgID, draftID).
			First(&main).Error; err != nil {
			if errors.Is(err, gorm.ErrRecordNotFound) {
				return ErrInvalidImageGroup
			}
			return err
		}

		now := uint32(time.Now().Unix())
		if err := tx.Model(&model.StreetSnapDraftImage{}).
			Where("draft_id = ? AND is_deleted = 0 AND parent_image_id IN ?", draftID, ids).
			Updates(map[string]any{"parent_image_id": mainImgID, "updated_at": now}).Error; err != nil {
			return err
		}
		return tx.Model(&model.StreetSnapDraftImage{}).
			Where("draft_id = ? AND is_deleted = 0 AND id IN ?", draftID, ids).
			Updates(map[string]any{"is_detail": 1, "parent_image_id": mainImgID, "updated_at": now}).Error
	})
}

// AttachPrevStreetDraftImage 把 imgID 并入「同草稿内 sort_order 紧邻的前一行所属组的主图」。
// 折回组主图是刻意的:连续「并入上一张」应链式归并到同一主图,而不是造出副图的副图。
func (r *reviewRepository) AttachPrevStreetDraftImage(ctx context.Context, draftID, imgID uint32) error {
	var target uint32
	err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
		var cur model.StreetSnapDraftImage
		if err := tx.Where("id = ? AND draft_id = ? AND is_deleted = 0", imgID, draftID).First(&cur).Error; err != nil {
			if errors.Is(err, gorm.ErrRecordNotFound) {
				return ErrNotFound
			}
			return err
		}

		var prev model.StreetSnapDraftImage
		err := tx.Where("draft_id = ? AND is_deleted = 0 AND (sort_order < ? OR (sort_order = ? AND id < ?))",
			draftID, cur.SortOrder, cur.SortOrder, cur.ID).
			Order("sort_order DESC, id DESC").First(&prev).Error
		if err != nil {
			if errors.Is(err, gorm.ErrRecordNotFound) {
				return ErrNotFound
			}
			return err
		}

		if prev.IsDetail == 1 && prev.ParentImageID != 0 {
			target = prev.ParentImageID
		} else {
			target = prev.ID
		}
		if target == imgID {
			return ErrInvalidImageGroup
		}

		now := uint32(time.Now().Unix())
		if err := tx.Model(&model.StreetSnapDraftImage{}).
			Where("draft_id = ? AND is_deleted = 0 AND parent_image_id = ?", draftID, imgID).
			Updates(map[string]any{"parent_image_id": target, "updated_at": now}).Error; err != nil {
			return err
		}
		return tx.Model(&model.StreetSnapDraftImage{}).
			Where("id = ? AND draft_id = ?", imgID, draftID).
			Updates(map[string]any{"is_detail": 1, "parent_image_id": target, "updated_at": now}).Error
	})
	return err
}

// DetachStreetDraftImage 把副图恢复为主图。对主图调用为幂等成功。
// 拆出的图若自身带着副图,那些副图保持挂在它下面——它重新成为主图,不变量依然成立。
func (r *reviewRepository) DetachStreetDraftImage(ctx context.Context, draftID, imgID uint32) error {
	return r.db.WithContext(ctx).
		Model(&model.StreetSnapDraftImage{}).
		Where("id = ? AND draft_id = ? AND is_deleted = 0 AND is_detail = 1", imgID, draftID).
		Updates(map[string]any{"is_detail": 0, "parent_image_id": 0, "updated_at": uint32(time.Now().Unix())}).
		Error
}
  • 步骤 6:运行测试验证通过

运行:go test -tags integration ./internal/repository/ -run TestStreetMainDetail -v 预期:PASS(两个测试函数都通过)。

同时跑既有测试确认没打破别的:go test -tags integration ./internal/repository/ -v 预期:全绿。

  • 步骤 7:Commit
git add internal/model/street_snap_draft.go internal/repository/review_repository.go internal/repository/street_main_detail_integration_test.go
git commit -m "feat(street): 草稿图支持主副分组(并入/并入上一张/拆出)"

任务 2:晋升时重建父引用

文件:

  • 修改:internal/model/street_snap.go(StreetSnapImage 加两字段)
  • 修改:internal/repository/review_repository.go:376-464(SaveStreetSnapFromDraft)
  • 测试:internal/repository/street_main_detail_integration_test.go(追加)

为什么必须重建:晋升会软删正式表旧图并整批重建,新插入的正式图行拿到的是全新的行 id。副图的 parent_image_id 里存的是草稿表的行 id,直接复制过去会指向不存在(或属于别的图)的行 —— 详情页折叠结构就错乱了。

  • 步骤 1:编写失败的测试

在 internal/repository/street_main_detail_integration_test.go 追加:

// TestStreetPromoteRebuildsParentRef 晋升时必须把副图的父引用从「草稿行 id」改写为「新正式行 id」。
func TestStreetPromoteRebuildsParentRef(t *testing.T) {
	repo := NewReviewRepository(testDB(t))
	ctx := context.Background()

	draftID := seedStreetDraft(t, repo)
	m1 := seedStreetDraftImage(t, repo, draftID, "main", 1)
	d2 := seedStreetDraftImage(t, repo, draftID, "detail-a", 2)
	d3 := seedStreetDraftImage(t, repo, draftID, "detail-b", 3)
	if err := repo.AttachStreetDraftImages(ctx, draftID, m1, []uint32{d2, d3}); err != nil {
		t.Fatalf("预置主副图失败: %v", err)
	}

	snapID, err := repo.SaveStreetSnapFromDraft(ctx, draftID)
	if err != nil {
		t.Fatalf("晋升失败: %v", err)
	}
	t.Cleanup(func() {
		db := repo.(*reviewRepository).db
		db.Exec("DELETE FROM street_snap_images WHERE snap_id = ?", snapID)
		db.Exec("DELETE FROM street_snaps WHERE id = ?", snapID)
	})

	db := repo.(*reviewRepository).db
	var rows []model.StreetSnapImage
	if err := db.WithContext(ctx).Where("snap_id = ? AND is_deleted = 0", snapID).
		Order("sort_order ASC, id ASC").Find(&rows).Error; err != nil {
		t.Fatalf("读正式图失败: %v", err)
	}
	if len(rows) != 3 {
		t.Fatalf("期望 3 行正式图,实际 %d", len(rows))
	}

	mainRow := rows[0]
	if mainRow.IsDetail != 0 || mainRow.ParentImageID != 0 {
		t.Fatalf("首行应是主图,实际 is_detail=%d parent=%d", mainRow.IsDetail, mainRow.ParentImageID)
	}
	for _, row := range rows[1:] {
		if row.IsDetail != 1 {
			t.Fatalf("第 %d 行应是副图", row.ID)
		}
		if row.ParentImageID != mainRow.ID {
			t.Fatalf("副图 parent 应指向新主图行 id %d,实际 %d", mainRow.ID, row.ParentImageID)
		}
	}
}
  • 步骤 2:运行测试验证失败

运行:go test -tags integration ./internal/repository/ -run TestStreetPromoteRebuildsParentRef -v 预期:FAIL,副图 parent 应指向新主图行 id N,实际 0(当前晋升不复制分组字段)。

  • 步骤 3:加模型字段

在 internal/model/street_snap.go 的 StreetSnapImage 内、DupOf 之后追加(与草稿表同款注释):

	// 主副图分组(列由 db/migrations/2026-09-21-01-street-main-detail.sql 添加):
	// is_detail=0 为主图(parent_image_id=0);=1 为副图,parent_image_id 指向同专辑内主图的行 id。
	IsDetail      uint8  `gorm:"column:is_detail;not null;default:0" json:"is_detail"`
	ParentImageID uint32 `gorm:"column:parent_image_id;not null;default:0" json:"parent_image_id"`
  • 步骤 4:改晋升逻辑

把 SaveStreetSnapFromDraft 里「替换图片」那一段(当前是 if len(imgs) > 0 { rows := ...; tx.Create(&rows) })整体替换为:

		if len(imgs) > 0 {
			// 第一遍:插入主图(is_detail=0),并记下「草稿行 id → 新正式行 id」的映射。
			//
			// 必须两遍插入:副图的 parent_image_id 存的是**草稿表**的行 id,
			// 而这里插入的正式行拿到的是全新 id。若先插副图,父引用就会指向草稿表的旧 id,
			// 详情页折叠结构随之错乱(见本计划「任务 2」标题下的说明)。
			newIDByDraftID := make(map[uint32]uint32, len(imgs))
			mains := make([]model.StreetSnapImage, 0, len(imgs))
			for _, im := range imgs {
				if im.IsDetail == 1 {
					continue
				}
				mains = append(mains, model.StreetSnapImage{
					SnapID:      snapID,
					Image:       im.Image,
					Name:        im.Name,
					SortOrder:   uint32(len(mains) + 1),
					IsDetail:    0,
					Phash:       im.Phash,
					IsDuplicate: im.IsDuplicate,
					DupOf:       im.DupOf,
					CreatedAt:   now,
					UpdatedAt:   now,
				})
			}
			for i := range mains {
				if cErr := tx.Create(&mains[i]).Error; cErr != nil {
					return cErr
				}
			}
			mainIdx := 0
			for _, im := range imgs {
				if im.IsDetail == 1 {
					continue
				}
				newIDByDraftID[im.ID] = mains[mainIdx].ID
				mainIdx++
			}

			// 第二遍:插入副图,把父引用改写成新主图行 id。
			details := make([]model.StreetSnapImage, 0, len(imgs)-len(mains))
			for _, im := range imgs {
				if im.IsDetail != 1 {
					continue
				}
				newParent, ok := newIDByDraftID[im.ParentImageID]
				row := model.StreetSnapImage{
					SnapID:      snapID,
					Image:       im.Image,
					Name:        im.Name,
					SortOrder:   uint32(len(mains) + len(details) + 1),
					IsDetail:    1,
					Phash:       im.Phash,
					IsDuplicate: im.IsDuplicate,
					DupOf:       im.DupOf,
					CreatedAt:   now,
					UpdatedAt:   now,
				}
				if ok {
					row.ParentImageID = newParent
				} else {
					row.IsDetail = 0 // 父行缺失:按主图渲染,与读侧容错口径一致
				}
				details = append(details, row)
			}
			if len(details) > 0 {
				if cErr := tx.Create(&details).Error; cErr != nil {
					return cErr
				}
			}
		}

同时把 common map 里的 "image_count" 保持不变(仍是 uint16(len(imgs)),即全部图 —— 规格明确不改语义)。

  • 步骤 5:运行测试验证通过

运行:go test -tags integration ./internal/repository/ -run TestStreetPromote -v 预期:PASS。

再跑整个仓储包:go test -tags integration ./internal/repository/ -v 预期:全绿(特别是既有 TestDedup* 不受影响)。

  • 步骤 6:Commit
git add internal/model/street_snap.go internal/repository/review_repository.go internal/repository/street_main_detail_integration_test.go
git commit -m "fix(street): 晋升时重建副图父引用(草稿行 id → 正式行 id)"

任务 3:审核 service 暴露分组操作 + 详情分组视图

文件:

  • 修改:internal/service/review_service.go(DraftImageRef、DraftImageGroup、DraftDetailView、ReviewService 接口、实现、streetModule.DraftDetail、新增错误)

  • 测试:internal/service/review_street_group_test.go(新建)

  • 步骤 1:编写失败的单测

创建 internal/service/review_street_group_test.go:

package service

import (
	"context"
	"errors"
	"testing"

	"fashionapi/internal/dto"
	"fashionapi/internal/model"
	"fashionapi/internal/repository"
)

// fakeGroupRepo 只实现分组相关方法与详情读取,其余由内嵌 nil 接口兜底(不会被调用)。
type fakeGroupRepo struct {
	repository.ReviewRepository

	detail   *model.StreetSnapDraft
	images   []model.StreetSnapDraftImage
	mainImg  uint32
	attach   []uint32
	prevImg  uint32
	detachID uint32
	attachFn func(ctx context.Context, draftID, mainImgID uint32, imgIDs []uint32) error
}

func (f *fakeGroupRepo) StreetDraftDetail(ctx context.Context, id uint32) (*model.StreetSnapDraft, error) {
	if f.detail == nil {
		return nil, repository.ErrNotFound
	}
	return f.detail, nil
}

func (f *fakeGroupRepo) StreetDraftImages(ctx context.Context, draftID uint32) ([]model.StreetSnapDraftImage, error) {
	return f.images, nil
}

func (f *fakeGroupRepo) AttachStreetDraftImages(ctx context.Context, draftID, mainImgID uint32, imgIDs []uint32) error {
	f.mainImg = mainImgID
	f.attach = imgIDs
	if f.attachFn != nil {
		return f.attachFn(ctx, draftID, mainImgID, imgIDs)
	}
	return nil
}

func (f *fakeGroupRepo) AttachPrevStreetDraftImage(ctx context.Context, draftID, imgID uint32) error {
	f.prevImg = imgID
	return nil
}

func (f *fakeGroupRepo) DetachStreetDraftImage(ctx context.Context, draftID, imgID uint32) error {
	f.detachID = imgID
	return nil
}

// TestStreetGroupDetailExposesGrouping 详情视图必须把分组信息带给模板(Images 扁平 + Groups 按组)。
func TestStreetGroupDetailExposesGrouping(t *testing.T) {
	repo := &fakeGroupRepo{
		detail: &model.StreetSnapDraft{ID: 7, Title: "T", Year: 2026, City: "C", Status: model.DraftStatusPending},
		images: []model.StreetSnapDraftImage{
			{ID: 1, Name: "主图", SortOrder: 1},
			{ID: 2, Name: "副图", SortOrder: 2, IsDetail: 1, ParentImageID: 1},
			{ID: 3, Name: "独立", SortOrder: 3},
		},
	}
	svc := NewReviewService(repo, nil, nil, nil)

	v, err := svc.DraftDetail(context.Background(), dto.IngestKindStreet, 7)
	if err != nil {
		t.Fatalf("取详情失败: %v", err)
	}
	if len(v.Images) != 3 {
		t.Fatalf("期望 3 张图,实际 %d", len(v.Images))
	}
	if len(v.Groups) != 2 {
		t.Fatalf("期望 2 组(主图1带副图2;独立3自成一主图),实际 %d", len(v.Groups))
	}
	if v.Groups[0].Image.ID != 1 || len(v.Groups[0].Details) != 1 {
		t.Fatalf("第 1 组应是主图1 + 1 张副图,实际 main=%d details=%d", v.Groups[0].Image.ID, len(v.Groups[0].Details))
	}
	if v.Groups[1].Image.ID != 3 {
		t.Fatalf("第 2 组主图应是 3,实际 %d", v.Groups[1].Image.ID)
	}
	if v.Images[0].DetailCount != 1 {
		t.Fatalf("主图应统计到 1 张副图,实际 %d", v.Images[0].DetailCount)
	}
}

// TestStreetGroupAttachDelegates 并入操作应委派到仓储,并校验主图合法。
func TestStreetGroupAttachDelegates(t *testing.T) {
	repo := &fakeGroupRepo{detail: &model.StreetSnapDraft{ID: 7, Status: model.DraftStatusPending}, images: []model.StreetSnapDraftImage{{ID: 1, SortOrder: 1}}}
	svc := NewReviewService(repo, nil, nil, nil)

	if err := svc.AttachDraftImages(context.Background(), dto.IngestKindStreet, 7, 1, []uint32{2, 3}); err != nil {
		t.Fatalf("并入失败: %v", err)
	}
	if repo.mainImg != 1 || len(repo.attach) != 2 {
		t.Fatalf("委派参数不对: main=%d ids=%v", repo.mainImg, repo.attach)
	}
	if err := svc.AttachDraftImages(context.Background(), dto.IngestKindStreet, 7, 1, nil); err == nil {
		t.Fatalf("空集合应报错")
	}
	if err := svc.AttachDraftImages(context.Background(), dto.IngestKindRunway, 7, 1, []uint32{2}); err == nil {
		t.Fatalf("走秀模块应拒绝主副图操作")
	}
}

// TestStreetGroupPreventCrossDraft 跨草稿 / 非法主图应被拦下并给出可读错误。
func TestStreetGroupPreventCrossDraft(t *testing.T) {
	repo := &fakeGroupRepo{
		detail: &model.StreetSnapDraft{ID: 7, Status: model.DraftStatusPending},
		images: []model.StreetSnapDraftImage{{ID: 1, SortOrder: 1}},
		attachFn: func(ctx context.Context, draftID, mainImgID uint32, imgIDs []uint32) error {
			return repository.ErrInvalidImageGroup
		},
	}
	svc := NewReviewService(repo, nil, nil, nil)

	err := svc.AttachDraftImages(context.Background(), dto.IngestKindStreet, 7, 99, []uint32{2})
	if !errors.Is(err, ErrInvalidImageGroup) {
		t.Fatalf("期望 ErrInvalidImageGroup,实际 %v", err)
	}
	if got := err.Error(); got != "主图无效:它必须属于本草稿、未被删除,且自身是主图" {
		t.Fatalf("错误文案应为给用户看的中文,实际 %q", got)
	}
}

// TestStreetGroupDetachAndPrev 拆出与「并入上一张」的委派。
func TestStreetGroupDetachAndPrev(t *testing.T) {
	repo := &fakeGroupRepo{detail: &model.StreetSnapDraft{ID: 7, Status: model.DraftStatusPending}}
	svc := NewReviewService(repo, nil, nil, nil)

	if err := svc.DetachDraftImage(context.Background(), dto.IngestKindStreet, 7, 5); err != nil {
		t.Fatalf("拆出失败: %v", err)
	}
	if repo.detachID != 5 {
		t.Fatalf("拆出应委派 id=5,实际 %d", repo.detachID)
	}
	if err := svc.AttachPrevDraftImage(context.Background(), dto.IngestKindStreet, 7, 6); err != nil {
		t.Fatalf("并入上一张失败: %v", err)
	}
	if repo.prevImg != 6 {
		t.Fatalf("并入上一张应委派 id=6,实际 %d", repo.prevImg)
	}
}
  • 步骤 2:运行测试验证失败

运行:go test ./internal/service/ -run TestStreetGroup -v 预期:编译失败,报 svc.AttachDraftImages undefined、v.Groups undefined、ErrInvalidImageGroup undefined。

  • 步骤 3:扩展类型与新增错误

DraftImageRef(internal/service/review_service.go:28-34)替换为:

// DraftImageRef 草稿图片引用(统一结构)。
//
// IsDetail / ParentImageID 只有街拍模块会填(走秀的主/细节图走 LookIndex 那套,语义不同,不复用);
// DetailCount 让模板不用自己数副图。
type DraftImageRef struct {
	ID            uint32 `json:"id"`
	Image         string `json:"image"`
	Name          string `json:"name"`
	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"`   // 仅主图有意义:名下(存活)副图数量
}

在该文件顶部错误声明区(与既有 ErrXxx 放一起)追加:

// ErrInvalidImageGroup 主副图分组操作不合法:主图不存在 / 不属于本草稿 / 自身不是主图 / 空集合。
// 这里是给用户看的文案,handler 直接渲染 err.Error()。
var ErrInvalidImageGroup = errors.New("主图无效:它必须属于本草稿、未被删除,且自身是主图")

若该文件当前未导入 errors,在 import 块补上(review_service.go 现有 import 里没有它,需加)。

在 DraftDetailView(review_service.go:45-66)的 Fields 之前加两字段:

	// MainID 审核页「当前主图」的行 id,来自 URL 查询参数 ?main=;0 表示未选定。
	// 只有街拍模块会用到(走秀无主副图概念)。
	MainID uint32 `json:"main_id"`
	// Groups 街拍详情的「主图 → 其副图」分组视图(按 sort_order 顺序)。走秀模块留空。
	// 与 Images 同源同序:Images 保持扁平(既有渲染 / runway 仍用),Groups 供按组折叠。
	Groups []DraftImageGroup `json:"groups,omitempty"`

在该类型之后新增 DraftImageGroup:

// DraftImageGroup 一张主图及其副图,供审核页按组折叠渲染。
type DraftImageGroup struct {
	Image   DraftImageRef   `json:"image"`   // 主图
	Details []DraftImageRef `json:"details"` // 该主图名下的副图(可为空)
}
  • 步骤 4:接口加三方法

ReviewService 接口(review_service.go:97-107)在 DeleteDraftImage 之后插入:

	// 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
  • 步骤 5:实现三个方法 + 错误翻译 + 详情组装

在 reviewService.DeleteDraftImage(review_service.go:238-261)之后追加:

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
}

把 streetModule.DraftDetail(review_service.go:364-392)里 Images 的组装循环与 v 的字段装配替换为:

	// 先算存活的主图 id 集合(副图 parent 必须命中它,否则按主图渲染),以及每张主图名下副图数。
	mainSet := make(map[uint32]struct{}, len(imgs))
	for _, im := range imgs {
		if im.IsDetail == 0 {
			mainSet[im.ID] = struct{}{}
		}
	}
	detailCount := make(map[uint32]int, len(imgs))
	for _, im := range imgs {
		if im.IsDetail == 1 && im.ParentImageID != 0 {
			detailCount[im.ParentImageID]++
		}
	}

	v := &DraftDetailView{
		Kind:       dto.IngestKindStreet,
		ID:         d.ID,
		Title:      d.Title,
		Year:       d.Year,
		City:       d.City,
		Cover:      m.img.Compose(d.Cover),
		ImageCount: d.ImageCount,
		Status:     d.Status,
		Fields: []EditField{
			{Name: "title", Label: "标题", Value: d.Title, Type: "text"},
			{Name: "year", Label: "年份", Value: yearStr(d.Year), Type: "number"},
			{Name: "city", Label: "城市", Value: d.City, Type: "text"},
		},
	}

	// 按 sort_order 顺序成组:主图各成一组;副图归入其(存活)主图组,父引用失效则自成一主图组。
	groups := make([]DraftImageGroup, 0, len(imgs))
	groupIndex := make(map[uint32]int, len(imgs))
	for _, im := range imgs {
		isDetail := im.IsDetail
		parent := im.ParentImageID
		if isDetail == 1 {
			if _, ok := mainSet[parent]; !ok {
				isDetail = 0 // 容错:父行失效 → 按主图渲染
				parent = 0
			}
		}
		ref := DraftImageRef{
			ID:            im.ID,
			Image:         m.img.Compose(im.Image),
			Name:          im.Name,
			SortOrder:     im.SortOrder,
			IsDetail:      isDetail,
			ParentImageID: parent,
			DetailCount:   detailCount[im.ID],
		}
		v.Images = append(v.Images, ref)
		if isDetail == 0 {
			groups = append(groups, DraftImageGroup{Image: ref})
			groupIndex[im.ID] = len(groups) - 1
			continue
		}
		if gi, ok := groupIndex[parent]; 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

(注意:上面的 Fields 构造块与你源码里既有的 streetModule.DraftDetail 一致,仅把最后的 for _, im := range imgs { v.Images = append(...) } 循环替换成了成组逻辑;DraftView 字段名以你源码实际为准。)

  • 步骤 6:运行测试验证通过

运行:go test ./internal/service/ -run TestStreetGroup -v 预期:PASS(4 个 TestStreetGroup* 全绿);既有 TestProcessMediaCleanup* / TestIngestRetryBackoff 不受影响。

  • 步骤 7:Commit
git add internal/service/review_service.go internal/service/review_street_group_test.go
git commit -m "feat(street): 审核 service 暴露主副图分组操作与详情分组视图"

任务 4:后台路由 + handler + 审核页分组渲染

文件:

  • 修改:internal/router/backstage.go:86-98(加 3 条路由)

  • 修改:internal/handler/backstage_handler.go(加 3 个 handler + 改 tplReviewDetail + ReviewDetail 透传 ?main=)

  • 修改:internal/router/backstage_test.go(加 fakeReview + 断言)

  • 修改:internal/handler/backstage_handler_test.go(既有解析测试自动覆盖新模板,确认通过)

  • 步骤 1:编写失败的测试

在 internal/router/backstage_test.go 的假实现区(fakeStreet 之后)追加 fakeReview,并把 TestBackstage 里构造 handler 的行改成传入 fakeReview:

// fakeReview 记录审核操作入参,并返回可配置结果,用于断言新路由的 302 与委派 + 分组渲染。
type fakeReview struct {
	attachMain uint32
	attachIDs  []uint32
	attachOK   bool
}

func (f *fakeReview) ListDrafts(ctx context.Context, kind, status string, page, size int) ([]service.DraftCard, int64, error) {
	return nil, 0, nil
}
func (f *fakeReview) DraftDetail(ctx context.Context, kind string, id uint32) (*service.DraftDetailView, error) {
	return &service.DraftDetailView{
		Kind:      kind,
		ID:        id,
		Title:     "街拍标题",
		Status:    model.DraftStatusPending,
		ImageCount: 3,
		Images: []service.DraftImageRef{
			{ID: 11, Name: "主图", SortOrder: 1, DetailCount: 1},
			{ID: 12, Name: "副图", SortOrder: 2, IsDetail: 1, ParentImageID: 11},
			{ID: 13, Name: "独立", SortOrder: 3},
		},
		Groups: []service.DraftImageGroup{
			{Image: service.DraftImageRef{ID: 11, Name: "主图", SortOrder: 1, DetailCount: 1},
				Details: []service.DraftImageRef{{ID: 12, Name: "副图", SortOrder: 2, IsDetail: 1, ParentImageID: 11}}},
			{Image: service.DraftImageRef{ID: 13, Name: "独立", SortOrder: 3}},
		},
	}, nil
}
func (f *fakeReview) UpdateDraft(ctx context.Context, kind string, id uint32, fields map[string]any) error { return nil }
func (f *fakeReview) Approve(ctx context.Context, kind string, id uint32, reviewer string) (uint32, error) { return 1, nil }
func (f *fakeReview) Reject(ctx context.Context, kind string, id uint32, reviewer, reason string) error { return nil }
func (f *fakeReview) DeleteDraftImage(ctx context.Context, kind string, draftID, imgID uint32) error { return nil }
func (f *fakeReview) KindTabs(ctx context.Context) []service.KindTab {
	return []service.KindTab{{Kind: "", Label: "全部", Pending: 2}, {Kind: dto.IngestKindStreet, Label: "街拍", Pending: 2}}
}
func (f *fakeReview) AttachDraftImages(ctx context.Context, kind string, draftID, mainImgID uint32, imgIDs []uint32) error {
	f.attachMain = mainImgID
	f.attachIDs = imgIDs
	if !f.attachOK {
		return service.ErrInvalidImageGroup
	}
	return nil
}
func (f *fakeReview) AttachPrevDraftImage(ctx context.Context, kind string, draftID, imgID uint32) error { return nil }
func (f *fakeReview) DetachDraftImage(ctx context.Context, kind string, draftID, imgID uint32) error { return nil }

// 编译期接口满足性检查
var _ service.ReviewService = (*fakeReview)(nil)

把 TestBackstage 里的 handler 构造改为:

	h := handler.NewBackstageHandler(auth, brand, &fakeArticle{}, &fakeStreet{}, &fakeReview{attachOK: true}, nil)

并在「街拍详情渲染图集」那段之后追加四个断言:

	// 10) 审核详情页(街拍):主图条 + 分组渲染
	w = httptest.NewRecorder()
	req = httptest.NewRequest(http.MethodGet, "/admin/reviews/street/7?main=11", nil)
	req.AddCookie(&http.Cookie{Name: adminCookie, Value: token})
	r.ServeHTTP(w, req)
	if w.Code != 200 {
		t.Fatalf("审核详情应 200,实际 %d %s", w.Code, w.Body.String())
	}
	body := w.Body.String()
	if !strings.Contains(body, "当前主图:#11") {
		t.Fatalf("缺少常驻主图提示条: %s", body)
	}
	if !strings.Contains(body, "副图 · 主图 #11") { // 分组渲染:副图挂在主图 11 下
		t.Fatalf("缺少分组渲染:副图未标注所属主图: %s", body)
	}
	if !strings.Contains(body, "并入上一张") || !strings.Contains(body, "拆出") {
		t.Fatalf("缺少并入上一张 / 拆出入口: %s", body)
	}

	// 11) 并入当前主图(单张):302 回详情页
	w = httptest.NewRecorder()
	req = httptest.NewRequest(http.MethodPost, "/admin/reviews/street/7/images/attach",
		strings.NewReader("main=11&img=12"))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	req.AddCookie(&http.Cookie{Name: adminCookie, Value: token})
	r.ServeHTTP(w, req)
	if w.Code != 302 || !strings.Contains(w.Header().Get("Location"), "/admin/reviews/street/7") {
		t.Fatalf("并入应 302 回详情,实际 %d %s", w.Code, w.Header().Get("Location"))
	}

	// 12) 拆出:302 回详情页
	w = httptest.NewRecorder()
	req = httptest.NewRequest(http.MethodPost, "/admin/reviews/street/7/images/12/detach", nil)
	req.AddCookie(&http.Cookie{Name: adminCookie, Value: token})
	r.ServeHTTP(w, req)
	if w.Code != 302 {
		t.Fatalf("拆出应 302,实际 %d", w.Code)
	}

	// 13) 并入上一张:302 回详情页
	w = httptest.NewRecorder()
	req = httptest.NewRequest(http.MethodPost, "/admin/reviews/street/7/images/13/attach-prev", nil)
	req.AddCookie(&http.Cookie{Name: adminCookie, Value: token})
	r.ServeHTTP(w, req)
	if w.Code != 302 {
		t.Fatalf("并入上一张应 302,实际 %d", w.Code)
	}
  • 步骤 2:运行测试验证失败

运行:go test ./internal/router/ -v 预期:编译失败或路由未注册(fakeReview 未满足 service.ReviewService,或 /images/attach 不存在)。

  • 步骤 3:加路由

internal/router/backstage.go 在 admin.POST("/reviews/:kind/:id/images/:img/delete", ...)(第 96 行)之后插入:

		// 街拍主副图分组:并入当前主图 / 并入上一张 / 拆出(零 JS 同步表单,均 302 回详情页)。
		admin.POST("/reviews/:kind/:id/images/attach", opt.Backstage.ReviewDraftImagesAttach)
		admin.POST("/reviews/:kind/:id/images/:img/attach-prev", opt.Backstage.ReviewDraftImageAttachPrev)
		admin.POST("/reviews/:kind/:id/images/:img/detach", opt.Backstage.ReviewDraftImageDetach)
  • 步骤 4:加 handler

internal/handler/backstage_handler.go 在 ReviewDraftImageDelete(第 453-466 行)之后插入:

// ReviewDraftImagesAttach 把一张草稿图并入「当前主图」(逐张并入,零 JS 无表单嵌套)。
// 当前主图由表单隐藏字段 main 携带(来自 URL 查询参数 ?main=)。
func (h *BackstageHandler) ReviewDraftImagesAttach(c *gin.Context) {
	kind := c.Param("kind")
	id := atoiDefault(c.Param("id"), 0)
	mainID := atoiDefault(c.PostForm("main"), 0)
	imgID := atoiDefault(c.PostForm("img"), 0)
	if id == 0 || mainID == 0 || imgID == 0 {
		renderHTML(c, http.StatusBadRequest, tplError, gin.H{"Error": "请先选定主图,并指定要并入的图片"})
		return
	}
	if err := h.review.AttachDraftImages(c.Request.Context(), kind, uint32(id), uint32(mainID), []uint32{imgID}); err != nil {
		renderHTML(c, http.StatusInternalServerError, tplError, gin.H{"Error": err.Error()})
		return
	}
	c.Redirect(http.StatusFound, reviewDetailURL(kind, c.Param("id")))
}

// ReviewDraftImageAttachPrev 把某张草稿图并入「上一行所属组的主图」。
func (h *BackstageHandler) ReviewDraftImageAttachPrev(c *gin.Context) {
	kind := c.Param("kind")
	id := atoiDefault(c.Param("id"), 0)
	imgID := atoiDefault(c.Param("img"), 0)
	if id == 0 || imgID == 0 {
		renderHTML(c, http.StatusBadRequest, tplError, gin.H{"Error": "参数无效"})
		return
	}
	if err := h.review.AttachPrevDraftImage(c.Request.Context(), kind, uint32(id), uint32(imgID)); err != nil {
		renderHTML(c, http.StatusInternalServerError, tplError, gin.H{"Error": err.Error()})
		return
	}
	c.Redirect(http.StatusFound, reviewDetailURL(kind, c.Param("id")))
}

// ReviewDraftImageDetach 把副图拆出、恢复为主图。
func (h *BackstageHandler) ReviewDraftImageDetach(c *gin.Context) {
	kind := c.Param("kind")
	id := atoiDefault(c.Param("id"), 0)
	imgID := atoiDefault(c.Param("img"), 0)
	if id == 0 || imgID == 0 {
		renderHTML(c, http.StatusBadRequest, tplError, gin.H{"Error": "参数无效"})
		return
	}
	if err := h.review.DetachDraftImage(c.Request.Context(), kind, uint32(id), uint32(imgID)); err != nil {
		renderHTML(c, http.StatusInternalServerError, tplError, gin.H{"Error": err.Error()})
		return
	}
	c.Redirect(http.StatusFound, reviewDetailURL(kind, c.Param("id")))
}

// reviewDetailURL 生成审核详情页地址(原样保留路径参数,避免对 id 二次编码)。
func reviewDetailURL(kind, rawID string) string {
	return "/admin/reviews/" + kind + "/" + rawID
}
  • 步骤 5:改 ReviewDetail 透传 ?main=

internal/handler/backstage_handler.go 的 ReviewDetail(第 384-411 行)在 draft, err := h.review.DraftDetail(...) 成功之后、renderHTML 之前加:

	// 当前主图由 URL 查询参数承载(零 JS、无状态、可刷新可书签)。
	draft.MainID = uint32(atoiDefault(c.Query("main"), 0))
  • 步骤 6:改模板 tplReviewDetail

internal/handler/backstage_handler.go 的 tplReviewDetail(第 1275-1292 行的图片区块)整体替换为下面这段(街拍按组折叠;走秀保持原扁平网格)。注意:每个按钮都是独立 <form>,并列于 .acts / .strip 容器,不存在表单嵌套。

<h2 style="margin-top:24px">图片管理({{len .Draft.Images}} 张)</h2>
{{if eq .Draft.Kind "street"}}
{{if .Draft.MainID}}<div class="note" style="padding:8px 10px;background:#f4f6f8;border-radius:6px;margin-bottom:12px">当前主图:<b>#{{.Draft.MainID}}</b> —— 每张图点「并入当前主图」即可归组;点「设为主图」可改选。</div>
{{else}}<div class="note" style="padding:8px 10px;background:#fff8e1;border-radius:6px;margin-bottom:12px">未选定主图。先在图片卡片点「设为主图」。</div>{{end}}
<div class="grid">
{{range .Draft.Groups}}<div class="cell">
<img src="{{.Image.Image}}" loading="lazy">
<span style="display:block;padding:6px 8px;font-size:12px;color:#666">#{{.Image.ID}} · {{.Image.Name}}{{if gt .Image.DetailCount 0}}<span class="badge">{{.Image.DetailCount}} 张副图</span>{{end}}</span>
<div class="acts">
{{if eq $.Draft.MainID .Image.ID}}<span class="muted">当前主图</span>{{else}}<a href="/admin/reviews/{{$.Draft.Kind}}/{{$.Draft.ID}}?main={{.Image.ID}}">设为主图</a>{{end}}
{{if ne .Image.ID $.Draft.MainID}}<form method="post" action="/admin/reviews/{{$.Draft.Kind}}/{{$.Draft.ID}}/images/attach" style="display:inline">
<input type="hidden" name="main" value="{{$.Draft.MainID}}"><input type="hidden" name="img" value="{{.Image.ID}}">
<button type="submit"{{if not $.Draft.MainID}} disabled title="请先选定主图"{{end}}>并入当前主图</button>
</form>
<form method="post" action="/admin/reviews/{{$.Draft.Kind}}/{{$.Draft.ID}}/images/{{.Image.ID}}/attach-prev" style="display:inline"><button type="submit">并入上一张</button></form>{{end}}
<form method="post" action="/admin/reviews/{{$.Draft.Kind}}/{{$.Draft.ID}}/images/{{.Image.ID}}/delete" style="display:inline"><button type="submit" class="reject" onclick="return confirm('确认删除这张图片?')">删除</button></form>
</div>
{{if .Details}}<div class="strip" style="display:flex;gap:8px;flex-wrap:wrap;padding:6px 8px;background:#fafafa;border-radius:6px">
{{range .Details}}<div class="det">
<img src="{{.Image}}" loading="lazy" style="height:60px">
<span style="display:block;font-size:11px;color:#666">#{{.ID}} · {{.Name}}<span class="badge">副图 · 主图 #{{.ParentImageID}}</span></span>
<div class="acts">
<form method="post" action="/admin/reviews/{{$.Draft.Kind}}/{{$.Draft.ID}}/images/{{.ID}}/detach" style="display:inline"><button type="submit">拆出</button></form>
<form method="post" action="/admin/reviews/{{$.Draft.Kind}}/{{$.Draft.ID}}/images/{{.ID}}/delete" style="display:inline"><button type="submit" class="reject" onclick="return confirm('确认删除这张图片?')">删除</button></form>
</div>
</div>{{end}}
</div>{{end}}
</div>{{end}}
{{else}}
<div class="grid">
{{range .Draft.Images}}<div class="cell">
<img src="{{.Image}}" loading="lazy">
<span style="display:block;padding:6px 8px;font-size:12px;color:#666">{{.Name}}</span>
<div class="acts">
<form method="post" action="/admin/reviews/{{$.Draft.Kind}}/{{$.Draft.ID}}/images/{{.ID}}/delete"><button type="submit" class="reject" onclick="return confirm('确认删除这张图片?')">删除</button></form>
</div>
</div>{{end}}
</div>
{{end}}
  • 步骤 7:运行测试验证通过

运行:go test ./internal/router/ -run TestBackstage -v 预期:PASS。

再跑模板解析冒烟与全部单测:

go test ./internal/handler/ -v
go test ./...

预期:全绿。

  • 步骤 8:Commit
git add internal/router/backstage.go internal/router/backstage_test.go internal/handler/backstage_handler.go internal/handler/backstage_handler_test.go internal/service/review_service.go
git commit -m "feat(street): 审核页支持人工指定主图/副图(零 JS 表单 + 按组折叠)"

任务 5:公开 API 新增分组字段

文件:

  • 修改:internal/dto/street_snap.go(PublicStreetSnapDetail)

  • 修改:internal/service/street_snap_service.go:122-143(Detail)+ 新增 groupSnapImages

  • 测试:internal/service/street_snap_group_test.go(新建)

  • 步骤 1:编写失败的单测

创建 internal/service/street_snap_group_test.go:

package service

import (
	"testing"

	"fashionapi/internal/model"
	"fashionapi/internal/pkg/imgurl"
)

// groupSnapImages 是纯函数:把扁平图片列表按主副关系重排成组。
func TestGroupSnapImages(t *testing.T) {
	s := &streetSnapService{img: imgurl.New("", "", "")}

	imgs := []model.StreetSnapImage{
		{ID: 1, Image: "a.jpg", Name: "A", SortOrder: 1},
		{ID: 2, Image: "b.jpg", Name: "B", SortOrder: 2, IsDetail: 1, ParentImageID: 1},
		{ID: 3, Image: "c.jpg", Name: "C", SortOrder: 3},
		{ID: 4, Image: "d.jpg", Name: "D", SortOrder: 4, IsDetail: 1, ParentImageID: 3},
		{ID: 5, Image: "e.jpg", Name: "E", SortOrder: 5, IsDetail: 1, ParentImageID: 2}, // 父指向副图 2 → 上浮到组主图 1
	}

	flat, groups := s.groupSnapImages(imgs)

	if len(flat) != 5 {
		t.Fatalf("images 应保持 5 张,实际 %d", len(flat))
	}
	if flat[0].Name != "A" || flat[4].Name != "E" {
		t.Fatalf("images 顺序应保持输入顺序")
	}
	if len(groups) != 2 {
		t.Fatalf("应成 2 组,实际 %d", len(groups))
	}
	if groups[0].Image.Name != "A" {
		t.Fatalf("第 1 组主图应是 A,实际 %s", groups[0].Image.Name)
	}
	if len(groups[0].Details) != 2 {
		t.Fatalf("第 1 组应有 2 张副图(父引用指向副图的 5 要上浮到组主图),实际 %d", len(groups[0].Details))
	}
	if groups[1].Image.Name != "C" || len(groups[1].Details) != 1 {
		t.Fatalf("第 2 组应是 C + 1 张副图,实际 %s + %d", groups[1].Image.Name, len(groups[1].Details))
	}
}

// TestGroupSnapImagesToleratesDanglingParent 父引用失效时按主图渲染,不丢图。
func TestGroupSnapImagesToleratesDanglingParent(t *testing.T) {
	s := &streetSnapService{img: imgurl.New("", "", "")}

	imgs := []model.StreetSnapImage{
		{ID: 1, Name: "A", SortOrder: 1},
		{ID: 2, Name: "孤儿副图", SortOrder: 2, IsDetail: 1, ParentImageID: 999}, // 父行不存在
	}

	flat, groups := s.groupSnapImages(imgs)

	if len(flat) != 2 {
		t.Fatalf("容错不应丢图,实际 %d", len(flat))
	}
	if len(groups) != 2 {
		t.Fatalf("孤儿副图应自成一「主图」组,实际 %d 组", len(groups))
	}
	if groups[1].Image.Name != "孤儿副图" {
		t.Fatalf("孤儿副图应按主图渲染,实际 %s", groups[1].Image.Name)
	}
}

// TestGroupSnapImagesEmpty 空输入返回 nil,不 panic。
func TestGroupSnapImagesEmpty(t *testing.T) {
	s := &streetSnapService{img: imgurl.New("", "", "")}
	flat, groups := s.groupSnapImages(nil)
	if flat != nil || groups != nil {
		t.Fatalf("空输入应返回 nil,实际 %v / %v", flat, groups)
	}
}
  • 步骤 2:运行测试验证失败

运行:go test ./internal/service/ -run TestGroupSnapImages -v 预期:编译失败,报 s.groupSnapImages undefined。

  • 步骤 3:加 DTO

internal/dto/street_snap.go 在 PublicStreetSnapDetail 定义之前插入:

// PublicStreetSnapGroup 街拍详情里的一组「主图 + 其副图」。
// 与 PublicStreetSnapDetail.Images **同源同序**:Groups 只是 Images 按主副关系重排后的视图,不引入新数据来源。
type PublicStreetSnapGroup struct {
	Image   PublicArticleImage   `json:"image"`   // 主图
	Details []PublicArticleImage `json:"details"` // 该主图下的副图(可为空)
}

并把 PublicStreetSnapDetail 替换为:

// PublicStreetSnapDetail 对外只读街拍详情:含完整图片集。
// 保守扩展:Images(扁平、按 sort_order 升序)保持原样不变,现有前端不受影响;新增 Groups 供择期迁移。
type PublicStreetSnapDetail struct {
	UID       string                 `json:"id"`
	Title     string                 `json:"title"`
	Cover     string                 `json:"cover"`
	Images    []PublicArticleImage   `json:"images"`
	Groups    []PublicStreetSnapGroup `json:"groups,omitempty"`
	Favorited bool                   `json:"favorited,omitempty"` // 已登录时该图集是否被当前用户收藏(gallery 级)
}
  • 步骤 4:实现分组并接进 Detail

在 internal/service/street_snap_service.go 的 Detail(第 122-143 行)之后追加:

// groupSnapImages 把扁平图片列表按主副关系重排成组视图,并返回扁平列表本身。
// 两个切片都从同一份输入派生,保证「Images 与 Groups 同源同序」。
// 容错(与后台审核页一致):父引用缺失或指向非主图的图**按主图渲染**,自成一组,绝不丢图。
// 父引用指向「副图」时向上一级归并到该副图所在组的主图。
func (s *streetSnapService) groupSnapImages(imgs []model.StreetSnapImage) ([]dto.PublicArticleImage, []dto.PublicStreetSnapGroup) {
	if len(imgs) == 0 {
		return nil, nil
	}

	flat := s.toPublicSnapImages(imgs)

	mainOf := make(map[uint32]uint32, len(imgs)) // 行 id → 组主图 id
	for _, im := range imgs {
		if im.IsDetail == 0 {
			mainOf[im.ID] = im.ID
		}
	}
	for _, im := range imgs { // 第一轮:副图直接挂到其 parent(可能是主图,也可能是另一张副图)
		if im.IsDetail != 1 {
			continue
		}
		if root, ok := mainOf[im.ParentImageID]; ok {
			mainOf[im.ID] = root
		} else if _, isDetailOf := mainOf[im.ParentImageID]; isDetailOf {
			mainOf[im.ID] = im.ParentImageID
		}
		// 父不存在 → 不写 mainOf,下方按主图渲染
	}
	for _, im := range imgs { // 第二轮:parent→副图→主图 的链再向上归并一层
		root, ok := mainOf[im.ID]
		if !ok {
			continue
		}
		if up, ok2 := mainOf[root]; ok2 && up != root {
			mainOf[im.ID] = up
		}
	}

	groups := make([]dto.PublicStreetSnapGroup, 0, len(imgs))
	index := make(map[uint32]int, len(imgs))
	for i, im := range imgs {
		root, isDetail := mainOf[im.ID]
		if !isDetail {
			groups = append(groups, dto.PublicStreetSnapGroup{Image: flat[i]})
			index[im.ID] = len(groups) - 1
			continue
		}
		gi, ok := index[root]
		if !ok { // 兜底(理论不发生)
			groups = append(groups, dto.PublicStreetSnapGroup{Image: flat[i]})
			index[im.ID] = len(groups) - 1
			continue
		}
		groups[gi].Details = append(groups[gi].Details, flat[i])
	}
	return flat, groups
}

把 Detail 的返回改成:

	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: flat,
		Groups: groups,
	}, nil
  • 步骤 5:运行测试验证通过

运行:go test ./internal/service/ -run TestGroupSnapImages -v 预期:PASS(三个用例全绿)。

再跑全部:go test ./... 预期:全绿。

  • 步骤 6:Commit
git add internal/dto/street_snap.go internal/service/street_snap_service.go internal/service/street_snap_group_test.go
git commit -m "feat(street): 公开详情新增 groups 分组(images 保持兼容)"

收尾(全部任务完成后)

  • gofmt -l .:本次改动涉及的文件必须无输出(注意:仓库 .go 文件多为 CRLF,gofmt -l . 会列出大量既有文件,与本计划无关 —— 只需确认你改过的文件不在其中)。
  • go vet ./... 与 go vet -tags integration ./internal/repository/:均为 0。
  • go build ./...:0。
  • go test ./...:全绿。
  • go test -tags integration ./internal/repository/ -v:全绿(需库已就绪)。
  • 若改了表结构相关代码(本计划没有),记得重导 dump:dbtool dump -clean -out db/backups/db_dump.sql。本计划不触碰 DB 结构,通常无需重导;但若你在实现时顺手改了 model 的 gorm tag 导致结构变化,则必须重导。
  • 手工验收(需启动后端并登录后台):
    • 打开一篇街拍草稿详情 → 点某张图「设为主图」→ URL 出现 ?main=<id>、顶部出现「当前主图:#」
    • 点另一张图的「并入当前主图」→ 该图出现「副图 · 主图 #」角标,主图出现「1 张副图」,详情页该主图卡片下出现副图缩略条
    • 点副图的「拆出」→ 角标消失、缩略条消失
    • 点第 3 张图的「并入上一张」→ 归并到与上一张相同的组
    • 审核通过后,公开详情接口返回的 groups 结构与后台一致,且 images 仍为扁平全量

不做的事(防止越界)

  • 不改 image_count 语义(仍计全部图)。
  • 不动 PublicStreetSnap(列表)结构。
  • 不给 brand_runway_* 加同类字段,也不改 look_index / is_detail(走秀既有语义)。
  • 不加 parent_image_id 索引(图片表千行级,顺序扫描足够;需要时另开迁移脚本)。
  • 不引入任何 JS。