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

101 KiB
Raw Permalink Blame History

单表发布模型(取消草稿表)实现计划

面向 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:写状态常量

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 字段之后)追加:

	// 审核态与溯源(单表发布模型;列由 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 之后追加(后台列表展示状态用):

	Status string `gorm:"column:status"`
  • 步骤 3:给 StreetSnap 加 4 列

在 internal/model/street_snap.go 的 StreetSnap 结构体末尾(UpdatedAt 之前)追加同样的 4 个字段:

	// 审核态与溯源(单表发布模型;列由 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: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:

-- 单表发布模型(方案 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:

-- 一次性数据迁移:把存量正式行视为已发布。
--
-- ⚠️ 只执行一次;必须在部署本计划的新代码之前、且在 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
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:

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: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):

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 行)改调基表版本:

	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 之后新增声明:

	// ListAdmin 后台管理列表:读基表(含 pending / rejected),与 article_repository.ListAdmin 对称。
	ListAdmin(ctx context.Context, q dto.StreetSnapQuery) ([]model.StreetSnap, int64, error)

2)实现(与 List 同构,只是 Model(&model.StreetSnap{}) 读基表):

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):

// 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 改为:

	items, total, err := h.street.AdminList(c.Request.Context(), q)
  • 步骤 6:brand_repository.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
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: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,覆盖三分支的决策逻辑(不需要数据库,因为它是纯函数):

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,替换为:

	// 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 中原草稿方法位置写入:

// ---- 入库直写正式表(单表发布模型)----

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:

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 末尾追加:

// 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 的去重段替换为:

	// 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 行的写草稿段整体替换为:

	// 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 行去重段替换为:

	// 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 行写草稿段替换为:

	// 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:

	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
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: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:

-- 实体键唯一索引:单表发布模型下「一个实体一行」的结构性保证。
--
-- 为什么必须有:旧流程靠晋升时的实体键 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 里原有的「不应命中」断言实际从未生效:

	// 全 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:

	// 全 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:

		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
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 改成两段断言:

// ① 同城同年**同标题**的第二行必须被拒(实体键仍具备唯一性)。
// ② 同城同年**不同标题**的第二行必须允许(这就是「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:

-- 街拍实体键细化:由 (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 参数与条件, 并与索引表达式保持同一口径:

// 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
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 已在该文件,需同步改名):

// 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 的字段与桩函数同步改名:

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 的接口整体替换为:

// 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 改为:

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
}
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 不变):

		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 末尾加一列:

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 的表头与行各加一格:

<th>状态</th>
<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
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:

// 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
}

同文件追加两个搬迁函数:

// 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

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:

-- 单表发布模型收尾:删除 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:

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
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)。