61 KiB
街拍主副图 实现计划
面向 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 层会拆成两次写、失去原子性。
实现裁决(规格未明确、由本计划定的三处)
- 并入一张自带副图的主图时:把它原有的副图一并改挂到新主图(而不是拒绝、也不是留成孤儿)。理由:保留不变量,且「把这一组并到那组」符合直觉。
- 「并入上一张」的目标:以「上一行所属组的主图」为目标;若上一行本身是主图,它就是目标。这样连续并入会链式归并到同一个主图。
- 拆出(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 中:
- 在
ReviewRepository接口的SoftDeleteStreetDraftImage声明之后插入三个方法声明; - 在
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仍为扁平全量
- 打开一篇街拍草稿详情 → 点某张图「设为主图」→ URL 出现
不做的事(防止越界)
- 不改
image_count语义(仍计全部图)。 - 不动
PublicStreetSnap(列表)结构。 - 不给
brand_runway_*加同类字段,也不改look_index/is_detail(走秀既有语义)。 - 不加
parent_image_id索引(图片表千行级,顺序扫描足够;需要时另开迁移脚本)。 - 不引入任何 JS。