2342 lines
101 KiB
Markdown
2342 lines
101 KiB
Markdown
# 单表发布模型(取消草稿表)实现计划
|
||
|
||
> **面向 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`)。
|