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

1447 lines
61 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 街拍主副图 实现计划
> **面向 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` 那两行之后):**
```go
// 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` 放一起):**
```go
// ErrInvalidImageGroup 主副图分组操作不合法:主图不存在 / 不属于该草稿 / 自身不是主图 / 空集合。
var ErrInvalidImageGroup = errors.New("invalid image group")
```
**模型新增(`internal/model/street_snap_draft.go`,`StreetSnapDraftImage` 内 `DupOf` 之后):**
```go
// 主副图分组(列由 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
//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` 末尾追加:
```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**
```bash
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` 追加:
```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` 之后追加(与草稿表同款注释):
```go
// 主副图分组(列由 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) }`)整体替换为:
```go
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**
```bash
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`:
```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`)替换为:
```go
// 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` 放一起)追加:
```go
// ErrInvalidImageGroup 主副图分组操作不合法:主图不存在 / 不属于本草稿 / 自身不是主图 / 空集合。
// 这里是给用户看的文案,handler 直接渲染 err.Error()。
var ErrInvalidImageGroup = errors.New("主图无效:它必须属于本草稿、未被删除,且自身是主图")
```
> 若该文件当前未导入 `errors`,在 import 块补上(`review_service.go` 现有 import 里没有它,需加)。
在 `DraftDetailView`(`review_service.go:45-66`)的 `Fields` 之前加两字段:
```go
// MainID 审核页「当前主图」的行 id,来自 URL 查询参数 ?main=;0 表示未选定。
// 只有街拍模块会用到(走秀无主副图概念)。
MainID uint32 `json:"main_id"`
// Groups 街拍详情的「主图 → 其副图」分组视图(按 sort_order 顺序)。走秀模块留空。
// 与 Images 同源同序:Images 保持扁平(既有渲染 / runway 仍用),Groups 供按组折叠。
Groups []DraftImageGroup `json:"groups,omitempty"`
```
在该类型之后新增 `DraftImageGroup`:
```go
// DraftImageGroup 一张主图及其副图,供审核页按组折叠渲染。
type DraftImageGroup struct {
Image DraftImageRef `json:"image"` // 主图
Details []DraftImageRef `json:"details"` // 该主图名下的副图(可为空)
}
```
- [ ] **步骤 4:接口加三方法**
`ReviewService` 接口(`review_service.go:97-107`)在 `DeleteDraftImage` 之后插入:
```go
// 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`)之后追加:
```go
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` 的字段装配替换为:
```go
// 先算存活的主图 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**
```bash
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`:
```go
// 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 构造改为:
```go
h := handler.NewBackstageHandler(auth, brand, &fakeArticle{}, &fakeStreet{}, &fakeReview{attachOK: true}, nil)
```
并在「街拍详情渲染图集」那段之后追加四个断言:
```go
// 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 行)之后插入:
```go
// 街拍主副图分组:并入当前主图 / 并入上一张 / 拆出(零 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 行)之后插入:
```go
// 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` 之前加:
```go
// 当前主图由 URL 查询参数承载(零 JS、无状态、可刷新可书签)。
draft.MainID = uint32(atoiDefault(c.Query("main"), 0))
```
- [ ] **步骤 6:改模板 `tplReviewDetail`**
`internal/handler/backstage_handler.go` 的 `tplReviewDetail`(第 1275-1292 行的图片区块)整体替换为下面这段(**街拍按组折叠;走秀保持原扁平网格**)。注意:**每个按钮都是独立 `<form>`,并列于 `.acts` / `.strip` 容器,不存在表单嵌套**。
```html
<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`。
再跑模板解析冒烟与全部单测:
```bash
go test ./internal/handler/ -v
go test ./...
```
预期:全绿。
- [ ] **步骤 8:Commit**
```bash
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`:
```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` 定义之前插入:
```go
// PublicStreetSnapGroup 街拍详情里的一组「主图 + 其副图」。
// 与 PublicStreetSnapDetail.Images **同源同序**:Groups 只是 Images 按主副关系重排后的视图,不引入新数据来源。
type PublicStreetSnapGroup struct {
Image PublicArticleImage `json:"image"` // 主图
Details []PublicArticleImage `json:"details"` // 该主图下的副图(可为空)
}
```
并把 `PublicStreetSnapDetail` 替换为:
```go
// 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 行)之后追加:
```go
// 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` 的返回改成:
```go
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**
```bash
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>`、顶部出现「当前主图:#<id>」
- 点另一张图的「并入当前主图」→ 该图出现「副图 · 主图 #<id>」角标,主图出现「1 张副图」,详情页该主图卡片下出现副图缩略条
- 点副图的「拆出」→ 角标消失、缩略条消失
- 点第 3 张图的「并入上一张」→ 归并到与上一张相同的组
- 审核通过后,公开详情接口返回的 `groups` 结构与后台一致,且 `images` 仍为扁平全量
## 不做的事(防止越界)
- 不改 `image_count` 语义(仍计全部图)。
- 不动 `PublicStreetSnap`(列表)结构。
- 不给 `brand_runway_*` 加同类字段,也不改 `look_index` / `is_detail`(走秀既有语义)。
- 不加 `parent_image_id` 索引(图片表千行级,顺序扫描足够;需要时另开迁移脚本)。
- 不引入任何 JS。