Files
backend_v2/docs/superpowers/plans/2026-09-22-single-table-publish.md
toom1996 42f6316125 update
2026-09-25 11:31:52 +08:00

2342 lines
101 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 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。
**目标:** 把「草稿表 → 人工审核 → 晋升正式表」的两套表结构合并为单表,用 `status` 承载审核态,并让公开读只走只读视图。
**架构:** `brand_runways` / `street_snaps` 各加 `status`、`job_id`、`reviewer`、`reject_reason` 四列(默认 `pending`,fail-closed)。爬虫直接写入正式表(`status=pending`);审核通过只是把 `status` 改成 `published`(**不再重建图片**,图片行 id 终身不变);公开查询一律读 `public_*` 只读视图,视图内固化「已发布且未删除」,漏写过滤在物理上不可能。
**技术栈:** Go 1.x + Gin + GORM + PostgreSQL(+ pgvector);后台服务端渲染(`go:embed` 的 `.html` 模板,见 `internal/handler/templates.go`)。
**规格:** `docs/superpowers/specs/2026-09-22-single-table-publish-design.md`(执行者必须两份都读)
## 全局约束
- **状态取值只有三个**:`pending` / `published` / `rejected`;常量定义在 `internal/model/content_status.go`,用 `model.StatusPending` 等引用,禁止散落字符串字面量。
- **默认值 `pending`**:新增记录若漏赋值状态,结果必须是「不可见」而非「已发布」。
- **公开读只读视图**:`public_brand_runways`、`public_brand_runway_images`、`public_street_snaps`、`public_street_snap_images`。公开仓储方法禁止直接读基表。
- **后台读写基表**:`ListAdmin`、`GetForEdit`、所有 `Update*` / `Set*` / `Delete*` 一律针对基表。
- **零 JS 交互不变**:后台表单仍是同步 POST,成功返回 302。
- **不改公开 API 响应结构**:`images` / `groups` / 列表字段保持不变。
- **命名**:新代码用 `Record*`(不再有 `Draft*` 概念);模板数据键 `Record` / `Records`。
- **数据库结构以 `db_dump.sql` 为准**,`db/migrations/*.sql` 是手工执行的 DDL;迁移文件必须**幂等**(`IF NOT EXISTS` / `DROP VIEW IF EXISTS`)。
- **集成测试**用 `//go:build integration` 隔离,通过 `testDB(t)` 连真实库(见 `internal/repository/dedup_integration_test.go:34`)。
- **提交粒度**:每个任务一次 commit,消息用中文 Conventional Commits(`feat` / `refactor` / `fix` / `chore`)。
---
## 文件结构
| 文件 | 职责 |
|---|---|
| 创建 `internal/model/content_status.go` | 三个状态常量,全局唯一来源 |
| 创建 `internal/repository/public_views.go` | 公开视图表名常量(含 `AS brand_runways` 别名技巧) |
| 创建 `db/migrations/2026-09-22-01-single-table-publish.sql` | 加列 + 状态索引 + 建 4 个视图(幂等,可重复执行) |
| 创建 `db/migrations/2026-09-22-01b-publish-existing-rows.sql` | **一次性**:把存量正式行置为 published(必须只执行一次) |
| 创建 `db/migrations/2026-09-22-05-drop-draft-tables.sql` | 删 4 张草稿表 |
| 创建 `scripts/migrate_single_table/main.go` | 历史草稿搬迁到正式表(含校验、`-dry-run`) |
| 创建 `internal/repository/publish_status_integration_test.go` | 视图屏蔽未发布内容 |
| 创建 `internal/repository/public_read_integration_test.go` | 公开读走视图、后台仍读基表 |
| 创建 `internal/repository/ingest_single_table_integration_test.go` | 入库三分支(新建 / 放弃 / 复用驳回) |
| 修改 `internal/model/runway.go` | `BrandRunway` 加 4 列;`RunwayRow` 加 `Status` |
| 修改 `internal/model/street_snap.go` | `StreetSnap` 加 4 列 |
| 修改 `internal/repository/ingest_repository.go` | 入库直写正式表(6 个草稿方法 → 6 个正式表方法) |
| 修改 `internal/repository/review_repository.go` | 审核改基表;删除全部晋升/聚合代码 |
| 修改 `internal/repository/article_repository.go` | 公开方法读视图;`ListImages` 拆出基表版本 |
| 修改 `internal/repository/street_snap_repository.go` | 公开方法读视图;`ListImages` 拆出基表版本 |
| 修改 `internal/repository/brand_repository.go` | `hasArticlesSubQuery` / `FeaturedIDs` / `PopularWithCover` 读视图 |
| 修改 `internal/service/ingest_service.go` | 入库三分支;去重比对表单表化 |
| 修改 `internal/service/review_service.go` | 改名 + `Approve` 只改状态 |
| 修改 `internal/handler/backstage_handler.go` | 改名 + 模板数据键 `Record`/`Records` |
| 修改 `internal/handler/templates/pages/review-list.html` | `.Records`、tab 值 `published` |
| 修改 `internal/handler/templates/pages/review-detail.html` | `.Record` |
| 删除 `internal/model/runway_draft.go`、`internal/model/street_snap_draft.go` | 草稿模型 |
| 修改 `internal/router/backstage_test.go` | `fakeReview` 跟随改名 |
---
## 任务 1:状态地基(模型字段 + 迁移 + 公开视图)
**文件:**
- 创建:`internal/model/content_status.go`
- 修改:`internal/model/runway.go:3-25`(`BrandRunway`)、`internal/model/runway.go:33-49`(`RunwayRow`)
- 修改:`internal/model/street_snap.go:9-19`(`StreetSnap`)
- 创建:`db/migrations/2026-09-22-01-single-table-publish.sql`
- 创建:`db/migrations/2026-09-22-01b-publish-existing-rows.sql`
- 测试:`internal/repository/publish_status_integration_test.go`
- [ ] **步骤 1:写状态常量**
```go
package model
// 内容发布状态(单表发布模型)。
//
// 历史上「爬虫草稿」与「正式记录」是两张表,审核 = 从草稿晋升到正式表。
// 现在合并为一张表,审核态由本字段承载:
// - pending:刚入库,等待审核;公开视图不可见
// - published:已通过,对外可见
// - rejected:已驳回,留在库中备查;公开视图不可见
//
// 新增记录默认 pending(见各自模型的 gorm default),漏赋值的结果是「不可见」,
// 即 fail-closed,而不是意外对外发布。
const (
StatusPending = "pending"
StatusPublished = "published"
StatusRejected = "rejected"
)
```
- [ ] **步骤 2:给 `BrandRunway` 加 4 列**
在 `internal/model/runway.go` 的 `BrandRunway` 结构体末尾(`SeasonCode` 字段之后)追加:
```go
// 审核态与溯源(单表发布模型;列由 db/migrations/2026-09-22-01-single-table-publish.sql 添加)。
Status string `gorm:"column:status;size:16;not null;default:'pending'" json:"status"`
JobID uint32 `gorm:"column:job_id;not null;default:0" json:"job_id"` // 关联 ingest_jobs.id(重跑溯源)
Reviewer string `gorm:"column:reviewer;size:64;not null;default:''" json:"reviewer"` // 审核人(admin 账号)
RejectReason string `gorm:"column:reject_reason;size:255;not null;default:''" json:"reject_reason"`
```
并在 `RunwayRow` 的 `SeasonCode` 之后追加(后台列表展示状态用):
```go
Status string `gorm:"column:status"`
```
- [ ] **步骤 3:给 `StreetSnap` 加 4 列**
在 `internal/model/street_snap.go` 的 `StreetSnap` 结构体末尾(`UpdatedAt` 之前)追加同样的 4 个字段:
```go
// 审核态与溯源(单表发布模型;列由 db/migrations/2026-09-22-01-single-table-publish.sql 添加)。
Status string `gorm:"column:status;size:16;not null;default:'pending'" json:"status"`
JobID uint32 `gorm:"column:job_id;not null;default:0" json:"job_id"`
Reviewer string `gorm:"column:reviewer;size:64;not null;default:''" json:"reviewer"`
RejectReason string `gorm:"column:reject_reason;size:255;not null;default:''" json:"reject_reason"`
```
- [ ] **步骤 4:写失败的集成测试**
创建 `internal/repository/publish_status_integration_test.go`:
```go
//go:build integration
// 集成测试:公开视图必须屏蔽未发布内容 —— 这是「单表发布模型」的核心保证。
//
// 运行(库需先就绪,见 internal/repository/dedup_integration_test.go 顶部说明):
//
// go test -tags integration ./internal/repository/ -run TestPublicViews -v
package repository
import (
"context"
"os"
"path/filepath"
"testing"
"fashionapi/internal/model"
"gorm.io/gorm"
)
// applyMigration 读取并执行 db/migrations 下的迁移文件。
// 迁移 SQL 因此每次测试都被真实跑一遍:语法错、视图列集漂移会立刻暴露。
// 迁移文件必须幂等,重复调用安全。
func applyMigration(t *testing.T, db *gorm.DB, name string) {
t.Helper()
path := filepath.Join("..", "..", "db", "migrations", name)
sqlBytes, err := os.ReadFile(path)
if err != nil {
t.Fatalf("读取迁移失败 %s: %v", path, err)
}
if err := db.Exec(string(sqlBytes)).Error; err != nil {
t.Fatalf("执行迁移失败 %s: %v", name, err)
}
}
// seedRunway 插入一条指定状态的走秀记录 + 一张主图,返回记录 id。
// year 用测试专用值(入参),便于列表按年份隔离断言;created_at 取未来时间戳,
// 保证不会被迁移里「存量行置已发布」的时间戳护栏误刷。
func seedRunway(t *testing.T, db *gorm.DB, status string, year uint16) uint32 {
t.Helper()
now := uint32(1_800_000_000)
rw := model.BrandRunway{TitleEn: "view-test", Year: year, Status: status, CreatedAt: now, UpdatedAt: now}
if err := db.Create(&rw).Error; err != nil {
t.Fatalf("插入走秀失败: %v", err)
}
img := model.BrandRunwayImage{
RunwayID: rw.ID, BrandID: 1, Image: "view-test.jpg", Name: "look",
SortOrder: 1, CreatedAt: now, UpdatedAt: now,
}
if err := db.Create(&img).Error; err != nil {
t.Fatalf("插入走秀图片失败: %v", err)
}
t.Cleanup(func() {
db.Exec("DELETE FROM brand_runway_images WHERE runway_id = ?", rw.ID)
db.Exec("DELETE FROM brand_runways WHERE id = ?", rw.ID)
})
return rw.ID
}
// seedStreetSnap 插入一条指定状态的街拍记录 + 一张图,返回记录 id。
func seedStreetSnap(t *testing.T, db *gorm.DB, status string, year uint16) uint32 {
t.Helper()
now := uint32(1_800_000_000)
snap := model.StreetSnap{Title: "view-test", City: "ViewTestCity", Year: year, Status: status, CreatedAt: now, UpdatedAt: now}
if err := db.Create(&snap).Error; err != nil {
t.Fatalf("插入街拍失败: %v", err)
}
img := model.StreetSnapImage{SnapID: snap.ID, Image: "view-test.jpg", Name: "look", SortOrder: 1, CreatedAt: now, UpdatedAt: now}
if err := db.Create(&img).Error; err != nil {
t.Fatalf("插入街拍图片失败: %v", err)
}
t.Cleanup(func() {
db.Exec("DELETE FROM street_snap_images WHERE snap_id = ?", snap.ID)
db.Exec("DELETE FROM street_snaps WHERE id = ?", snap.ID)
})
return snap.ID
}
// TestPublicViewsHideUnpublished 走秀:pending / rejected 的记录与图片都不得出现在公开视图,published 才可见。
func TestPublicViewsHideUnpublished(t *testing.T) {
db := testDB(t)
applyMigration(t, db, "2026-09-22-01-single-table-publish.sql")
ctx := context.Background()
id := seedRunway(t, db, model.StatusPending, 1999)
count := func() (records, images int64) {
db.WithContext(ctx).Table("public_brand_runways").Where("id = ?", id).Count(&records)
db.WithContext(ctx).Table("public_brand_runway_images").Where("runway_id = ?", id).Count(&images)
return
}
if r, i := count(); r != 0 || i != 0 {
t.Fatalf("pending 不应出现在公开视图,实际 records=%d images=%d", r, i)
}
if err := db.Model(&model.BrandRunway{}).Where("id = ?", id).Update("status", model.StatusPublished).Error; err != nil {
t.Fatalf("置为已发布失败: %v", err)
}
if r, i := count(); r != 1 || i != 1 {
t.Fatalf("published 应出现在公开视图,实际 records=%d images=%d", r, i)
}
if err := db.Model(&model.BrandRunway{}).Where("id = ?", id).Update("status", model.StatusRejected).Error; err != nil {
t.Fatalf("置为已驳回失败: %v", err)
}
if r, i := count(); r != 0 || i != 0 {
t.Fatalf("rejected 不应出现在公开视图,实际 records=%d images=%d", r, i)
}
}
// TestPublicViewsHideUnpublishedStreet 街拍:同上,覆盖另外两个视图。
func TestPublicViewsHideUnpublishedStreet(t *testing.T) {
db := testDB(t)
applyMigration(t, db, "2026-09-22-01-single-table-publish.sql")
ctx := context.Background()
id := seedStreetSnap(t, db, model.StatusPending, 1998)
count := func() (records, images int64) {
db.WithContext(ctx).Table("public_street_snaps").Where("id = ?", id).Count(&records)
db.WithContext(ctx).Table("public_street_snap_images").Where("snap_id = ?", id).Count(&images)
return
}
if r, i := count(); r != 0 || i != 0 {
t.Fatalf("pending 不应出现在公开视图,实际 records=%d images=%d", r, i)
}
if err := db.Model(&model.StreetSnap{}).Where("id = ?", id).Update("status", model.StatusPublished).Error; err != nil {
t.Fatalf("置为已发布失败: %v", err)
}
if r, i := count(); r != 1 || i != 1 {
t.Fatalf("published 应出现在公开视图,实际 records=%d images=%d", r, i)
}
}
```
- [ ] **步骤 5:运行测试,确认失败**
运行:`go test -tags integration ./internal/repository/ -run TestPublicViews -v`
预期:FAIL —— `读取迁移失败 ../../db/migrations/2026-09-22-01-single-table-publish.sql: no such file or directory`
- [ ] **步骤 6:写迁移文件**
创建 `db/migrations/2026-09-22-01-single-table-publish.sql`:
```sql
-- 单表发布模型(方案 2):取消草稿表,审核态由 status 承载,公开读走只读视图。
--
-- 本文件只做三件事:加列 / 存量行置已发布 / 建视图。幂等,可重复执行。
-- 数据搬迁见 scripts/migrate_single_table;删草稿表见 2026-09-22-05-drop-draft-tables.sql。
-- 1) 正式表补审核态与溯源列。默认 'pending' 是刻意的(fail-closed):
-- 任何漏赋值的行默认不可见,而不是意外对外发布。
ALTER TABLE brand_runways
ADD COLUMN IF NOT EXISTS status varchar(16) NOT NULL DEFAULT 'pending',
ADD COLUMN IF NOT EXISTS job_id bigint NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS reviewer varchar(64) NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS reject_reason varchar(255) NOT NULL DEFAULT '';
ALTER TABLE street_snaps
ADD COLUMN IF NOT EXISTS status varchar(16) NOT NULL DEFAULT 'pending',
ADD COLUMN IF NOT EXISTS job_id bigint NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS reviewer varchar(64) NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS reject_reason varchar(255) NOT NULL DEFAULT '';
-- 2) 存量行置已发布**不在本文件里**:它是数据变更且只能执行一次,见同目录
-- 2026-09-22-01b-publish-existing-rows.sql。
-- 为什么必须拆开:数据搬迁(scripts/migrate_single_table)会把 pending 草稿连原 created_at
-- 一起搬进正式表;若本文件含那条 UPDATE 且被重复执行(测试每次都会跑),
-- 这些待审内容会被误刷成已发布 —— 恰好是本设计要防的泄漏。
-- 3) 状态索引:审核列表与公开视图都按 status 过滤。
CREATE INDEX IF NOT EXISTS idx_br_status ON brand_runways (status);
CREATE INDEX IF NOT EXISTS idx_ss_status ON street_snaps (status);
-- 4) 公开只读视图:把「必须是已发布且未删除」固化成结构性保证,公开查询漏写过滤成为不可能。
-- 注意:视图列集被 SELECT * 冻结 —— 以后给这 4 张基表加列,必须同批重建视图。
DROP VIEW IF EXISTS public_brand_runway_images;
DROP VIEW IF EXISTS public_brand_runways;
DROP VIEW IF EXISTS public_street_snap_images;
DROP VIEW IF EXISTS public_street_snaps;
CREATE VIEW public_brand_runways AS
SELECT * FROM brand_runways WHERE status = 'published' AND is_deleted = 0;
CREATE VIEW public_brand_runway_images AS
SELECT i.* FROM brand_runway_images i
JOIN brand_runways r ON r.id = i.runway_id
WHERE i.is_deleted = 0 AND r.status = 'published' AND r.is_deleted = 0;
CREATE VIEW public_street_snaps AS
SELECT * FROM street_snaps WHERE status = 'published' AND is_deleted = 0;
CREATE VIEW public_street_snap_images AS
SELECT i.* FROM street_snap_images i
JOIN street_snaps s ON s.id = i.snap_id
WHERE i.is_deleted = 0 AND s.status = 'published' AND s.is_deleted = 0;
```
- [ ] **步骤 7:运行测试,确认通过**
运行:`go test -tags integration ./internal/repository/ -run TestPublicViews -v`
预期:PASS(两个用例均通过)
- [ ] **步骤 8:编译与非集成测试无回归**
运行:`go build ./... && go test -count=1 ./...`
预期:编译通过、全绿
- [ ] **步骤 9:写一次性数据迁移文件(不参与自动化测试)**
创建 `db/migrations/2026-09-22-01b-publish-existing-rows.sql`:
```sql
-- 一次性数据迁移:把存量正式行视为已发布。
--
-- ⚠️ 只执行一次;必须在部署本计划的新代码之前、且在 scripts/migrate_single_table 之前执行。
-- 为什么单独成文件而不放进可重复执行的 2026-09-22-01:
-- 数据搬迁会把 pending 草稿连同旧的 created_at 一起搬进正式表;
-- 若这条 UPDATE 可被重复执行(集成测试每次都会跑 01 号文件),
-- 这些待审内容会被误刷成 published —— 恰好是本设计要防的泄漏。
UPDATE brand_runways SET status = 'published' WHERE status = 'pending';
UPDATE street_snaps SET status = 'published' WHERE status = 'pending';
```
- [ ] **步骤 10:Commit**
```bash
git add internal/model/content_status.go internal/model/runway.go internal/model/street_snap.go \
db/migrations/2026-09-22-01-single-table-publish.sql \
db/migrations/2026-09-22-01b-publish-existing-rows.sql \
internal/repository/publish_status_integration_test.go
git commit -m "feat(publish): 正式表加发布状态与公开只读视图"
```
> **部署提示**(不在自动化步骤内):
> 1. 必须**手工执行一次** 01 号结构迁移,再一次 01b 数据迁移。漏执行 01b 的症状是前台整站内容为空(存量行仍是 `pending`)。
> 2. ⚠️ 01/01b 与任务 1–6 的新代码**必须同一发布窗口上线**。执行 01b 之后、任务 4 上线之前,
> 旧审核路径「通过」写入的正式行会因列默认值落到 `pending`,即审核通过的内容对前台不可见。
> 不要把结构迁移单独提前上线。
---
## 任务 2:公开读切到视图
后台仍读基表,公开读改读视图。`ListImages` 被后台 `GetForEdit` 复用,需要拆出基表版本,否则后台编辑待审内容时会看不到图。
**文件:**
- 创建:`internal/repository/public_views.go`
- 修改:`internal/repository/article_repository.go:140-167`(`List`)、`:199-214`(`FindByID`)、`:216-223`(`ListImages`)、`:225-244`(`ImagesByRunwayIDs`)、`:260-273`(`GetForEdit`)
- 修改:`internal/repository/street_snap_repository.go:78-103`(`List`)、`:105-117`(`FindByID`)、`:119-126`(`ListImages`)、`:128-147`(`ImagesBySnapIDs`)、`:151-161`(`Popular`)、`:173-186`(`GetForEdit`)
- 修改:`internal/repository/brand_repository.go:76-80`(`hasArticlesSubQuery`)、`:151-177`(`FeaturedIDs`)、`:268-289`(`PopularWithCover` 的原始 SQL)
- 修改:`internal/service/street_snap_service.go`(新增 `AdminList` + 接口声明)
- 修改:`internal/handler/backstage_handler.go:254`(街拍后台列表改调 `AdminList`)
- 测试:`internal/repository/public_read_integration_test.go`
- [ ] **步骤 1:写视图表名常量**
创建 `internal/repository/public_views.go`:
```go
package repository
// 公开只读视图的表名。
//
// 把「已发布且未删除」的条件固化在视图里(见 db/migrations/2026-09-22-01-single-table-publish.sql),
// 因此公开查询**不可能**漏写可见性过滤 —— 这是单表发布模型相对「每处记得加 status」的核心优势。
//
// publicRunways 带 `AS brand_runways` 别名:article_repository 里既有的查询大量使用
// `brand_runways.xxx` 限定列与 JOIN 条件,起同名别名后这些 SQL 一行都不用改。
// 后台读基表时**不要**使用这些常量。
const (
publicRunways = "public_brand_runways AS brand_runways"
publicRunwayImages = "public_brand_runway_images"
publicStreetSnaps = "public_street_snaps AS street_snaps"
publicStreetSnapImages = "public_street_snap_images"
)
```
⚠️ 两个别名(`AS brand_runways` / `AS street_snaps`)都是必需的,不是可选优化:
`article_repository` 用 `brand_runways.xxx` 限定列,`street_snap_repository` 的 `snapOrderBy` / `snapFilterScope`
用 `street_snaps.xxx` 限定列。视图若无同名别名,会直接报
`ERROR: missing FROM-clause entry for table "..." (SQLSTATE 42P01)`。
起别名后这些既有 SQL **一行都不用改**,也不必去动后台共享的 `snapOrderBy`。
- [ ] **步骤 2:写失败的集成测试**
创建 `internal/repository/public_read_integration_test.go`:
```go
//go:build integration
// 集成测试:公开仓储方法必须读视图(读不到 pending),后台方法必须读基表(读得到 pending)。
package repository
import (
"context"
"errors"
"strconv"
"testing"
"fashionapi/internal/dto"
"fashionapi/internal/model"
)
// TestPublicReadHidesUnpublishedRunway 走秀公开列表/详情/图片看不到 pending;后台 GetForEdit 看得到。
func TestPublicReadHidesUnpublishedRunway(t *testing.T) {
db := testDB(t)
applyMigration(t, db, "2026-09-22-01-single-table-publish.sql")
ctx := context.Background()
repo := NewArticleRepository(db)
const year = 1997
id := seedRunway(t, db, model.StatusPending, year)
idStr := strconv.FormatUint(uint64(id), 10)
if _, err := repo.FindByID(ctx, idStr); !errors.Is(err, ErrNotFound) {
t.Fatalf("pending 走秀详情应 ErrNotFound,实际 %v", err)
}
imgs, err := repo.ListImages(ctx, idStr)
if err != nil {
t.Fatalf("ListImages 出错: %v", err)
}
if len(imgs) != 0 {
t.Fatalf("pending 走秀的图片不应公开,实际 %d 张", len(imgs))
}
_, total, err := repo.List(ctx, dto.ArticleQuery{Year: year, Size: 20})
if err != nil {
t.Fatalf("List 出错: %v", err)
}
if total != 0 {
t.Fatalf("pending 走秀不应出现在公开列表,实际 total=%d", total)
}
// 后台读基表:必须能看到这条待审记录与其图片,否则审核页是空的。
rw, baseImgs, err := repo.GetForEdit(ctx, id)
if err != nil {
t.Fatalf("后台 GetForEdit 应读到 pending 行: %v", err)
}
if rw == nil || len(baseImgs) == 0 {
t.Fatalf("后台应读到 pending 行与其图片,实际 rw=%v imgs=%d", rw != nil, len(baseImgs))
}
// 置为已发布后,公开侧才可见。
if err := db.Model(&model.BrandRunway{}).Where("id = ?", id).Update("status", model.StatusPublished).Error; err != nil {
t.Fatalf("置为已发布失败: %v", err)
}
if _, err := repo.FindByID(ctx, idStr); err != nil {
t.Fatalf("已发布走秀详情应可读: %v", err)
}
_, total, err = repo.List(ctx, dto.ArticleQuery{Year: year, Size: 20})
if err != nil {
t.Fatalf("List 出错: %v", err)
}
if total != 1 {
t.Fatalf("已发布走秀应出现在公开列表,实际 total=%d", total)
}
}
// TestPublicReadHidesUnpublishedStreet 街拍公开列表/详情/图片看不到 pending;后台 GetForEdit 看得到。
func TestPublicReadHidesUnpublishedStreet(t *testing.T) {
db := testDB(t)
applyMigration(t, db, "2026-09-22-01-single-table-publish.sql")
ctx := context.Background()
repo := NewStreetSnapRepository(db)
const year = 1996
id := seedStreetSnap(t, db, model.StatusPending, year)
idStr := strconv.FormatUint(uint64(id), 10)
if _, err := repo.FindByID(ctx, idStr); !errors.Is(err, ErrNotFound) {
t.Fatalf("pending 街拍详情应 ErrNotFound,实际 %v", err)
}
imgs, err := repo.ListImages(ctx, idStr)
if err != nil {
t.Fatalf("ListImages 出错: %v", err)
}
if len(imgs) != 0 {
t.Fatalf("pending 街拍的图片不应公开,实际 %d 张", len(imgs))
}
_, total, err := repo.List(ctx, dto.StreetSnapQuery{City: "ViewTestCity", Size: 20})
if err != nil {
t.Fatalf("List 出错: %v", err)
}
if total != 0 {
t.Fatalf("pending 街拍不应出现在公开列表,实际 total=%d", total)
}
snap, baseImgs, err := repo.GetForEdit(ctx, id)
if err != nil {
t.Fatalf("后台 GetForEdit 应读到 pending 行: %v", err)
}
if snap == nil || len(baseImgs) == 0 {
t.Fatalf("后台应读到 pending 行与其图片,实际 snap=%v imgs=%d", snap != nil, len(baseImgs))
}
// 后台列表 ListAdmin 读基表:必须能看到这条待审记录,否则审核员无从下手。
_, adminTotal, err := repo.ListAdmin(ctx, dto.StreetSnapQuery{City: "ViewTestCity", Size: 20})
if err != nil {
t.Fatalf("ListAdmin 出错: %v", err)
}
if adminTotal != 1 {
t.Fatalf("后台列表应看到 pending 街拍,实际 total=%d", adminTotal)
}
if err := db.Model(&model.StreetSnap{}).Where("id = ?", id).Update("status", model.StatusPublished).Error; err != nil {
t.Fatalf("置为已发布失败: %v", err)
}
_, total, err = repo.List(ctx, dto.StreetSnapQuery{City: "ViewTestCity", Size: 20})
if err != nil {
t.Fatalf("List 出错: %v", err)
}
if total != 1 {
t.Fatalf("已发布街拍应出现在公开列表,实际 total=%d", total)
}
}
```
- [ ] **步骤 3:运行测试,确认失败**
运行:`go test -tags integration ./internal/repository/ -run TestPublicRead -v`
预期:FAIL —— `pending 走秀详情应 ErrNotFound,实际 <nil>`(公开方法仍在读基表,能读到 pending)
- [ ] **步骤 4:`article_repository.go` 公开方法改读视图**
四处把 `Model(&model.BrandRunway{})` 换成 `Table(publicRunways)`:
- `List`(第 145 行与第 156 行各一处)
- `FindByID`(第 202 行)
- `ListAdmin`(第 175、186 行)**保持 `Model(&model.BrandRunway{})` 不变**(后台读基表)
`ListImages` 与 `ImagesByRunwayIDs` 加 `Table(publicRunwayImages)`:
```go
func (r *articleRepository) ListImages(ctx context.Context, runwayID string) ([]model.BrandRunwayImage, error) {
var imgs []model.BrandRunwayImage
err := r.db.WithContext(ctx).
Table(publicRunwayImages).
Where("runway_id = ?", runwayID).
Order("sort_order ASC, id ASC").
Find(&imgs).Error
return imgs, err
}
// listImagesBase 读基表(含未发布),仅供后台 GetForEdit 使用。
func (r *articleRepository) listImagesBase(ctx context.Context, runwayID string) ([]model.BrandRunwayImage, error) {
var imgs []model.BrandRunwayImage
err := r.db.WithContext(ctx).
Where("runway_id = ? AND is_deleted = 0", runwayID).
Order("sort_order ASC, id ASC").
Find(&imgs).Error
return imgs, err
}
```
`ImagesByRunwayIDs` 里 `.Select(...).Where("runway_id IN ? AND is_deleted = 0 AND is_detail = 0", ids)` 加 `.Table(publicRunwayImages)`,并把 `is_deleted = 0` 从 WHERE 去掉(视图已含)。
`GetForEdit`(第 268 行)改调基表版本:
```go
imgs, err := r.listImagesBase(ctx, strconv.FormatUint(uint64(id), 10))
```
- [ ] **步骤 5:`street_snap_repository.go` 公开方法改读视图**
- `List`:两处 `Model(&model.StreetSnap{})` → `Table(publicStreetSnaps)`。
- `FindByID`:加 `Table(publicStreetSnaps)`,WHERE 保留 `id = ?`(去掉 `is_deleted = 0` 亦可,保留无害)。
- `Popular`:加 `Table(publicStreetSnaps)`。
- `ListImages` → `Table(publicStreetSnapImages)`,WHERE 去掉 `is_deleted = 0`。
- `ImagesBySnapIDs` → `Table(publicStreetSnapImages)`,WHERE 去掉 `is_deleted = 0`。
- 新增 `listImagesBase`(与 article 同构,`Where("snap_id = ? AND is_deleted = 0", snapID)`),`GetForEdit`(第 181 行)改调它。
`GetForEdit` 读记录本身用 `First(&snap)`(基表),**不动**。
- [ ] **步骤 5b:补上街拍的后台列表方法(否则后台看不到待审内容)**
⚠️ 这一步是必需的。后台街拍列表(`internal/handler/backstage_handler.go:254`)调用的正是 `h.street.List(...)`
→ `streetSnapRepository.List`。步骤 5 把 `List` 改成读视图后,**后台街拍列表将只剩已发布内容**,
待审记录在后台「消失」,审核无从下手。走秀侧没有这个问题,因为它本来就有独立的 `ListAdmin`
(`internal/repository/article_repository.go:170`,读基表)。街拍侧缺这个对称方法。
1)在 `StreetSnapRepository` 接口里、`List` 之后新增声明:
```go
// ListAdmin 后台管理列表:读基表(含 pending / rejected),与 article_repository.ListAdmin 对称。
ListAdmin(ctx context.Context, q dto.StreetSnapQuery) ([]model.StreetSnap, int64, error)
```
2)实现(与 `List` 同构,只是 `Model(&model.StreetSnap{})` 读基表):
```go
func (r *streetSnapRepository) ListAdmin(ctx context.Context, q dto.StreetSnapQuery) ([]model.StreetSnap, int64, error) {
scope := snapFilterScope(q)
var total int64
if err := r.db.WithContext(ctx).
Model(&model.StreetSnap{}).
Scopes(scope).
Count(&total).Error; err != nil {
return nil, 0, err
}
if total == 0 {
return []model.StreetSnap{}, 0, nil
}
var rows []model.StreetSnap
if err := r.db.WithContext(ctx).
Model(&model.StreetSnap{}).
Scopes(scope).
Order(snapOrderBy(q.Sort)).
Offset(q.Offset()).
Limit(q.Size).
Find(&rows).Error; err != nil {
return nil, 0, err
}
return rows, total, nil
}
```
3)`internal/service/street_snap_service.go` 新增 `AdminList`(与 `List` 同构,只把仓储调用换成 `ListAdmin`):
```go
// AdminList 后台街拍列表:读基表,包含 pending / rejected(否则审核员看不到待审内容)。
func (s *streetSnapService) AdminList(ctx context.Context, q dto.StreetSnapQuery) ([]dto.PublicStreetSnap, int64, error) {
rows, total, err := s.repo.ListAdmin(ctx, q)
if err != nil {
return nil, 0, err
}
return s.toPublicSnaps(rows), total, nil
}
```
(`toPublicSnaps` 用 `List` 里现有的那个映射函数名;若实际叫别的名字,按现状沿用,不要新建重复映射。)
并在 `StreetSnapService` 接口里加同签名声明。
4)`internal/handler/backstage_handler.go:254` 改为:
```go
items, total, err := h.street.AdminList(c.Request.Context(), q)
```
- [ ] **步骤 6:`brand_repository.go` 改读视图**
```go
// hasArticlesSubQuery 子查询:所有「至少有一篇已发布走秀档案」的品牌 id。
// 读公开视图而非基表:否则只有待审走秀的品牌会提前出现在前台的 A-Z 索引里。
func (r *brandRepository) hasArticlesSubQuery() *gorm.DB {
return r.db.Table(publicRunways).
Select("DISTINCT brand_id")
}
```
`FeaturedIDs`:`Model(&model.BrandRunway{})` → `Table(publicRunways)`,并去掉 `Where("is_deleted = 0")`。
`PopularWithCover` 原始 SQL 里第 284 行 `FROM brand_runways` 改为 `FROM public_brand_runways brand_runways`。
- [ ] **步骤 7:运行测试,确认通过**
运行:`go test -tags integration ./internal/repository/ -run TestPublicRead -v`
预期:PASS
- [ ] **步骤 8:全量编译与非集成测试**
运行:`go build ./... && go test -count=1 ./...`
预期:编译通过、全绿
- [ ] **步骤 9:Commit**
```bash
git add internal/repository/public_views.go internal/repository/article_repository.go \
internal/repository/street_snap_repository.go internal/repository/brand_repository.go \
internal/repository/public_read_integration_test.go \
internal/service/street_snap_service.go internal/handler/backstage_handler.go \
internal/router/backstage_test.go
git commit -m "refactor(publish): 公开读改走只读视图,后台仍读基表"
```
> **执行中补做的后台基表路径(已落地,后续任务勿当缺陷「修掉」)**
>
> 审查发现同一条「公开查询被后台复用」的坑还有两处(与步骤 5b 同类),已一并拆开:
> `ArticleRepository.FindByIDBase` / `ListImagesBase`、`StreetSnapRepository.FindByIDBase` / `ListImagesBase`、
> `ArticleService.DetailAdmin`、`StreetSnapService.DetailAdmin`、`BrandService.AdminList`、
> `BrandRepository.hasArticlesSubQueryBase` 与 `brandFilterScope(..., admin bool)`。
> 后台**详情页**与后台**品牌列表**已改走这些基表路径。
> 原因:`FindByID` 被后台详情处理器复用(`backstage_handler.go:237` / `:290`),
> 而详情页是通往编辑页的唯一入口——切视图后待审内容在后台直接 404;
> `brandFilterScope` 同样同时服务公开与后台品牌列表。
> 这两条都有断言锚定:`router/backstage_test.go` 断言后台详情页 200 且渲染待审标记;
> `TestBrandIndexSplitsPublicAndAdmin` 断言「只有待审走秀的品牌」不在公开索引、但在后台列表。
---
## 任务 3:入库直写正式表(含驳回行复用)
**文件:**
- 修改:`internal/repository/ingest_repository.go:23-62`(接口)、`:242-330`(6 个草稿方法)
- 修改:`internal/service/ingest_service.go:232-328`(`processRunway`)、`:411-470`(`processStreet`)、`:600-626`(`dedupImage`)、`:339-409`(`fetchLookImages` 返回类型)
- 测试:`internal/repository/ingest_single_table_integration_test.go`
- 测试:`internal/service/ingest_service_test.go`(新增分支决策单测)
- [ ] **步骤 1:写失败的集成测试**
创建 `internal/repository/ingest_single_table_integration_test.go`:
```go
//go:build integration
// 集成测试:入库直写正式表的三条分支(新建 / 放弃 / 复用驳回行)。
package repository
import (
"context"
"testing"
"fashionapi/internal/model"
)
func newRunwayForIngest(jobID uint32, brandID uint32, seasonCode, collectionType string, cover string) *model.BrandRunway {
return &model.BrandRunway{
JobID: jobID, BrandID: brandID, TitleEn: "ingest-test",
SeasonCode: seasonCode, CollectionType: collectionType,
Cover: cover, ImageCount: 1, Status: model.StatusPending,
}
}
func newRunwayImages(cover string) []model.BrandRunwayImage {
return []model.BrandRunwayImage{{BrandID: 1, Image: cover, Name: "Look 1", SortOrder: 1, LookIndex: 1}}
}
// TestIngestCreatesPendingRecord 新建分支:实体键不存在 → 写入正式表且 status=pending,图片落到正式图片表。
func TestIngestCreatesPendingRecord(t *testing.T) {
db := testDB(t)
applyMigration(t, db, "2026-09-22-01-single-table-publish.sql")
repo := NewIngestRepository(db)
ctx := context.Background()
const season = "SS97"
_, _, found, err := repo.RunwayEntityState(ctx, 1, season, "rtw")
if err != nil {
t.Fatalf("RunwayEntityState 出错: %v", err)
}
if found {
t.Fatalf("测试前置:实体键 %s 不应已存在", season)
}
id, err := repo.CreateRunwayWithImages(ctx, newRunwayForIngest(101, 1, season, "rtw", "ingest-ss97.jpg"), newRunwayImages("ingest-ss97.jpg"))
if err != nil {
t.Fatalf("CreateRunwayWithImages 出错: %v", err)
}
t.Cleanup(func() {
db.Exec("DELETE FROM brand_runway_images WHERE runway_id = ?", id)
db.Exec("DELETE FROM brand_runways WHERE id = ?", id)
})
var rw model.BrandRunway
if err := db.Where("id = ?", id).First(&rw).Error; err != nil {
t.Fatalf("读回记录失败: %v", err)
}
if rw.Status != model.StatusPending {
t.Fatalf("新入库记录应为 pending,实际 %s", rw.Status)
}
gotID, status, found, err := repo.RunwayEntityState(ctx, 1, season, "rtw")
if err != nil {
t.Fatalf("RunwayEntityState 出错: %v", err)
}
if !found || gotID != id || status != model.StatusPending {
t.Fatalf("实体键应命中刚建的行,实际 found=%v id=%d status=%s", found, gotID, status)
}
publicCount := int64(0)
db.Table("public_brand_runways").Where("id = ?", id).Count(&publicCount)
if publicCount != 0 {
t.Fatalf("pending 记录不应出现在公开视图,实际 %d", publicCount)
}
}
// TestIngestReusesRejectedRecord 复用分支:实体键命中 rejected → 覆盖内容、软删旧图、写新图、置回 pending。
func TestIngestReusesRejectedRecord(t *testing.T) {
db := testDB(t)
applyMigration(t, db, "2026-09-22-01-single-table-publish.sql")
repo := NewIngestRepository(db)
ctx := context.Background()
const season = "FW96"
oldID, err := repo.CreateRunwayWithImages(ctx, newRunwayForIngest(90, 1, season, "rtw", "old.jpg"), newRunwayImages("old.jpg"))
if err != nil {
t.Fatalf("预置记录失败: %v", err)
}
t.Cleanup(func() {
db.Exec("DELETE FROM brand_runway_images WHERE runway_id = ?", oldID)
db.Exec("DELETE FROM brand_runways WHERE id = ?", oldID)
})
if err := db.Model(&model.BrandRunway{}).Where("id = ?", oldID).
Updates(map[string]any{"status": model.StatusRejected, "reviewer": "admin", "reject_reason": "图片缺失"}).Error; err != nil {
t.Fatalf("置为已驳回失败: %v", err)
}
_, status, found, err := repo.RunwayEntityState(ctx, 1, season, "rtw")
if err != nil || !found || status != model.StatusRejected {
t.Fatalf("前置:应命中 rejected 行,实际 found=%v status=%s err=%v", found, status, err)
}
newRW := newRunwayForIngest(91, 1, season, "rtw", "new.jpg")
newRW.TitleEn = "ingest-test-reused"
if err := repo.ReuseRejectedRunway(ctx, oldID, newRW, newRunwayImages("new.jpg")); err != nil {
t.Fatalf("ReuseRejectedRunway 出错: %v", err)
}
var rw model.BrandRunway
if err := db.Where("id = ?", oldID).First(&rw).Error; err != nil {
t.Fatalf("读回记录失败: %v", err)
}
if rw.Status != model.StatusPending {
t.Fatalf("复用后应回到 pending,实际 %s", rw.Status)
}
if rw.TitleEn != "ingest-test-reused" || rw.Cover != "new.jpg" {
t.Fatalf("内容应被覆盖,实际 title=%s cover=%s", rw.TitleEn, rw.Cover)
}
if rw.Reviewer != "" || rw.RejectReason != "" {
t.Fatalf("复用应清空审核痕迹,实际 reviewer=%s reason=%s", rw.Reviewer, rw.RejectReason)
}
var alive []model.BrandRunwayImage
if err := db.Where("runway_id = ? AND is_deleted = 0", oldID).Find(&alive).Error; err != nil {
t.Fatalf("读图片失败: %v", err)
}
if len(alive) != 1 || alive[0].Image != "new.jpg" {
t.Fatalf("旧图应被软删、只留新图,实际 %d 张", len(alive))
}
var oldAlive int64
db.Model(&model.BrandRunwayImage{}).Where("runway_id = ? AND image = ? AND is_deleted = 0", oldID, "old.jpg").Count(&oldAlive)
if oldAlive != 0 {
t.Fatalf("旧图应被软删")
}
}
```
同时创建 `internal/service/ingest_service_test.go`,覆盖三分支的**决策逻辑**(不需要数据库,因为它是纯函数):
```go
package service
import (
"testing"
"fashionapi/internal/model"
)
// TestDecideIngestAction 入库三分支决策:未命中→新建;命中 pending/published→放弃;命中 rejected→复用重审。
// 这是「驳回不是永久黑名单」这条规则的直接证据。
func TestDecideIngestAction(t *testing.T) {
cases := []struct {
name string
found bool
status string
want ingestActionKind
}{
{"未命中→新建", false, "", ingestCreate},
{"命中 pending→放弃", true, model.StatusPending, ingestSkip},
{"命中 published→放弃", true, model.StatusPublished, ingestSkip},
{"命中 rejected→复用重审", true, model.StatusRejected, ingestReuseRejected},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := decideIngestAction(c.found, c.status); got != c.want {
t.Fatalf("decideIngestAction(%v, %q) = %v, want %v", c.found, c.status, got, c.want)
}
})
}
}
```
- [ ] **步骤 2:运行测试,确认失败**
运行:`go test -tags integration ./internal/repository/ -run TestIngest -v; go test -count=1 ./internal/service/ -run TestDecideIngestAction -v`
预期:仓库侧 FAIL —— 编译错误 `repo.RunwayEntityState undefined`;service 侧 FAIL —— `undefined: decideIngestAction`
- [ ] **步骤 3:替换 `IngestRepository` 接口中的草稿方法**
在 `internal/repository/ingest_repository.go` 中,删除这 6 个声明:`RunwayIDByEntity`、`CreateRunwayDraft`、`CreateRunwayDraftImages`、`StreetSnapIDByEntity`、`CreateStreetSnapDraft`、`CreateStreetSnapDraftImages`,替换为:
```go
// RunwayEntityState 按实体键(brand_id + season_code + collection_type)查正式表既有行,返回其状态。
// worker 据此分三支:pending/published → 直接放弃本次入库;rejected → 复用该行重审;未命中 → 新建。
RunwayEntityState(ctx context.Context, brandID uint32, seasonCode, collectionType string) (id uint32, status string, found bool, err error)
// CreateRunwayWithImages 新建走秀正式行(status=pending)并写入图片,返回记录主键(事务内完成)。
CreateRunwayWithImages(ctx context.Context, rw *model.BrandRunway, imgs []model.BrandRunwayImage) (uint32, error)
// ReuseRejectedRunway 复用一条 rejected 走秀:覆盖内容字段、软删旧图、写入新图、置回 pending(事务内完成)。
// 驳回因此不是永久黑名单:重爬同一实体即重新送审,且始终「一个实体一行」。
ReuseRejectedRunway(ctx context.Context, id uint32, rw *model.BrandRunway, imgs []model.BrandRunwayImage) error
// StreetSnapEntityState 按实体键(city + year)查正式表既有行,返回其状态。
StreetSnapEntityState(ctx context.Context, city string, year uint16) (id uint32, status string, found bool, err error)
// CreateStreetSnapWithImages 新建街拍正式行(status=pending)并写入图片(事务内完成)。
CreateStreetSnapWithImages(ctx context.Context, snap *model.StreetSnap, imgs []model.StreetSnapImage) (uint32, error)
// ReuseRejectedStreetSnap 复用一条 rejected 街拍记录(事务内完成)。
ReuseRejectedStreetSnap(ctx context.Context, id uint32, snap *model.StreetSnap, imgs []model.StreetSnapImage) error
```
同时删除这 6 个方法的实现体(第 242-330 行区间),新增下面步骤 4 的实现。
- [ ] **步骤 4:实现 6 个方法**
在 `ingest_repository.go` 中原草稿方法位置写入:
```go
// ---- 入库直写正式表(单表发布模型)----
func (r *ingestRepository) RunwayEntityState(ctx context.Context, brandID uint32, seasonCode, collectionType string) (uint32, string, bool, error) {
var row struct {
ID uint32 `gorm:"column:id"`
Status string `gorm:"column:status"`
}
err := r.db.WithContext(ctx).
Model(&model.BrandRunway{}).
Select("id, status").
Where("brand_id = ? AND season_code = ? AND collection_type = ? AND is_deleted = 0", brandID, seasonCode, collectionType).
Limit(1).
Scan(&row).Error
if err != nil {
return 0, "", false, err
}
if row.ID == 0 {
return 0, "", false, nil
}
return row.ID, row.Status, true, nil
}
func (r *ingestRepository) CreateRunwayWithImages(ctx context.Context, rw *model.BrandRunway, imgs []model.BrandRunwayImage) (uint32, error) {
now := uint32(time.Now().Unix())
rw.CreatedAt, rw.UpdatedAt = now, now
if rw.Status == "" {
rw.Status = model.StatusPending
}
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if cErr := tx.Create(rw).Error; cErr != nil {
return cErr
}
if len(imgs) == 0 {
return nil
}
for i := range imgs {
imgs[i].RunwayID = rw.ID
imgs[i].CreatedAt, imgs[i].UpdatedAt = now, now
}
return tx.Create(&imgs).Error
})
if err != nil {
return 0, err
}
return rw.ID, nil
}
func (r *ingestRepository) ReuseRejectedRunway(ctx context.Context, id uint32, rw *model.BrandRunway, imgs []model.BrandRunwayImage) error {
now := uint32(time.Now().Unix())
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if uErr := tx.Model(&model.BrandRunway{}).Where("id = ?", id).Updates(map[string]any{
"title_en": rw.TitleEn,
"title_cn": rw.TitleCn,
"description_en": rw.DescriptionEn,
"description_cn": rw.DescriptionCn,
"year": rw.Year,
"season": rw.Season,
"collection_type": rw.CollectionType,
"season_code": rw.SeasonCode,
"cover": rw.Cover,
"image_count": rw.ImageCount,
"job_id": rw.JobID,
"status": model.StatusPending,
"reviewer": "",
"reject_reason": "",
"updated_at": now,
}).Error; uErr != nil {
return uErr
}
if dErr := tx.Model(&model.BrandRunwayImage{}).
Where("runway_id = ? AND is_deleted = 0", id).
Updates(map[string]any{"is_deleted": 1, "updated_at": now}).Error; dErr != nil {
return dErr
}
if len(imgs) == 0 {
return nil
}
for i := range imgs {
imgs[i].RunwayID = id
imgs[i].CreatedAt, imgs[i].UpdatedAt = now, now
}
return tx.Create(&imgs).Error
})
}
func (r *ingestRepository) StreetSnapEntityState(ctx context.Context, city string, year uint16) (uint32, string, bool, error) {
var row struct {
ID uint32 `gorm:"column:id"`
Status string `gorm:"column:status"`
}
err := r.db.WithContext(ctx).
Model(&model.StreetSnap{}).
Select("id, status").
Where("city = ? AND year = ? AND is_deleted = 0", city, year).
Limit(1).
Scan(&row).Error
if err != nil {
return 0, "", false, err
}
if row.ID == 0 {
return 0, "", false, nil
}
return row.ID, row.Status, true, nil
}
func (r *ingestRepository) CreateStreetSnapWithImages(ctx context.Context, snap *model.StreetSnap, imgs []model.StreetSnapImage) (uint32, error) {
now := uint32(time.Now().Unix())
snap.CreatedAt, snap.UpdatedAt = now, now
if snap.Status == "" {
snap.Status = model.StatusPending
}
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if cErr := tx.Create(snap).Error; cErr != nil {
return cErr
}
if len(imgs) == 0 {
return nil
}
for i := range imgs {
imgs[i].SnapID = snap.ID
imgs[i].CreatedAt, imgs[i].UpdatedAt = now, now
}
return tx.Create(&imgs).Error
})
if err != nil {
return 0, err
}
return snap.ID, nil
}
func (r *ingestRepository) ReuseRejectedStreetSnap(ctx context.Context, id uint32, snap *model.StreetSnap, imgs []model.StreetSnapImage) error {
now := uint32(time.Now().Unix())
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if uErr := tx.Model(&model.StreetSnap{}).Where("id = ?", id).Updates(map[string]any{
"title": snap.Title,
"year": snap.Year,
"city": snap.City,
"cover": snap.Cover,
"image_count": snap.ImageCount,
"job_id": snap.JobID,
"status": model.StatusPending,
"reviewer": "",
"reject_reason": "",
"updated_at": now,
}).Error; uErr != nil {
return uErr
}
if dErr := tx.Model(&model.StreetSnapImage{}).
Where("snap_id = ? AND is_deleted = 0", id).
Updates(map[string]any{"is_deleted": 1, "updated_at": now}).Error; dErr != nil {
return dErr
}
if len(imgs) == 0 {
return nil
}
for i := range imgs {
imgs[i].SnapID = id
imgs[i].CreatedAt, imgs[i].UpdatedAt = now, now
}
return tx.Create(&imgs).Error
})
}
```
- [ ] **步骤 5:`fetchLookImages` 改返回正式图片类型**
`internal/service/ingest_service.go:339` 的签名与内部两处 `model.BrandRunwayDraftImage` 改为 `model.BrandRunwayImage`:
```go
func (s *IngestService) fetchLookImages(ctx context.Context, looks []dto.RunwayLook, prefix string) (string, []model.BrandRunwayImage, []string, bool, *fetchTiming) {
```
内部 `rows := make([]model.BrandRunwayImage, 0)` 与 `rows = append(rows, model.BrandRunwayImage{...})`(第 395 行)同步改类型;`BrandID` 不在此设置(由 `processRunway` 统一填)。
- [ ] **步骤 6:加入分支决策纯函数**
在 `internal/service/ingest_service.go` 末尾追加:
```go
// ingestActionKind 入库分支决策结果(单表发布模型)。
type ingestActionKind int
const (
ingestCreate ingestActionKind = iota // 实体键未命中 → 新建记录
ingestSkip // 命中 pending/published → 放弃本次入库
ingestReuseRejected // 命中 rejected → 复用该行重审
)
// decideIngestAction 按实体键命中情况与既有行状态决定入库分支。
// 抽成纯函数,是为了让「驳回不是永久黑名单」这条规则有独立的、不需要数据库的测试。
func decideIngestAction(found bool, status string) ingestActionKind {
switch {
case !found:
return ingestCreate
case status == model.StatusRejected:
return ingestReuseRejected
default:
return ingestSkip
}
}
```
- [ ] **步骤 7:`processRunway` 改为直写正式表**
`internal/service/ingest_service.go:245-256` 的去重段替换为:
```go
// 2) 实体键查重并决定分支(单表模型)
seasonCode := season.Derive(p.Year, p.CollectionType, p.Season)
reuseID, existStatus, exist, _ := s.repo.RunwayEntityState(ctx, brandID, seasonCode, p.CollectionType)
action := decideIngestAction(exist, existStatus)
if action == ingestSkip {
log.Printf("[ingest] job=%d runway 实体已存在(status=%s),跳过", job.ID, existStatus)
_ = s.repo.MarkDone(ctx, job.ID)
return
}
```
第 263 行的 `var draftImages []model.BrandRunwayDraftImage` 改为 `var images []model.BrandRunwayImage`;第 269 行 `s.fetchLookImages` 赋值给 `images`;第 276-285 行的 `append(draftImages, model.BrandRunwayDraftImage{...})` 改为 `append(images, model.BrandRunwayImage{...})`。
第 297-327 行的写草稿段整体替换为:
```go
// 5) 直写正式表(status=pending),等待后台审核通过(审核只改状态,不重建图片)
writeStart := time.Now()
rw := &model.BrandRunway{
JobID: job.ID,
BrandID: brandID,
TitleEn: p.TitleEn,
TitleCn: p.TitleCn,
DescriptionEn: p.DescriptionEn,
DescriptionCn: p.DescriptionCn,
Year: p.Year,
Season: p.Season,
CollectionType: p.CollectionType,
SeasonCode: seasonCode,
Cover: cover,
ImageCount: imageCount,
Status: model.StatusPending,
}
for i := range images {
images[i].BrandID = brandID
}
var saveErr error
if action == ingestReuseRejected {
saveErr = s.repo.ReuseRejectedRunway(ctx, reuseID, rw, images)
} else {
_, saveErr = s.repo.CreateRunwayWithImages(ctx, rw, images)
}
if saveErr != nil {
s.failOrRetry(ctx, job.ID, "save runway: "+saveErr.Error())
return
}
log.Printf("[ingest] job=%d runway 写正式表 耗时=%v 图片行=%d 复用驳回=%v",
job.ID, time.Since(writeStart).Round(time.Millisecond), len(images), action == ingestReuseRejected)
_ = s.repo.MarkDone(ctx, job.ID)
```
- [ ] **步骤 8:`processStreet` 改造(与步骤 7 同构)**
第 413-422 行去重段替换为:
```go
// 1) 实体键查重并决定分支(单表模型,同 runway)
reuseID, existStatus, exist, _ := s.repo.StreetSnapEntityState(ctx, p.City, p.Year)
action := decideIngestAction(exist, existStatus)
if action == ingestSkip {
log.Printf("[ingest] job=%d street 实体已存在(status=%s),跳过", job.ID, existStatus)
_ = s.repo.MarkDone(ctx, job.ID)
return
}
```
第 435-469 行写草稿段替换为:
```go
// 3) 直写正式表(status=pending)
writeStart := time.Now()
snap := &model.StreetSnap{
JobID: job.ID,
Title: p.TitleEn, // 街拍单标题,爬虫优先填 title_en
Year: p.Year,
City: p.City,
Cover: cover,
ImageCount: uint16(len(fimgs)),
Status: model.StatusPending,
}
rows := make([]model.StreetSnapImage, 0, len(fimgs))
for i, fi := range fimgs {
rows = append(rows, model.StreetSnapImage{
Image: fi.url,
Name: fmt.Sprintf("Look %d", i+1),
SortOrder: uint32(i + 1),
Phash: sqlNull(fi.phash),
IsDuplicate: fi.isDup,
DupOf: strconv.FormatUint(uint64(fi.dupID), 10),
})
}
var saveErr error
if action == ingestReuseRejected {
saveErr = s.repo.ReuseRejectedStreetSnap(ctx, reuseID, snap, rows)
} else {
_, saveErr = s.repo.CreateStreetSnapWithImages(ctx, snap, rows)
}
if saveErr != nil {
s.failOrRetry(ctx, job.ID, "save street snap: "+saveErr.Error())
return
}
log.Printf("[ingest] job=%d street 写正式表 耗时=%v 图片行=%d 复用驳回=%v",
job.ID, time.Since(writeStart).Round(time.Millisecond), len(rows), action == ingestReuseRejected)
_ = s.repo.MarkDone(ctx, job.ID)
```
- [ ] **步骤 9:`dedupImage` 的比对表单表化**
`internal/service/ingest_service.go:606-614`:
```go
var tables []string
switch kind {
case dto.IngestKindRunway:
tables = []string{"brand_runway_images"}
case dto.IngestKindStreet:
tables = []string{"street_snap_images"}
default:
return 0, 0
}
```
- [ ] **步骤 10:运行测试,确认通过**
运行:`go test -tags integration ./internal/repository/ -run TestIngest -v; go test -count=1 ./internal/service/ -run TestDecideIngestAction -v`
预期:两侧均 PASS
- [ ] **步骤 11:全量编译与非集成测试**
运行:`go build ./... && go test -count=1 ./...`
预期:编译通过。若 `internal/repository/ingest_repository_test.go` 因方法改名报错,按同样语义更新其桩:把 `CreateRunwayDraft` 等调用改为 `CreateRunwayWithImages` / `RunwayEntityState`。
- [ ] **步骤 12:Commit**
```bash
git add internal/repository/ingest_repository.go internal/service/ingest_service.go \
internal/service/ingest_service_test.go \
internal/repository/ingest_single_table_integration_test.go internal/repository/ingest_repository_test.go
git commit -m "feat(publish): 爬虫入库直写正式表,驳回行可复用重审"
```
---
## 任务 3b:实体键唯一索引(结构性保证「一个实体一行」)
**背景(为什么必须做)**:旧流程是**自愈**的——同一实体若产生两条草稿,晋升时的实体键 upsert 会把它们并成一条正式记录。
单表之后这个自愈机制消失了,而 `brand_runways` / `street_snaps` 的实体键上**没有任何唯一索引**,
于是「一个实体一行」只靠入库时的「先查后插」维持:一次瞬时读失败、或两个后端实例并发处理同一实体的两条任务,
就会留下两行;两行都会通过审核进入公开视图,前台出现重复内容。
**文件:**
- 创建:`db/migrations/2026-09-22-03-entity-key-unique.sql`
- 修改:`internal/repository/ingest_repository.go`(复用守卫补 `is_deleted = 0`)
- 修改:`internal/repository/dedup_integration_test.go:97`(修掉空转的负向断言)
- 测试:`internal/repository/entity_key_unique_integration_test.go`
- [ ] **步骤 1:写失败的集成测试**
创建 `internal/repository/entity_key_unique_integration_test.go`:
```go
//go:build integration
// 集成测试:实体键上的部分唯一索引必须阻止「同一实体两行」,
// 同时不阻止「实体键相同但已软删」的行(与 RunwayEntityState 的 is_deleted = 0 口径一致)。
package repository
import (
"context"
"testing"
"fashionapi/internal/model"
)
// TestRunwayEntityKeyUnique 同一实体键的第二行应被拒绝;软删后可以再插入。
func TestRunwayEntityKeyUnique(t *testing.T) {
db := testDB(t)
applyMigration(t, db, "2026-09-22-01-single-table-publish.sql")
applyMigration(t, db, "2026-09-22-03-entity-key-unique.sql")
repo := NewIngestRepository(db)
ctx := context.Background()
const season = "SS95"
rw := newRunwayForIngest(1, 1, season, "rtw", "uniq-a.jpg")
id, err := repo.CreateRunwayWithImages(ctx, rw, newRunwayImages("uniq-a.jpg"))
if err != nil {
t.Fatalf("首行应能插入: %v", err)
}
t.Cleanup(func() {
db.Exec("DELETE FROM brand_runway_images WHERE runway_id = ?", id)
db.Exec("DELETE FROM brand_runways WHERE id = ?", id)
})
dup := newRunwayForIngest(1, 1, season, "rtw", "uniq-b.jpg")
if _, err := repo.CreateRunwayWithImages(ctx, dup, newRunwayImages("uniq-b.jpg")); err == nil {
t.Fatalf("同一实体键的第二行应被唯一索引拒绝,实际插入成功")
}
// 软删首行后,实体键不再占用(与 is_deleted = 0 的部分索引口径一致)。
if err := db.Model(&model.BrandRunway{}).Where("id = ?", id).Update("is_deleted", 1).Error; err != nil {
t.Fatalf("软删失败: %v", err)
}
again := newRunwayForIngest(1, 1, season, "rtw", "uniq-c.jpg")
newID, err := repo.CreateRunwayWithImages(ctx, again, newRunwayImages("uniq-c.jpg"))
if err != nil {
t.Fatalf("软删后应可再插入同实体键: %v", err)
}
t.Cleanup(func() {
db.Exec("DELETE FROM brand_runway_images WHERE runway_id = ?", newID)
db.Exec("DELETE FROM brand_runways WHERE id = ?", newID)
})
}
// TestStreetEntityKeyUnique 街拍侧同理(实体键 = city + year)。
func TestStreetEntityKeyUnique(t *testing.T) {
db := testDB(t)
applyMigration(t, db, "2026-09-22-01-single-table-publish.sql")
applyMigration(t, db, "2026-09-22-03-entity-key-unique.sql")
repo := NewIngestRepository(db)
ctx := context.Background()
const city = "UniqTestCity"
const year = 1994
snap := &model.StreetSnap{JobID: 1, Title: "uniq", Year: year, City: city, Status: model.StatusPending}
id, err := repo.CreateStreetSnapWithImages(ctx, snap, []model.StreetSnapImage{{Image: "uniq-a.jpg", SortOrder: 1}})
if err != nil {
t.Fatalf("首行应能插入: %v", err)
}
t.Cleanup(func() {
db.Exec("DELETE FROM street_snap_images WHERE snap_id = ?", id)
db.Exec("DELETE FROM street_snaps WHERE id = ?", id)
})
dup := &model.StreetSnap{JobID: 1, Title: "uniq", Year: year, City: city, Status: model.StatusPending}
if _, err := repo.CreateStreetSnapWithImages(ctx, dup, []model.StreetSnapImage{{Image: "uniq-b.jpg", SortOrder: 1}}); err == nil {
t.Fatalf("同一实体键的第二行应被唯一索引拒绝,实际插入成功")
}
}
```
- [ ] **步骤 2:运行测试,确认失败**
运行:`go test -tags integration ./internal/repository/ -run TestRunwayEntityKeyUnique -v`
预期:FAIL —— `执行迁移失败 ...2026-09-22-03-entity-key-unique.sql: The system cannot find the file specified.`
- [ ] **步骤 3:写迁移**
创建 `db/migrations/2026-09-22-03-entity-key-unique.sql`:
```sql
-- 实体键唯一索引:单表发布模型下「一个实体一行」的结构性保证。
--
-- 为什么必须有:旧流程靠晋升时的实体键 upsert 把同一实体的多条草稿并成一行(自愈)。
-- 单表之后没有 upsert 阶段,只剩入库时的「先查后插」;一次瞬时读失败或多实例并发,
-- 就会留下两行,且两行都会通过审核进入公开视图 —— 前台出现重复内容。
--
-- 部分索引(WHERE is_deleted = 0):与 RunwayEntityState / StreetSnapEntityState 的查询口径一致,
-- 软删的旧实体键不再占用,允许同实体重新入库。
-- 建索引前先体检:有重复则中止,避免索引创建失败留下半成品状态。
DO $$
DECLARE dup int;
BEGIN
SELECT count(*) INTO dup FROM (
SELECT brand_id, season_code, collection_type
FROM brand_runways WHERE is_deleted = 0
GROUP BY 1, 2, 3 HAVING count(*) > 1
) t;
IF dup > 0 THEN
RAISE EXCEPTION 'brand_runways 有 % 组重复实体键,请先人工合并再加唯一索引', dup;
END IF;
SELECT count(*) INTO dup FROM (
SELECT city, year
FROM street_snaps WHERE is_deleted = 0
GROUP BY 1, 2 HAVING count(*) > 1
) t;
IF dup > 0 THEN
RAISE EXCEPTION 'street_snaps 有 % 组重复实体键,请先人工合并再加唯一索引', dup;
END IF;
END $$;
CREATE UNIQUE INDEX IF NOT EXISTS uq_br_entity
ON brand_runways (brand_id, season_code, collection_type) WHERE is_deleted = 0;
CREATE UNIQUE INDEX IF NOT EXISTS uq_ss_entity
ON street_snaps (city, year) WHERE is_deleted = 0;
```
- [ ] **步骤 4:运行测试,确认通过**
运行:`go test -tags integration ./internal/repository/ -run 'TestRunwayEntityKeyUnique|TestStreetEntityKeyUnique' -v`
预期:PASS(两个用例均通过)
- [ ] **步骤 5:确认入库侧的竞争会收敛到「跳过」而不是重试风暴**
唯一索引把并发竞争变成一次写库错误 → `CreateRunwayWithImages` 返回 err → `failOrRetry` 安排重试 →
重试时 `RunwayEntityState` 已能查到那一行(`pending`)→ 走 `ingestSkip` → `MarkDone`。
**这是既有重试机制的自然收敛,不需要为它写新代码。** 只需人工确认这条链路成立:
阅读 `internal/service/ingest_service.go` 的 `processRunway` 写库分支与 `failOrRetry`,
确认「写库失败 → ScheduleRetry → 重跑 → 实体键命中 pending → MarkDone」这条路径没有断点。
把确认结论写进报告(不需要为此写测试)。
- [ ] **步骤 6:全量回归**
运行:`go build ./... && go test -count=1 ./... && go test -tags integration ./internal/repository/ -count=1 2>&1 | Select-Object -Last 5`
预期:全绿
- [ ] **步骤 6b:顺带修两处同区域的小缺陷(由任务 3 的审查带出)**
**6b-1 修掉空转的负向断言** —— `internal/repository/dedup_integration_test.go` 里原有的「不应命中」断言实际从未生效:
```go
// 全 0 向量:与全 1 向量汉明距离 = 64,远超阈值,不应命中。
_, found2, _ := repo.FindNearDuplicateImage(ctx, tables, phash.ToVectorBits(0), phash.DefaultThreshold)
if found2 {
t.Fatalf("汉明距离 64 的图不应被误判为近重复")
}
```
问题:`phash.ToVectorBits(0)` 对 `h == 0` 返回**空串**(见 `internal/pkg/phash/phash.go:95-98`),
SQL 因此退化成 `phash <-> ''::vector`,PostgreSQL 报 `22P02`;而这里用 `_` 丢弃了 err,
于是 `if found2` 恒不触发 —— 断言永远通过,等于没有覆盖负向路径。
改为用「距离 = 64 的非空探针」并检查 err:
```go
// 全 1 向量(与库中那条距离 0)的取反不是合法探针(会有空串问题),
// 这里改用「首位为 0、其余为 1」的探针并先断言查询本身不报错。
probeSame, foundSame, errSame := repo.FindNearDuplicateImage(ctx, tables, phash.ToVectorBits(^uint64(0)), phash.DefaultThreshold)
if errSame != nil {
t.Fatalf("去重查询不应报错: %v", errSame)
}
if !foundSame {
t.Fatalf("与库中那条完全相同的 phash 应命中")
}
if probeSame == 0 {
t.Fatalf("命中的行 id 不应为 0")
}
```
(原「汉明距离 64 不应命中」的意图由既有正向断言 + `is_deleted` 过滤测试覆盖;
若要保留一个真正的远距离负向用例,请用与库中那条距离确实大于阈值的**非空** phash 向量,
并在断言前先检查 `err == nil`。)
**6b-2 复用守卫补 `is_deleted = 0`** —— `internal/repository/ingest_repository.go` 的
`ReuseRejectedRunway` / `ReuseRejectedStreetSnap`:
```go
upd := tx.Model(&model.BrandRunway{}).Where("id = ? AND status = ? AND is_deleted = 0", id, model.StatusRejected)
```
(street 侧同理。)理由:只加 `status` 守卫仍留着一条边——若该行在窗口期被管理员软删(状态仍是 `rejected`),
复用会照常覆盖内容并写入新图,而内容停在 `is_deleted = 1` 的行上永远不可见。
- [ ] **步骤 6c:跑测试确认 6b 没破坏东西**
运行:`go test -tags integration ./internal/repository/ -run 'TestDedup|TestIngest' -count=1 -v`
预期:全绿(含 6b-1 改写后的断言与既有的 `TestDedupIgnoresSoftDeletedImage`)
- [ ] **步骤 7:Commit**
```bash
git add db/migrations/2026-09-22-03-entity-key-unique.sql \
internal/repository/entity_key_unique_integration_test.go \
internal/repository/ingest_repository.go \
internal/repository/dedup_integration_test.go
git commit -m "feat(publish): 实体键唯一索引,保证一个实体一行"
```
---
## 任务 3c:街拍实体键细化(city + year + title)
**背景(用户在任务 5 的数据冲突上作出的决策)**:同城同年可以有多个专题——开发库实例如
「London / 2027 / *…Spring 2027 Day 2*」(草稿 4,329 图)与「London / 2027 / *…Spring 2027 Day 3*」
(正式行 id=6,424 主图)。用户要求**分成两个文章**。
而当前街拍实体键是 `(city, year)`,比内容粒度粗,导致两个后果:
1. `uq_ss_entity`(任务 3b 加的唯一索引)不允许两者共存 —— 搬迁直接撞键中止;
2. 入库查重 `StreetSnapEntityState` 会把 Day 3 当成 Day 2 的重复而**直接放弃**,第二个专题根本进不来。
**实体键改为 `(city, year, title)`**。理由:`title` 是数据里唯一能区分专题的字段,且入库时已知
(爬虫把 `p.TitleEn` 直接作为快照标题)。这与既定决策「实体键命中即放弃」兼容——同城同年**同标题**的重爬仍被放弃。
**文件:**
- 创建:`db/migrations/2026-09-22-04-street-entity-key-title.sql`
- 修改:`internal/repository/ingest_repository.go`(`StreetSnapEntityState` 增加 title 条件)
- 修改:`internal/service/ingest_service.go`(`processStreet` 的调用处传 `p.TitleEn`)
- 测试:`internal/repository/entity_key_unique_integration_test.go`(街拍用例改为按 title 区分)
- [ ] **步骤 1:先改测试(红)**
把 `internal/repository/entity_key_unique_integration_test.go` 的 `TestStreetEntityKeyUnique` 改成两段断言:
```go
// ① 同城同年**同标题**的第二行必须被拒(实体键仍具备唯一性)。
// ② 同城同年**不同标题**的第二行必须允许(这就是「Day 2 / Day 3 分成两个文章」)。
```
具体做法:把现有用例的第二次插入改成「同 city + 同 year + **同 title**」并断言失败;
再新增一次「同 city + 同 year + **不同 title**」的插入并断言成功。
- [ ] **步骤 2:运行测试,确认失败**
运行:`go test -tags integration ./internal/repository/ -run TestStreetEntityKeyUnique -v`
预期:FAIL —— 迁移文件缺失(`applyMigration` 报找不到 `2026-09-22-04-...sql`)
- [ ] **步骤 3:写迁移**
创建 `db/migrations/2026-09-22-04-street-entity-key-title.sql`:
```sql
-- 街拍实体键细化:由 (city, year) 改为 (city, year, title)。
--
-- 背景:同城同年可能有多个专题(如 London 2027 Day 2 / Day 3),原键把它们判为同一实体,
-- 导致唯一索引不允许共存、且入库查重把第二个专题当成重复直接放弃。
--
-- COALESCE(title, '') 是刻意的:唯一索引中 NULL 互不冲突,若直接用 title,
-- 两条 title 为 NULL 的行会被判为不同实体而共存。入库始终会写 title,
-- 这里只是把「NULL 也要参与唯一性」这件事钉死。
DROP INDEX IF EXISTS uq_ss_entity;
CREATE UNIQUE INDEX IF NOT EXISTS uq_ss_entity
ON street_snaps (city, year, COALESCE(title, '')) WHERE is_deleted = 0;
```
(`brand_runways` 的 `uq_br_entity` **不动**:走秀实体键已有 `collection_type` 区分系列。)
- [ ] **步骤 4:改入库查重**
`internal/repository/ingest_repository.go` 的 `StreetSnapEntityState` 增加 `title` 参数与条件,
并与索引表达式保持同一口径:
```go
// StreetSnapEntityState 按实体键(city + year + title)查正式表既有行,返回其状态。
// title 用 COALESCE 与唯一索引 uq_ss_entity 的表达式保持一致。
func (r *ingestRepository) StreetSnapEntityState(ctx context.Context, city string, year uint16, title string) (uint32, string, bool, error) {
// ... Select("id, status") 不变
Where("city = ? AND year = ? AND COALESCE(title, '') = ? AND is_deleted = 0", city, year, title).
// ... 其余不变
}
```
接口声明与 `internal/service/ingest_service.go` 的 `processStreet` 调用处同步(传 `p.TitleEn`,
与同函数里构造 `snap.Title` 用的是同一个值)。
- [ ] **步骤 5:运行测试,确认通过**
运行:`go test -tags integration ./internal/repository/ -run TestStreetEntityKeyUnique -v`
预期:PASS
- [ ] **步骤 6:全量回归**
运行:`go build ./... && go test -count=1 ./... && go vet -tags integration ./internal/repository/ && go test -tags integration ./internal/repository/ -count=1 2>&1 | Select-Object -Last 5`
预期:全绿
- [ ] **步骤 7:Commit**
```bash
git add db/migrations/2026-09-22-04-street-entity-key-title.sql \
internal/repository/ingest_repository.go internal/service/ingest_service.go \
internal/repository/entity_key_unique_integration_test.go
git commit -m "feat(publish): 街拍实体键细化为 city+year+title,支持同城同年多专题"
```
---
## 任务 4:审核侧切到正式表(含改名与模板)
**文件:**
- 修改:`internal/repository/review_repository.go`(整体重写)
- 修改:`internal/service/review_service.go`
- 修改:`internal/handler/backstage_handler.go:300-500` 区间与 `:690-830` 区间
- 修改:`internal/router/backstage.go`(三条图片路由的 handler 引用改名)
- 修改:`internal/handler/templates/pages/review-list.html`、`review-detail.html`
- 修改:`internal/router/backstage_test.go`(`fakeReview`)
- [ ] **步骤 1:写失败的测试**
在 `internal/router/backstage_test.go` 末尾追加(`fakeReview` 已在该文件,需同步改名):
```go
// TestBackstageApprovePublishes 通过 = 只改状态,不再晋升重建。
// 断言 service 收到的是 published 状态写入,而不是「先晋升再置 approved」。
func TestBackstageApprovePublishes(t *testing.T) {
r, rev := newReviewRouter(t)
tok := reviewToken(t, r)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/admin/reviews/street/7/approve", nil)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(tok)
r.ServeHTTP(w, req)
if w.Code != http.StatusFound {
t.Fatalf("通过应 302,实际 %d %s", w.Code, w.Body.String())
}
if rev.last.op != "approve" {
t.Fatalf("应调用 Approve,实际 op=%s", rev.last.op)
}
}
// TestBackstageStreetRenderGroups 详情页按组渲染,且模板数据键已更名为 Record。
func TestBackstageStreetRenderGroups(t *testing.T) {
r, rev := newReviewRouter(t)
tok := reviewToken(t, r)
rev.view = &service.RecordDetailView{
Kind: "street", ID: 7, Title: "T", Year: 2026, City: "Beijing", ImageCount: 3, Status: "pending",
Images: []service.RecordImageRef{
{ID: 1, Image: "main.jpg", Name: "main", IsDetail: 0},
{ID: 2, Image: "det.jpg", Name: "detail-a", IsDetail: 1, ParentImageID: 1},
},
Groups: []service.ImageGroup{
{Image: service.RecordImageRef{ID: 1, Image: "main.jpg", Name: "main"}, Details: []service.RecordImageRef{{ID: 2, Image: "det.jpg", Name: "detail-a"}}},
},
}
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/admin/reviews/street/7", nil)
req.AddCookie(tok)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("详情应 200,实际 %d %s", w.Code, w.Body.String())
}
body := w.Body.String()
if !strings.Contains(body, "detail-a") {
t.Fatalf("应渲染副图 detail-a")
}
if !strings.Contains(body, `id="batch"`) || !strings.Contains(body, `form="batch"`) {
t.Fatalf("应有勾选批量并入当前主图的表单")
}
}
```
`fakeReview` 的字段与桩函数同步改名:
```go
type fakeReview struct {
service.ReviewService
attachOK bool
last struct {
kind string
recordID uint32
mainID uint32
imgIDs []uint32
imgID uint32
op string
}
view *service.RecordDetailView
}
func (f *fakeReview) RecordDetail(_ context.Context, _ string, _ uint32) (*service.RecordDetailView, error) {
if f.view != nil {
return f.view, nil
}
return &service.RecordDetailView{Kind: "street", ID: 7, Title: "T", Year: 2026, City: "Beijing", ImageCount: 2, Status: "pending"}, nil
}
func (f *fakeReview) Approve(_ context.Context, kind string, id uint32, _ string) (uint32, error) {
f.last.kind, f.last.recordID, f.last.op = kind, id, "approve"
return id, nil
}
func (f *fakeReview) AttachImages(_ context.Context, kind string, recordID, mainID uint32, imgIDs []uint32) error {
f.last.kind, f.last.recordID, f.last.mainID, f.last.imgIDs, f.last.op = kind, recordID, mainID, imgIDs, "attach"
if !f.attachOK {
return errors.New("主图无效")
}
return nil
}
func (f *fakeReview) DetachImage(_ context.Context, kind string, recordID, imgID uint32) error {
f.last.kind, f.last.recordID, f.last.imgID, f.last.op = kind, recordID, imgID, "detach"
if !f.attachOK {
return errors.New("主图无效")
}
return nil
}
```
原有 `TestBackstageStreetAttachRedirect` / `TestBackstageStreetAttachPrev` / `TestBackstageStreetDetach` 三处把 `rev.last.draftID` 改为 `rev.last.recordID`;`attach-prev` 那条路由与测试**删除**(该能力已不在代码中)。
- [ ] **步骤 2:运行测试,确认失败**
运行:`go test -count=1 ./internal/router/ -run TestBackstage -v`
预期:FAIL —— 编译错误 `service.RecordDetailView undefined`、`unknown backstage page` 相继出现
- [ ] **步骤 3:重写 `ReviewRepository` 接口**
`internal/repository/review_repository.go` 的接口整体替换为:
```go
// ReviewRepository 审核数据访问接口。
//
// 单表发布模型下,审核对象就是正式表里的记录本身(不再有草稿表):
// 列出待审记录、查看详情、审核前微调字段、通过(置 published)/ 驳回(置 rejected)。
type ReviewRepository interface {
// ListRecords 分页列出记录;status 为空表示全部,否则按状态过滤。排序:pending 优先,其次 id 倒序。
ListRecords(ctx context.Context, status string, page, size int) ([]model.BrandRunway, int64, error)
// RecordDetail 查单条走秀记录(不含图片)。
RecordDetail(ctx context.Context, id uint32) (*model.BrandRunway, error)
// RecordImages 查走秀记录的全部未删图片,按排序值升序。
RecordImages(ctx context.Context, recordID uint32) ([]model.BrandRunwayImage, error)
// UpdateRecord 审核前微调字段(白名单,杜绝任意列注入)。
UpdateRecord(ctx context.Context, id uint32, fields map[string]any) error
// SetRecordStatus 设置状态(通过/驳回)与审核人/驳回理由。
SetRecordStatus(ctx context.Context, id uint32, status, reviewer, reason string) error
// 街拍镜像方法(对象为 street_snaps / street_snap_images)。
ListStreetRecords(ctx context.Context, status string, page, size int) ([]model.StreetSnap, int64, error)
StreetRecordDetail(ctx context.Context, id uint32) (*model.StreetSnap, error)
StreetRecordImages(ctx context.Context, recordID uint32) ([]model.StreetSnapImage, error)
UpdateStreetRecord(ctx context.Context, id uint32, fields map[string]any) error
SetStreetRecordStatus(ctx context.Context, id uint32, status, reviewer, reason string) error
// SoftDeleteRunwayImage 软删单张走秀图片(审核阶段剔除坏图),并同步 image_count。
SoftDeleteRunwayImage(ctx context.Context, recordID, imgID uint32) error
// SoftDeleteStreetImage 软删单张街拍图片,并同步 image_count。
SoftDeleteStreetImage(ctx context.Context, recordID, imgID uint32) error
// AttachStreetSnapImages 把 imgIDs 变成 mainImgID 的副图(批量并入)。
// mainImgID 必须属于同一记录、未被软删、且自身是主图(is_detail=0),否则返回 ErrInvalidImageGroup。
// 若某个被并入的图自身还带着副图,则它原有的副图一并改挂到 mainImgID —— 避免「副图的副图」。
// 幂等:已是 mainImgID 副图的行重复并入不报错。
AttachStreetSnapImages(ctx context.Context, snapID, mainImgID uint32, imgIDs []uint32) error
// DetachStreetSnapImage 把副图恢复为主图(is_detail=0, parent_image_id=0)。对主图调用是幂等成功。
DetachStreetSnapImage(ctx context.Context, snapID, imgID uint32) error
// GetImageKey 取单张图片的S4 key(删除前清理用);不存在返回空串。
GetImageKey(ctx context.Context, kind string, recordID, imgID uint32) (string, error)
// CountByStatus 统计某表(dest 传表对应的零值指针,如 &model.BrandRunway{})中指定状态的行数。
CountByStatus(ctx context.Context, status string, dest any) (int64, error)
}
```
- [ ] **步骤 4:实现新方法、删除晋升代码**
逐条改写实现(对象从草稿表换成正式表):
- `ListRecords`:`Model(&model.BrandRunway{}).Where("is_deleted = 0")`,`Order("CASE status WHEN '"+model.StatusPending+"' THEN 0 ELSE 1 END ASC, id DESC")`。
- `RecordDetail`:`First(&model.BrandRunway{})`。
- `RecordImages`:`Where("runway_id = ? AND is_deleted = 0", recordID)`。
- `UpdateRecord`:白名单 `draftEditable` 更名为 `recordEditable`(内容与原 `draftEditable` 相同),`Model(&model.BrandRunway{})`。
- `SetRecordStatus`:`Model(&model.BrandRunway{})`,字段 `status` / `reviewer` / `reject_reason` / `updated_at`。
- `ListStreetRecords` / `StreetRecordDetail` / `StreetRecordImages` / `UpdateStreetRecord`(白名单 `streetRecordEditable` = title/year/city)/ `SetStreetRecordStatus`:同上换 `StreetSnap`。
- `SoftDeleteRunwayImage`:`Model(&model.BrandRunwayImage{}).Where("id = ? AND runway_id = ? AND is_deleted = 0", imgID, recordID)`,随后 `Exec("UPDATE brand_runways SET image_count = GREATEST(image_count - 1, 0) WHERE id = ?", recordID)`。
- `SoftDeleteStreetImage`:同上,表名换 `street_snap_images` / `street_snaps`。
- `AttachStreetSnapImages` / `DetachStreetSnapImage`:把 `model.StreetSnapDraftImage` 换成 `model.StreetSnapImage`、`draft_id` 换成 `snap_id`;`draftID` 参数改名 `snapID`。事务与两阶段顺序(先改挂孙辈、再标自身为副图)**保持不变**。
- `GetImageKey`:两张表换成 `brand_runway_images` / `street_snap_images`,`draft_id` → `runway_id` / `snap_id`。
- `CountByStatus`:原 `CountDraftPending` 改名,`Model(dest).Where("status = ? AND is_deleted = 0", status)`。
**删除**以下设施(连同 `runwayApprovedSiblingImages`、`streetApprovedSiblingImages`、`unionRunwayDraftImages`、`unionStreetDraftImages`、`SaveRunwayFromDraft`、`SaveStreetSnapFromDraft`):单表模型下图片不再于审核通过时重建,多来源聚合由入库时的实体键唯一化取代。
- [ ] **步骤 5:`review_service.go` 改名与 `Approve` 语义**
类型与接口改名(用编辑器全局重命名,编译期兜底):
`DraftCard`→`RecordCard`、`DraftImageRef`→`RecordImageRef`、`DraftDetailView`→`RecordDetailView`、`DraftImageGroup`→`ImageGroup`;`ReviewModule` 的 `ListDrafts`→`ListRecords`、`DraftDetail`→`RecordDetail`、`UpdateDraft`→`UpdateRecord`;`ReviewService` 的 `ListDrafts`→`ListRecords`、`DraftDetail`→`RecordDetail`、`UpdateDraft`→`UpdateRecord`、`DeleteDraftImage`→`DeleteImage`、`AttachDraftImages`→`AttachImages`、`DetachDraftImage`→`DetachImage`。
两个模块的方法体改为操作正式表(`repo.ListRecords` / `repo.RecordDetail` / `repo.RecordImages` / `repo.ListStreetRecords` / `repo.StreetRecordDetail` / `repo.StreetRecordImages`),`toRunwayCards(rows []model.BrandRunway)` / `toStreetCards(rows []model.StreetSnap)`。
两个 `Approve` 改为:
```go
func (m *runwayModule) Approve(ctx context.Context, id uint32, reviewer string) (uint32, error) {
if err := m.repo.SetRecordStatus(ctx, id, model.StatusPublished, reviewer, ""); err != nil {
return 0, err
}
return id, nil
}
```
```go
func (m *streetModule) Approve(ctx context.Context, id uint32, reviewer string) (uint32, error) {
if err := m.repo.SetRecordStatus(ctx, id, model.StatusPublished, reviewer, ""); err != nil {
return 0, err
}
return id, nil
}
```
两个 `Reject` 把状态常量换成 `model.StatusRejected`,分别调 `SetRecordStatus` / `SetStreetRecordStatus`;两个 `CountPending` 调 `CountByStatus(ctx, model.StatusPending, &model.BrandRunway{})` / `&model.StreetSnap{}`。
`DeleteImage` 里把 `SoftDeleteRunwayDraftImage` / `SoftDeleteStreetDraftImage` 换成 `SoftDeleteRunwayImage` / `SoftDeleteStreetImage`;`AttachImages` / `DetachImage` 换成 `AttachStreetSnapImages` / `DetachStreetSnapImage`;`GetDraftImageKey` → `GetImageKey`。
`streetModule.RecordDetail` 里的分组算法(`resolveRoot` 链式上浮、`detailCount`、`groups` 组装)**逐行保留**,只把 `model.StreetSnapDraftImage` 换成 `model.StreetSnapImage`、`byID` 的键类型不变。
- [ ] **步骤 6:`backstage_handler.go` 改名与模板键**
- `h.review.ListDrafts` → `h.review.ListRecords`;`h.review.DraftDetail` → `h.review.RecordDetail`;`h.review.UpdateDraft` → `h.review.UpdateRecord`;`h.review.DeleteDraftImage` → `h.review.DeleteImage`;`h.review.AttachDraftImages` → `h.review.AttachImages`;`h.review.DetachDraftImage` → `h.review.DetachImage`。
- `draftFormFields(c)` → `reviewFormFields(c)`。
- `ReviewList`(第 335 行)的 `"Drafts": drafts` → `"Records": drafts`。
- `ReviewDetail`(第 375 行)的 `"Draft": draft` → `"Record": draft`。
- handler 方法名去 `Draft`(编译期兜底):`ReviewDraftImageDelete`→`ReviewImageDelete`、`ReviewAttachDraftImages`→`ReviewAttachImages`、`ReviewDetachDraftImage`→`ReviewDetachImage`。
同步更新 `internal/router/backstage.go` 里这三条路由的 handler 引用(URL 路径 `/images/attach`、`/images/:img/detach`、`/images/:img/delete` **不变**):
```go
admin.POST("/reviews/:kind/:id/images/:img/delete", opt.Backstage.ReviewImageDelete)
admin.POST("/reviews/:kind/:id/images/attach", opt.Backstage.ReviewAttachImages)
admin.POST("/reviews/:kind/:id/images/:img/detach", opt.Backstage.ReviewDetachImage)
```
- [ ] **步骤 7:模板同步**
`review-list.html`:
- 第 27 行 `{{range .Drafts}}` → `{{range .Records}}`
- 第 15 行 tab:`?status=approved` → `?status=published`,`eq .Status "approved"` → `eq .Status "published"`,文案保持「已通过」
- 第 35 行提示文案 `按当前内容直接晋升到正式表` → `按当前内容直接发布`
`review-detail.html`:全文 `.Draft.` → `.Record.`(含 `{{reviewDetailURL .Draft.Kind .Draft.ID}}`、`.Draft.Fields`、`.Draft.Images`、`.Draft.Groups` 等)。
- [ ] **步骤 7b:后台走秀列表补状态列**
任务 1 给 `RunwayRow` 加了 `Status` 字段,但 `articleAdminColumns`(`internal/repository/article_repository.go:66-70`)没有 `SELECT` 它,所以字段永远是零值、状态列会是空的。这里补上:
1)`articleAdminColumns` 末尾加一列:
```go
const articleAdminColumns = `brand_runways.id, brand_runways.brand_id,
brand_runways.title_en, brand_runways.title_cn,
brand_runways.cover, brand_runways.image_count, brand_runways.year,
brand_runways.collection_type, brand_runways.season, brand_runways.season_code,
brand_runways.status,
b.name_en AS brand_name_en, b.name_cn AS brand_name_cn`
```
2)`internal/handler/templates/pages/runways.html` 的表头与行各加一格:
```html
<th>状态</th>
```
```html
<td><span class="badge {{.Status}}">{{.Status}}</span></td>
```
(`.badge` 样式已由 `admin.css` 提供,见 `review-list.html:34` 的既有用法。街拍后台列表 `street-snaps.html` 同理加状态列——它渲染的是 `model.StreetSnap`,任务 1 已加 `Status` 字段。)
- [ ] **步骤 8:运行测试,确认通过**
运行:`go test -count=1 ./internal/router/ ./internal/handler/ ./internal/service/ -v 2>&1 | Select-String 'FAIL|ok '`
预期:全绿。`internal/handler/backstage_handler_test.go` 的「逐页渲染」用例是模板键改名的安全网——漏改会导致模板执行报错 → 500 → 该用例红。
- [ ] **步骤 9:集成测试与全量回归**
运行:`go build ./... && go test -count=1 ./... && go test -tags integration ./internal/repository/ 2>&1 | Select-String 'FAIL|ok '`
预期:全绿
- [ ] **步骤 10:Commit**
```bash
git add internal/repository/review_repository.go internal/service/review_service.go \
internal/handler/backstage_handler.go internal/handler/templates/pages/review-list.html \
internal/handler/templates/pages/review-detail.html internal/router/backstage_test.go
git commit -m "refactor(publish): 审核侧切到正式表,通过即改状态不再晋升"
```
---
## 任务 5:历史草稿搬迁脚本
> **执行顺序(重要)**:本任务必须在**任务 3c 之后**执行。
> 任务 3c 把街拍实体键细化为 `(city, year, title)`,因此开发库里那条「London 2027 Day 2」草稿
> 不会再与既有的「London 2027 Day 3」正式行撞键,搬迁可以正常完成。
>
> **并且必须在跑搬迁之前先执行一次 `db/migrations/2026-09-22-01b-publish-existing-rows.sql`。**
> 原因有两条:
> 1. 该文件把存量正式行置为 `published`。不跑的后果是公开视图返回 0 行(前台整站为空)。
> 2. 它只能在搬迁**之前**跑:搬迁会把 pending 草稿插进正式表,之后再跑 01b 会把那些待审内容一并刷成已发布。
>
> 顺序:**01b → `go run ./scripts/migrate_single_table` → 三项核对 → 任务 6 删表**。
**文件:**
- 创建:`scripts/migrate_single_table/main.go`
- [ ] **步骤 1:实现搬迁命令**
创建 `scripts/migrate_single_table/main.go`:
```go
// migrate_single_table 把历史草稿数据搬迁进正式表(单表发布模型的数据迁移,一次性执行)。
//
// 用法:
//
// go run ./scripts/migrate_single_table -dry-run # 只统计与校验,不写库
// go run ./scripts/migrate_single_table # 真正搬迁
//
// 搬迁规则:
// - 正式表既有行:status 已由迁移 2026-09-22-01 置为 published,本脚本不动。
// - pending / rejected 草稿:插入正式表并保留原状态,草稿图片插入正式图片表。
// - approved 草稿:其内容当年已由「晋升」写入正式表,**不重复插入**,只丢弃元数据。
// - 街拍副图:parent_image_id 从「草稿图片 id」改写为「新正式图片 id」;映射缺失则降级为主图。
//
// 校验不通过即中止(退出码 1),且不删任何数据;删草稿表由后续迁移人工执行。
package main
import (
"context"
"flag"
"fmt"
"log"
"os"
"fashionapi/internal/config"
"fashionapi/internal/database"
"fashionapi/internal/model"
"gorm.io/gorm"
)
func main() {
dryRun := flag.Bool("dry-run", false, "只统计与校验,不写库")
flag.Parse()
cfg, err := config.Load("")
if err != nil {
log.Fatalf("加载配置失败: %v", err)
}
db, err := database.New(cfg.Database)
if err != nil {
log.Fatalf("连接数据库失败: %v", err)
}
defer database.Close(db)
ctx := context.Background()
if err := run(ctx, db, *dryRun); err != nil {
log.Fatalf("搬迁失败: %v", err)
}
if *dryRun {
fmt.Println("dry-run 结束:未写入任何数据")
}
}
func run(ctx context.Context, db *gorm.DB, dryRun bool) error {
// 1) 前置校验:每个 approved 草稿都必须能在正式表找到对应实体行,
// 否则说明有「只存在于草稿」的内容,需要人工处理,不能盲目删表。
var orphans int64
if err := db.WithContext(ctx).Raw(`
SELECT count(*) FROM brand_runway_drafts d
WHERE d.status = 'approved' AND d.is_deleted = 0
AND NOT EXISTS (
SELECT 1 FROM brand_runways r
WHERE r.brand_id = d.brand_id AND r.season_code = d.season_code
AND r.collection_type = d.collection_type AND r.is_deleted = 0)`).Scan(&orphans).Error; err != nil {
return fmt.Errorf("校验走秀孤儿草稿: %w", err)
}
if orphans > 0 {
return fmt.Errorf("有 %d 条 approved 走秀草稿在正式表找不到对应记录,请人工处理后重跑", orphans)
}
if err := db.WithContext(ctx).Raw(`
SELECT count(*) FROM street_snap_drafts d
WHERE d.status = 'approved' AND d.is_deleted = 0
AND NOT EXISTS (
SELECT 1 FROM street_snaps s
WHERE s.city = d.city AND s.year = d.year AND s.is_deleted = 0)`).Scan(&orphans).Error; err != nil {
return fmt.Errorf("校验街拍孤儿草稿: %w", err)
}
if orphans > 0 {
return fmt.Errorf("有 %d 条 approved 街拍草稿在正式表找不到对应记录,请人工处理后重跑", orphans)
}
total, err := migrateRunways(ctx, db, dryRun)
if err != nil {
return err
}
streetTotal, err := migrateStreets(ctx, db, dryRun)
if err != nil {
return err
}
fmt.Printf("搬迁完成:走秀草稿 %d 条,街拍草稿 %d 条\n", total, streetTotal)
return nil
}
```
同文件追加两个搬迁函数:
```go
// migrateRunways 搬迁 pending / rejected 的走秀草稿(含图片),返回搬迁条数。
func migrateRunways(ctx context.Context, db *gorm.DB, dryRun bool) (int, error) {
var drafts []model.BrandRunwayDraft
if err := db.WithContext(ctx).
Where("status IN ? AND is_deleted = 0", []string{model.StatusPending, model.StatusRejected}).
Order("id ASC").Find(&drafts).Error; err != nil {
return 0, err
}
fmt.Printf("走秀待搬迁草稿:%d 条\n", len(drafts))
if dryRun {
for _, d := range drafts {
var n int64
db.WithContext(ctx).Model(&model.BrandRunwayDraftImage{}).
Where("draft_id = ? AND is_deleted = 0", d.ID).Count(&n)
fmt.Printf(" draft=%d status=%s title=%q 图片=%d\n", d.ID, d.Status, d.TitleEn, n)
}
return len(drafts), nil
}
moved := 0
for _, d := range drafts {
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
rw := model.BrandRunway{
JobID: d.JobID, BrandID: d.BrandID,
TitleEn: d.TitleEn, TitleCn: d.TitleCn,
DescriptionEn: d.DescriptionEn, DescriptionCn: d.DescriptionCn,
Year: d.Year, Season: d.Season,
CollectionType: d.CollectionType, SeasonCode: d.SeasonCode,
Cover: d.Cover, ImageCount: d.ImageCount,
Status: d.Status, Reviewer: d.Reviewer, RejectReason: d.RejectReason,
IsDeleted: d.IsDeleted, CreatedAt: d.CreatedAt, UpdatedAt: d.UpdatedAt,
}
if cErr := tx.Create(&rw).Error; cErr != nil {
return cErr
}
var imgs []model.BrandRunwayDraftImage
if fErr := tx.Where("draft_id = ?", d.ID).Order("sort_order ASC, id ASC").Find(&imgs).Error; fErr != nil {
return fErr
}
if len(imgs) == 0 {
return nil
}
rows := make([]model.BrandRunwayImage, 0, len(imgs))
for _, im := range imgs {
rows = append(rows, model.BrandRunwayImage{
RunwayID: rw.ID, BrandID: d.BrandID,
Image: im.Image, Name: im.Name, SortOrder: im.SortOrder,
LookIndex: im.LookIndex, IsDetail: im.IsDetail,
IsDeleted: im.IsDeleted, Phash: im.Phash,
IsDuplicate: im.IsDuplicate, DupOf: im.DupOf,
CreatedAt: im.CreatedAt, UpdatedAt: im.UpdatedAt,
})
}
return tx.Create(&rows).Error
})
if err != nil {
return moved, fmt.Errorf("搬迁走秀草稿 %d: %w", d.ID, err)
}
moved++
}
return moved, nil
}
// migrateStreets 搬迁 pending / rejected 的街拍草稿,并改写副图父引用。
func migrateStreets(ctx context.Context, db *gorm.DB, dryRun bool) (int, error) {
var drafts []model.StreetSnapDraft
if err := db.WithContext(ctx).
Where("status IN ? AND is_deleted = 0", []string{model.StatusPending, model.StatusRejected}).
Order("id ASC").Find(&drafts).Error; err != nil {
return 0, err
}
fmt.Printf("街拍待搬迁草稿:%d 条\n", len(drafts))
if dryRun {
for _, d := range drafts {
var n int64
db.WithContext(ctx).Model(&model.StreetSnapDraftImage{}).
Where("draft_id = ? AND is_deleted = 0", d.ID).Count(&n)
fmt.Printf(" draft=%d status=%s title=%q city=%s 图片=%d\n", d.ID, d.Status, d.Title, d.City, n)
}
return len(drafts), nil
}
moved := 0
for _, d := range drafts {
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
snap := model.StreetSnap{
JobID: d.JobID, Title: d.Title, Year: d.Year, City: d.City,
Cover: d.Cover, ImageCount: d.ImageCount,
Status: d.Status, Reviewer: d.Reviewer, RejectReason: d.RejectReason,
IsDeleted: d.IsDeleted, CreatedAt: d.CreatedAt, UpdatedAt: d.UpdatedAt,
}
if cErr := tx.Create(&snap).Error; cErr != nil {
return cErr
}
var imgs []model.StreetSnapDraftImage
if fErr := tx.Where("draft_id = ?", d.ID).Order("sort_order ASC, id ASC").Find(&imgs).Error; fErr != nil {
return fErr
}
if len(imgs) == 0 {
return nil
}
// 第一遍:插入主图,记下「草稿图片 id → 新正式图片 id」。
idMap := 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: snap.ID, Image: im.Image, Name: im.Name, SortOrder: im.SortOrder,
IsDetail: 0, IsDeleted: im.IsDeleted, Phash: im.Phash,
IsDuplicate: im.IsDuplicate, DupOf: im.DupOf,
CreatedAt: im.CreatedAt, UpdatedAt: im.UpdatedAt,
})
}
for i := range mains {
if cErr := tx.Create(&mains[i]).Error; cErr != nil {
return cErr
}
}
mi := 0
for _, im := range imgs {
if im.IsDetail == 1 {
continue
}
idMap[im.ID] = mains[mi].ID
mi++
}
// 第二遍:插入副图并把父引用从小表 id 改写成新正式行 id;映射缺失则降级为主图。
details := make([]model.StreetSnapImage, 0, len(imgs)-len(mains))
for _, im := range imgs {
if im.IsDetail != 1 {
continue
}
row := model.StreetSnapImage{
SnapID: snap.ID, Image: im.Image, Name: im.Name, SortOrder: im.SortOrder,
IsDetail: 1, IsDeleted: im.IsDeleted, Phash: im.Phash,
IsDuplicate: im.IsDuplicate, DupOf: im.DupOf,
CreatedAt: im.CreatedAt, UpdatedAt: im.UpdatedAt,
}
if newParent, ok := idMap[im.ParentImageID]; 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
}
}
return nil
})
if err != nil {
return moved, fmt.Errorf("搬迁街拍草稿 %d: %w", d.ID, err)
}
moved++
}
return moved, nil
}
```
- [ ] **步骤 2:编译**
运行:`go build ./... && go vet ./scripts/migrate_single_table/`
预期:无输出(通过)
- [ ] **步骤 3:dry-run 核对影响面**
运行:`go run ./scripts/migrate_single_table -dry-run`
预期:打印待搬迁的走秀/街拍草稿条数与各自图片数。核对条数与库里 `SELECT status, count(*) FROM street_snap_drafts GROUP BY status` 一致;若报「孤儿草稿」,先人工处理再继续。
- [ ] **步骤 4:真实搬迁并核对守恒**
运行:`go run ./scripts/migrate_single_table`
预期:`搬迁完成:走秀草稿 N 条,街拍草稿 M 条`,无错误。
随后核对(在库里执行):
- `SELECT count(*) FROM street_snap_drafts WHERE status IN ('pending','rejected') AND is_deleted=0` 与搬迁条数一致;
- 街拍副图父引用无悬空:`SELECT count(*) FROM street_snap_images c WHERE c.is_detail=1 AND c.parent_image_id <> 0 AND NOT EXISTS (SELECT 1 FROM street_snap_images p WHERE p.id=c.parent_image_id)` 结果应为 0。
- [ ] **步骤 5:Commit**
```bash
git add scripts/migrate_single_table/main.go
git commit -m "chore(publish): 增加历史草稿搬迁脚本(含校验与 dry-run)"
```
---
## 任务 6:删除草稿表与死代码
**文件:**
- 创建:`db/migrations/2026-09-22-05-drop-draft-tables.sql`
- 删除:`internal/model/runway_draft.go`、`internal/model/street_snap_draft.go`
- 删除:`scripts/migrate_single_table/`(**必需**:它 import 草稿模型,草稿模型一删它就无法编译)
> ⚠️ **顺序**:`scripts/migrate_single_table` 必须在**本任务之前**跑完(它对草稿表的依赖只在那一次运行里需要)。
> 本任务第一步就要先把它删掉,否则 `go build ./...` 会因它引用已删的草稿模型而失败。
- [ ] **步骤 1:确认已无代码引用草稿模型**
运行:`go build ./... 2>&1 | Select-Object -First 20`
预期:无输出。若仍有草稿模型/草稿约束的引用,先补完改名再继续。
再运行:`Get-ChildItem -Recurse -Filter *.go -Path internal | Select-String -Pattern 'BrandRunwayDraft|StreetSnapDraft|DraftStatus' | Select-Object -First 20`
预期:无输出(没有任何草稿模型/常量引用;仅剩的中文「草稿」字样注释不影响)。
- [ ] **步骤 2:写删表迁移**
创建 `db/migrations/2026-09-22-05-drop-draft-tables.sql`:
```sql
-- 单表发布模型收尾:删除 4 张草稿表。
--
-- ⚠️ 执行前置条件:scripts/migrate_single_table 已跑完且校验通过(否则草稿数据会丢失)。
-- 本迁移不可逆:执行前请确认 db/backups 有可用备份。
DROP TABLE IF EXISTS brand_runway_draft_images;
DROP TABLE IF EXISTS brand_runway_drafts;
DROP TABLE IF EXISTS street_snap_draft_images;
DROP TABLE IF EXISTS street_snap_drafts;
```
- [ ] **步骤 3:删除草稿模型文件与一次性搬迁脚本**
删除 `internal/model/runway_draft.go`、`internal/model/street_snap_draft.go`,
以及 `scripts/migrate_single_table/`(整个目录)。
后者是**必需**的:它 import 草稿模型,模型一删它就无法编译,`go build ./...` 会直接失败。
搬迁脚本是一次性工具,跑完(见任务 5 步骤 4)就没有存在价值了。
- [ ] **步骤 4:编译与全量测试**
运行:`go build ./... && go vet ./... && go test -count=1 ./...`
预期:编译通过、vet 无输出、测试全绿
- [ ] **步骤 5:集成测试(迁移 05 生效后)**
先在库上执行迁移 05:
```bash
psql -U fashion -d fashion -v ON_ERROR_STOP=1 -f db/migrations/2026-09-22-05-drop-draft-tables.sql
```
(本机无 `psql` 时用 WSL / docker:见 `scripts/setup_pg_wsl.sh`、`scripts/pgvector/docker-compose.yml`)
再运行:`go test -tags integration ./internal/repository/ -count=1 2>&1 | Select-String 'FAIL|ok '`
预期:全绿(集成测试已不依赖草稿表)
- [ ] **步骤 6:刷新 schema dump**
运行:`go build -o bin/dbtool ./cmd/dbtool && ./bin/dbtool dump -out db_dump.sql`
预期:`db_dump.sql` 更新——不再含 4 张草稿表,含 4 个 `public_*` 视图与新增列。
- [ ] **步骤 7:Commit**
```bash
git add db/migrations/2026-09-22-05-drop-draft-tables.sql db_dump.sql
git rm internal/model/runway_draft.go internal/model/street_snap_draft.go
git commit -m "chore(publish): 删除草稿表与草稿模型"
```
---
## 任务 7:收尾验收
- [ ] **步骤 0:两处由前序审查带出的小修**
**0-1 给备份补上 4 张草稿表的 `PRIMARY KEY`** —— `db/backups/draft-tables-before-drop.sql` 数据完整(1618 行)、
索引与序列复位已补,但 4 张表**都没有主键**(原文全文 0 处 `PRIMARY KEY`)。而删表前的库是有主键的
(旧 dump 里的 `brand_runway_drafts_pkey` / `brand_runway_draft_images_pkey` /
`street_snap_drafts_pkey` / `street_snap_draft_images_pkey`)。
后果:按该备份还原出的表**唯一性约束与原库不等价**,严格说不是「删表前一模一样」。
从 git 历史取回这 4 条约束定义并补进备份:`git show HEAD:cmd/dbtool/db_dump.sql`(这份**陈旧的第二份 dump 仍在版本控制里**,正好含旧 schema 的主键定义)。
**0-2 清掉陈旧的第二份 dump** —— `cmd/dbtool/db_dump.sql` 是一份**旧产物**(仍含 4 张草稿表与旧 schema),
与权威的根 `db_dump.sql` 同名、容易混淆,且未被 `.gitignore` 覆盖。
修法:删除它,或重新生成(若它已不再被任何流程引用,删除更干净——请在报告里说明你的选择与理由)。
- [ ] **步骤 1:格式与静态检查**
运行:`gofmt -l internal scripts cmd` 与 `go vet ./...`
预期:无输出
- [ ] **步骤 2:全量测试**
运行:`go build ./... && go test -count=1 ./... && go test -tags integration ./internal/repository/ 2>&1 | Select-String 'FAIL|ok '`
预期:全绿
(`repository` 集成套件中的错误日志是刻意构造的负向用例,属预期输出。)
- [ ] **步骤 3:端到端走查(真实库 + 真实 HTTP 请求,不需要浏览器)**
本机没有可用的浏览器,用 HTTP 请求(`curl` 或 PowerShell `Invoke-WebRequest`)等价验证。
先起服务(`go run ./cmd/server` 或已有的启动方式),再做这 6 项:
1. 后台审核列表 `/admin/reviews`(需先 `POST /admin/login` 拿会话 cookie):应能看到待审记录
(当前库里应有 **4 条街拍 pending**,即搬迁进来的 id=146–149),「已通过」tab 的链接应带 `?status=published`。
2. 打开其中一条街拍待审记录的审核页 `/admin/reviews/street/<id>`:响应 200 且 HTML 里应含
图片网格与「合并为一组」表单(`id="batch"` / `form="batch"`)。
3. `POST /admin/reviews/street/<id>/approve` → 应 **302** 回列表;随后该记录的 `status` 应为 `published`。
⚠️ 这一步会真的改数据:**只对一条**记录做,并在报告里写明是哪一条(我会据此核对)。
4. 公开 API 列表 `GET /api/v1/public/street-snaps`:**刚通过的那条应出现**;另外 3 条 pending 的**不应出现**。
5. 公开 API 详情 `GET /api/v1/public/street-snaps/<uid>`(uid 用 hashid 编码):
对已发布的那条 → 200 且能读到;对仍 pending 的那条 → **404**。
6. 后台编辑页 `/admin/street-snaps/<uid>` 打开一条**仍 pending** 的记录 → 图片**应能显示**
(这是任务 2 把 `ListImages` 拆出基表版本要保证的;若这里为空,说明后台读到了公开视图)。
> 若服务起不来或缺少必要配置(如 S4/签名密钥),**不要**硬凑:把具体的阻塞错误写进报告并标注为未完成项,
> 我会据此决定是否需要补做。
- [ ] **步骤 4:确认工作区干净**
运行:`git status --short`
预期:无未提交的代码改动(仅可能有用户自己的文档改动)
---
## 附:明确不做的事
1. **不合并后台的「审核列表」与「正式列表」两个入口**(`/admin/reviews` 与 `/admin/runways`·`/admin/street-snaps`)——单表之后它们数据重叠,但合并属独立重构,留待后续。
2. **不改 `image_count` 口径**:runway 计主图、street 计全部图,现状保留。
3. **不清理死物**:`street_snaps.source/source_url`、`image_duplicates`、`image_embeddings` 仍为零引用,另起迁移处理。
4. **不修 `favorites` 唯一索引**(实建 `uniq_user_target(target_uid)` 单列,疑似缺陷)。
5. ~~不给实体键加唯一索引~~ → **已改为任务 3b 实现**。原理由「改动前即如此」是错的:旧流程靠晋升时的实体键 upsert 把同一实体的多条草稿并成一行(自愈),单表后这个自愈机制消失,只剩「先查后插」,挡不住并发或一次瞬时读失败。故补部分唯一索引(`uq_br_entity` / `uq_ss_entity`)。