1447 lines
61 KiB
Markdown
1447 lines
61 KiB
Markdown
# 街拍主副图 实现计划
|
||
|
||
> **面向 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。
|